import type { CryptoProvider } from '@shade/core';
import { ValidationError } from '@shade/core';
import {
  deriveBlobSlotId,
  deriveBlobKey,
  deriveBlobSigningSeed,
  aeadSeal,
  aeadOpen,
} from '@shade/storage-encrypted/crypto';
import { ed25519PublicKeyFromSeed } from '@shade/crypto-web';
import { BlobClient, slotIdToHex } from '@shade/inbox';

/**
 * V4.9 — relay-side encrypted profile storage.
 *
 * The `Profile` namespace lets a Shade-based app store a small,
 * AEAD-sealed JSON blob on the relay keyed by a deterministic slotId
 * derived from the user's master key. A brand new device that knows
 * only the credentials (password + PIN → masterKey via the existing
 * `@shade/storage-encrypted` KDF) can locate, decrypt, and update the
 * blob. The relay sees only opaque slotIds and AEAD-sealed bytes — it
 * never decrypts and cannot link slots to users.
 *
 * This is the *primitive* Prism uses for credential-driven device
 * linking (Phase 2 of the Prism device-linking plan): the blob holds
 * the list of paired hosts, the new device reads it, picks the first
 * online host, and starts a link-request handshake. But it's
 * deliberately app-shaped — any Shade app needing a credential-only
 * bootstrap into existing E2EE state can use it. Pass a different
 * `app` namespace string per use-case so two apps under the same
 * master never collide on the same slot.
 *
 * Usage:
 *   const km = await KeyManager.unlock(...);          // existing v4.5 flow
 *   const profile = createProfileNamespace({
 *     baseUrl: 'https://shade.example/',
 *     crypto: new SubtleCryptoProvider(),
 *     masterKey: km.masterKey,
 *     app: 'prism-profile-v1',
 *   });
 *
 *   const current = await profile.get();
 *   //  -> { plaintext: Uint8Array, etag: string } | null
 *
 *   await profile.put(JSON.stringify({ hosts: [...] }), {
 *     ifMatch: current?.etag,
 *   });
 *
 *   await profile.delete();   // "forget everything"
 */

export interface ProfileNamespaceOptions {
  /** Base URL of the Shade relay. */
  baseUrl: string;
  /** CryptoProvider — typically a fresh SubtleCryptoProvider instance. */
  crypto: CryptoProvider;
  /**
   * 32-byte master key, exactly the value you'd hand to
   * `@shade/storage-encrypted`'s row-codec — the existing v4.5 KDF
   * chain (passphrase + scrypt → masterKey, possibly upgraded with
   * argon2id over a PIN) lands you here. Profile storage uses HKDF
   * subderivations under separate `info` strings, so it can't leak
   * the storage encryption key or vice versa.
   */
  masterKey: Uint8Array;
  /**
   * Per-app namespace string. Distinct apps under the same master key
   * MUST pass different values so they don't collide on the same slot.
   * Convention: `"<app-id>-<purpose>-<schema-version>"`, e.g.
   * `"prism-profile-v1"`.
   */
  app: string;
  /** Optional fetch override (defaults to globalThis.fetch). */
  fetch?: typeof fetch;
}

export interface ProfileGetResult {
  /** Decrypted plaintext bytes. The shape is up to the caller. */
  plaintext: Uint8Array;
  /** Pass back as `ifMatch` to do a CAS update. */
  etag: string;
  /** Wall-clock ms when the relay last accepted a write. */
  updatedAt: number;
}

export interface ProfilePutOptions {
  /**
   * - `undefined`        : create-only. Slot must be empty (else 409).
   * - `<etag-string>`    : compare-and-swap. Must match current etag (else 412).
   * - `'*'`              : unconditional overwrite. Slot must already exist (else 412).
   */
  ifMatch?: string;
}

export interface ProfilePutResult {
  /** 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 interface ProfileNamespace {
  readonly slotIdHex: string;
  get(): Promise<ProfileGetResult | null>;
  put(plaintext: Uint8Array | string, options?: ProfilePutOptions): Promise<ProfilePutResult>;
  delete(): Promise<boolean>;
}

const TEXT = new TextEncoder();
const TEXT_DECODER = new TextDecoder();

export function createProfileNamespace(
  options: ProfileNamespaceOptions,
): ProfileNamespace {
  if (options.masterKey.length !== 32) {
    throw new ValidationError('masterKey must be 32 bytes');
  }
  if (options.app.length === 0) {
    throw new ValidationError('app namespace must be non-empty');
  }

  const slotIdBytes = deriveBlobSlotId(options.masterKey, options.app);
  const slotIdHex = slotIdToHex(slotIdBytes);
  const blobKey = deriveBlobKey(options.masterKey, options.app);
  const signingSeed = deriveBlobSigningSeed(options.masterKey, options.app);
  const ownerPubkey = ed25519PublicKeyFromSeed(signingSeed);

  // AAD binds the slotId into the AEAD seal: a relay returning the
  // wrong slot's blob (mistake or malice) fails to open. The slotId is
  // already part of the URL path, but binding it cryptographically
  // prevents any kind of cross-slot replay regardless of how the bytes
  // got to us.
  const aad = TEXT.encode(`shade-profile-aad-v1:${slotIdHex}`);

  const clientOptions: ConstructorParameters<typeof BlobClient>[0] = {
    baseUrl: options.baseUrl,
    crypto: options.crypto,
  };
  if (options.fetch) clientOptions.fetch = options.fetch;
  const client = new BlobClient(clientOptions);

  return {
    slotIdHex,

    async get(): Promise<ProfileGetResult | null> {
      const result = await client.get(slotIdHex);
      if (!result) return null;
      // Deterministic 12-byte nonce from (slotId, etag): the relay
      // stores `nonce || ct||tag` as one blob, so the AEAD layer
      // pulls the nonce off the front. We don't pre-compute it —
      // aeadOpen handles the prefix automatically.
      const plaintext = await aeadOpen(blobKey, result.blob, aad);
      return {
        plaintext,
        etag: result.etag,
        updatedAt: result.updatedAt,
      };
    },

    async put(
      plaintext: Uint8Array | string,
      options?: ProfilePutOptions,
    ): Promise<ProfilePutResult> {
      const ptBytes =
        typeof plaintext === 'string' ? TEXT.encode(plaintext) : plaintext;
      // Random per-write 12-byte nonce. We don't reuse a deterministic
      // nonce because two consecutive writes of the same plaintext
      // (rare but possible — re-uploading after a transient error)
      // would otherwise reuse (key, nonce, plaintext), which is a
      // nonce-reuse condition for AES-GCM. A fresh random nonce per
      // PUT keeps each AEAD invocation unique.
      const nonce = clientOptions.crypto.randomBytes(12);
      const sealed = await aeadSeal(blobKey, nonce, ptBytes, aad);

      const putArgs: Parameters<BlobClient['put']>[0] = {
        slotIdHex,
        blob: sealed,
        signingPrivateKey: signingSeed,
        ownerPubkey,
      };
      if (options?.ifMatch !== undefined) putArgs.ifMatch = options.ifMatch;
      return client.put(putArgs);
    },

    async delete(): Promise<boolean> {
      return client.delete({
        slotIdHex,
        signingPrivateKey: signingSeed,
      });
    },
  };
}

// Re-export the raw KDF helpers so apps that want to drive a custom
// flow (skip the AEAD layer, use a different client, run interop
// against a non-Shade relay) don't have to re-import from
// `@shade/storage-encrypted/crypto`.
export {
  deriveBlobSlotId,
  deriveBlobKey,
  deriveBlobSigningSeed,
} from '@shade/storage-encrypted/crypto';
export { ed25519PublicKeyFromSeed } from '@shade/crypto-web';
export { slotIdToHex } from '@shade/inbox';

/** Decode a UTF-8 plaintext from a `ProfileGetResult`. */
export function profilePlaintextToString(result: ProfileGetResult): string {
  return TEXT_DECODER.decode(result.plaintext);
}
