// Trusted Tester program — application intake + owner-gated approval.
//
// Nucleic's iOS companion ships through TestFlight. Rather than hand out the public join link to
// anyone, outside people *apply* to become trusted testers through a form (repo `website/
// trusted-tester.html`, plus in-app links). Each application is gated behind the owner's one-click
// approval, delivered by email; on approval the owner hand-invites the applicant into the internal
// TestFlight group (Apple can't add an outside email to an *internal* group via API — see the plan).
//
// Two routes, wired in src/index.ts:
//   • POST /v1/tester/apply            — validate → store (pending) → email the owner with signed
//                                        Approve / Reject links. Called cross-origin by the site form.
//   • GET  /v1/tester/review?id&action&token — verify the HMAC link → flip status → render a page.
//
// Design mirrors src/push.ts: pure, KV-backed helpers that are unit-tested directly
// (test/testers.test.ts), with thin HTTP handlers on top. No shared secret ships in any client —
// the apply route is open (rate-limited + optional Turnstile); the review links are unguessable
// HMACs only the owner receives.

const enc = new TextEncoder();

/** Applications live a year — long enough to dedupe re-applies and keep an audit trail. */
export const APP_TTL = 60 * 60 * 24 * 365;
/** Coarse per-IP intake limit (best-effort; KV isn't atomic but this only blunts abuse). */
const RATE_WINDOW = 60 * 60; // 1 hour
const RATE_MAX = 5;
/** Reject absurdly long fields outright rather than storing megabytes of paste. */
const MAX_FIELD = 5000;

/** The single approver. The OWNER_EMAIL send_email binding is also pinned to this address, so a
 * compromised Worker can't retarget the notification elsewhere. */
export const OWNER_ADDRESS = "accounts@abkslm.com";
/** Which internal TestFlight group the owner should add approved testers to (shown on the approve
 * page). Beta is the nearest public iOS track today (signing/README.md §5). */
const TARGET_GROUP = "Nucleic (Beta) · internal TestFlight group";

// MARK: - Application shape + validation

export type ApplicationStatus = "pending" | "approved" | "rejected";

/** One trusted-tester application. The string fields are the "signal" the owner judges. */
export interface Application {
  id: string;
  status: ApplicationStatus;
  createdAt: string; // ISO-8601
  decidedAt?: string;
  // identity (required for the eventual TestFlight invite)
  firstName: string;
  lastName: string;
  email: string;
  // background — the fields the owner weighs
  jobTitle: string;
  employer: string;
  experienceYears: string;
  techBackground: string; // languages / platforms / tools
  agentExperience: string; // AI coding agents / terminal dev workflows
  applePlatform: string; // Apple-platform dev experience
  testDevices: string; // devices + iOS versions they'll test on
  motivation: string; // why they'd be an effective tester / what feedback they offer
  // optional
  links?: string; // GitHub / LinkedIn / portfolio
  referral?: string; // how they heard about Nucleic
}

/** Hidden form field a human never fills; a bot that auto-fills every input trips it. */
export const HONEYPOT_FIELD = "company_website";

/** Fields that must be present + non-empty, mapped to a human label for the error message. */
const REQUIRED: Array<[keyof Application, string]> = [
  ["firstName", "first name"],
  ["lastName", "last name"],
  ["email", "email"],
  ["jobTitle", "job title"],
  ["employer", "employer"],
  ["experienceYears", "years of experience"],
  ["techBackground", "technical background"],
  ["agentExperience", "coding-agent experience"],
  ["applePlatform", "Apple-platform experience"],
  ["testDevices", "test devices"],
  ["motivation", "why you'd be an effective tester"],
];

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export type ParseResult =
  | { ok: true; value: Omit<Application, "id" | "status" | "createdAt"> }
  | { ok: false; error: string; honeypot?: boolean };

/** Validate + normalize a raw form body. Trims every field, requires the core set, sanity-checks
 * the email, and flags a filled honeypot separately so the caller can 200-and-drop it. */
export function parseApplication(body: unknown): ParseResult {
  if (typeof body !== "object" || body === null) return { ok: false, error: "expected an object" };
  const raw = body as Record<string, unknown>;

  // Honeypot: present + non-empty ⇒ almost certainly a bot. Surface it distinctly.
  if (typeof raw[HONEYPOT_FIELD] === "string" && raw[HONEYPOT_FIELD].trim() !== "") {
    return { ok: false, error: "rejected", honeypot: true };
  }

  const str = (k: string): string => (typeof raw[k] === "string" ? (raw[k] as string).trim() : "");
  for (const k of Object.keys(raw)) {
    if (typeof raw[k] === "string" && (raw[k] as string).length > MAX_FIELD) {
      return { ok: false, error: `field too long: ${k}` };
    }
  }

  for (const [key, label] of REQUIRED) {
    if (!str(key)) return { ok: false, error: `missing ${label}` };
  }

  const email = str("email");
  if (!EMAIL_RE.test(email)) return { ok: false, error: "invalid email" };

  const value = {
    firstName: str("firstName"),
    lastName: str("lastName"),
    email,
    jobTitle: str("jobTitle"),
    employer: str("employer"),
    experienceYears: str("experienceYears"),
    techBackground: str("techBackground"),
    agentExperience: str("agentExperience"),
    applePlatform: str("applePlatform"),
    testDevices: str("testDevices"),
    motivation: str("motivation"),
    links: str("links") || undefined,
    referral: str("referral") || undefined,
  };
  return { ok: true, value };
}

// MARK: - Signed review links (stateless HMAC)

function hmacKey(secret: string): Promise<CryptoKey> {
  return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
    "verify",
  ]);
}

/** Hex HMAC-SHA256 over `"<id>:<action>"` — the unguessable token in the Approve/Reject link. */
export async function signAction(secret: string, id: string, action: string): Promise<string> {
  const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(`${id}:${action}`));
  return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

/** Constant-time compare a presented token against the expected signature for (id, action). */
export async function verifyAction(secret: string, id: string, action: string, token: string): Promise<boolean> {
  if (!token) return false;
  return safeEqual(token, await signAction(secret, id, action));
}

/** Constant-time-ish compare (both operands are fixed-length hex here). */
function safeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

// MARK: - Storage (KV: `app:<id>` records + `email:<addr>` dedupe index)

export async function getApplication(kv: KVNamespace, id: string): Promise<Application | null> {
  const raw = await kv.get(`app:${id}`);
  return raw ? (JSON.parse(raw) as Application) : null;
}

/** Persist a new pending application, unless a non-rejected one already exists for this email
 * (a rejected applicant may re-apply). Returns the stored record, or `{ duplicate: true }`. */
export async function storeApplication(
  kv: KVNamespace,
  app: Application,
): Promise<{ duplicate: boolean; existing?: Application }> {
  const idxKey = `email:${app.email.toLowerCase()}`;
  const priorId = await kv.get(idxKey);
  if (priorId) {
    const prior = await getApplication(kv, priorId);
    if (prior && prior.status !== "rejected") return { duplicate: true, existing: prior };
  }
  await kv.put(`app:${app.id}`, JSON.stringify(app), { expirationTtl: APP_TTL });
  await kv.put(idxKey, app.id, { expirationTtl: APP_TTL });
  return { duplicate: false };
}

/** Flip a pending application to approved/rejected. Idempotent: an already-decided record is
 * returned unchanged, so re-clicking a link (or a mail-scanner prefetch) is safe. */
export async function decideApplication(
  kv: KVNamespace,
  id: string,
  action: "approve" | "reject",
  now = new Date(),
): Promise<Application | null> {
  const app = await getApplication(kv, id);
  if (!app) return null;
  if (app.status === "pending") {
    app.status = action === "approve" ? "approved" : "rejected";
    app.decidedAt = now.toISOString();
    await kv.put(`app:${id}`, JSON.stringify(app), { expirationTtl: APP_TTL });
  }
  return app;
}

async function rateLimited(kv: KVNamespace, ip: string | null): Promise<boolean> {
  if (!ip) return false;
  const key = `rl:${ip}`;
  const n = parseInt((await kv.get(key)) ?? "0", 10) || 0;
  if (n >= RATE_MAX) return true;
  await kv.put(key, String(n + 1), { expirationTtl: RATE_WINDOW });
  return false;
}

// MARK: - HTTP handlers

/** Only the marketing site posts the form; reflect that one origin so a random site can't. */
const SITE_ORIGIN = "https://nucleic.blakeslee.xyz";

function corsHeaders(request: Request): Record<string, string> {
  const origin = request.headers.get("origin");
  // Allow the production site and any localhost origin (local `wrangler dev` + file testing).
  const allow = origin && (origin === SITE_ORIGIN || /^http:\/\/localhost(:\d+)?$/.test(origin)) ? origin : SITE_ORIGIN;
  return {
    "access-control-allow-origin": allow,
    "access-control-allow-methods": "POST, OPTIONS",
    "access-control-allow-headers": "content-type",
    "access-control-max-age": "86400",
    vary: "origin",
  };
}

function jsonCors(request: Request, obj: unknown, status = 200): Response {
  return new Response(JSON.stringify(obj), {
    status,
    headers: { "content-type": "application/json", ...corsHeaders(request) },
  });
}

/** POST /v1/tester/apply — the site form target. Open (no auth), rate-limited, optional Turnstile. */
export async function handleApply(request: Request, env: TesterEnv): Promise<Response> {
  if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders(request) });
  if (request.method !== "POST")
    return jsonCors(request, { error: "method not allowed" }, 405);
  if (!env.TESTER_APP_SECRET) return jsonCors(request, { error: "not configured" }, 503);

  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return jsonCors(request, { error: "bad json" }, 400);
  }

  const parsed = parseApplication(body);
  // Silently accept a bot submission: 200 so it thinks it succeeded, but store/notify nothing.
  if (!parsed.ok && parsed.honeypot) return jsonCors(request, { ok: true });
  if (!parsed.ok) return jsonCors(request, { error: parsed.error }, 422);

  const ip = request.headers.get("cf-connecting-ip");
  if (await rateLimited(env.NUCLEIC_TESTER_APPS, ip))
    return jsonCors(request, { error: "too many applications, try again later" }, 429);

  // Turnstile is enforced only when configured, so local dev and tests don't need a token.
  if (env.TURNSTILE_SECRET) {
    const token = typeof (body as Record<string, unknown>)["turnstileToken"] === "string"
      ? ((body as Record<string, unknown>)["turnstileToken"] as string)
      : "";
    if (!(await turnstileOk(env.TURNSTILE_SECRET, token, ip))) {
      return jsonCors(request, { error: "challenge failed" }, 403);
    }
  }

  const app: Application = {
    id: crypto.randomUUID(),
    status: "pending",
    createdAt: new Date().toISOString(),
    ...parsed.value,
  };

  const stored = await storeApplication(env.NUCLEIC_TESTER_APPS, app);
  if (stored.duplicate) return jsonCors(request, { ok: true, duplicate: true });

  // Best-effort: a mail hiccup must not fail the applicant — the record is already saved.
  try {
    await notifyOwner(env, app, new URL(request.url).origin);
  } catch (err) {
    console.error("tester: owner notification failed", err);
  }

  return jsonCors(request, { ok: true });
}

/** GET /v1/tester/review?id&action&token — the owner clicks this in the notification email. */
export async function handleReview(request: Request, env: TesterEnv, url: URL): Promise<Response> {
  if (request.method !== "GET")
    return page(405, "Method not allowed", "Use the link from the email.");
  if (!env.TESTER_APP_SECRET) return page(503, "Not configured", "Review is not available.");

  const id = url.searchParams.get("id") ?? "";
  const action = url.searchParams.get("action") ?? "";
  const token = url.searchParams.get("token") ?? "";
  if (action !== "approve" && action !== "reject")
    return page(400, "Bad request", "Unknown action.");
  if (!id || !(await verifyAction(env.TESTER_APP_SECRET, id, action, token)))
    return page(401, "Invalid or expired link", "This approval link isn't valid.");

  const app = await decideApplication(env.NUCLEIC_TESTER_APPS, id, action);
  if (!app) return page(404, "Not found", "That application no longer exists.");

  return app.status === "approved" ? approvedPage(app) : rejectedPage(app);
}

// MARK: - Owner email + Turnstile

/** Bindings + secrets this module needs (a subset of the Worker's Env). */
export interface TesterEnv {
  NUCLEIC_TESTER_APPS: KVNamespace;
  OWNER_EMAIL: SendEmail;
  TESTER_APP_SECRET?: string;
  TURNSTILE_SECRET?: string;
  /** From address for the owner notification; must be on a Cloudflare Email-enabled domain. */
  TESTER_FROM_ADDRESS?: string;
}

async function turnstileOk(secret: string, token: string, ip: string | null): Promise<boolean> {
  if (!token) return false;
  const form = new FormData();
  form.append("secret", secret);
  form.append("response", token);
  if (ip) form.append("remoteip", ip);
  try {
    const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      body: form,
    });
    const data = (await res.json()) as { success?: boolean };
    return data.success === true;
  } catch {
    return false;
  }
}

function reviewUrl(origin: string, id: string, action: string, token: string): string {
  const q = new URLSearchParams({ id, action, token });
  return `${origin}/v1/tester/review?${q.toString()}`;
}

const esc = (s: string): string =>
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

/** Email the owner the applicant's details + one-click Approve/Reject links. Uses the send_email
 * binding's builder overload (no cloudflare:email import / MIME needed). */
async function notifyOwner(env: TesterEnv, app: Application, apiOrigin: string): Promise<void> {
  const from = env.TESTER_FROM_ADDRESS ?? "nucleic-testers@nucleic.blakeslee.xyz";
  const approveToken = await signAction(env.TESTER_APP_SECRET!, app.id, "approve");
  const rejectToken = await signAction(env.TESTER_APP_SECRET!, app.id, "reject");
  const approve = reviewUrl(apiOrigin, app.id, "approve", approveToken);
  const reject = reviewUrl(apiOrigin, app.id, "reject", rejectToken);

  const rows: Array<[string, string]> = [
    ["Name", `${app.firstName} ${app.lastName}`],
    ["Email", app.email],
    ["Job title", app.jobTitle],
    ["Employer", app.employer],
    ["Experience", app.experienceYears],
    ["Tech background", app.techBackground],
    ["Coding-agent experience", app.agentExperience],
    ["Apple-platform experience", app.applePlatform],
    ["Test devices", app.testDevices],
    ["Why an effective tester", app.motivation],
    ["Links", app.links ?? "—"],
    ["Heard about Nucleic", app.referral ?? "—"],
  ];
  const table = rows
    .map(
      ([k, v]) =>
        `<tr><td style="padding:6px 12px 6px 0;color:#8b8590;vertical-align:top;white-space:nowrap">${esc(
          k,
        )}</td><td style="padding:6px 0">${esc(v)}</td></tr>`,
    )
    .join("");

  const html = `<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;color:#181520;line-height:1.5">
  <h2 style="margin:0 0 4px">New trusted-tester application</h2>
  <p style="color:#5b5560;margin:0 0 16px">${esc(app.firstName)} ${esc(app.lastName)} · ${esc(app.email)}</p>
  <table style="border-collapse:collapse;font-size:14px;margin-bottom:24px">${table}</table>
  <p style="margin:0 0 16px">
    <a href="${approve}" style="display:inline-block;background:#5fa775;color:#fff;text-decoration:none;padding:11px 20px;border-radius:10px;font-weight:600;margin-right:10px">Approve</a>
    <a href="${reject}" style="display:inline-block;background:#f3ede6;color:#181520;text-decoration:none;padding:11px 20px;border-radius:10px;font-weight:600">Reject</a>
  </p>
  <p style="color:#8b8590;font-size:12px">Approving shows the details to add them to the ${esc(
    TARGET_GROUP,
  )} in App Store Connect.</p>
  </body></html>`;

  const text =
    `New trusted-tester application\n\n` +
    rows.map(([k, v]) => `${k}: ${v}`).join("\n") +
    `\n\nApprove: ${approve}\nReject:  ${reject}\n`;

  await env.OWNER_EMAIL.send({
    from,
    to: OWNER_ADDRESS,
    subject: `Trusted tester: ${app.firstName} ${app.lastName} (${app.employer})`,
    html,
    text,
  });
}

// MARK: - Rendered pages (owner-facing, clicked from the email)

function page(status: number, title: string, body: string): Response {
  const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(
    title,
  )}</title></head><body style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;background:#faf7f3;color:#181520;display:flex;min-height:100vh;margin:0;align-items:center;justify-content:center">
  <div style="max-width:520px;padding:32px;background:#fffdfb;border:1px solid #e6ded4;border-radius:16px;margin:24px">
  <h1 style="margin:0 0 12px;font-size:22px">${esc(title)}</h1>${body}</div></body></html>`;
  return new Response(html, { status, headers: { "content-type": "text/html; charset=utf-8" } });
}

function approvedPage(app: Application): Response {
  return page(
    200,
    "Approved ✓",
    `<p style="color:#5b5560">Add this person as an <b>internal tester</b> in App Store Connect →
     TestFlight → ${esc(TARGET_GROUP)}:</p>
     <table style="border-collapse:collapse;font-size:15px;margin:12px 0">
       <tr><td style="padding:4px 12px 4px 0;color:#8b8590">First name</td><td><b>${esc(app.firstName)}</b></td></tr>
       <tr><td style="padding:4px 12px 4px 0;color:#8b8590">Last name</td><td><b>${esc(app.lastName)}</b></td></tr>
       <tr><td style="padding:4px 12px 4px 0;color:#8b8590">Email</td><td><b>${esc(app.email)}</b></td></tr>
     </table>
     <p style="color:#8b8590;font-size:13px">Apple emails them the TestFlight invite once you add them. This page is safe to close.</p>`,
  );
}

function rejectedPage(app: Application): Response {
  return page(
    200,
    "Rejected",
    `<p style="color:#5b5560">${esc(app.firstName)} ${esc(
      app.lastName,
    )}'s application was declined. No further action needed.</p>`,
  );
}
