#!/usr/bin/env node
/**
 * The ECZ-ID quickstart, as one runnable file.
 *
 *   curl -sO https://developers.ecocitizenz.com/examples/eczid-quickstart.mjs
 *   node eczid-quickstart.mjs                       # uses the first-party record
 *   node eczid-quickstart.mjs ECZ-GB-XXXXXX         # or any ECZ-ID you like
 *
 * Node 18+. Zero dependencies, on purpose: an integration example that makes you
 * install something has already stopped being an example of the integration.
 *
 * It does four things and refuses to do a fifth:
 *   1. reads the free-Passport acquisition contract, live
 *   2. resolves a public record, live
 *   3. applies a policy to what came back
 *   4. builds the acquisition hand-off URL for a machine with no record
 *
 * It never issues, mints, binds or writes anything. There is no such call in
 * here, and there is no credential for one.
 *
 * Canonical: https://developers.ecocitizenz.com/examples/eczid-quickstart.mjs
 * Walkthrough: https://developers.ecocitizenz.com/quickstart/
 */

const MACHINE_RECORD_BASE = 'https://api.ecocitizenz.com';
const RESOLVER_BASE = 'https://resolver.ecocitizenz.org';
const TRUSTOPS_BASE = 'https://trustops.ecocitizenz.com';
const FAMILIES = `${TRUSTOPS_BASE}/api/v1/acquisition/families`;

/** The operator of the Developer Gateway. A real, live record to read. */
const DEFAULT_TARGET = 'ECZ-GB-RBS1NW';

const TIMEOUT_MS = 10_000;

async function get(url) {
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
  try {
    return await fetch(url, { signal: ac.signal, headers: { accept: 'application/json' } });
  } finally {
    clearTimeout(timer);
  }
}

/* ------------------------------------------------------------------ *
 * 1. Which families are free and open — read live, never hard-coded.
 *
 * The contract is explicit that `available` decides whether you render a
 * call to action, and that it changes with no release on your side. A
 * copied list is the commonest way an integration starts lying.
 * ------------------------------------------------------------------ */
async function readAcquisitionContract() {
  const res = await get(FAMILIES);
  if (!res.ok) {
    // Unreadable is not "no families". Show nothing rather than a stale list.
    return { readable: false, status: res.status, families: [] };
  }
  const body = await res.json();
  return {
    readable: true,
    contract: body.contract_version,
    free: body.law?.payment_required === false,
    signIns: body.law?.authentications_required,
    families: body.families ?? [],
  };
}

/* ------------------------------------------------------------------ *
 * 2. Resolve a public record.
 *
 * Three outcomes, and the third is not the second:
 *   found       a record exists — which is still not proof
 *   absent      404/410 → NO_PUBLIC_ECZ_ID_FOUND, never a verdict
 *   unreadable  429/503 → retry; do NOT downgrade the counterparty
 * ------------------------------------------------------------------ */
async function resolve(eczId) {
  const url = `${MACHINE_RECORD_BASE}/api/p/${encodeURIComponent(eczId)}.json`;
  let res;
  try {
    res = await get(url);
  } catch (err) {
    return { state: 'RESOLVER_UNREADABLE', retryable: true, detail: String(err.message ?? err) };
  }

  if (res.status === 404 || res.status === 410) {
    return { state: 'NO_PUBLIC_ECZ_ID_FOUND', retryable: false };
  }
  if (res.status === 429 || res.status === 503) {
    return { state: 'RESOLVER_UNREADABLE', retryable: true, detail: `HTTP ${res.status}` };
  }
  if (!res.ok) {
    return { state: 'RESOLVER_UNREADABLE', retryable: true, detail: `HTTP ${res.status}` };
  }

  return { state: 'PUBLIC_RECORD_FOUND', retryable: false, record: await res.json() };
}

/* ------------------------------------------------------------------ *
 * 3. Your policy. ECZ-ID reports; it does not decide for you.
 *
 * The record names the states its publisher expects you to fail closed on.
 * Using that list beats inventing your own, and it moves when they move.
 * ------------------------------------------------------------------ */
function decide(resolution, { requireRecord = false } = {}) {
  if (resolution.state === 'RESOLVER_UNREADABLE') {
    return { allow: false, why: 'Could not read the record. Retry — this is not a finding.' };
  }
  if (resolution.state === 'NO_PUBLIC_ECZ_ID_FOUND') {
    return {
      allow: !requireRecord,
      why: requireRecord
        ? 'No public record, and your policy requires one. Not a judgement about the machine.'
        : 'No public record. Your policy does not require one, so this changes nothing.',
    };
  }

  const v2 = resolution.record.resolver_v2;
  const lifecycle = v2?.state?.lifecycle_state ?? 'UNKNOWN';
  const failClosed = v2?.machine_policy?.fail_closed_states ?? [];

  if (failClosed.includes(lifecycle)) {
    return { allow: false, why: `Lifecycle state ${lifecycle} is one the publisher says to fail closed on.` };
  }
  return { allow: true, why: `Lifecycle state ${lifecycle}. Re-check before the next reliance.` };
}

/* ------------------------------------------------------------------ *
 * 4. The hand-off, for a machine with no record.
 *
 * Forwardable context only, so the operator does not retype what you
 * already know. Never `tier`, `verified`, `ecz_id`, `assured`,
 * `independently_verified`, `parent_tier` or `lifecycle_state` — a surface
 * cannot assert identity or tier by URL, and the far end refuses them.
 * ------------------------------------------------------------------ */
function buildHandoff(family, { identifier, identifierKind, returnTo } = {}) {
  const url = new URL(family.start_path, TRUSTOPS_BASE);
  url.searchParams.set('source_surface', 'developer_gateway');
  url.searchParams.set('origin_surface', 'eczid-quickstart-example');
  url.searchParams.set('requested_passport_type', family.type_code);
  // Send the identifier exactly as the operator typed it. Do NOT normalise:
  // the digest is computed from the far end's normalisation, not yours.
  if (identifier) url.searchParams.set('identifier', identifier);
  if (identifierKind) url.searchParams.set('identifier_kind', identifierKind);
  if (returnTo) url.searchParams.set('return_to', returnTo);
  return url.toString();
}

/* ------------------------------------------------------------------ */

async function main() {
  const target = process.argv[2] ?? DEFAULT_TARGET;
  const line = (s = '') => process.stdout.write(s + '\n');

  line('ECZ-ID quickstart');
  line('=================');
  line();

  line('1. Acquisition contract (live)');
  const contract = await readAcquisitionContract();
  if (!contract.readable) {
    line(`   Could not read it (HTTP ${contract.status}). That is a read failure here,`);
    line('   not a statement about any family. Nothing is rendered from memory.');
  } else {
    line(`   ${contract.contract}`);
    line(`   payment required: ${contract.free ? 'no' : 'yes'}   sign-ins: ${contract.signIns}`);
    for (const f of contract.families) {
      const mark = (f.available ? 'open' : 'not yet').padEnd(7);
      line(`   ${mark}  ${f.family_display.padEnd(18)} ${f.display_name}`);
    }
  }
  line();

  line(`2. Resolve ${target}`);
  const resolution = await resolve(target);
  line(`   ${resolution.state}`);
  if (resolution.state === 'PUBLIC_RECORD_FOUND') {
    const v2 = resolution.record.resolver_v2 ?? {};
    line(`   entity          ${resolution.record.entity_name ?? '(not published)'}`);
    line(`   lifecycle       ${v2.state?.lifecycle_state ?? '(none)'}`);
    line(`   parent tier     ${v2.state?.parent_tier ?? '(none)'}`);
    line(`   binding         ${v2.binding?.state ?? '(none)'}`);
    line(`   can rely on     ${(v2.reliance?.can_rely_on ?? []).join(', ') || '(nothing stated)'}`);
    line(`   human record    ${RESOLVER_BASE}/p/${target}`);
  } else if (resolution.detail) {
    line(`   ${resolution.detail}`);
  }
  line();

  line('3. Policy decision (yours, not ours)');
  const open = decide(resolution);
  const strict = decide(resolution, { requireRecord: true });
  line(`   permissive  allow=${open.allow}  ${open.why}`);
  line(`   strict      allow=${strict.allow}  ${strict.why}`);
  line();

  line('4. Hand-off for a machine with no record');
  const openFamilies = contract.families.filter((f) => f.available);
  if (openFamilies.length === 0) {
    line('   No family is open right now, so there is no call to action to show.');
    line('   That is a destination, not an error.');
  } else {
    for (const f of openFamilies) {
      line(`   ${f.display_name}`);
      line(`     ${buildHandoff(f)}`);
    }
  }
  line();

  line('A record is not proof. Absence of a record is not a verdict.');
  line('Re-check before reliance. Local policy decides.');
}

main().catch((err) => {
  process.stderr.write(`failed: ${err?.message ?? err}\n`);
  process.exitCode = 1;
});
