/**
 * Browser-friendly request-response `FileClient` for `@shade/files`.
 *
 * The default `shade.files.client(peer)` ships RPC envelopes via
 * `Shade.send` + `Shade.deliverControlEnvelope`, which means the
 * server has to be able to call back outbound to the client. That
 * doesn't work for browser tabs (no inbound HTTP listener). This
 * client posts each RPC envelope to a single server endpoint and
 * reads the encrypted response from the same HTTP response — pure
 * request-response, no inbound channel required.
 *
 * Inline payloads only (≤ 256 KiB). For larger reads/writes, use the
 * stateful path: `shade.files.client(peer)` server-to-server, with
 * `@shade/transfer` chunk routes for content I/O.
 *
 * @see {@link createFilesRpcRoute} for the matching server-side route.
 */
import type { ZodTypeAny } from 'zod';
import { decodeEnvelope, encodeEnvelope as encodeWireEnvelope } from '@shade/proto';
import type { ShadeBridge } from '../integration/shade-bridge.js';
import {
  encodeEnvelope as encodeRpcEnvelope,
  tryParseEnvelope,
} from '../protocol/envelope-codec.js';
import {
  KIND_CUSTOM_V1,
  KIND_DELETE_V1,
  KIND_GET_THUMBNAIL_V1,
  KIND_LIST_V1,
  KIND_MKDIR_V1,
  KIND_MOVE_V1,
  KIND_READ_V1,
  KIND_STAT_V1,
  KIND_WRITE_V1,
} from '../protocol/kinds.js';
import {
  CustomArgsSchema,
  CustomResultSchema,
  DeleteArgsSchema,
  DeleteResultSchema,
  GetThumbnailArgsSchema,
  GetThumbnailResultSchema,
  ListArgsSchema,
  ListResultSchema,
  MkdirArgsSchema,
  MkdirResultSchema,
  MoveArgsSchema,
  MoveResultSchema,
  ReadArgsSchema,
  ReadResultSchema,
  StatArgsSchema,
  StatResultSchema,
  WriteArgsSchema,
  WriteResultSchema,
  type ListResult,
  type MkdirResult,
  type DeleteResult,
  type MoveResult,
  type StatResult,
  type ThumbnailSize,
  type WriteResult,
} from '../schemas/ops.js';
import {
  fileErrorFromPayload,
  CancelledError,
  InternalFileError,
  ConflictError,
} from '../schemas/errors.js';
import { buildRpcRequest } from '../protocol/rpc-builder.js';
import { decideInline, INLINE_THRESHOLD, type WriteSource } from './inline-threshold.js';
import { base64ToBytes, bytesToBase64 } from '../protocol/canonical.js';
import { startQueueDrainer, type QueueDrainerHandle } from './queue-drainer.js';
import {
  createClientStreamsBridge,
  type ClientStreamsBridge,
} from './streams-bridge.js';
import type {
  FileClient,
  ReadOpts,
  ReadOutput,
  ThumbnailResult,
  WriteOpts,
  CreateFileClientOptions,
  BaseOpts,
} from './client.js';

export interface FilesHttpClientOptions
  extends Omit<CreateFileClientOptions, 'streamsBridge'> {
  /**
   * Server endpoint that hosts `createFilesRpcRoute(...)`. Typically:
   * `https://server.example.com/api/v1/shade-files/rpc`.
   */
  rpcUrl: string;
  /**
   * Optional `fetch` override. Defaults to `globalThis.fetch`. Wire a
   * custom `fetch` to thread auth-cookies, CSRF tokens, or
   * service-worker interception.
   */
  fetch?: typeof globalThis.fetch;
  /**
   * Extra HTTP headers applied to every RPC POST. Useful for app-level
   * auth (CSRF, session cookies via custom header, etc.) — these are
   * orthogonal to the ratchet authentication on the envelope itself.
   */
  headers?: Record<string, string>;
  /**
   * Server endpoint that hosts `transferQueueRoute()`'s long-poll
   * endpoint. Typically:
   * `https://server.example.com/api/v1/shade-files/queue`.
   *
   * When supplied, the client starts a background long-poll that
   * drains queued envelopes + chunks from the server and dispatches
   * them via `shade.acceptTransferEnvelope`. This unlocks
   * **streamed reads** (>256 KiB) for browser-style consumers.
   */
  outboundQueueUrl?: string;
  /**
   * Base URL for outbound transfer routes (browser → server). Required
   * alongside `outboundQueueUrl` to enable streamed writes. Typically:
   * `https://server.example.com/api/v1/shade-files`.
   *
   * The client POSTs:
   *   - chunks to `<base>/v1/transfer/<streamId>/chunk`
   *   - control envelopes to `<base>/v1/transfer/control`
   */
  transferBaseUrl?: string;
  /**
   * Long-poll block timeout, milliseconds. Default 30_000. Server
   * clamps to its own `maxBlockMs` (default 55_000).
   */
  queueBlockMs?: number;
}

interface RoundTripOpts {
  signal?: AbortSignal;
  timeoutMs?: number;
  idempotencyKey?: string;
}

/**
 * Create a request-response `FileClient` bound to `peerAddress` and a
 * server-side RPC URL. The session must already be established
 * (via `shade.initSessionFromBundle(peerAddress, bundle)` or an
 * incoming first-message). Otherwise the first RPC will fail with
 * "decrypt failed: no session for peer".
 *
 * When `outboundQueueUrl` + `transferBaseUrl` are supplied, the
 * client also unlocks **streamed reads/writes** for files larger than
 * the inline threshold (256 KiB). The browser polls the server's
 * outbound queue for chunks/envelopes and POSTs its own outbound
 * chunks to the server's transfer-receive routes.
 */
export function createFilesHttpClient(
  shade: ShadeBridge,
  peerAddress: string,
  options: FilesHttpClientOptions,
): FileClient {
  const rpcUrl = options.rpcUrl;
  const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
  const extraHeaders = options.headers ?? {};
  const defaultTimeoutMs = options.defaultTimeoutMs ?? 30_000;
  const ioTimeoutMs = options.ioTimeoutMs ?? 60_000;
  const signRequest = options.signRequest;
  const senderAddress = shade.myAddress;

  // ─── Streamed-mode bootstrap ─────────────────────────────────
  //
  // When `outboundQueueUrl` is supplied, the client:
  //  1. Configures `shade.configureTransfers(...)` so outbound
  //     chunks POST to `<transferBaseUrl>/v1/transfer/<streamId>/chunk`
  //     and outbound control envelopes POST to
  //     `<transferBaseUrl>/v1/transfer/control`.
  //  2. Spawns a streams-bridge so streamed reads can be awaited.
  //  3. Starts a long-poll drainer that pulls queued envelopes +
  //     chunks from the server and dispatches via
  //     `shade.acceptTransferEnvelope`.

  let drainer: QueueDrainerHandle | null = null;
  let streamsBridgePromise: Promise<ClientStreamsBridge> | null = null;
  let streamsBridge: ClientStreamsBridge | null = null;

  if (options.outboundQueueUrl !== undefined) {
    const outboundQueueUrl = options.outboundQueueUrl;
    if (options.transferBaseUrl === undefined) {
      throw new Error(
        'createFilesHttpClient: outboundQueueUrl was supplied without transferBaseUrl. Pass `transferBaseUrl` (the server prefix that hosts /v1/transfer/...) so outbound chunks have a destination.',
      );
    }
    if (shade.configureTransfers === undefined) {
      throw new Error(
        'createFilesHttpClient: shade.configureTransfers is required for streamed mode (the underlying ShadeBridge must surface it).',
      );
    }
    const transferBaseUrl = options.transferBaseUrl.replace(/\/$/, '');
    shade.configureTransfers({
      resolveBaseUrl: async (peer) => {
        if (peer !== peerAddress) {
          throw new Error(
            `httpClient is bound to peer "${peerAddress}" — refusing to resolve outgoing chunks for "${peer}" without a multi-peer registry. Use shade.files.client(peer) for server-to-server multi-peer.`,
          );
        }
        return transferBaseUrl;
      },
    });
    // Build the streams-bridge eagerly. The engine's incoming-transfer
    // subscription has to be in place BEFORE the drainer dispatches the
    // first stream-init envelope, otherwise the engine emits the
    // IncomingTransfer to zero handlers and the read silently never
    // accepts. We kick off the drainer once the bridge has subscribed.
    streamsBridgePromise = createClientStreamsBridge(shade).then((bridge) => {
      streamsBridge = bridge;
      drainer = startQueueDrainer(shade, {
        outboundQueueUrl,
        peerAddress,
        senderAddress,
        ...(options.fetch !== undefined ? { fetch: options.fetch } : {}),
        ...(options.headers !== undefined ? { headers: options.headers } : {}),
        ...(options.queueBlockMs !== undefined ? { blockMs: options.queueBlockMs } : {}),
      });
      return bridge;
    });
    // Surface bridge-construction failures eagerly via a rejected
    // promise the next read/write picks up.
    streamsBridgePromise.catch(() => {
      /* observed via getStreamsBridge() */
    });
  }

  async function getStreamsBridge(): Promise<ClientStreamsBridge> {
    if (streamsBridge !== null) return streamsBridge;
    if (streamsBridgePromise === null) {
      throw new ConflictError(
        `http RPC client supports inline writes/reads only (≤ ${INLINE_THRESHOLD} bytes) — pass { outboundQueueUrl, transferBaseUrl } to enable streamed transfers.`,
      );
    }
    streamsBridge = await streamsBridgePromise;
    return streamsBridge;
  }

  /**
   * Encrypt + POST + decrypt + parse one RPC round-trip.
   *
   * Throws a typed `FileError` subclass when the server returns an
   * encrypted `RpcError`, or `InternalFileError` for transport-level
   * failures (network, 4xx/5xx, malformed body).
   */
  async function roundTrip<TResult>(
    kind: string,
    op: 'list' | 'stat' | 'mkdir' | 'delete' | 'move' | 'read' | 'write' | 'getThumbnail' | 'custom',
    args: unknown,
    resultSchema: ZodTypeAny,
    opts: RoundTripOpts | undefined,
  ): Promise<TResult> {
    const requestEnv = await buildRpcRequest({
      senderAddress,
      kind,
      op,
      args,
      ...(opts?.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
      ...(signRequest !== undefined ? { signRequest } : {}),
    });

    const plaintext = encodeRpcEnvelope(requestEnv);
    const ratchetEnvelope = await shade.send(peerAddress, plaintext);
    const wireBytes = encodeWireEnvelope(ratchetEnvelope);

    const ac = new AbortController();
    const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs;
    const timer = setTimeout(
      () => ac.abort(new Error(`RPC timeout after ${timeoutMs}ms`)),
      timeoutMs,
    );
    (timer as unknown as { unref?: () => void }).unref?.();
    if (opts?.signal !== undefined) {
      const userSignal = opts.signal;
      if (userSignal.aborted) ac.abort(userSignal.reason);
      else userSignal.addEventListener('abort', () => ac.abort(userSignal.reason), { once: true });
    }

    let response: Response;
    try {
      // Wrap the wire bytes in a Blob so the body type satisfies the
      // common-denominator `BodyInit` across DOM, Bun, and node-fetch
      // (some runtimes accept `Uint8Array` directly, others don't).
      // Cast through `unknown` because TS's `bun-types` and `lib.dom`
      // disagree about whether `Uint8Array<ArrayBufferLike>` is itself
      // a `BlobPart`; the runtime accepts it on every platform.
      response = await fetchFn(rpcUrl, {
        method: 'POST',
        body: new Blob([wireBytes as unknown as ArrayBuffer]),
        signal: ac.signal,
        headers: {
          'Content-Type': 'application/octet-stream',
          'X-Shade-Sender-Address': senderAddress,
          ...extraHeaders,
        },
      });
    } catch (err) {
      clearTimeout(timer);
      if ((err as Error).name === 'AbortError') {
        throw new CancelledError(`RPC ${kind} aborted: ${(err as Error).message}`);
      }
      throw new InternalFileError(`RPC ${kind} fetch failed: ${(err as Error).message}`);
    }
    clearTimeout(timer);

    if (!response.ok) {
      let body: { error?: string } | null = null;
      try {
        body = (await response.json()) as { error?: string };
      } catch {
        /* server emitted non-JSON body */
      }
      throw new InternalFileError(
        `RPC ${kind} → ${response.status} ${response.statusText}: ${
          body?.error ?? '(no error body)'
        }`,
      );
    }

    const ab = await response.arrayBuffer();
    if (ab.byteLength === 0) {
      throw new InternalFileError(`RPC ${kind}: empty response body`);
    }

    let responseRatchet;
    try {
      responseRatchet = decodeEnvelope(new Uint8Array(ab));
    } catch (err) {
      throw new InternalFileError(
        `RPC ${kind}: response body is not a valid wire envelope: ${(err as Error).message}`,
      );
    }
    let responsePlaintext: string;
    try {
      responsePlaintext = await shade.receive(peerAddress, responseRatchet);
    } catch (err) {
      throw new InternalFileError(
        `RPC ${kind}: response decrypt failed: ${(err as Error).message}`,
      );
    }
    const classified = tryParseEnvelope(responsePlaintext);
    if (classified === null) {
      throw new InternalFileError(
        `RPC ${kind}: response plaintext is not a valid @shade/files envelope`,
      );
    }
    if (classified.kind === 'error') {
      throw fileErrorFromPayload(classified.envelope.error);
    }
    if (classified.kind !== 'response') {
      throw new InternalFileError(
        `RPC ${kind}: unexpected response envelope kind: ${classified.kind}`,
      );
    }
    if (classified.envelope.id !== requestEnv.id) {
      throw new InternalFileError(
        `RPC ${kind}: response correlation id mismatch (got ${classified.envelope.id}, expected ${requestEnv.id})`,
      );
    }
    return resultSchema.parse(classified.envelope.result) as TResult;
  }

  return {
    async list(path, opts): Promise<ListResult> {
      const args = ListArgsSchema.parse({
        path,
        ...(opts?.cursor !== undefined ? { cursor: opts.cursor } : {}),
        ...(opts?.pageSize !== undefined ? { pageSize: opts.pageSize } : {}),
        ...(opts?.filter !== undefined ? { filter: opts.filter } : {}),
      });
      return await roundTrip<ListResult>(
        KIND_LIST_V1,
        'list',
        args,
        ListResultSchema,
        opts,
      );
    },

    async stat(path, opts): Promise<StatResult> {
      const args = StatArgsSchema.parse({ path });
      return await roundTrip<StatResult>(KIND_STAT_V1, 'stat', args, StatResultSchema, opts);
    },

    async mkdir(path, opts): Promise<MkdirResult> {
      const args = MkdirArgsSchema.parse({
        path,
        ...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}),
      });
      return await roundTrip<MkdirResult>(
        KIND_MKDIR_V1,
        'mkdir',
        args,
        MkdirResultSchema,
        opts,
      );
    },

    async delete(path, opts): Promise<DeleteResult> {
      const args = DeleteArgsSchema.parse({
        path,
        ...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}),
      });
      return await roundTrip<DeleteResult>(
        KIND_DELETE_V1,
        'delete',
        args,
        DeleteResultSchema,
        opts,
      );
    },

    async move(src, dst, opts): Promise<MoveResult> {
      const args = MoveArgsSchema.parse({
        src,
        dst,
        ...(opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : {}),
      });
      return await roundTrip<MoveResult>(KIND_MOVE_V1, 'move', args, MoveResultSchema, opts);
    },

    async read(path, opts: ReadOpts = {}): Promise<ReadOutput> {
      const args = ReadArgsSchema.parse({
        path,
        ...(opts.range !== undefined ? { range: opts.range } : {}),
        ...(opts.preferInline !== undefined ? { preferInline: opts.preferInline } : {}),
      });
      const wire = await roundTrip<import('../schemas/ops.js').ReadResult>(
        KIND_READ_V1,
        'read',
        args,
        ReadResultSchema,
        opts,
      );
      if (wire.kind === 'inline') {
        const bytes = base64ToBytes(wire.bytesB64);
        const out: ReadOutput = {
          kind: 'inline',
          bytes,
          size: wire.size,
          sha256: wire.sha256,
          ...(wire.contentType !== undefined ? { contentType: wire.contentType } : {}),
        };
        return out;
      }
      // Streamed read — only supported when pull-mode was configured.
      // `drainer` is assigned asynchronously after the streams bridge has
      // subscribed, so checking it here races client construction. The
      // promise itself is the synchronous configuration signal; awaiting it
      // also guarantees the drainer has been installed by its `.then()`.
      if (streamsBridgePromise === null) {
        throw new InternalFileError(
          `http RPC client received a streamed read (size ${wire.size}) but is in inline-only mode. Pass { outboundQueueUrl, transferBaseUrl } when constructing the client to enable streamed reads.`,
        );
      }
      const bridge = await getStreamsBridge();
      const bridgeSignal = opts.signal ?? new AbortController().signal;
      const parked = await bridge.awaitRead(wire.streamId, {
        expectedFrom: peerAddress,
        signal: bridgeSignal,
        timeoutMs: ioTimeoutMs,
      });
      const out: ReadOutput = {
        kind: 'streams',
        stream: parked.readable,
        size: wire.size,
        sha256: wire.sha256,
        ...(wire.contentType !== undefined ? { contentType: wire.contentType } : {}),
        done: async () => {
          await parked.done;
        },
      };
      return out;
    },

    async write(path, input: WriteSource, opts: WriteOpts = {}): Promise<WriteResult> {
      const decision = await decideInline(input);
      const overwrite = opts.overwrite ?? false;
      const contentType = opts.contentType ?? decision.contentType;

      if (decision.kind === 'inline' || opts.forceInline === true) {
        const bytes = decision.kind === 'inline' ? decision.bytes : null;
        if (bytes === null) {
          // forceInline === true with a streams-typed decision —
          // decideInline always produced a `streams` shape because the
          // input was a bare ReadableStream. We can't drain a stream
          // synchronously here without a streams-bridge.
          throw new ConflictError(
            'http RPC client cannot forceInline a streamed input — pass a Uint8Array / Blob, or pre-buffer the stream.',
          );
        }
        if (bytes.byteLength > INLINE_THRESHOLD) {
          throw new ConflictError(
            `inline write exceeds ${INLINE_THRESHOLD}-byte threshold (got ${bytes.byteLength}); pass forceInline=true to override`,
          );
        }
        const args = WriteArgsSchema.parse({
          kind: 'inline',
          path,
          bytesB64: bytesToBase64(bytes),
          ...(contentType !== undefined ? { contentType } : {}),
          overwrite,
        });
        return await roundTrip<WriteResult>(
          KIND_WRITE_V1,
          'write',
          args,
          WriteResultSchema,
          opts,
        );
      }

      // Streamed write — requires pull-mode configuration. Do not inspect
      // `drainer` as a readiness flag: bridge registration is asynchronous,
      // and an immediate large write must wait for it instead of being
      // misclassified as an inline-only client.
      if (streamsBridgePromise === null) {
        throw new ConflictError(
          `http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Pass { outboundQueueUrl, transferBaseUrl } to enable streamed writes.`,
        );
      }
      const bridge = await getStreamsBridge();
      const size = decision.size;
      if (size === undefined) {
        throw new ConflictError(
          'streams write requires a known plaintext size; pass `{ stream, size }` instead of a bare ReadableStream',
        );
      }
      const { writeId, handle } = await bridge.initiateWrite({
        peer: peerAddress,
        stream: decision.stream,
        size,
        ...(contentType !== undefined ? { contentType } : {}),
        name: path,
        ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
      });
      const args = WriteArgsSchema.parse({
        kind: 'streams',
        path,
        size,
        ...(contentType !== undefined ? { contentType } : {}),
        overwrite,
        writeId,
      });
      try {
        const [result] = await Promise.all([
          roundTrip<WriteResult>(KIND_WRITE_V1, 'write', args, WriteResultSchema, opts),
          handle.done(),
        ]);
        return result;
      } catch (err) {
        await handle.abort('rpc-failed').catch(() => undefined);
        throw err;
      }
    },

    async getThumbnail(path, size: ThumbnailSize, opts): Promise<ThumbnailResult> {
      const args = GetThumbnailArgsSchema.parse({
        path,
        size,
        ...(opts?.format !== undefined ? { format: opts.format } : {}),
      });
      const raw = await roundTrip<import('../schemas/ops.js').GetThumbnailResult>(
        KIND_GET_THUMBNAIL_V1,
        'getThumbnail',
        args,
        GetThumbnailResultSchema,
        opts,
      );
      return {
        bytes: base64ToBytes(raw.bytesB64),
        format: raw.format,
        width: raw.width,
        height: raw.height,
        sha256: raw.sha256,
      };
    },

    async custom(name, args, opts?: BaseOpts): Promise<unknown> {
      const wireArgs = CustomArgsSchema.parse({ name, args });
      return await roundTrip(KIND_CUSTOM_V1, 'custom', wireArgs, CustomResultSchema, opts);
    },

    close(): void {
      // Stop the long-poll drainer + tear down the streams-bridge if
      // we built one. Idempotent — safe to call multiple times.
      drainer?.stop();
      drainer = null;
      if (streamsBridge !== null) {
        void streamsBridge.destroy().catch(() => undefined);
        streamsBridge = null;
      }
      streamsBridgePromise = null;
    },
  } as FileClient;
}
