/**
 * Backend-for-Frontend (BFF) Cloudflare Worker, an in-app-purchase- and/or
 * App-Attest-gated Claude proxy with server-side revocation and spend
 * controls.
 *
 * Flow:
 *   1. Service routes first: Apple's server notifications land on
 *      /apple/notifications (writing the revocation record), the app fetches
 *      a one-time registration challenge from /app-attest/challenge, and
 *      registers its App Attest key on /app-attest/register. Each exists only
 *      when configured.
 *   2. For everything else, every configured gate must pass: StoreKit proves
 *      the caller is entitled (and the revocation record proves Apple has not
 *      taken it back), App Attest proves the caller is a genuine instance of
 *      your app. If neither gate is configured the Worker refuses to run
 *      (500).
 *   3. Spend controls run after the gates: model allowlist, max_tokens cap,
 *      and a per-caller daily token budget debited in a Durable Object.
 *   4. On success it injects the Anthropic key server-side and forwards the
 *      request to an allowlisted upstream path. The app ships no key.
 */
import {
  ASSERTION_HEADER,
  CHALLENGE_PATH,
  DEFAULT_UPSTREAM_PATHS,
  KEY_ID_HEADER,
  NOTIFICATIONS_PATH,
  REGISTER_PATH,
  TRANSACTION_HEADER,
  type Env,
} from "./env.ts";
import {
  evaluateEntitlement,
  parseList,
  type GateResult,
} from "./entitlement.ts";
import {
  verifyTransactionJWS,
  type TransactionVerifier,
} from "./storekit.ts";
import {
  appAttestConfig,
  verifyAppAttestAssertion,
  type AppAttestVerifier,
} from "./appattest.ts";
import {
  handleRegistration,
  verifyAttestation,
  type AttestationVerifier,
} from "./attestation.ts";
import {
  attestKeyStoreFromEnv,
  type AttestKeyStore,
} from "./attestkey.ts";
import {
  challengeStoreFromEnv,
  type ChallengeStore,
} from "./challenge.ts";
import {
  handleNotification,
  verifyNotification,
  type NotificationVerifier,
} from "./notifications.ts";
import {
  revocationStoreFromEnv,
  type RevocationStore,
} from "./revocation.ts";
import { budgetStoreFromEnv, type BudgetStore } from "./budget.ts";
import { evaluateSpend, MAX_BODY_BYTES, spendConfig } from "./limits.ts";
import { forwardToAnthropic, resolveUpstreamPath } from "./proxy.ts";
import type { JWSTransaction } from "./transaction.ts";

// wrangler resolves the Durable Object classes from `main`.
export { TokenBudget } from "./budget.ts";
export { AttestKey } from "./attestkey.ts";
export { AttestChallenge } from "./challenge.ts";
export { RevocationRecord } from "./revocation.ts";

/**
 * Verifiers and stores are injectable so tests exercise the flow without real
 * crypto or Durable Objects.
 */
export interface Handlers {
  verifyTransaction: TransactionVerifier;
  verifyAppAttest: AppAttestVerifier;
  verifyAttestation: AttestationVerifier;
  verifyNotification: NotificationVerifier;
  budgetStore: (env: Env) => BudgetStore | null;
  attestKeyStore: (env: Env) => AttestKeyStore | null;
  challengeStore: (env: Env) => ChallengeStore | null;
  revocationStore: (env: Env) => RevocationStore | null;
}

const defaultHandlers: Handlers = {
  verifyTransaction: verifyTransactionJWS,
  verifyAppAttest: verifyAppAttestAssertion,
  verifyAttestation,
  verifyNotification,
  budgetStore: budgetStoreFromEnv,
  attestKeyStore: attestKeyStoreFromEnv,
  challengeStore: challengeStoreFromEnv,
  revocationStore: revocationStoreFromEnv,
};

function storeKitConfig(env: Env) {
  if (!env.ALLOWED_BUNDLE_IDS || !env.APPLE_ROOT_CA_SHA256) return null;
  return {
    allowedBundleIds: parseList(env.ALLOWED_BUNDLE_IDS),
    allowedProductIds: env.ALLOWED_PRODUCT_IDS
      ? parseList(env.ALLOWED_PRODUCT_IDS)
      : null,
    rootSha256: env.APPLE_ROOT_CA_SHA256,
  };
}

export async function handleRequest(
  request: Request,
  env: Env,
  handlers: Handlers = defaultHandlers,
): Promise<Response> {
  if (request.method !== "POST") {
    return new Response("method not allowed", { status: 405 });
  }

  const incoming = new URL(request.url);

  // Service routes come before the gates: Apple authenticates by signature,
  // and a registering app has no assertion yet. Each route exists only when
  // its configuration and stores do.
  if (incoming.pathname === NOTIFICATIONS_PATH) {
    const revocations = handlers.revocationStore(env);
    if (!env.APPLE_ROOT_CA_SHA256 || !revocations) {
      return json(404, { error: "not found" });
    }
    return handleNotification(
      request,
      env.APPLE_ROOT_CA_SHA256,
      handlers.verifyNotification,
      revocations,
    );
  }
  if (incoming.pathname === CHALLENGE_PATH) {
    const cfg = appAttestConfig(env);
    const challenges = handlers.challengeStore(env);
    if (!cfg || !challenges) {
      return json(404, { error: "not found" });
    }
    return json(200, { challenge: await challenges.issue() });
  }
  if (incoming.pathname === REGISTER_PATH) {
    const cfg = appAttestConfig(env);
    const keys = handlers.attestKeyStore(env);
    const challenges = handlers.challengeStore(env);
    if (!cfg || !env.APP_ATTEST_ROOT_CA || !keys || !challenges) {
      return json(404, { error: "not found" });
    }
    return handleRegistration(
      request,
      { ...cfg, attestRootCa: env.APP_ATTEST_ROOT_CA },
      handlers.verifyAttestation,
      keys,
      challenges,
    );
  }

  const storeKit = storeKitConfig(env);
  const appAttest = appAttestConfig(env);

  // A Worker with no gate configured is an open relay for your API bill. Refuse
  // to run rather than forward anything.
  if (!storeKit && !appAttest) {
    return json(500, { error: "no entitlement gate configured" });
  }

  // Partial configuration fails closed too: a gate or a budget that cannot
  // reach its store must refuse, not wave callers through.
  const keys = handlers.attestKeyStore(env);
  if (appAttest && !keys) {
    return json(500, { error: "App Attest key store not configured" });
  }
  const spend = spendConfig(env);
  const budget = handlers.budgetStore(env);
  if (spend?.dailyTokenBudget != null && !budget) {
    return json(500, { error: "token budget store not configured" });
  }

  const upstreamPath = resolveUpstreamPath(
    incoming.pathname,
    parseList(env.ALLOWED_UPSTREAM_PATHS ?? DEFAULT_UPSTREAM_PATHS),
  );
  if (!upstreamPath) {
    return json(403, { error: "upstream path not allowed" });
  }

  const jws = request.headers.get(TRANSACTION_HEADER);
  let txn: JWSTransaction | null = null;

  // StoreKit gate (if configured): verify the signed transaction, then decide.
  if (storeKit) {
    if (!jws) {
      return json(401, { error: "missing in-app purchase transaction" });
    }
    try {
      txn = await handlers.verifyTransaction(jws, storeKit.rootSha256);
    } catch {
      return json(401, { error: "invalid in-app purchase transaction" });
    }
    const gate: GateResult = evaluateEntitlement(txn, {
      allowedBundleIds: storeKit.allowedBundleIds,
      allowedProductIds: storeKit.allowedProductIds,
      now: Date.now(),
    });
    if (!gate.ok) {
      return json(gate.status, { error: gate.reason });
    }

    // Server-side revocation: the JWS in hand can predate a refund. The
    // record holds what Apple has told us since, via the notifications webhook.
    const revocations = handlers.revocationStore(env);
    if (
      revocations &&
      txn.originalTransactionId &&
      (await revocations.isRevoked(txn.originalTransactionId))
    ) {
      return json(403, { error: "entitlement_revoked" });
    }
  }

  // App Attest gate (if configured): prove the caller is a genuine app instance.
  const keyId = request.headers.get(KEY_ID_HEADER);
  if (appAttest) {
    const assertion = request.headers.get(ASSERTION_HEADER);
    if (!assertion || !keyId) {
      return json(401, { error: "missing App Attest assertion" });
    }
    try {
      // Bind the assertion to the request line and the IAP token, so it cannot
      // be lifted onto a different call. The body is left untouched for forwarding.
      const clientData = `${request.method}\n${upstreamPath}\n${jws ?? ""}`;
      const clientDataHash = await crypto.subtle.digest(
        "SHA-256",
        new TextEncoder().encode(clientData),
      );
      await handlers.verifyAppAttest(
        { keyId, assertion, clientDataHash },
        appAttest,
        keys!,
      );
    } catch {
      return json(401, { error: "invalid App Attest assertion" });
    }
  }

  // Spend controls, after every auth gate: an unauthorized caller never gets
  // its body parsed. The body is read once, inspected, then forwarded verbatim.
  if (spend) {
    const bodyText = await request.text();
    // A cheap guard before JSON.parse; code units approximate bytes well
    // enough to stop a deliberately huge body.
    if (bodyText.length > MAX_BODY_BYTES) {
      return json(413, { error: "body_too_large" });
    }
    let body: unknown;
    try {
      body = JSON.parse(bodyText);
    } catch {
      return json(400, { error: "body_not_json" });
    }
    const verdict = evaluateSpend(body, spend);
    if (!verdict.ok) {
      return json(verdict.status, { error: verdict.reason });
    }
    if (spend.dailyTokenBudget != null) {
      // The budget keys on a verified identity only: the purchase's
      // originalTransactionId, or the keyId an assertion just proved. A
      // client-chosen header is not an identity.
      const verifiedKeyId = appAttest ? keyId : null;
      const callerId = txn?.originalTransactionId ?? verifiedKeyId;
      if (!callerId) {
        return json(403, { error: "caller_identity_required" });
      }
      const debit = await budget!.debit(
        callerId,
        verdict.maxTokens,
        spend.dailyTokenBudget,
      );
      if (!debit.ok) {
        return json(429, {
          error: "daily_token_budget_exhausted",
          remaining: debit.remaining,
        });
      }
    }
    return forwardToAnthropic(request, env, upstreamPath, bodyText);
  }

  // Every configured gate passed. Inject the secret and forward upstream.
  return forwardToAnthropic(request, env, upstreamPath);
}

function json(status: number, body: unknown): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json" },
  });
}

export default {
  fetch(request: Request, env: Env): Promise<Response> {
    return handleRequest(request, env);
  },
};
