import type { CryptoProvider } from '@shade/core';
import { ValidationError } from '@shade/core';

/**
 * V4.10 — cross-host approval routing.
 *
 * Two pieces, both app-level (no relay changes):
 *
 * 1. A canonical schema for the V4.9 profile blob. The blob is opaque to
 *    Shade, but Prism's `cross-host-approval-routing` FR pointed out that
 *    every Shade app that supports credential-driven device linking will
 *    end up reinventing the same `hosts[]` / `clients[]` /
 *    `trustedApproverFingerprints[]` shape. We bless one here so apps can
 *    interop and so the proxy-approval verify path has a stable place to
 *    look up trusted-approver public keys.
 *
 * 2. Build / sign / verify helpers for `approvalNeeded` and
 *    `linkApproveByProxy` frames. The frames themselves are app-defined
 *    payloads sent over the existing `Shade.send` / `Shade.receive`
 *    bilateral E2EE channel — Shade just exposes the signing-payload
 *    canonicalization and Ed25519 plumbing so a fingerprint+signature
 *    bound to a long-term identity key can be verified on any host that
 *    has a fresh copy of the user's profile blob.
 *
 * The proxy-approval signature is *belt-and-suspenders* on top of the
 * E2EE that delivered the frame: the E2EE channel already authenticates
 * the sender's session, but the signature ties the approval explicitly
 * to the approver's long-term identity key. That makes the approval
 * verifiable independently of session state — useful if a host
 * receives a forwarded approval without a prior session, and necessary
 * for replay-resistance in the face of a compromised relay that
 * reorders bilateral sends.
 *
 * Headless servers without a GUI use this with the existing X3DH-on-
 * first-send behavior of `Shade.send` to ask any trusted-approver
 * client (typically a phone) to approve a `linkRequest` from a new
 * device. The approver phone biometrically gates a long-term Ed25519
 * sign, ships the frame back, and the server runs `verifyProxyApproval`
 * against the freshest profile blob before completing pairing.
 */

// ─── Canonical profile-blob schema ─────────────────────────────

/**
 * A host: a device that *receives* `linkRequest` frames from new
 * devices and runs the pairing handshake. Typically a desktop, server,
 * or always-on laptop. Hosts have no special crypto status — they're
 * just where the user's data lives.
 */
export interface ProfileHostEntry {
  /** Shade address (`device:...`). */
  address: string;
  /** Human-friendly name (e.g. "PC", "Server"). */
  name: string;
  /** Open enum: `'desktop' | 'server' | 'laptop' | ...`. */
  kind: string;
  /** Wall-clock ms when this host was added to the profile. */
  addedAt: number;
}

/**
 * A client: a device that *initiates* link/approval flows and may
 * proxy-approve link-requests on behalf of the account when
 * `trustedApprover === true`. Typically a phone or tablet.
 *
 * `identityPublicKey` is the 32-byte Ed25519 long-term identity key.
 * `identityFingerprint` is the human-readable safety-number derived
 * from it (the same value `Shade.getFingerprintFor(address)` returns).
 * Both are stored: the public key is what `verifyProxyApproval` uses
 * to check signatures; the fingerprint is what UIs show users for
 * out-of-band verification.
 */
export interface ProfileClientEntry {
  /** Shade address (`device:...`). */
  address: string;
  /**
   * 32-byte Ed25519 long-term identity public key, lowercase hex
   * (64 chars). This is the key that anchors the client's
   * prekey-bundle and that proxy-approvals are signed with.
   */
  identityPublicKey: string;
  /**
   * Safety-number-style fingerprint of the identity key, exactly the
   * format `computeFingerprint` returns. Stored for fast denormalized
   * lookups and so UIs don't have to recompute it.
   */
  identityFingerprint: string;
  /** Human-friendly name (e.g. "iPhone 15"). */
  name: string;
  /** Open enum: `'mobile' | 'tablet' | 'browser' | ...`. */
  kind: string;
  /** Wall-clock ms when this client was added to the profile. */
  addedAt: number;
  /**
   * When true, this client is allowed to proxy-approve `linkRequest`s
   * that arrive at any host. Toggled via the workstation's
   * Settings → Devices UI. Hosts MUST verify against the freshest
   * profile blob before honoring an approval (to close the
   * revocation TOCTOU window — see FR §5).
   */
  trustedApprover?: boolean;
}

export interface CanonicalProfileBlob {
  /** Schema version. Bump when the shape changes incompatibly. */
  version: 1;
  hosts: ProfileHostEntry[];
  clients: ProfileClientEntry[];
  /**
   * Denormalized list of `clients[].identityFingerprint` values where
   * `trustedApprover === true`. Hosts use this for the fast-path
   * "is X allowed to approve?" check; the authoritative source is
   * still the per-client `trustedApprover` flag.
   */
  trustedApproverFingerprints: string[];
  /** Wall-clock ms of the last write that produced this blob. */
  updatedAt: number;
  /**
   * Optional hex-encoded pubkey that wrote this blob. Mirrors the
   * profile-storage owner-pubkey but kept in-band so apps that need
   * to display "last edited by X" don't have to round-trip the
   * relay. Not used for verification — the relay's TOFU on
   * owner-pubkey is the authoritative auth boundary.
   */
  signedBy?: string;
}

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

/** Build a fresh empty profile blob (timestamp = `now ?? Date.now()`). */
export function emptyCanonicalProfile(now?: number): CanonicalProfileBlob {
  return {
    version: 1,
    hosts: [],
    clients: [],
    trustedApproverFingerprints: [],
    updatedAt: now ?? Date.now(),
  };
}

/**
 * Decode a profile-blob plaintext (the `plaintext` of a
 * `ProfileGetResult`) into the canonical shape. Throws
 * `ValidationError` on malformed JSON or wrong shape.
 *
 * Forward-compatibility: unknown top-level fields are preserved so a
 * device on an older version can round-trip a blob written by a
 * newer device without losing data. Unknown fields inside `hosts[]` /
 * `clients[]` entries are also preserved.
 */
export function parseCanonicalProfile(
  plaintext: Uint8Array | string,
): CanonicalProfileBlob {
  const text =
    typeof plaintext === 'string' ? plaintext : TEXT_DECODER.decode(plaintext);
  let json: unknown;
  try {
    json = JSON.parse(text);
  } catch (err) {
    throw new ValidationError(
      `profile blob is not valid JSON: ${(err as Error).message}`,
    );
  }
  if (!json || typeof json !== 'object' || Array.isArray(json)) {
    throw new ValidationError('profile blob must be a JSON object');
  }
  const obj = json as Record<string, unknown>;
  if (obj.version !== 1) {
    throw new ValidationError(
      `unsupported profile blob version: ${String(obj.version)}`,
    );
  }
  const hosts = validateArray(obj.hosts, 'hosts', validateHostEntry);
  const clients = validateArray(obj.clients, 'clients', validateClientEntry);
  const tApp = obj.trustedApproverFingerprints;
  const trustedApproverFingerprints = Array.isArray(tApp)
    ? tApp.map((v, i) => {
        if (typeof v !== 'string') {
          throw new ValidationError(
            `trustedApproverFingerprints[${i}] must be a string`,
          );
        }
        return v;
      })
    : [];
  const updatedAt =
    typeof obj.updatedAt === 'number' && Number.isFinite(obj.updatedAt)
      ? obj.updatedAt
      : 0;

  const out: CanonicalProfileBlob = {
    version: 1,
    hosts,
    clients,
    trustedApproverFingerprints,
    updatedAt,
  };
  if (typeof obj.signedBy === 'string') out.signedBy = obj.signedBy;
  return out;
}

/** Serialize a profile blob to UTF-8 JSON ready for `Profile.put`. */
export function serializeCanonicalProfile(
  blob: CanonicalProfileBlob,
): Uint8Array {
  return TEXT.encode(JSON.stringify(blob));
}

function validateArray<T>(
  v: unknown,
  field: string,
  validate: (entry: unknown, index: number, field: string) => T,
): T[] {
  if (v === undefined) return [];
  if (!Array.isArray(v)) {
    throw new ValidationError(`${field} must be an array`);
  }
  return v.map((entry, i) => validate(entry, i, field));
}

function validateHostEntry(
  entry: unknown,
  index: number,
  field: string,
): ProfileHostEntry {
  if (!entry || typeof entry !== 'object') {
    throw new ValidationError(`${field}[${index}] must be an object`);
  }
  const e = entry as Record<string, unknown>;
  return {
    address: requireString(e.address, `${field}[${index}].address`),
    name: requireString(e.name, `${field}[${index}].name`),
    kind: requireString(e.kind, `${field}[${index}].kind`),
    addedAt: requireNumber(e.addedAt, `${field}[${index}].addedAt`),
  };
}

function validateClientEntry(
  entry: unknown,
  index: number,
  field: string,
): ProfileClientEntry {
  if (!entry || typeof entry !== 'object') {
    throw new ValidationError(`${field}[${index}] must be an object`);
  }
  const e = entry as Record<string, unknown>;
  const identityPublicKey = requireString(
    e.identityPublicKey,
    `${field}[${index}].identityPublicKey`,
  );
  if (!/^[0-9a-f]{64}$/.test(identityPublicKey)) {
    throw new ValidationError(
      `${field}[${index}].identityPublicKey must be 64 lowercase hex chars`,
    );
  }
  const out: ProfileClientEntry = {
    address: requireString(e.address, `${field}[${index}].address`),
    identityPublicKey,
    identityFingerprint: requireString(
      e.identityFingerprint,
      `${field}[${index}].identityFingerprint`,
    ),
    name: requireString(e.name, `${field}[${index}].name`),
    kind: requireString(e.kind, `${field}[${index}].kind`),
    addedAt: requireNumber(e.addedAt, `${field}[${index}].addedAt`),
  };
  if (typeof e.trustedApprover === 'boolean') {
    out.trustedApprover = e.trustedApprover;
  }
  return out;
}

function requireString(v: unknown, field: string): string {
  if (typeof v !== 'string') {
    throw new ValidationError(`${field} must be a string`);
  }
  return v;
}

function requireNumber(v: unknown, field: string): number {
  if (typeof v !== 'number' || !Number.isFinite(v)) {
    throw new ValidationError(`${field} must be a finite number`);
  }
  return v;
}

// ─── Mutators (immutable; return new blob, never mutate input) ──

/**
 * Insert or replace a host entry by address. Any existing host with
 * the same address is overwritten. The output's `updatedAt` is set to
 * `now ?? Date.now()` so callers don't have to remember to bump it.
 */
export function upsertHost(
  blob: CanonicalProfileBlob,
  host: ProfileHostEntry,
  now?: number,
): CanonicalProfileBlob {
  const hosts = blob.hosts.filter((h) => h.address !== host.address);
  hosts.push(host);
  return { ...blob, hosts, updatedAt: now ?? Date.now() };
}

/** Remove the host with the given address, if any. */
export function removeHost(
  blob: CanonicalProfileBlob,
  address: string,
  now?: number,
): CanonicalProfileBlob {
  const hosts = blob.hosts.filter((h) => h.address !== address);
  if (hosts.length === blob.hosts.length) return blob;
  return { ...blob, hosts, updatedAt: now ?? Date.now() };
}

/**
 * Insert or replace a client entry by `identityFingerprint` (the
 * stable cryptographic identifier). Address can change without
 * losing the trust record — e.g. if a phone re-pairs to a new device
 * row but keeps its identity key — but a new `identityFingerprint`
 * is treated as a new client.
 *
 * Re-derives `trustedApproverFingerprints` from the resulting
 * `clients[]` so the denormalized list never drifts.
 */
export function upsertClient(
  blob: CanonicalProfileBlob,
  client: ProfileClientEntry,
  now?: number,
): CanonicalProfileBlob {
  const clients = blob.clients.filter(
    (c) => c.identityFingerprint !== client.identityFingerprint,
  );
  clients.push(client);
  return {
    ...blob,
    clients,
    trustedApproverFingerprints: deriveTrustedApprovers(clients),
    updatedAt: now ?? Date.now(),
  };
}

/** Remove the client with the given `identityFingerprint`, if any. */
export function removeClient(
  blob: CanonicalProfileBlob,
  identityFingerprint: string,
  now?: number,
): CanonicalProfileBlob {
  const clients = blob.clients.filter(
    (c) => c.identityFingerprint !== identityFingerprint,
  );
  if (clients.length === blob.clients.length) return blob;
  return {
    ...blob,
    clients,
    trustedApproverFingerprints: deriveTrustedApprovers(clients),
    updatedAt: now ?? Date.now(),
  };
}

/**
 * Toggle the `trustedApprover` flag on a client by fingerprint.
 * Returns the input unchanged if the fingerprint isn't found.
 */
export function setTrustedApprover(
  blob: CanonicalProfileBlob,
  identityFingerprint: string,
  trusted: boolean,
  now?: number,
): CanonicalProfileBlob {
  let touched = false;
  const clients = blob.clients.map((c) => {
    if (c.identityFingerprint !== identityFingerprint) return c;
    if ((c.trustedApprover ?? false) === trusted) return c;
    touched = true;
    const next: ProfileClientEntry = { ...c };
    if (trusted) next.trustedApprover = true;
    else delete next.trustedApprover;
    return next;
  });
  if (!touched) return blob;
  return {
    ...blob,
    clients,
    trustedApproverFingerprints: deriveTrustedApprovers(clients),
    updatedAt: now ?? Date.now(),
  };
}

/**
 * True if the given fingerprint resolves to a client whose
 * `trustedApprover` flag is set. Cross-checks both `clients[]` and
 * the denormalized `trustedApproverFingerprints[]` — both must agree
 * to count as trusted.
 */
export function isTrustedApprover(
  blob: CanonicalProfileBlob,
  identityFingerprint: string,
): boolean {
  if (!blob.trustedApproverFingerprints.includes(identityFingerprint)) {
    return false;
  }
  const c = findClientByFingerprint(blob, identityFingerprint);
  return c?.trustedApprover === true;
}

export function findClientByFingerprint(
  blob: CanonicalProfileBlob,
  identityFingerprint: string,
): ProfileClientEntry | null {
  return (
    blob.clients.find((c) => c.identityFingerprint === identityFingerprint) ??
    null
  );
}

export function findClientByAddress(
  blob: CanonicalProfileBlob,
  address: string,
): ProfileClientEntry | null {
  return blob.clients.find((c) => c.address === address) ?? null;
}

function deriveTrustedApprovers(clients: ProfileClientEntry[]): string[] {
  return clients
    .filter((c) => c.trustedApprover === true)
    .map((c) => c.identityFingerprint);
}

// ─── Approval frames ───────────────────────────────────────────

/**
 * Default domain separator for the proxy-approval signing payload.
 * Apps with their own canonical name (e.g. Prism) MAY override this
 * via the `domain` option, but they must use the SAME value on both
 * the signing and verifying side. The frame itself carries the
 * domain so a verifier can detect mismatch and reject.
 */
export const DEFAULT_APPROVAL_DOMAIN = 'shade-link-approve-v1';

/** ms-since-epoch defaults: build = 5 minutes from now. */
const DEFAULT_EXPIRES_IN_MS = 5 * 60 * 1000;

export interface ApprovalRequestingDevice {
  /** Safety-number fingerprint of the requesting device's identity. */
  fingerprint: string;
  /** Optional human label (e.g. `"cafe-laptop"`). */
  deviceName?: string;
  /** Optional `User-Agent`-like hint for display in the approve modal. */
  userAgent?: string;
  /** Optional best-effort source IP for display (NOT authenticated). */
  ipHint?: string;
  /** Wall-clock ms when the host received the original linkRequest. */
  receivedAt: number;
}

export interface ApprovalRequestFrame {
  kind: 'approvalNeeded';
  /**
   * 128-bit random hex (32 chars) — host-generated, used as the
   * idempotency key for the approval. The verifier matches the
   * `linkApproveByProxy.requestId` against this exact value.
   */
  requestId: string;
  /** Shade address of the host that received the original linkRequest. */
  hostAddress: string;
  /** Identity fingerprint of the host (the same value the approver UI shows). */
  hostFingerprint: string;
  requestingDevice: ApprovalRequestingDevice;
  /**
   * Wall-clock ms after which the host won't accept a proxy-approval
   * for this `requestId`. The approver SHOULD also reject locally if
   * the user takes too long to respond.
   */
  expiresAt: number;
  /** Domain separator the host expects this approval to be signed under. */
  domain: string;
}

export interface ProxyApprovalFrame {
  kind: 'linkApproveByProxy';
  /** Echoed from the matching `ApprovalRequestFrame.requestId`. */
  requestId: string;
  /** `'approve'` or `'reject'`. The verifier checks against this exactly. */
  decision: 'approve' | 'reject';
  /**
   * Identity fingerprint of the approving client. MUST match an entry
   * in the host's profile-blob `clients[]` whose `trustedApprover`
   * flag is set.
   */
  approverFingerprint: string;
  /**
   * Ed25519 signature over the canonical signing payload, lowercase
   * hex (128 chars). See `canonicalApprovalSigningBytes` for the
   * exact byte layout.
   */
  signature: string;
  /** Domain separator used to produce the signature. */
  domain: string;
}

export interface BuildApprovalRequestOptions {
  hostAddress: string;
  hostFingerprint: string;
  requestingDevice: Omit<ApprovalRequestingDevice, 'receivedAt'> & {
    receivedAt?: number;
  };
  /**
   * ms TTL after which the host won't honor a proxy-approval for
   * this request. Default 5 minutes — long enough for the user to
   * fish their phone out, short enough to bound replay.
   */
  expiresInMs?: number;
  /**
   * Source of randomness for the `requestId`. Pass the same
   * `CryptoProvider` you use elsewhere in the SDK (typically a
   * `SubtleCryptoProvider`) — it satisfies the `randomBytes` shape.
   */
  crypto: { randomBytes(n: number): Uint8Array };
  /** Domain separator. Default: `shade-link-approve-v1`. */
  domain?: string;
  /** Override `Date.now()` (tests). */
  now?: () => number;
}

/**
 * Build a fresh `approvalNeeded` frame with a 128-bit random
 * `requestId`. The host then ships this to each trusted-approver
 * client via `Shade.send`. Hosts SHOULD persist `(requestId, expiresAt,
 * requestingDevice.fingerprint)` somewhere durable so they can match
 * up the eventual `linkApproveByProxy` reply.
 */
export function buildApprovalRequest(
  options: BuildApprovalRequestOptions,
): ApprovalRequestFrame {
  const now = (options.now ?? Date.now)();
  const requestId = bytesToHex(options.crypto.randomBytes(16));
  return {
    kind: 'approvalNeeded',
    requestId,
    hostAddress: options.hostAddress,
    hostFingerprint: options.hostFingerprint,
    requestingDevice: {
      fingerprint: options.requestingDevice.fingerprint,
      ...(options.requestingDevice.deviceName !== undefined
        ? { deviceName: options.requestingDevice.deviceName }
        : {}),
      ...(options.requestingDevice.userAgent !== undefined
        ? { userAgent: options.requestingDevice.userAgent }
        : {}),
      ...(options.requestingDevice.ipHint !== undefined
        ? { ipHint: options.requestingDevice.ipHint }
        : {}),
      receivedAt: options.requestingDevice.receivedAt ?? now,
    },
    expiresAt: now + (options.expiresInMs ?? DEFAULT_EXPIRES_IN_MS),
    domain: options.domain ?? DEFAULT_APPROVAL_DOMAIN,
  };
}

export interface SignProxyApprovalOptions {
  /** The frame the host sent. The verifier rebuilds the signing payload from it. */
  request: ApprovalRequestFrame;
  decision: 'approve' | 'reject';
  /**
   * The approving client's identity fingerprint. Must match the
   * `clients[]` entry the host expects to find as a trusted approver.
   */
  approverFingerprint: string;
  /**
   * 32-byte Ed25519 *seed* — `crypto.sign(seed, msg)` works directly
   * (the noble convention `@shade/crypto-web` uses).
   */
  approverSigningKey: Uint8Array;
  /** CryptoProvider for `sign`. */
  crypto: Pick<CryptoProvider, 'sign'>;
}

/**
 * Build a `linkApproveByProxy` frame signed with the approver's
 * long-term Ed25519 identity key. The signing payload is
 * domain-separated and binds together every field that protects
 * against cross-frame replay (see `canonicalApprovalSigningBytes`).
 */
export async function signProxyApproval(
  options: SignProxyApprovalOptions,
): Promise<ProxyApprovalFrame> {
  if (options.approverSigningKey.length !== 32) {
    throw new ValidationError('approverSigningKey must be 32 bytes (Ed25519 seed)');
  }
  if (options.decision !== 'approve' && options.decision !== 'reject') {
    throw new ValidationError(`decision must be 'approve' or 'reject'`);
  }
  const payload = canonicalApprovalSigningBytes({
    domain: options.request.domain,
    requestId: options.request.requestId,
    hostFingerprint: options.request.hostFingerprint,
    requestingDeviceFingerprint: options.request.requestingDevice.fingerprint,
    decision: options.decision,
  });
  const sig = await options.crypto.sign(options.approverSigningKey, payload);
  return {
    kind: 'linkApproveByProxy',
    requestId: options.request.requestId,
    decision: options.decision,
    approverFingerprint: options.approverFingerprint,
    signature: bytesToHex(sig),
    domain: options.request.domain,
  };
}

export interface VerifyProxyApprovalOptions {
  /** The original `approvalNeeded` frame the host sent (replay-binding). */
  request: ApprovalRequestFrame;
  /** The `linkApproveByProxy` frame received from the approver. */
  approval: ProxyApprovalFrame;
  /**
   * Profile blob to verify against. The approver must resolve to a
   * `clients[]` entry whose `trustedApprover` flag is set.
   * Hosts MUST refetch the blob fresh before verifying — see FR §5
   * for the revocation TOCTOU rationale.
   */
  profile: CanonicalProfileBlob;
  /** CryptoProvider for `verify`. */
  crypto: Pick<CryptoProvider, 'verify'>;
  /** Override `Date.now()` (tests). */
  now?: () => number;
}

export type VerifyProxyApprovalReason =
  | 'request-id-mismatch'
  | 'domain-mismatch'
  | 'unknown-approver'
  | 'not-trusted'
  | 'bad-signature'
  | 'expired';

export type VerifyProxyApprovalResult =
  | { ok: true; approver: ProfileClientEntry }
  | { ok: false; reason: VerifyProxyApprovalReason };

/**
 * Verify a `linkApproveByProxy` against the originating
 * `approvalNeeded` and the host's freshest profile blob. Returns a
 * tagged result rather than throwing — callers usually want to log
 * the reason before deciding what to surface to the user.
 *
 * Order of checks:
 *
 *  1. `requestId` must match exactly. Defends against an attacker
 *     replaying a stale approval against a fresh request.
 *  2. `domain` must match exactly. Defends against an approval signed
 *     under one app's separator being honored by another.
 *  3. Approver must resolve to a `clients[]` entry. Identity unknown =
 *     reject regardless of signature validity.
 *  4. Approver's `trustedApprover` flag must be set AND its
 *     fingerprint must be in `trustedApproverFingerprints[]` (cross-
 *     check via `isTrustedApprover`).
 *  5. Signature must verify against the approver's `identityPublicKey`.
 *  6. `expiresAt` must be in the future (gives the host a lower
 *     bound; the host's own pending-state is the authoritative source).
 */
export async function verifyProxyApproval(
  options: VerifyProxyApprovalOptions,
): Promise<VerifyProxyApprovalResult> {
  const { request, approval, profile } = options;

  if (approval.requestId !== request.requestId) {
    return { ok: false, reason: 'request-id-mismatch' };
  }
  if (approval.domain !== request.domain) {
    return { ok: false, reason: 'domain-mismatch' };
  }

  const approver = findClientByFingerprint(profile, approval.approverFingerprint);
  if (!approver) {
    return { ok: false, reason: 'unknown-approver' };
  }
  if (!isTrustedApprover(profile, approval.approverFingerprint)) {
    return { ok: false, reason: 'not-trusted' };
  }

  const now = (options.now ?? Date.now)();
  if (now > request.expiresAt) {
    return { ok: false, reason: 'expired' };
  }

  let pubkey: Uint8Array;
  let sig: Uint8Array;
  try {
    pubkey = hexToBytes(approver.identityPublicKey);
    sig = hexToBytes(approval.signature);
  } catch {
    return { ok: false, reason: 'bad-signature' };
  }
  if (pubkey.length !== 32 || sig.length !== 64) {
    return { ok: false, reason: 'bad-signature' };
  }

  const payload = canonicalApprovalSigningBytes({
    domain: approval.domain,
    requestId: approval.requestId,
    hostFingerprint: request.hostFingerprint,
    requestingDeviceFingerprint: request.requestingDevice.fingerprint,
    decision: approval.decision,
  });
  const valid = await options.crypto.verify(pubkey, payload, sig);
  if (!valid) {
    return { ok: false, reason: 'bad-signature' };
  }
  return { ok: true, approver };
}

// ─── Canonical signing payload ─────────────────────────────────

export interface ApprovalSigningInput {
  domain: string;
  requestId: string;
  hostFingerprint: string;
  requestingDeviceFingerprint: string;
  decision: 'approve' | 'reject';
}

/**
 * Build the exact bytes that get Ed25519-signed for a proxy-approval.
 *
 * Format (length-prefixed UTF-8, big-endian u16 lengths):
 *
 *   u16(len(domain))     || domain
 *   u16(len(requestId))  || requestId
 *   u16(len(hostFp))     || hostFingerprint
 *   u16(len(requestFp))  || requestingDeviceFingerprint
 *   u16(len(decision))   || decision
 *
 * Length-prefixed rather than delimiter-joined so the encoding is
 * unambiguous regardless of what bytes appear in any field. u16
 * (max 65535) is plenty: domain < 256 chars by convention,
 * fingerprint ≈ 71 chars, requestId 32 hex chars, decision 6-7 chars.
 *
 * Exposed publicly so other Shade implementations (Android Kotlin,
 * iOS Swift, etc.) can produce byte-identical signing input from
 * test vectors without depending on this TypeScript code.
 */
export function canonicalApprovalSigningBytes(
  input: ApprovalSigningInput,
): Uint8Array {
  const fields: Uint8Array[] = [
    TEXT.encode(input.domain),
    TEXT.encode(input.requestId),
    TEXT.encode(input.hostFingerprint),
    TEXT.encode(input.requestingDeviceFingerprint),
    TEXT.encode(input.decision),
  ];
  for (const f of fields) {
    if (f.length > 0xffff) {
      throw new ValidationError(
        `signing field too long: ${f.length} bytes (max 65535)`,
      );
    }
  }
  const total = fields.reduce((sum, f) => sum + 2 + f.length, 0);
  const out = new Uint8Array(total);
  let offset = 0;
  for (const f of fields) {
    out[offset] = (f.length >> 8) & 0xff;
    out[offset + 1] = f.length & 0xff;
    offset += 2;
    out.set(f, offset);
    offset += f.length;
  }
  return out;
}

// ─── Hex helpers (kept local so this module has no extra deps) ──

function bytesToHex(bytes: Uint8Array): string {
  let s = '';
  for (let i = 0; i < bytes.length; i++) s += bytes[i]!.toString(16).padStart(2, '0');
  return s;
}

function hexToBytes(hex: string): Uint8Array {
  if (hex.length % 2 !== 0) throw new ValidationError('hex length must be even');
  if (!/^[0-9a-f]*$/.test(hex)) {
    throw new ValidationError('hex must be lowercase 0-9a-f');
  }
  const out = new Uint8Array(hex.length / 2);
  for (let i = 0; i < out.length; i++) {
    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
  }
  return out;
}
