/**
 * App Attest assertion verification, the per-request half.
 *
 * StoreKit answers "is this user entitled?" App Attest answers "is this even
 * my app talking?" The app attests a hardware-backed key once (attestation.ts
 * verifies it and stores the public key), then signs an assertion over each
 * request. This file verifies that per-request assertion:
 *
 *   1. look up the registered public key for keyId,
 *   2. CBOR-decode the assertion into { signature, authenticatorData },
 *   3. check rpIdHash === SHA-256("<teamId>.<bundleId>"),
 *   4. verify the ES256 signature over (authenticatorData || clientDataHash),
 *   5. check the signature counter is strictly increasing (replay defense),
 *      and only after a valid signature, so forgeries never burn a counter.
 *
 * The verifier is injectable, mirroring storekit.ts, so tests run offline
 * with fakes in place of a real device assertion and a stored public key.
 */
import type { Env } from "./env.ts";
import type { AttestKeyStore } from "./attestkey.ts";
import { base64ToBytes, bytesEqual, concatBytes } from "./bytes.ts";

export interface AppAttestConfig {
  teamId: string;
  bundleId: string;
}

export interface AppAttestAssertion {
  keyId: string;
  /** Base64 CBOR assertion produced by DCAppAttestService.generateAssertion. */
  assertion: string;
  /** SHA-256 of the exact request payload the client signed over. */
  clientDataHash: ArrayBuffer;
}

/** Resolves if the assertion is authentic; throws otherwise. */
export type AppAttestVerifier = (
  assertion: AppAttestAssertion,
  cfg: AppAttestConfig,
  keys: AttestKeyStore,
) => Promise<void>;

/** App Attest is "configured" only when both identity values are present. */
export function appAttestConfig(env: Env): AppAttestConfig | null {
  if (!env.APP_ATTEST_TEAM_ID || !env.APP_ATTEST_BUNDLE_ID) return null;
  return {
    teamId: env.APP_ATTEST_TEAM_ID,
    bundleId: env.APP_ATTEST_BUNDLE_ID,
  };
}

export const verifyAppAttestAssertion: AppAttestVerifier = async (
  assertion,
  cfg,
  keys,
) => {
  if (!assertion.keyId || !assertion.assertion) {
    throw new Error("missing App Attest assertion");
  }

  // 1. The registered key. An unregistered keyId is untrusted, full stop.
  const spkiBase64 = await keys.getPublicKey(assertion.keyId);
  if (!spkiBase64) {
    throw new Error("unregistered App Attest key");
  }

  // 2. Decode. The bytes are untrusted, so guard lengths before slicing.
  const cbor = await import("cbor-x");
  const decoded = cbor.decode(base64ToBytes(assertion.assertion)) as {
    authenticatorData?: Uint8Array;
    signature?: Uint8Array;
  };
  const authData = decoded.authenticatorData;
  const signature = decoded.signature;
  if (!authData || !signature || authData.length < 37) {
    throw new Error("malformed App Attest assertion");
  }

  // 3. The assertion must bind to this App ID.
  const rpIdHash = authData.slice(0, 32);
  const appId = `${cfg.teamId}.${cfg.bundleId}`;
  const expected = new Uint8Array(
    await crypto.subtle.digest("SHA-256", new TextEncoder().encode(appId)),
  );
  if (!bytesEqual(rpIdHash, expected)) {
    throw new Error("App Attest rpId does not match this app");
  }

  // 4. The signature: ES256 over (authenticatorData || clientDataHash) with
  //    the registered key. Apple emits DER-encoded ECDSA signatures and
  //    WebCrypto verifies the raw r||s form, so convert first; skipping that
  //    step makes every verification fail and is a classic landmine.
  const key = await crypto.subtle.importKey(
    "spki",
    base64ToBytes(spkiBase64),
    { name: "ECDSA", namedCurve: "P-256" },
    false,
    ["verify"],
  );
  const message = concatBytes(authData, new Uint8Array(assertion.clientDataHash));
  const ok = await crypto.subtle.verify(
    { name: "ECDSA", hash: "SHA-256" },
    key,
    derToRaw(signature),
    message,
  );
  if (!ok) {
    throw new Error("App Attest signature verification failed");
  }

  // 5. The counter, last: only a valid signature may advance it.
  const counter = new DataView(
    authData.buffer,
    authData.byteOffset + 33,
    4,
  ).getUint32(0);
  if (!(await keys.bump(assertion.keyId, counter))) {
    throw new Error("App Attest assertion replayed");
  }
};

/**
 * Convert a DER ECDSA-Sig-Value (SEQUENCE of two INTEGERs) to the raw 64-byte
 * r||s form WebCrypto expects for P-256.
 */
function derToRaw(der: Uint8Array): Uint8Array {
  if (der[0] !== 0x30) throw new Error("not a DER signature");
  let offset = der[1] & 0x80 ? 2 + (der[1] & 0x7f) : 2;
  const readInteger = (): Uint8Array => {
    if (der[offset] !== 0x02) throw new Error("not a DER signature");
    const len = der[offset + 1];
    const start = offset + 2;
    if (!Number.isInteger(len) || start + len > der.length) {
      throw new Error("not a DER signature");
    }
    offset = start + len;
    let bytes = der.slice(start, offset);
    // Strip the sign padding, then left-pad back to 32 bytes.
    while (bytes.length > 32 && bytes[0] === 0) bytes = bytes.slice(1);
    if (bytes.length > 32) throw new Error("not a P-256 signature");
    const out = new Uint8Array(32);
    out.set(bytes, 32 - bytes.length);
    return out;
  };
  const r = readInteger();
  const s = readInteger();
  const raw = new Uint8Array(64);
  raw.set(r, 0);
  raw.set(s, 32);
  return raw;
}
