import type { CryptoProvider } from '@shade/core';
import {
  NetworkError,
  toBase64,
  fromBase64,
  ShadeError,
  ValidationError,
} from '@shade/core';
import { signPayload } from '@shade/server';

/**
 * Low-level HTTP client for the V4.9 encrypted-blob primitive
 * (`/v1/blob/<slotId>`). Stateless and reusable; higher-level wrappers
 * (e.g. `Profile` in `@shade/sdk`) compose this client.
 *
 * The client doesn't care what the blob bytes mean — it just transports
 * them. Callers are responsible for AEAD-sealing/opening, deriving the
 * slotId from the master key, and managing the signing key.
 */
export interface BlobClientOptions {
  baseUrl: string;
  crypto: CryptoProvider;
  /** Optional fetch override (defaults to globalThis.fetch). */
  fetch?: typeof fetch;
}

export interface BlobGetResult {
  blob: Uint8Array;
  /** ETag string — pass back as `ifMatch` to do a CAS update. */
  etag: string;
  updatedAt: number;
}

export interface BlobPutResult {
  /** True if this PUT created the slot, false if it updated an existing one. */
  created: boolean;
  /** New ETag after the write. */
  etag: string;
  updatedAt: number;
}

export class BlobClient {
  private readonly fetchImpl: typeof fetch;

  constructor(private readonly options: BlobClientOptions) {
    const f = options.fetch ?? globalThis.fetch;
    this.fetchImpl = f.bind(globalThis);
  }

  /**
   * Read a slot. Returns null if no blob has ever been written there
   * (or if it was DELETE'd). GET is unauthenticated — see the
   * `BlobStore` JSDoc for the threat-model rationale.
   */
  async get(slotIdHex: string): Promise<BlobGetResult | null> {
    validateSlotIdHex(slotIdHex);
    const url = joinUrl(this.options.baseUrl, `/v1/blob/${slotIdHex}`);
    let res: Response;
    try {
      res = await this.fetchImpl(url, { method: 'GET' });
    } catch (err) {
      throw new NetworkError(`Blob GET failed: ${(err as Error).message}`);
    }
    if (res.status === 404) return null;
    const text = await res.text();
    let json: any;
    try {
      json = text.length > 0 ? JSON.parse(text) : {};
    } catch {
      throw new NetworkError(`Blob response not JSON: ${text.slice(0, 200)}`, res.status);
    }
    if (!res.ok) {
      throw new ShadeError(
        String(json.code ?? 'SHADE_NETWORK'),
        String(json.message ?? text),
      );
    }
    return {
      blob: fromBase64(String(json.blob)),
      etag: String(json.etag),
      updatedAt: Number(json.updatedAt),
    };
  }

  /**
   * Create or update a slot.
   *
   * `ifMatch` semantics:
   *  - `undefined`: create-only. Slot must be empty (else 409).
   *  - `<etag-string>`: compare-and-swap. Must match (else 412).
   *  - `'*'`: unconditional overwrite. Slot must already exist (else 412).
   */
  async put(args: {
    slotIdHex: string;
    blob: Uint8Array;
    /** 32-byte Ed25519 seed (== `signingPrivateKey`). */
    signingPrivateKey: Uint8Array;
    /** Pubkey paired to `signingPrivateKey`. */
    ownerPubkey: Uint8Array;
    ifMatch?: string;
  }): Promise<BlobPutResult> {
    validateSlotIdHex(args.slotIdHex);
    if (args.blob.length === 0) {
      throw new ValidationError('Empty blob');
    }
    if (args.ownerPubkey.length !== 32) {
      throw new ValidationError('ownerPubkey must be 32 bytes (Ed25519)');
    }

    const payload: Record<string, unknown> = {
      ownerPubkey: toBase64(args.ownerPubkey),
      blob: toBase64(args.blob),
      slotId: args.slotIdHex,
    };
    if (args.ifMatch !== undefined) payload.ifMatch = args.ifMatch;

    const signed = await signPayload(
      this.options.crypto,
      args.signingPrivateKey,
      payload,
    );
    // `slotId` was used for the signature canonicalization to bind it
    // into the payload; the server rebuilds the same canonical form
    // by mixing the URL slotId back in. Strip it from the wire body
    // so we don't send it twice (URL is the path param).
    const { slotId: _omit, ...wireBody } = signed as Record<string, unknown>;

    const url = joinUrl(this.options.baseUrl, `/v1/blob/${args.slotIdHex}`);
    const json = await this.requestJson('PUT', url, wireBody);
    return {
      created: Boolean(json.created),
      etag: String(json.etag),
      updatedAt: Number(json.updatedAt),
    };
  }

  /**
   * Delete a slot — the next PUT TOFU-claims it again, possibly under
   * a fresh signing key (e.g. after a rotation). Used by the "forget
   * everything" path.
   */
  async delete(args: {
    slotIdHex: string;
    signingPrivateKey: Uint8Array;
  }): Promise<boolean> {
    validateSlotIdHex(args.slotIdHex);
    const signed = await signPayload(this.options.crypto, args.signingPrivateKey, {
      slotId: args.slotIdHex,
    });
    const { slotId: _omit, ...wireBody } = signed as Record<string, unknown>;
    const url = joinUrl(this.options.baseUrl, `/v1/blob/${args.slotIdHex}`);
    const json = await this.requestJson('DELETE', url, wireBody);
    return Boolean(json.ok);
  }

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

  private async requestJson(method: string, url: string, body: unknown): Promise<any> {
    let res: Response;
    try {
      res = await this.fetchImpl(url, {
        method,
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body),
      });
    } catch (err) {
      throw new NetworkError(`Blob 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(`Blob response not JSON: ${text.slice(0, 200)}`, res.status);
    }
    if (!res.ok) {
      throw new ShadeError(
        String(json.code ?? 'SHADE_NETWORK'),
        String(json.message ?? text),
      );
    }
    return json;
  }
}

function validateSlotIdHex(s: string): void {
  if (!/^[0-9a-f]{64}$/.test(s)) {
    throw new ValidationError('slotIdHex must be 64 lowercase hex chars (32 bytes)');
  }
}

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;
}

/** Convert a 32-byte slotId Uint8Array into the lowercase-hex wire form. */
export function slotIdToHex(slotId: Uint8Array): string {
  if (slotId.length !== 32) {
    throw new ValidationError('slotId must be 32 bytes');
  }
  let s = '';
  for (let i = 0; i < slotId.length; i++) {
    s += slotId[i]!.toString(16).padStart(2, '0');
  }
  return s;
}
