// API panel — the org-facing integration reference.
//
// Replaces the old fictional "API Playground" (a mock POST /v1/retrieve with
// invented pk_* keys). Everything here is a real endpoint on the configured
// backend, so a customer can read this page and write a working client.
//
// The prose mirrors parcle-knowledge/docs/partner_api_quickstart.md — when an
// endpoint's contract changes, update both. Base URL and org account id are
// filled in from the live session so the snippets are copy-paste runnable.

const { useState: useApiDoc } = React;

// Mock mode has no backend to point at. The snippets still need *a* host to
// read as runnable, so they fall back to a marked placeholder rather than to
// Parcle's own production domain — on a customer-branded deploy that domain
// would read as "this workspace's backend", which it is not.
const API_DOC_PLACEHOLDER_BASE = 'https://<your-parcle-backend>';

// True when this console is wired to a real backend. Everything that would
// send the reader somewhere (Swagger, OpenAPI) is gated on it.
function apiDocConfigured() {
  try { return !!(typeof parcleApiBase === 'function' && parcleApiBase()); }
  catch (e) { return false; }
}

function apiDocBase() {
  try { return (typeof parcleApiBase === 'function' && parcleApiBase()) || API_DOC_PLACEHOLDER_BASE; }
  catch (e) { return API_DOC_PLACEHOLDER_BASE; }
}

// The signed-in org's account id — the value a member-user integration would
// pass as ?target_account_id=. Placeholder when signed out (mock mode).
function apiDocOrgId() {
  try {
    const a = (typeof parcleAccount === 'function') ? parcleAccount() : null;
    return (a && a.id) || '<org_account_id>';
  } catch (e) { return '<org_account_id>'; }
}

function apiDocOrgEmail() {
  try {
    const a = (typeof parcleAccount === 'function') ? parcleAccount() : null;
    return (a && a.email) || 'team@example.com';
  } catch (e) { return 'team@example.com'; }
}

// The fixed code this provisioned workspace signs in with (the backend's
// `provisioned_account_credentials` — its login name is not a mailbox and is
// never emailed). Normally UNSET here, so the verify snippet carries a
// placeholder: config.js ships inside the image and nginx serves it verbatim,
// so a real code there would be downloadable by anyone before signing in. We
// hand the customer their code directly. `signinCode` stays supported for local
// work via the gitignored config.local.js.
function apiDocSigninCode() {
  try { return window.PARCLE_SIGNIN_CODE || '<6-digit-code>'; }
  catch (e) { return '<6-digit-code>'; }
}

// Whether the snippet above is runnable as-is or is waiting on the reader to
// paste their own code — the prose around it has to say different things.
function apiDocHasSigninCode() {
  try { return !!window.PARCLE_SIGNIN_CODE; }
  catch (e) { return false; }
}

// ── small building blocks ─────────────────────────────────────────────────

const API_METHOD_COLORS = {
  GET:    { bg: 'var(--accent-muted)', fg: 'var(--accent-text)' },
  POST:   { bg: 'var(--accent)',       fg: 'var(--on-accent)' },
  DELETE: { bg: 'var(--hover)',        fg: 'var(--text-secondary)' },
};

function ApiEndpoint({ method, path, note }) {
  const c = API_METHOD_COLORS[method] || API_METHOD_COLORS.GET;
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', margin: '18px 0 10px' }}>
      <span className="mono" style={{
        fontSize: 10, fontWeight: 600, letterSpacing: '0.04em',
        padding: '2px 7px', borderRadius: 4,
        background: c.bg, color: c.fg,
      }}>{method}</span>
      <span className="mono" style={{ fontSize: 13, color: 'var(--text-primary)' }}>{path}</span>
      {note && <span className="mono-sm mono text-tertiary">{note}</span>}
    </div>
  );
}

function ApiSection({ title, children }) {
  return (
    <div style={{ marginTop: 28 }}>
      <h2 className="t-h2" style={{ margin: 0, marginBottom: 6 }}>{title}</h2>
      {children}
    </div>
  );
}

// kind: 'warn' (something will bite you) | 'info' (context)
function ApiNote({ kind = 'info', children }) {
  const warn = kind === 'warn';
  return (
    <div style={{
      display: 'flex', gap: 10, alignItems: 'flex-start',
      padding: '10px 14px', marginTop: 12,
      borderRadius: 8,
      border: '1px solid var(--border-subtle)',
      borderLeft: `2px solid ${warn ? 'var(--error)' : 'var(--accent)'}`,
      background: 'var(--surface)',
    }}>
      {warn
        ? <Icons.AlertTri size={14} style={{ color: 'var(--error)', flexShrink: 0, marginTop: 2 }}/>
        : <Icons.Dot size={14} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }}/>}
      <div className="t-small text-secondary" style={{ lineHeight: '20px' }}>{children}</div>
    </div>
  );
}

function ApiTable({ head, rows }) {
  return (
    <div className="card" style={{ overflow: 'hidden', marginTop: 12 }}>
      <table className="parcle">
        <thead><tr>{head.map((h, i) => <th key={i}>{h}</th>)}</tr></thead>
        <tbody>
          {rows.map((r, i) => (
            /* These tables are reference material, not a list of things to click —
               opt out of the global row-hover pointer. */
            <tr key={i} style={{ cursor: 'default' }}>
              {r.map((cell, j) => (
                <td key={j} className={j === 0 ? 'mono-sm mono' : ''}
                    style={j === 0 ? { whiteSpace: 'nowrap', color: 'var(--text-primary)' } : { color: 'var(--text-secondary)' }}>
                  {cell}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function ApiP({ children }) {
  return <div className="text-secondary t-body" style={{ marginTop: 10, lineHeight: '22px' }}>{children}</div>;
}

function ApiCode({ children }) {
  return <span className="mono" style={{ fontSize: 12, color: 'var(--text-primary)' }}>{children}</span>;
}

// CodeBlock carries no outer margin of its own, so a snippet following prose, a
// note, or another snippet sits flush against it — two adjacent blocks read as
// one box with a doubled border. Every snippet on this page goes through here.
function ApiSnippet(props) {
  return <div style={{ marginTop: 12 }}><CodeBlock {...props}/></div>;
}

// ── 1. Authentication ─────────────────────────────────────────────────────

function ApiAuthSection() {
  const base = apiDocBase();
  const email = apiDocOrgEmail();
  return (
    <div>
      <ApiP>
        Sign-in is <strong>email + one-time code</strong> — there is no password and no OAuth.
        Two calls get you a token; a third turns it into a long-lived one for unattended use.
      </ApiP>

      <ApiSection title="Step 1 — request a code">
        <ApiEndpoint method="POST" path="/auth/register/web/start"/>
        <ApiSnippet language="bash">{`curl -X POST ${base}/auth/register/web/start \\
  -H 'Content-Type: application/json' \\
  -d '{"email":"${email}","kind":"org"}'`}</ApiSnippet>
        <ApiP>
          A 6-digit code is emailed and stays valid for about 10 minutes.
          The organization account must already exist: this call is both sign-up and sign-in,
          but it only ever <em>creates</em> personal accounts, so <ApiCode>"kind":"org"</ApiCode> is
          refused with <strong>403</strong> unless the email already is an organization — ask us
          to provision the workspace first. Re-send with <ApiCode>POST /auth/register/resend</ApiCode>;
          calling it too soon returns <strong>429</strong>.
        </ApiP>
        <ApiNote>
          <strong>This workspace doesn't use email at all.</strong> It is a <em>provisioned</em>
          account: it signs in as the login name <ApiCode>{apiDocOrgEmail()}</ApiCode>, which is not a
          mailbox, so nothing is ever sent to it. Instead the account is issued a fixed code when we
          create it, and you type that code in. The call above is still required — it is what opens the
          10-minute window the code is checked against — there is just no message to go and fetch.
          {' '}{apiDocHasSigninCode()
            ? <>Your code is already filled into the Step 2 snippet below.</>
            : <><strong>We give you that code directly.</strong> It is deliberately not published in
              this console — everything these pages contain is downloadable by anyone who can reach the
              site — so the Step 2 snippet below leaves a <ApiCode>&lt;6-digit-code&gt;</ApiCode>{' '}
              placeholder to paste it into. Ask us if you don't have it.</>}
          {' '}The login name is case-insensitive. The fixed code is checked <em>after</em> the expiry
          and attempt limits, so it is not a way around them: a wrong guess burns the same attempt
          counter as any other code, too many in one window return <strong>429</strong>, and once the
          10 minutes are up it is <strong>410</strong> — start again from Step 1.
        </ApiNote>
      </ApiSection>

      <ApiSection title="Step 2 — exchange the code for a token">
        <ApiEndpoint method="POST" path="/auth/register/web/verify"/>
        <ApiSnippet language="bash">{`curl -X POST ${base}/auth/register/web/verify \\
  -H 'Content-Type: application/json' \\
  -d '{"email":"${email}","code":"${apiDocSigninCode()}"}'`}</ApiSnippet>
        <ApiSnippet language="json">{`{
  "access_token": "…",
  "token_type": "bearer",
  "account": { "id": "${apiDocOrgId()}", "kind": "org", "email": "${email}" }
}`}</ApiSnippet>
        <ApiP>
          Check <ApiCode>account.kind === "org"</ApiCode> before using the token. The token is valid
          for <strong>30 days</strong>; <ApiCode>POST /auth/logout</ApiCode> revokes it, and
          <ApiCode> GET /auth/me</ApiCode> is a cheap liveness probe. An expired or revoked token
          returns <strong>401</strong> everywhere. A wrong code is also <strong>401</strong> and burns an
          attempt; once the 10-minute window has closed it is <strong>410</strong> — go back to step 1.
        </ApiP>
      </ApiSection>

      <ApiSection title="Step 3 — a long-lived token (recommended for servers)">
        <ApiEndpoint method="POST" path="/v1/memories/hook-session" note="365 days"/>
        <ApiSnippet language="bash">{`curl -X POST ${base}/v1/memories/hook-session \\
  -H "Authorization: Bearer $SESSION_TOKEN"
# → {"token":"…","expires_in_days":365}`}</ApiSnippet>
        <ApiP>
          A 30-day token means someone has to read an inbox every month. The token this returns is an
          ordinary bearer for the same account and works on every endpoint on this page.
        </ApiP>
        <ApiNote kind="warn">
          It is shown once and cannot be listed or retrieved again — store it as a secret, and mint a
          replacement before it expires. Revoking one means calling <ApiCode>/auth/logout</ApiCode> with
          that exact token.
        </ApiNote>
      </ApiSection>

      <ApiSection title="Using the token">
        <ApiSnippet language="http">{`Authorization: Bearer <token>`}</ApiSnippet>
        <ApiNote kind="warn">
          The <ApiCode>pmem_…</ApiCode> developer keys are <strong>not</strong> usable here. They
          authenticate the personal-memory API (<ApiCode>/v1/memories/*</ApiCode>) only, and are rejected
          by <ApiCode>/skill/*</ApiCode> and <ApiCode>/ingestion/*</ApiCode>.
        </ApiNote>
      </ApiSection>
    </div>
  );
}

// ── 2. File upload ────────────────────────────────────────────────────────

function ApiUploadSection() {
  const base = apiDocBase();
  return (
    <div>
      <ApiP>
        Uploading is asynchronous: the request returns a <strong>job</strong>, and the file only becomes
        answerable by the ask stream once that job reaches <ApiCode>done</ApiCode>.
      </ApiP>

      <ApiSection title="Submit a file">
        <ApiEndpoint method="POST" path="/ingestion/file" note="multipart/form-data"/>
        <ApiSnippet language="bash">{`curl -X POST ${base}/ingestion/file \\
  -H "Authorization: Bearer $TOKEN" \\
  -F 'file=@handbook.pdf'`}</ApiSnippet>
        <ApiSnippet language="json">{`{
  "id": "<job_uuid>", "account_id": "…", "kind": "ingest_file",
  "status": "pending", "progress": 0.0, "error": null,
  "started_at": null, "finished_at": null, "created_at": "…"
}`}</ApiSnippet>
        <ApiP>
          Single form field named <ApiCode>file</ApiCode>. Let your HTTP client set
          <ApiCode> Content-Type</ApiCode> itself so the multipart boundary is generated correctly.
        </ApiP>
        <ApiNote kind="warn">
          <strong>Calling as a member user?</strong> Ingestion does not enforce the org scope the way
          <ApiCode> /skill/*</ApiCode> does. Without
          <ApiCode> ?target_account_id={apiDocOrgId()}</ApiCode> the upload returns <strong>200</strong>,
          the job runs to <ApiCode>done</ApiCode> — and the file lands in <em>your personal</em> memory,
          where the org's ask stream will never find it. There is no error to catch; the only symptom is
          an answer that cannot see the document. Always send the parameter:
        </ApiNote>
        <ApiSnippet language="bash">{`curl -X POST "${base}/ingestion/file?target_account_id=${apiDocOrgId()}" \\
  -H "Authorization: Bearer $TOKEN" \\
  -F 'file=@handbook.pdf'`}</ApiSnippet>
      </ApiSection>

      <ApiSection title="What you can upload">
        <ApiTable
          head={['Category', 'Extensions']}
          rows={[
            ['documents', '.pdf .docx .pptx .txt .md .markdown .html .htm .json .xml .msg'],
            ['tables', '.csv .tsv .xlsx .xlsm'],
            ['native databases', '.sqlite .sqlite3 .duckdb'],
            ['images', '.png .jpg .jpeg .gif .webp .bmp .tiff — transcribed to text'],
          ]}/>
        <ApiP>
          Anything else is <strong>415</strong>. Files are capped at <strong>100 MB</strong> (<strong>413</strong> beyond),
          and storage counts against the org's plan quota. Tables and database files are classified on
          ingest: real datasets become <strong>SQL-queryable</strong> and show up as tables in the graph;
          everything else is chunked and indexed as a document.
        </ApiP>
      </ApiSection>

      <ApiSection title="Poll until it lands">
        <ApiEndpoint method="GET" path="/ingestion/jobs/{job_id}"/>
        <ApiSnippet language="bash">{`curl -H "Authorization: Bearer $TOKEN" \\
  ${base}/ingestion/jobs/$JOB_ID`}</ApiSnippet>
        <ApiP>
          Same job shape. <ApiCode>status</ApiCode> moves <ApiCode>pending → running → done | failed</ApiCode>,
          <ApiCode> progress</ApiCode> is 0.0–1.0, and <ApiCode>error</ApiCode> carries the reason on
          failure. Poll every few seconds — large PDFs and spreadsheets take minutes. Content is only
          retrievable through the ask stream, or visible in the graph, after <ApiCode>done</ApiCode>.
        </ApiP>
      </ApiSection>
    </div>
  );
}

// ── 3. Graph editing ──────────────────────────────────────────────────────

function ApiGraphSection() {
  const base = apiDocBase();
  return (
    <div>
      <ApiNote>
        Node and edge ids are assigned by the backend. Always read ids from a snapshot — never construct
        them.
      </ApiNote>

      <ApiSection title="Read the graph">
        <ApiEndpoint method="GET" path="/skill/graph"/>
        <ApiP>
          Returns the ontology-shaped snapshot: seven separate arrays, <em>not</em> a flattened
          <ApiCode> nodes</ApiCode>/<ApiCode>edges</ApiCode> pair.
        </ApiP>
        <ApiSnippet language="json">{`{
  "factual_nodes": [
    { "id": "table_orders", "name": "orders", "type": "table",
      "properties": {
        "description": "Customer orders",
        "columns": { "amount": { "type": "DOUBLE", "description": "USD" } }
      },
      "origin": "schema_scan", "state": "active" }
  ],
  "concept_nodes": [
    { "id": "concept_gmv", "name": "GMV", "synonyms": [],
      "properties": { "description": "…", "formula": "sum(orders.amount)" },
      "origin": "conversation_learning", "state": "active" }
  ],
  "structure_edges": [], "mapping_edges": [], "dependency_edges": [],
  "relation_edges": [], "category_edges": []
}`}</ApiSnippet>
        <ApiP>
          <ApiCode>factual_nodes</ApiCode> mirror physical sources (tables, columns, documents, chunks) —
          a table's schema lives in <ApiCode>properties.columns</ApiCode> as a <strong>dict keyed by
          column name</strong>, not an array. <ApiCode>concept_nodes</ApiCode> are business concepts.
          <ApiCode> state</ApiCode> is <ApiCode>active</ApiCode> or <ApiCode>undo</ApiCode>.
        </ApiP>
      </ApiSection>

      <ApiSection title="Patch a node or edge">
        <ApiEndpoint method="POST" path="/skill/graph/edit"/>
        <ApiSnippet language="bash">{`curl -X POST ${base}/skill/graph/edit \\
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\
  -d '{"target_type":"table","target_id":"table_orders",
       "patch":{"properties.description":"Orders placed by customers"}}'

# as a member user of the org, every /skill/* call carries the scope:
curl -X POST "${base}/skill/graph/edit?target_account_id=${apiDocOrgId()}" \\
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\
  -d '{"target_type":"table","target_id":"table_orders","patch":{}}'`}</ApiSnippet>
        <ApiTable
          head={['target_type', 'Selected by', 'Patchable keys']}
          rows={[
            ['concept', 'target_id', 'name, properties.description, properties.formula, synonyms'],
            ['table', 'target_id', 'properties.description, properties.columns.<column>.description, properties.columns.<column>.value_description'],
            ['edge', 'edge (selector)', 'confidence — the only editable edge field'],
          ]}/>
        <ApiP>
          <ApiCode>synonyms</ApiCode> must contain <strong>every currently-active synonym plus your
          additions</strong> — a list that drops one is rejected. Column edits propagate to the promoted
          column node when one exists. Structure edges have no confidence and reject the patch.
          Errors: <strong>400</strong> unsupported field or target, <strong>404</strong> target not found.
        </ApiP>
        <ApiSnippet language="json">{`{
  "changed": true,
  "nodes_upserted": [ { "id": "table_orders", "name": "orders", "type": "table", "state": "active" } ],
  "edges_upserted": []
}`}</ApiSnippet>
      </ApiSection>

      <ApiSection title="Selecting an edge">
        <ApiP>
          An edge has no id — it is identified by a selector object, passed as the
          <ApiCode> edge</ApiCode> field instead of <ApiCode>target_id</ApiCode>:
        </ApiP>
        <ApiSnippet language="json">{`{
  "target_type": "edge",
  "edge": { "type": "relation", "source": "concept_gmv",
            "target": "concept_revenue", "relation": "references" },
  "patch": { "confidence": 0.9 }
}`}</ApiSnippet>
        <ApiP>
          <ApiCode>type</ApiCode> is one of <ApiCode>structure | mapping | dependency | relation | category</ApiCode>.
          <ApiCode> source</ApiCode>/<ApiCode>target</ApiCode> are always required;
          <ApiCode> relation</ApiCode> is required for relation edges, <ApiCode>value</ApiCode> for category
          edges, and <ApiCode>from_column</ApiCode>/<ApiCode>to_column</ApiCode> for FK structure edges.
        </ApiP>
      </ApiSection>

      <ApiSection title="Remove and restore">
        <ApiEndpoint method="POST" path="/skill/graph/undo"/>
        <ApiEndpoint method="POST" path="/skill/graph/recover"/>
        <ApiSnippet language="json">{`{ "target_type": "node", "target_id": "table_orders" }`}</ApiSnippet>
        <ApiP>
          <ApiCode>target_type</ApiCode> ∈ <ApiCode>node | edge | dedup | rerank | schema_inference</ApiCode>
          (for <ApiCode>edge</ApiCode>, pass an <ApiCode>edge</ApiCode> selector instead of
          <ApiCode> target_id</ApiCode>). Undo flips <ApiCode>state</ApiCode> to <ApiCode>undo</ApiCode> so
          the agent stops using it; recover flips it back. This is the reversible removal — use it.
        </ApiP>
      </ApiSection>

      <ApiSection title="Create a concept or edge">
        <ApiEndpoint method="POST" path="/skill/graph/create"/>
        <ApiSnippet language="json">{`{ "target_type": "concept",
  "concept": { "name": "Net revenue", "description": "GMV less refunds",
               "formula": "sum(orders.amount) - sum(refunds.amount)",
               "synonyms": ["NR"] } }`}</ApiSnippet>
        <ApiSnippet language="json">{`{ "target_type": "edge",
  "edge": { "type": "relation", "source": "concept_gmv", "target": "concept_revenue",
            "relation": "references", "confidence": 0.8 } }`}</ApiSnippet>
        <ApiP>
          <ApiCode>target_type</ApiCode> is <ApiCode>concept</ApiCode> or <ApiCode>edge</ApiCode>. A concept
          needs only <ApiCode>name</ApiCode>; an edge needs <ApiCode>type</ApiCode>,
          <ApiCode> source</ApiCode> and <ApiCode>target</ApiCode>, with
          <ApiCode> relation</ApiCode> for relation edges and <ApiCode>value</ApiCode> for category edges
          (both <strong>400</strong> when blank). <ApiCode>confidence</ApiCode> defaults to
          <ApiCode> 0.8</ApiCode>, and category edges also take <ApiCode>data_type</ApiCode> (default
          <ApiCode> TEXT</ApiCode>). Returns the same shape as <ApiCode>/skill/graph/edit</ApiCode>.
        </ApiP>
        <ApiTable
          head={['Edge type', 'What it may connect']}
          rows={[
            ['mapping', 'concept (source) → factual node (target)'],
            ['dependency · relation · category', 'concept → concept'],
            ['structure', 'not creatable — comes from the ingested schema'],
          ]}/>
        <ApiP>
          Both endpoints of a new edge must already exist (<strong>404</strong> otherwise) and be
          <ApiCode> active</ApiCode> — pointing at a node left in <ApiCode>undo</ApiCode> is
          <strong> 400</strong> ("recover it first"). A concept whose name or synonym is already active is
          <strong> 409</strong>, as is an edge that already exists — including one sitting in
          <ApiCode> undo</ApiCode>, which is a signal to call <ApiCode>/skill/graph/recover</ApiCode>
          instead of creating a second copy. A concept's id is derived from its name
          (<ApiCode>concept_&lt;slug&gt;</ApiCode>, suffixed on collision) — read it back from
          <ApiCode> nodes_upserted[0].id</ApiCode> rather than guessing it.
        </ApiP>
      </ApiSection>

      <ApiSection title="Hard delete">
        <ApiEndpoint method="POST" path="/skill/graph/delete" note="permanent"/>
        <ApiSnippet language="json">{`{ "target_type": "node", "target_id": "concept_gmv" }`}</ApiSnippet>
        <ApiSnippet language="json">{`{
  "changed": true,
  "nodes_deleted": ["concept_gmv"],
  "edges_deleted": [
    { "type": "mapping",  "source": "concept_gmv", "target": "table_orders" },
    { "type": "relation", "source": "concept_gmv", "target": "concept_revenue",
      "relation": "references" }
  ]
}`}</ApiSnippet>
        <ApiP>
          <ApiCode>target_type</ApiCode> is <ApiCode>node</ApiCode> or <ApiCode>edge</ApiCode> (for an edge,
          pass an <ApiCode>edge</ApiCode> selector instead of <ApiCode>target_id</ApiCode>). The response is
          a <ApiCode>nodes_deleted</ApiCode>/<ApiCode>edges_deleted</ApiCode> pair, not the upsert one.
        </ApiP>
        <ApiNote kind="warn">
          This removes the rows for good — there is no <ApiCode>recover</ApiCode> for a hard delete. Reach
          for <ApiCode>/skill/graph/undo</ApiCode> unless you really mean to erase.
          <strong> It also cascades</strong>: deleting a node deletes every edge touching it, and deleting a
          table or document takes its column / chunk nodes (and their edges) with it — so the reply can
          list far more ids than you named. Nothing warns you first.
        </ApiNote>
        <ApiP>
          It removes <strong>graph rows only, not the underlying source</strong>. Factual nodes mirror
          something physical, so a deleted table, column, document or chunk comes back the next time that
          source is scanned or ingested — keeping it out of the graph for good means removing or excluding
          the source itself. Concepts and concept edges have no physical mirror and stay deleted, though
          the agent may learn a similar concept again from later conversations.
        </ApiP>
      </ApiSection>

      <ApiSection title="Change notifications">
        <ApiEndpoint method="GET" path="/skill/events" note="server-sent events · best-effort"/>
        <ApiP>
          A stream of data-change notifications (<ApiCode>ready</ApiCode> on connect, periodic
          <ApiCode> ping</ApiCode>, then events such as <ApiCode>concept_created</ApiCode>).
        </ApiP>
        <ApiNote kind="warn">
          Treat this as a hint, never as the source of truth. The hub is in-process and
          <strong> not partitioned by organization</strong>: it takes no token, and a backend running
          several workers only tells you about changes that happened on the worker you are connected to.
          Refresh from <ApiCode>GET /skill/graph</ApiCode> on your own schedule; use this only to refresh
          sooner.
        </ApiNote>
      </ApiSection>
    </div>
  );
}

// ── 4. Ask stream ─────────────────────────────────────────────────────────

function ApiAskSection() {
  const base = apiDocBase();
  return (
    <div>
      <ApiP>
        An analysis turn: the agent plans, queries the ingested tables, may render a chart, and answers.
        Use <ApiCode>/skill/ask/progress</ApiCode> whenever a human is watching — it streams intermediate
        progress. <ApiCode>/skill/ask</ApiCode> takes the identical request and emits only the
        <ApiCode> final</ApiCode> event.
      </ApiP>
      <ApiNote>
        <strong>Don't want to speak SSE at all?</strong> Use <ApiCode>/skill/ask</ApiCode> and just read
        the whole response body. It is served as <ApiCode>text/event-stream</ApiCode>, but it contains a
        single frame and then closes — around 1–2 KB. Any ordinary HTTP client works: POST, read the
        body, take the last <ApiCode>data:</ApiCode> line, parse it as JSON. No streaming library, no
        incremental parsing. The one requirement is a <strong>long read timeout</strong>, because the
        connection stays open for the whole turn.
      </ApiNote>
      <ApiSnippet language="bash">{`# the entire non-streaming client:
curl -s -X POST ${base}/skill/ask \\
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\
  -d '{"query":"How did revenue trend last quarter?","top_k":5}' \\
  | grep '^data:' | tail -1 | cut -c6- | jq -r .message`}</ApiSnippet>
      <ApiNote kind="warn">
        Send <ApiCode>conversation_id</ApiCode> only with an id the server gave you in an earlier
        <ApiCode> final</ApiCode>. An id you invent yourself is accepted and echoed back, the answer
        returns normally — but the turn is <strong>not stored</strong>, so it never appears in
        <ApiCode> /skill/conversations</ApiCode>. Omit the field to start a conversation and let the
        server mint the id.
      </ApiNote>

      <ApiSection title="Request">
        <ApiEndpoint method="POST" path="/skill/ask/progress" note="text/event-stream"/>
        <ApiSnippet language="bash">{`curl -N -X POST ${base}/skill/ask/progress \\
  -H "Authorization: Bearer $TOKEN" \\
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream' \\
  -d '{"query":"How did revenue trend last quarter?","top_k":5,"conversation_id":null}'`}</ApiSnippet>
        <ApiP>
          <ApiCode>query</ApiCode> is required; <ApiCode>top_k</ApiCode> is 1–20 (default 5); pass the
          <ApiCode> conversation_id</ApiCode> returned by a previous turn to continue that conversation,
          or <ApiCode>null</ApiCode> to start a new one.
        </ApiP>
      </ApiSection>

      <ApiSection title="Events">
        <ApiTable
          head={['event', 'When', 'Payload']}
          rows={[
            ['meta', 'first frame, before any work starts', '{conversation_id}'],
            ['step', 'each user-visible stage', '{id, label, step}'],
            ['knowledge', 'each source the agent consulted', '{type, id, name}'],
            ['final', 'exactly once — the answer', 'the full AskResponse'],
            ['done', 'stream terminator', 'metadata'],
            ['error', 'recoverable or terminal service error', '{detail, retryable?}'],
          ]}/>
        <ApiP>
          Standard SSE framing (<ApiCode>event:</ApiCode> + <ApiCode>data:</ApiCode>, blank line between).
          Lines starting with <ApiCode>:</ApiCode> are keepalive comments — ignore them.
        </ApiP>
        <ApiSnippet language="json">{`{
  "conversation_id": "uuid",
  "status": "success",
  "message": "Answer text to show the user.",
  "steps": [ { "id": "execute_sql", "title": "Query data", "status": "done",
               "message": "Found 12 rows.", "thought": "…" } ],
  "result": {
    "payloads": [ { "type": "chart", "format": "png",
                    "filename": "chart.png", "url": "/files/chart.png" } ],
    "sources":  [ { "type": "database", "id": "table:orders", "name": "orders" } ],
    "confidence": 0.82
  },
  "graph_delta": { "version": 1, "changed": false,
                   "nodes_upserted": [], "edges_upserted": [] }
}`}</ApiSnippet>
        <ApiP>
          When <ApiCode>status</ApiCode> is not <ApiCode>success</ApiCode>, <ApiCode>payloads</ApiCode> and
          <ApiCode> sources</ApiCode> are empty and <ApiCode>confidence</ApiCode> is <ApiCode>null</ApiCode>.
          Resolve payload <ApiCode>url</ApiCode>s against the base URL. <ApiCode>graph_delta</ApiCode>
          carries what the turn learned, so a client holding a snapshot can patch it instead of refetching.
        </ApiP>
      </ApiSection>

      <ApiSection title="Client requirements">
        <ApiP>These four bite in practice:</ApiP>
        <ApiTable
          head={['', 'What to do']}
          rows={[
            ['Long turns', 'A turn can run for minutes — set a long read timeout and disable response buffering in any proxy in front of your client.'],
            ['Silence', 'Keepalive comment frames arrive periodically; a quiet connection is not a failure.'],
            ['Backpressure', 'The 200 is already sent, so an overloaded server reports itself as an error event with retryable: true. Back off and retry.'],
            ['Dropped stream', 'The run is not cancelled — the backend finishes it and stores the turn. On /skill/ask/progress you can always fetch it back, because the meta frame hands you the conversation_id before any work starts: GET /skill/conversations/{id}. Keep that id the moment it arrives. /skill/ask has no meta frame, so a drop there loses a brand-new conversation’s answer, and re-asking bills a second LLM turn.'],
          ]}/>
        <ApiP>
          History: <ApiCode>GET /skill/conversations</ApiCode>,
          <ApiCode> GET /skill/conversations/{'{id}'}</ApiCode>,
          <ApiCode> DELETE /skill/conversations/{'{id}'}</ApiCode>.
        </ApiP>
      </ApiSection>

      <ApiSection title="Minimal client">
        <ApiSnippet language="python">{`import json, httpx

BASE, TOKEN = "${base}", "…"
H = {"Authorization": f"Bearer {TOKEN}", "Accept": "text/event-stream"}

with httpx.Client(base_url=BASE, headers=H, timeout=httpx.Timeout(600.0)) as c:
    with c.stream("POST", "/skill/ask/progress",
                  json={"query": "How did revenue trend last quarter?", "top_k": 5}) as s:
        s.raise_for_status()
        event, data = None, []
        for line in s.iter_lines():          # httpx strips the newline
            if line.startswith(":"):
                continue                     # keepalive
            if line.startswith("event:"):
                event = line[6:].strip()
            elif line.startswith("data:"):
                data.append(line[5:].lstrip())
            elif line == "":                 # end of frame
                if data:
                    payload = json.loads("\\n".join(data))
                    if event == "meta":
                        # arrives first — keep it, it is how you recover the
                        # answer if the connection drops mid-turn
                        print("conversation:", payload["conversation_id"])
                    elif event == "step":
                        print("·", payload.get("label"))
                    elif event == "knowledge":
                        print("source:", payload.get("name"))
                    elif event == "final":
                        print(payload["message"])
                        print("charts:", [p["url"] for p in payload["result"]["payloads"]])
                    elif event == "error":
                        print("error:", payload.get("detail"))
                event, data = None, []`}</ApiSnippet>
        <ApiP>
          Signed in as a member user rather than the org account? Add
          <ApiCode> params={'{'}"target_account_id": "{apiDocOrgId()}"{'}'}</ApiCode> to the
          <ApiCode> stream()</ApiCode> call — and to every upload and graph call.
        </ApiP>
      </ApiSection>
    </div>
  );
}

// ── status codes (shared tail) ────────────────────────────────────────────

function ApiStatusCodes() {
  return (
    <ApiSection title="Status codes">
      <ApiTable
        head={['Code', 'Meaning']}
        rows={[
          ['400', 'Malformed request, or an unsupported patch field'],
          ['401', 'Missing, invalid, or expired bearer token — or a wrong sign-in code'],
          ['403', 'Wrong scope — a user session without target_account_id, or not a member of the org'],
          ['404', 'Node, edge, conversation, or job not found'],
          ['409', 'Duplicate — concept name/synonym already active, or edge already exists'],
          ['410', 'Sign-in code expired — go back to step 1 for a fresh one'],
          ['413 / 415', 'Upload over 100 MB / unsupported file type'],
          ['429', 'Code re-sent too soon, too many wrong codes in one window, or the server is at capacity — honour Retry-After (3–8s)'],
          ['500', 'Genuine server error — send us the timestamp'],
        ]}/>
      <ApiP>
        Bodies are <ApiCode>{'{"detail": "…"}'}</ApiCode>. A 429 reading "Server is busy — too many
        requests in flight" is transient backpressure, not a quota: retry with jitter.
      </ApiP>
    </ApiSection>
  );
}

// ── page ──────────────────────────────────────────────────────────────────

const API_DOC_TABS = [
  ['auth',   '1 · Authentication', 'Key'],
  ['upload', '2 · File upload',    'Upload'],
  ['graph',  '3 · Graph editing',  'Graph'],
  ['ask',    '4 · Ask stream',     'Chat'],
];

function APIPage() {
  const [tab, setTab] = useApiDoc('auth');
  const base = apiDocBase();
  const orgId = apiDocOrgId();
  const configured = apiDocConfigured();

  return (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
      {/* Header — identity of the API this org is calling. Same max width as the
          body so the base URL lines up with the snippets that use it. */}
      <div style={{ borderBottom: '1px solid var(--border-subtle)', background: 'var(--bg)' }}>
        <div style={{ padding: '24px 32px 0', maxWidth: 964, margin: '0 auto' }}>
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 20, flexWrap: 'wrap' }}>
          <div style={{ flex: 1, minWidth: 280 }}>
            <h1 className="t-h1" style={{ margin: 0 }}>API</h1>
            <div className="text-secondary t-body mt-4">
              Call your organization's knowledge and graph from your own code.
            </div>
          </div>
          {/* Only offered when there is a real backend — otherwise these would
              send the reader to someone else's host. */}
          {configured && (
            <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
              <a className="btn sm" href={base + '/docs'} target="_blank" rel="noreferrer">
                <Icons.Doc size={13}/> Swagger <Icons.External size={11}/>
              </a>
              <a className="btn sm" href={base + '/openapi.json'} target="_blank" rel="noreferrer">
                <Icons.Code size={13}/> OpenAPI <Icons.External size={11}/>
              </a>
            </div>
          )}
        </div>

        {/* Base URL + scope — the two facts every snippet below depends on */}
        <div style={{ display: 'flex', gap: 28, flexWrap: 'wrap', marginTop: 18 }}>
          <div>
            <div className="t-micro text-tertiary">
              Base URL {!configured && <span style={{ color: 'var(--text-tertiary)' }}>· not configured</span>}
            </div>
            <div className="mono" style={{ fontSize: 13, marginTop: 4, color: configured ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>{base}</div>
          </div>
          <div>
            <div className="t-micro text-tertiary">
              Org account id {orgId.charAt(0) === '<' && <span>· sign in to fill</span>}
            </div>
            <div className="mono" style={{ fontSize: 13, marginTop: 4, color: orgId.charAt(0) === '<' ? 'var(--text-tertiary)' : 'var(--text-primary)' }}>{orgId}</div>
          </div>
        </div>

        <div style={{ display: 'flex', gap: 4, marginTop: 18, borderBottom: '1px solid var(--border-subtle)', overflowX: 'auto' }}>
          {API_DOC_TABS.map(([id, label, icon]) => {
            const Icon = Icons[icon];
            const active = tab === id;
            return (
              <button key={id} onClick={() => setTab(id)} style={{
                display: 'flex', alignItems: 'center', gap: 7,
                padding: '10px 14px',
                fontSize: 13, fontWeight: active ? 500 : 400,
                color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
                borderBottom: `2px solid ${active ? 'var(--accent)' : 'transparent'}`,
                marginBottom: -1,
                whiteSpace: 'nowrap',
              }}>
                <Icon size={14}/> {label}
              </button>
            );
          })}
        </div>
        </div>
      </div>

      {/* Body */}
      <div style={{ flex: 1, overflowY: 'auto' }}>
        <div style={{ padding: '24px 32px 80px', maxWidth: 964, margin: '0 auto' }}>
          <ApiNote>
            Every endpoint below needs <ApiCode>Authorization: Bearer &lt;token&gt;</ApiCode> and reads or
            writes <strong>this organization's</strong> data. Authenticating as the org account needs
            nothing else. Authenticating as a <em>member user</em> means appending
            <ApiCode> ?target_account_id={orgId}</ApiCode> to every request — an integration signed in as a
            member reads that id from <ApiCode>GET /me/orgs</ApiCode> (<ApiCode>orgs[].id</ApiCode>).
            Omitting it is not equally safe everywhere:
            <ApiCode> /skill/*</ApiCode> rejects the call with <strong>403</strong>, but
            an upload <strong>succeeds and lands in the caller's own account</strong> — see the file
            upload tab.
          </ApiNote>

          {tab === 'auth'   && <ApiAuthSection/>}
          {tab === 'upload' && <ApiUploadSection/>}
          {tab === 'graph'  && <ApiGraphSection/>}
          {tab === 'ask'    && <ApiAskSection/>}

          <ApiStatusCodes/>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { APIPage });
