import type { CryptoProvider, ShadeEnvelope } from '@shade/core';
import {
  NetworkError,
  toBase64,
  fromBase64,
  ShadeError,
  ValidationError,
} from '@shade/core';
import { signPayload } from '@shade/server';
import { encodeEnvelope, decodeEnvelope } from '@shade/proto';
import { computeMsgId } from './msg-id.js';

/**
 * Low-level HTTP client for `@shade/inbox-server`.
 *
 * Stateless and reusable across many recipients. Higher-level orchestration
 * (queue, poll loop, ack-on-decrypt) lives in `Inbox` (see `inbox.ts`),
 * which composes this client.
 */
export interface InboxClientOptions {
  baseUrl: string;
  crypto: CryptoProvider;
  /** Used to sign requests on behalf of *this* identity. */
  signingPrivateKey: Uint8Array;
  /** Optional fetch override (defaults to globalThis.fetch). */
  fetch?: typeof fetch;
}

export interface PutResult {
  msgId: string;
  receivedAt: number;
  idempotent: boolean;
}

export interface FetchedBlob {
  msgId: string;
  /** Wire-encoded ShadeEnvelope bytes. */
  ciphertext: Uint8Array;
  /** Server-assigned monotonic timestamp; pass back as `sinceCursor`. */
  receivedAt: number;
  /** Absolute expiry time (ms since epoch) reported by the server. */
  expiresAt: number;
  /**
   * Sender fingerprint — 8-byte hex of SHA-256(senderSigningKey),
   * captured by the relay at PUT time when the sender's signature was
   * verified. Empty/undefined when the relay is pre-4.8 or the blob
   * predates sender-fingerprint tracking. Use as an unknown-sender
   * bootstrap label (`fp:<hex>`) for X3DH first-contact; the
   * authoritative sender identity is recovered post-decrypt from the
   * envelope itself, so `from` is a hint, not a trust anchor.
   */
  from?: string;
}

export interface FetchResult {
  blobs: FetchedBlob[];
  cursor: number;
  hasMore: boolean;
}

export class InboxClient {
  private readonly fetchImpl: typeof fetch;

  constructor(private readonly options: InboxClientOptions) {
    // Bind once. The browser's `globalThis.fetch` is a WebIDL bound
    // operation that throws "Illegal invocation" when called as a method
    // on another object (which is what `this.fetchImpl(...)` does).
    // Node/Bun fetch tolerates a free receiver, but binding is harmless.
    // A consumer-supplied `options.fetch` is bound to the global too —
    // a fetch that requires a specific receiver must bind itself.
    const f = options.fetch ?? globalThis.fetch;
    this.fetchImpl = f.bind(globalThis);
  }

  /**
   * TOFU-register the address that this signing key will own.
   * Idempotent if the same key re-registers; rejected by the server if a
   * different key has already claimed the address.
   */
  async register(args: {
    address: string;
    signingKey: Uint8Array;
  }): Promise<void> {
    const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
      address: args.address,
      signingKey: toBase64(args.signingKey),
    });
    await this.postJson(`/v1/inbox/register`, body);
  }

  /**
   * Unregister the address. Drops every queued blob. Signed by the
   * registered signing key.
   */
  async unregister(address: string): Promise<void> {
    const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
      address,
    });
    await this.requestJson('DELETE', `/v1/inbox/register/${encodeURIComponent(address)}`, body);
  }

  /**
   * PUT a ShadeEnvelope to a recipient's inbox. Idempotent: same
   * ciphertext yields the same msgId and the server folds the second PUT
   * into a 200 with `idempotent: true`.
   */
  async put(args: {
    /** Recipient address (inbox owner). */
    recipientAddress: string;
    /** Sender's identity signing public key (the one matching `signingPrivateKey`). */
    senderSigningKey: Uint8Array;
    /** Encrypted Shade envelope. Either pre-encoded bytes or a parsed envelope. */
    envelope: ShadeEnvelope | Uint8Array;
    ttlSeconds?: number;
  }): Promise<PutResult> {
    const ciphertext =
      args.envelope instanceof Uint8Array ? args.envelope : encodeEnvelope(args.envelope);
    if (ciphertext.length === 0) {
      throw new ValidationError('Empty ciphertext');
    }
    const msgId = await computeMsgId(ciphertext);

    const payload: Record<string, unknown> = {
      senderSigningKey: toBase64(args.senderSigningKey),
      msgId,
      ciphertext: toBase64(ciphertext),
    };
    if (args.ttlSeconds !== undefined) payload.ttlSeconds = args.ttlSeconds;

    const signed = await signPayload(
      this.options.crypto,
      this.options.signingPrivateKey,
      payload,
    );

    const json = await this.postJson(
      `/v1/inbox/${encodeURIComponent(args.recipientAddress)}`,
      signed,
    );
    return {
      msgId: String(json.msgId),
      receivedAt: Number(json.receivedAt),
      idempotent: Boolean(json.idempotent),
    };
  }

  /**
   * Fetch all blobs for `address` whose `received_at > sinceCursor`.
   * Returns at most one server page; clients keep calling with the new
   * cursor until `hasMore === false`.
   */
  async fetch(args: { address: string; sinceCursor?: number }): Promise<FetchResult> {
    const sinceCursor = args.sinceCursor ?? 0;
    const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
      address: args.address,
      sinceCursor,
    });
    const json = await this.postJson(
      `/v1/inbox/${encodeURIComponent(args.address)}/fetch`,
      body,
    );
    const blobs = Array.isArray(json.blobs) ? json.blobs : [];
    return {
      blobs: blobs.map((b: any): FetchedBlob => {
        const out: FetchedBlob = {
          msgId: String(b.msgId),
          ciphertext: fromBase64(String(b.ciphertext)),
          receivedAt: Number(b.receivedAt),
          expiresAt: Number(b.expiresAt),
        };
        // V4.8 — relay-supplied sender fingerprint hint. Optional; absent
        // on pre-4.8 relays or for blobs persisted before tracking landed.
        if (typeof b.from === 'string' && b.from.length > 0) out.from = b.from;
        return out;
      }),
      cursor: Number(json.cursor ?? sinceCursor),
      hasMore: Boolean(json.hasMore),
    };
  }

  /**
   * Acknowledge — delete a fetched blob. Should be called after the
   * caller has successfully decrypted (or persisted) the message.
   */
  async ack(args: { address: string; msgId: string }): Promise<boolean> {
    const body = await signPayload(this.options.crypto, this.options.signingPrivateKey, {
      address: args.address,
      msgId: args.msgId,
    });
    const json = await this.requestJson(
      'DELETE',
      `/v1/inbox/${encodeURIComponent(args.address)}/${encodeURIComponent(args.msgId)}`,
      body,
    );
    return Boolean(json.ok);
  }

  // ─── HTTP plumbing ──────────────────────────────────────────

  private async postJson(path: string, body: unknown): Promise<any> {
    return this.requestJson('POST', path, body);
  }

  private async requestJson(method: string, path: string, body: unknown): Promise<any> {
    const url = joinUrl(this.options.baseUrl, path);
    let res: Response;
    try {
      res = await this.fetchImpl(url, {
        method,
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body),
      });
    } catch (err) {
      throw new NetworkError(`Inbox request failed: ${(err as Error).message}`);
    }
    const text = await res.text();
    let json: any;
    try {
      json = text.length > 0 ? JSON.parse(text) : {};
    } catch {
      throw new NetworkError(`Inbox response not JSON: ${text.slice(0, 200)}`, res.status);
    }
    if (!res.ok) {
      // Surface server-mapped Shade errors in their original shape.
      const code = String(json.code ?? '');
      const message = String(json.message ?? text);
      throw new ShadeError(code || 'SHADE_NETWORK', message);
    }
    return json;
  }
}

function joinUrl(base: string, path: string): string {
  if (base.endsWith('/') && path.startsWith('/')) return base + path.slice(1);
  if (!base.endsWith('/') && !path.startsWith('/')) return base + '/' + path;
  return base + path;
}

/** Decode a fetched blob into a ShadeEnvelope. */
export function decodeFetchedEnvelope(b: FetchedBlob): ShadeEnvelope {
  return decodeEnvelope(b.ciphertext);
}
