/**
 * Wire protocol for `@shade/recovery`.
 *
 * Every payload is a JSON object that fits inside a single
 * `Shade.send(plaintext)` call (we travel over the existing 1:1
 * Double-Ratchet sessions — no new transport). All envelopes carry a
 * `shadeRecovery` discriminator so a guardian-side `Shade.onMessage`
 * handler can cheaply skip non-recovery traffic without misparsing it.
 *
 * Envelope versions:
 *   - v1: initial release (V3.10).
 *
 * Message types:
 *
 *   share-deposit          primary → guardian (during setup)
 *     The guardian receives one Shamir share for the primary user's
 *     identity, plus the AEAD-protected `backupBlob` string (duplicated
 *     to every guardian so any threshold subset can reconstruct).
 *
 *   recovery-request       new-device → guardian
 *     Asks the guardian to release its stored share. The new device's
 *     temporary identity fingerprint is included so the guardian can
 *     OOB-confirm before approving.
 *
 *   share-grant            guardian → new-device
 *     The guardian's response when its approve handler returns true:
 *     ships the share + backupBlob back to the new device.
 *
 *   share-decline          guardian → new-device
 *     The guardian's response when the user (or a hard policy) refused
 *     to release the share. Carries a short reason for diagnostics.
 *
 * The encoder is deliberately a thin JSON serializer with stable keys —
 * we do NOT need canonical hashing here because every payload is already
 * authenticated by the underlying Double-Ratchet AEAD. Stability matters
 * only for forward-compatible parsing (tolerate future, additive fields
 * but reject malformed ones).
 *
 * `backupBlob` is opaque from this module's perspective — it's whatever
 * `Shade.exportBackup(passphrase, addresses)` produces, where the
 * `passphrase` is a base64url encoding of the random `recoveryKey` that
 * was Shamir-split. The new device combines shares to recover the key,
 * derives the same passphrase, and calls `Shade.importBackup`. The
 * AES-GCM authentication tag inside the backup blob doubles as the
 * sentinel for a successful reconstruction.
 */

import { RecoveryProtocolError } from './errors.js';

export const RECOVERY_PROTOCOL_VERSION = 1;
export const RECOVERY_DISCRIMINATOR = 'shadeRecovery';

export type RecoveryMessageType =
  | 'share-deposit'
  | 'recovery-request'
  | 'share-grant'
  | 'share-decline';

interface BaseEnvelope<T extends RecoveryMessageType> {
  shadeRecovery: 1;
  type: T;
  /** Stable per-flow identifier; correlates request → grant/decline. */
  flowId: string;
}

export interface ShareDepositEnvelope extends BaseEnvelope<'share-deposit'> {
  /** Address of the primary (the user being protected). */
  originalAddress: string;
  /** Stable session id — primary chooses; same across all guardians in this setup. */
  setupId: string;
  /** Threshold this setup was created with. */
  threshold: number;
  /** Number of guardians in this setup. */
  guardianCount: number;
  /** Index of THIS guardian in 1..guardianCount. Matches the Shamir x-coordinate. */
  shareIndex: number;
  /** Base64-encoded `encodeShare(...)` bytes (1 byte x + N bytes y). */
  shareBytes: string;
  /**
   * Output of `Shade.exportBackup(passphrase, knownAddresses)`. Opaque
   * string of the form `shade-backup:v1:<salt>:<nonce>:<ciphertext>`.
   * Stored verbatim by the guardian and shipped back on
   * `share-grant`. Identical for every guardian in the same setup.
   */
  backupBlob: string;
  /** Original-device fingerprint at setup time — guardians can persist for sanity-checks. */
  setupFingerprint: string;
  /** Unix-ms timestamp at setup time. */
  createdAt: number;
}

export interface RecoveryRequestEnvelope extends BaseEnvelope<'recovery-request'> {
  /** Address of the original (lost) identity. The guardian uses this to look up its stored share. */
  originalAddress: string;
  /** Setup id for the share the new device wants. Lets a guardian disambiguate multiple deposits. */
  setupId: string;
  /** Safety number of the *temporary* identity making the request. */
  requesterFingerprint: string;
  /** Unix-ms timestamp; receiver MAY enforce a freshness window. */
  requestedAt: number;
}

export interface ShareGrantEnvelope extends BaseEnvelope<'share-grant'> {
  /** Echoes the request's originalAddress so the receiver can route it. */
  originalAddress: string;
  /** Echoes setupId. */
  setupId: string;
  /** Index 1..guardianCount — same as the deposit's shareIndex. */
  shareIndex: number;
  /** Base64-encoded `encodeShare(...)` bytes. */
  shareBytes: string;
  /** Verbatim copy of the backup blob the guardian stored at setup time. */
  backupBlob: string;
}

export interface ShareDeclineEnvelope extends BaseEnvelope<'share-decline'> {
  originalAddress: string;
  setupId: string;
  /** Short, user-visible reason ("fingerprint mismatch", "user declined", "no share for this address"). */
  reason: string;
}

export type RecoveryEnvelope =
  | ShareDepositEnvelope
  | RecoveryRequestEnvelope
  | ShareGrantEnvelope
  | ShareDeclineEnvelope;

/**
 * Serialize an envelope to the JSON string that goes through
 * `Shade.send`. Throws if the input is structurally invalid (defensive —
 * shouldn't fire under normal flows, but cheap to keep).
 */
export function encodeRecoveryEnvelope(env: RecoveryEnvelope): string {
  validateEnvelope(env);
  return JSON.stringify(env);
}

/**
 * Parse a string payload from `Shade.onMessage`. Returns `null` when the
 * payload is not a recovery envelope (so guardian apps can multiplex
 * recovery + non-recovery traffic on the same handler). Throws
 * `RecoveryProtocolError` on payloads that *claim* to be recovery
 * envelopes but are malformed.
 */
export function tryParseRecoveryEnvelope(plaintext: string): RecoveryEnvelope | null {
  let parsed: unknown;
  try {
    parsed = JSON.parse(plaintext);
  } catch {
    return null;
  }
  if (!isRecord(parsed)) return null;
  if (parsed[RECOVERY_DISCRIMINATOR] !== 1) return null;
  if (typeof parsed.type !== 'string') {
    throw new RecoveryProtocolError('recovery envelope missing string `type` field');
  }
  validateEnvelope(parsed as unknown as RecoveryEnvelope);
  return parsed as unknown as RecoveryEnvelope;
}

function validateEnvelope(envIn: RecoveryEnvelope): void {
  const env = envIn as unknown as Record<string, unknown>;
  if (env.shadeRecovery !== 1) {
    throw new RecoveryProtocolError(
      `unsupported recovery envelope version: ${env.shadeRecovery as unknown as string}`,
    );
  }
  if (typeof env.flowId !== 'string' || (env.flowId as string).length === 0) {
    throw new RecoveryProtocolError('recovery envelope missing flowId');
  }
  switch (env.type) {
    case 'share-deposit': {
      requireString(env, 'originalAddress');
      requireString(env, 'setupId');
      requireFiniteInt(env, 'threshold', 1, 255);
      requireFiniteInt(env, 'guardianCount', 1, 255);
      const threshold = env.threshold as number;
      const guardianCount = env.guardianCount as number;
      if (threshold > guardianCount) {
        throw new RecoveryProtocolError('share-deposit: threshold > guardianCount');
      }
      requireFiniteInt(env, 'shareIndex', 1, guardianCount);
      requireString(env, 'shareBytes');
      requireString(env, 'backupBlob');
      requireString(env, 'setupFingerprint');
      requireFinite(env, 'createdAt');
      break;
    }
    case 'recovery-request':
      requireString(env, 'originalAddress');
      requireString(env, 'setupId');
      requireString(env, 'requesterFingerprint');
      requireFinite(env, 'requestedAt');
      break;
    case 'share-grant':
      requireString(env, 'originalAddress');
      requireString(env, 'setupId');
      requireFiniteInt(env, 'shareIndex', 1, 255);
      requireString(env, 'shareBytes');
      requireString(env, 'backupBlob');
      break;
    case 'share-decline':
      requireString(env, 'originalAddress');
      requireString(env, 'setupId');
      requireString(env, 'reason');
      break;
    default:
      throw new RecoveryProtocolError(
        `unknown recovery envelope type: ${env.type as unknown as string}`,
      );
  }
}

function isRecord(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

function requireString(env: Record<string, unknown>, key: string): void {
  const v = env[key];
  if (typeof v !== 'string' || v.length === 0) {
    throw new RecoveryProtocolError(`recovery envelope missing string field "${key}"`);
  }
}

function requireFinite(env: Record<string, unknown>, key: string): void {
  const v = env[key];
  if (typeof v !== 'number' || !Number.isFinite(v)) {
    throw new RecoveryProtocolError(`recovery envelope missing numeric field "${key}"`);
  }
}

function requireFiniteInt(
  env: Record<string, unknown>,
  key: string,
  min: number,
  max: number,
): void {
  const v = env[key];
  if (typeof v !== 'number' || !Number.isInteger(v) || v < min || v > max) {
    throw new RecoveryProtocolError(
      `recovery envelope field "${key}" must be an integer in [${min}, ${max}]`,
    );
  }
}
