'use client';

import { useCallback, useEffect, useState } from 'react';
import {
  adminGet,
  adminPost,
  type AdminPost,
  type AdminRun,
  type AdminSource,
  type AdSlotRow,
  type Overview,
  type RejectedResponse,
} from '@/lib/admin-api';
import { AdminShell, type TabKey } from './AdminShell';

export default function AdminPage() {
  const [tab, setTab] = useState<TabKey>('overview');

  return (
    <AdminShell tab={tab} onTab={setTab}>
      {tab === 'overview' && <OverviewTab />}
      {tab === 'published' && <PostsTab status="published" />}
      {tab === 'held' && <PostsTab status="held" />}
      {tab === 'rejected' && <RejectedTab />}
      {tab === 'sources' && <SourcesTab />}
      {tab === 'runs' && <RunsTab />}
      {tab === 'audience' && <AudienceTab />}
      {tab === 'ads' && <AdsTab />}
    </AdminShell>
  );
}

/** Small hook so every tab handles loading and failure the same way. */
function useData<T>(path: string, deps: unknown[] = []) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<string | null>(null);

  const reload = useCallback(() => {
    setError(null);
    adminGet<T>(path)
      .then(setData)
      .catch((err) => setError(err instanceof Error ? err.message : String(err)));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [path, ...deps]);

  useEffect(reload, [reload]);

  return { data, error, reload };
}

// -------------------------------------------------------- overview

function OverviewTab() {
  const { data, error, reload } = useData<Overview>('/admin/overview');
  const [busy, setBusy] = useState<string | null>(null);

  // What the last run actually did. The buttons used to discard this, so a
  // run that failed every item looked identical to one that published ten —
  // the only way to tell was to call the API by hand. Several real failures
  // (an unsupported model parameter, a token limit) were invisible here.
  const [result, setResult] = useState<string | null>(null);

  async function run(action: string, path: string) {
    setBusy(action);
    setResult(null);
    try {
      const data = (await adminPost(path)) as Record<string, unknown> | null;
      setResult(summarise(data));
      reload();
    } catch (err) {
      setResult(err instanceof Error ? err.message : 'Failed');
    } finally {
      setBusy(null);
    }
  }

  /**
   * Translate everything still waiting, not just the first batch.
   *
   * One call handles 20, so a backlog of 40 needed the button pressed
   * twice with no indication that more remained. It now repeats until the
   * queue is empty or a pass translates nothing — the latter matters
   * because a failing model would otherwise loop for ever, spending on
   * every attempt.
   */
  async function translateAll() {
    setBusy('translate');
    setResult(null);

    let total = 0;
    let passes = 0;
    try {
      // A ceiling as well as the empty-pass check: two independent stops,
      // because this one spends money on every iteration.
      while (passes < 10) {
        passes++;
        const response = await adminPost('/admin/run/translate?limit=20');
        const locales = Array.isArray(response) ? response : [response];

        const done = locales.reduce(
          (sum: number, l) =>
            sum + (Number((l as Record<string, unknown>)?.translated) || 0),
          0,
        );
        const stopped = locales.some((l) => (l as Record<string, unknown>)?.budgetStopped === true);

        total += done;
        setResult(`${total} translated…`);

        if (done === 0) break;
        if (stopped) {
          setResult(`${total} translated — stopped by the budget cap.`);
          reload();
          return;
        }
      }

      setResult(
        total > 0
          ? `${total} translated.`
          : 'Nothing was translated. Check AI_TRANSLATE_MODEL in the API log.',
      );
      reload();
    } catch (err) {
      setResult(err instanceof Error ? err.message : 'Failed');
    } finally {
      setBusy(null);
    }
  }

  /**
   * Clear the rendered-page cache.
   *
   * A plain fetch rather than adminPost: this endpoint belongs to the
   * Next.js app, not the API, because the cache it clears lives in this
   * process. Same-origin, so the session cookie rides along by default.
   */
  async function clearCache() {
    setBusy('cache');
    setResult(null);
    try {
      const response = await fetch('/admin/revalidate', { method: 'POST' });
      setResult(
        response.ok
          ? 'Cache cleared. Reload the site to see the latest.'
          : `Could not clear the cache (${response.status}).`,
      );
    } catch (err) {
      setResult(err instanceof Error ? err.message : 'Failed');
    } finally {
      setBusy(null);
    }
  }

  /**
   * One full cycle, the way the cron runs it: fetch, write, translate.
   *
   * All three stages, because the cron runs all three — a button that
   * stopped after writing published English-only stories onto a bilingual
   * site, which is exactly what happened when the stages were run by hand.
   *
   * Sequential rather than parallel: each stage works on what the previous
   * one produced, so running them together would process the last batch.
   */
  async function runAll() {
    setBusy('all');
    setResult(null);
    try {
      const ingested = (await adminPost('/admin/run/ingest')) as Record<string, unknown> | null;
      setResult(`Fetched. ${summarise(ingested)} — writing…`);

      const edited = (await adminPost('/admin/run/edit?limit=50')) as Record<string, unknown> | null;
      setResult(`${summarise(ingested)} · ${summarise(edited)} — translating…`);

      // Translation is best-effort: an English story on the site beats no
      // story, so a translation failure must not read as a failed run.
      let translatedNote = '';
      try {
        const translated = await adminPost('/admin/run/translate?limit=20');
        // The endpoint returns one result per locale.
        const locales = Array.isArray(translated) ? translated : [translated];
        const done = locales.reduce(
          (sum: number, l) =>
            sum + (typeof (l as Record<string, unknown>)?.translated === 'number'
              ? ((l as Record<string, unknown>).translated as number)
              : 0),
          0,
        );
        if (done > 0) translatedNote = ` · ${done} translated`;
      } catch {
        translatedNote = ' · translation failed';
      }

      // Clear the page cache so anything just published is visible now
      // rather than up to five minutes later. Best-effort: a cache that
      // did not clear is a stale page, not a failed run, so it must not
      // turn a successful publish into an error message.
      const published = typeof edited?.published === 'number' ? edited.published : 0;
      let note = '';
      if (published > 0) {
        try {
          const response = await fetch('/admin/revalidate', { method: 'POST' });
          note = response.ok ? ' · cache cleared' : ' · cache not cleared';
        } catch {
          note = ' · cache not cleared';
        }
      }

      setResult(`${summarise(ingested)} · ${summarise(edited)}${translatedNote}${note}`);
      reload();
    } catch (err) {
      setResult(err instanceof Error ? err.message : 'Failed');
    } finally {
      setBusy(null);
    }
  }

  if (error) return <p className="admin-banner">{error}</p>;
  if (!data) return <p className="admin-empty">Loading…</p>;

  const { counts, costs, sourceHealth, lastRun } = data;
  const spentPct = costs.budgetUsd > 0 ? (costs.totalUsd / costs.budgetUsd) * 100 : 0;
  const budgetLevel = spentPct >= 100 ? 'danger' : spentPct >= 75 ? 'warn' : '';

  return (
    <>
      {costs.exceeded && (
        <p className="admin-banner">
          Monthly AI budget reached. Paid processing has stopped. Ingestion continues, so no
          source data is lost.
        </p>
      )}

      {!data.aiEnabled && (
        <p className="admin-note" style={{ marginTop: 'var(--s4)' }}>
          AI processing is disabled (AI_ENABLED=false). Ingestion still runs.
        </p>
      )}

      <div className="admin-stats">
        <Stat label="Published" value={counts.published} tone="good" />
        <Stat label="Held for review" value={counts.held} tone={counts.held > 0 ? 'warn' : ''} />
        <Stat label="Awaiting triage" value={counts.awaiting_triage} />
        <Stat label="Awaiting writing" value={counts.awaiting_write} />
        <Stat label="Rejected" value={counts.rejected.toLocaleString()} />
        <Stat
          label="Awaiting Arabic"
          value={counts.awaiting_translation}
          tone={counts.awaiting_translation > 0 ? 'warn' : ''}
        />
        <Stat
          label={`Today (cap ${data.dailyCap})`}
          value={data.publishedToday}
          tone={data.publishedToday >= data.dailyCap ? 'warn' : ''}
        />
      </div>

      <section className="admin-section">
        <div className="admin-section-head">
          <h2 className="admin-h2">Budget — {costs.month}</h2>
          <span className="cell-sub">{costs.calls} calls this month</span>
        </div>

        <div className="admin-stat">
          <div className="admin-stat-value">
            ${costs.totalUsd.toFixed(4)}{' '}
            <span style={{ fontSize: 14, color: 'var(--text-3)', fontWeight: 400 }}>
              of ${costs.budgetUsd.toFixed(2)}
            </span>
          </div>
          <div className="budget-bar">
            <div
              className={`budget-fill ${budgetLevel}`}
              style={{ width: `${Math.min(100, spentPct)}%` }}
            />
          </div>
          <div className="admin-stat-label">
            ${costs.remainingUsd.toFixed(4)} remaining
            {Object.keys(costs.byStage).length > 0 && (
              <>
                {' · '}
                {Object.entries(costs.byStage)
                  .map(([stage, amount]) => `${stage} $${Number(amount).toFixed(4)}`)
                  .join(' · ')}
              </>
            )}
          </div>
        </div>
      </section>

      <section className="admin-section">
        <div className="admin-section-head">
          <h2 className="admin-h2">Pipeline</h2>
          <span className="cell-sub">
            {sourceHealth.active} active · {sourceHealth.failing} failing ·{' '}
            {sourceHealth.disabled} disabled
          </span>
        </div>

        {lastRun && (
          <p className="admin-note">
            Last run {lastRun.status} — {lastRun.fetched.toLocaleString()} fetched,{' '}
            {lastRun.prefilter_kept} kept, {lastRun.published} published, {lastRun.held} held, $
            {Number(lastRun.cost_usd).toFixed(4)}.
          </p>
        )}

        <div className="btn-row" style={{ marginTop: 'var(--s4)' }}>
          {/* The whole cycle in one click — what the cron does, on demand.
              Listed first because it is what you almost always want; the
              individual stages below stay for debugging one of them. */}
          <button
            type="button"
            className="btn primary"
            disabled={busy !== null || !data.aiEnabled || costs.exceeded}
            onClick={() => void runAll()}
          >
            {busy === 'all' ? 'Fetching and writing…' : 'Fetch news now'}
          </button>
          <button
            type="button"
            className="btn"
            disabled={busy !== null}
            onClick={() => run('ingest', '/admin/run/ingest')}
          >
            {busy === 'ingest' ? 'Fetching…' : 'Run ingestion (free)'}
          </button>
          <button
            type="button"
            className="btn"
            disabled={busy !== null || !data.aiEnabled || costs.exceeded}
            onClick={() => run('edit', '/admin/run/edit?limit=50')}
          >
            {busy === 'edit' ? 'Editing…' : 'Run editor (spends)'}
          </button>
          <button
            type="button"
            className="btn"
            disabled={busy !== null || !data.aiEnabled || costs.exceeded}
            onClick={() => void translateAll()}
          >
            {busy === 'translate'
              ? 'Translating…'
              : counts.awaiting_translation > 0
                ? `Translate ${counts.awaiting_translation} to Arabic (spends)`
                : 'Translate (spends)'}
          </button>
          {/* Not disabled by budget or AI_ENABLED: clearing the cache
              costs nothing and is most useful exactly when something
              looks stale. */}
          <button
            type="button"
            className="btn"
            disabled={busy !== null}
            onClick={() => void clearCache()}
          >
            {busy === 'cache' ? 'Clearing…' : 'Clear cache'}
          </button>
        </div>
        {result && (
          <p className="admin-note" style={{ marginTop: 'var(--s3)' }}>
            {result}
          </p>
        )}

        <p className="admin-note">
          A full run takes a minute or two — fetching all sources is the slow part. Ingestion
          is deterministic code and costs nothing. The editor and translation make paid calls,
          each checked against the budget before it fires.
        </p>
      </section>
    </>
  );
}

function Stat({
  label,
  value,
  tone = '',
}: {
  label: string;
  value: string | number;
  tone?: string;
}) {
  return (
    <div className={`admin-stat ${tone}`}>
      <div className="admin-stat-value">{value}</div>
      <div className="admin-stat-label">{label}</div>
    </div>
  );
}

// ----------------------------------------------------------- posts

function PostsTab({ status }: { status: 'published' | 'held' }) {
  const { data, error, reload } = useData<AdminPost[]>(`/admin/posts?status=${status}&limit=100`);
  const [busy, setBusy] = useState<number | null>(null);

  async function act(id: number, path: string, body?: unknown) {
    setBusy(id);
    try {
      await adminPost(path, body);
      reload();
    } finally {
      setBusy(null);
    }
  }

  if (error) return <p className="admin-banner">{error}</p>;
  if (!data) return <p className="admin-empty">Loading…</p>;

  if (data.length === 0) {
    return (
      <p className="admin-empty">
        {status === 'held'
          ? 'Nothing held. Every verified item cleared the confidence threshold.'
          : 'Nothing published yet.'}
      </p>
    );
  }

  return (
    <section className="admin-section">
      <div className="admin-section-head">
        <h2 className="admin-h2">
          {status === 'held' ? 'Held for review' : 'Published'} ({data.length})
        </h2>
      </div>

      {status === 'held' && (
        <p className="admin-note" style={{ marginBottom: 'var(--s4)' }}>
          These drafts could not be fully verified against their source. Read the reason, check
          the source, then publish or leave them.
        </p>
      )}

      <div className="table-scroll">
        <table className="admin-table">
          <thead>
            <tr>
              <th>Story</th>
              <th>Cat</th>
              <th className="num">Imp</th>
              <th className="num">Conf</th>
              <th>{status === 'held' ? 'Hold reason' : 'Source'}</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            {data.map((post) => (
              <tr key={post.id}>
                <td>
                  <div className="cell-title">{post.title}</div>
                  <div className="cell-sub">
                    {post.company_name ? `${post.company_name} · ` : ''}
                    {post.source_name}
                    {post.translations > 0 && ' · AR'}
                    {post.featured === 1 && ' · featured'}
                  </div>
                </td>
                <td>
                  <span className="pill">{post.category}</span>
                </td>
                <td className="num">{post.importance}</td>
                <td className="num">
                  <span
                    className={
                      post.confidence >= 80 ? 'pill ok' : post.confidence >= 60 ? 'pill warn' : 'pill bad'
                    }
                  >
                    {post.confidence}
                  </span>
                </td>
                <td>
                  {status === 'held' ? (
                    <div className="reject-detail">{post.hold_reason}</div>
                  ) : (
                    <a
                      href={post.source_url}
                      target="_blank"
                      rel="noreferrer"
                      className="cell-sub"
                    >
                      {hostOf(post.source_url)} ↗
                    </a>
                  )}
                </td>
                <td>
                  <div className="btn-row">
                    {status === 'held' ? (
                      <button
                        type="button"
                        className="btn primary"
                        disabled={busy === post.id}
                        onClick={() => act(post.id, `/admin/posts/${post.id}/publish`)}
                      >
                        Publish
                      </button>
                    ) : (
                      <>
                        <button
                          type="button"
                          className="btn"
                          disabled={busy === post.id}
                          onClick={() =>
                            act(post.id, `/admin/posts/${post.id}/feature`, {
                              featured: post.featured !== 1,
                            })
                          }
                        >
                          {post.featured === 1 ? 'Unfeature' : 'Feature'}
                        </button>
                        <button
                          type="button"
                          className="btn danger"
                          disabled={busy === post.id}
                          onClick={() => act(post.id, `/admin/posts/${post.id}/unpublish`)}
                        >
                          Unpublish
                        </button>
                      </>
                    )}
                  </div>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </section>
  );
}

// -------------------------------------------------------- rejected

/**
 * The most important view in the admin.
 *
 * The product's value is the filtering, so the only way to know whether
 * it works is to read what it threw away. A wrong rejection is otherwise
 * completely invisible.
 */
function RejectedTab() {
  const [reason, setReason] = useState<string | null>(null);
  const path = reason
    ? `/admin/rejected?reason=${encodeURIComponent(reason)}&limit=100`
    : '/admin/rejected?limit=100';
  const { data, error } = useData<RejectedResponse>(path, [reason]);

  if (error) return <p className="admin-banner">{error}</p>;
  if (!data) return <p className="admin-empty">Loading…</p>;

  return (
    <section className="admin-section">
      <div className="admin-section-head">
        <h2 className="admin-h2">Why we didn&apos;t publish</h2>
      </div>

      <p className="admin-note" style={{ marginBottom: 'var(--s4)' }}>
        Everything the filter dropped, and why. Read this regularly: a story wrongly rejected
        never appears anywhere else, so this view is the only place it is visible.
      </p>

      <div className="filter-row">
        <button
          type="button"
          className="filter-chip"
          aria-current={reason === null}
          onClick={() => setReason(null)}
        >
          all
        </button>
        {data.breakdown.map((b) => (
          <button
            key={`${b.reject_reason}-${b.reject_stage}`}
            type="button"
            className="filter-chip"
            aria-current={reason === b.reject_reason}
            onClick={() => setReason(b.reject_reason)}
          >
            {b.reject_reason.replace(/_/g, ' ')} {Number(b.count).toLocaleString()}
          </button>
        ))}
      </div>

      {data.items.length === 0 ? (
        <p className="admin-empty">Nothing rejected for this reason.</p>
      ) : (
        <div className="table-scroll">
          <table className="admin-table">
            <thead>
              <tr>
                <th>Item</th>
                <th>Reason</th>
                <th>Stage</th>
                <th className="num">Score</th>
                <th>Detail</th>
              </tr>
            </thead>
            <tbody>
              {data.items.map((item) => (
                <tr key={item.id}>
                  <td>
                    <a
                      href={item.url}
                      target="_blank"
                      rel="noreferrer"
                      className="cell-title"
                    >
                      {item.title}
                    </a>
                    <div className="cell-sub">
                      {item.source_name} · tier {item.trust_tier}
                    </div>
                  </td>
                  <td>
                    <span className="pill">{item.reject_reason.replace(/_/g, ' ')}</span>
                  </td>
                  <td className="muted">{item.reject_stage}</td>
                  <td className="num muted">{item.triage_score ?? '—'}</td>
                  <td>
                    <div className="reject-detail">{item.reject_detail}</div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </section>
  );
}

// --------------------------------------------------------- sources

function SourcesTab() {
  const { data, error, reload } = useData<AdminSource[]>('/admin/sources');
  const [busy, setBusy] = useState<number | null>(null);

  async function act(id: number, path: string, body: unknown) {
    setBusy(id);
    try {
      await adminPost(path, body);
      reload();
    } finally {
      setBusy(null);
    }
  }

  if (error) return <p className="admin-banner">{error}</p>;
  if (!data) return <p className="admin-empty">Loading…</p>;

  return (
    <section className="admin-section">
      <div className="admin-section-head">
        <h2 className="admin-h2">Sources ({data.length})</h2>
      </div>

      <div className="table-scroll">
        <table className="admin-table">
          <thead>
            <tr>
              <th>Source</th>
              <th>Kind</th>
              <th className="num">Items</th>
              <th className="num">Kept</th>
              <th>Status</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            {data.map((source) => (
              <tr key={source.id}>
                <td>
                  <div className="cell-title">{source.name}</div>
                  {source.last_error && (
                    <div className="reject-detail" style={{ color: 'var(--danger)' }}>
                      {source.last_error}
                    </div>
                  )}
                </td>
                <td className="muted">{source.kind}</td>
                <td className="num muted">{Number(source.total_items).toLocaleString()}</td>
                <td className="num">{Number(source.published)}</td>
                <td>
                  {source.blacklisted === 1 ? (
                    <span className="pill bad">blacklisted</span>
                  ) : source.enabled === 1 ? (
                    <span className={source.last_error ? 'pill warn' : 'pill ok'}>
                      {source.last_error ? `failing ${source.fail_count}` : 'active'}
                    </span>
                  ) : (
                    <span className="pill">disabled</span>
                  )}
                </td>
                <td>
                  <div className="btn-row">
                    <button
                      type="button"
                      className="btn"
                      disabled={busy === source.id}
                      onClick={() =>
                        act(source.id, `/admin/sources/${source.id}/toggle`, {
                          enabled: source.enabled !== 1,
                        })
                      }
                    >
                      {source.enabled === 1 ? 'Disable' : 'Enable'}
                    </button>
                    <button
                      type="button"
                      className="btn danger"
                      disabled={busy === source.id}
                      onClick={() =>
                        act(source.id, `/admin/sources/${source.id}/blacklist`, {
                          blacklisted: source.blacklisted !== 1,
                        })
                      }
                    >
                      {source.blacklisted === 1 ? 'Unblacklist' : 'Blacklist'}
                    </button>
                  </div>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </section>
  );
}

// ------------------------------------------------------------ runs

/**
 * Ad slots.
 *
 * A slot renders nothing until it has a provider AND a unit id, so the
 * table shows that state plainly rather than letting "enabled" imply an
 * ad is actually serving.
 */
function AdsTab() {
  const { data, error, reload } = useData<AdSlotRow[]>('/admin/ad-slots');
  const [busy, setBusy] = useState<string | null>(null);
  const [drafts, setDrafts] = useState<Record<string, string>>({});

  async function save(key: string, body: unknown) {
    setBusy(key);
    try {
      await adminPost(`/admin/ad-slots/${key}`, body);
      reload();
    } finally {
      setBusy(null);
    }
  }

  if (error) return <p className="admin-banner">{error}</p>;
  if (!data) return <p className="admin-empty">Loading…</p>;

  return (
    <section className="admin-section">
      <div className="admin-section-head">
        <h2 className="admin-h2">Ad slots</h2>
      </div>

      <p className="admin-note" style={{ marginBottom: 'var(--s4)' }}>
        A slot stays dark until it has both a provider and a unit id, so a
        half-configured slot renders nothing rather than an empty box. Set
        NEXT_PUBLIC_ADSENSE_CLIENT on the site for AdSense units to load.
      </p>

      <div className="table-scroll">
        <table className="admin-table">
          <thead>
            <tr>
              <th>Slot</th>
              <th>Position</th>
              <th>Unit id</th>
              <th>State</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            {data.map((slot) => {
              const live = slot.enabled === 1 && slot.provider && slot.unit_id;
              return (
                <tr key={slot.slot_key}>
                  <td>
                    <div className="cell-title">{slot.slot_key.replace(/_/g, ' ')}</div>
                    {slot.min_width && <div className="cell-sub">desktop only</div>}
                  </td>
                  <td className="muted">{slot.note}</td>
                  <td>
                    <input
                      id={`unit-${slot.slot_key}`}
                      className="login-input"
                      style={{ marginTop: 0, maxWidth: 170 }}
                      defaultValue={slot.unit_id ?? ''}
                      placeholder="1234567890"
                      onChange={(e) =>
                        setDrafts({ ...drafts, [slot.slot_key]: e.target.value })
                      }
                    />
                  </td>
                  <td>
                    {live ? (
                      <span className="pill ok">live</span>
                    ) : slot.enabled === 1 ? (
                      <span className="pill warn">needs unit id</span>
                    ) : (
                      <span className="pill">off</span>
                    )}
                  </td>
                  <td>
                    <div className="btn-row">
                      <button
                        type="button"
                        className="btn"
                        disabled={busy === slot.slot_key}
                        onClick={() =>
                          save(slot.slot_key, {
                            provider: 'adsense',
                            unitId: drafts[slot.slot_key] ?? slot.unit_id ?? '',
                          })
                        }
                      >
                        Save
                      </button>
                      <button
                        type="button"
                        className="btn"
                        disabled={busy === slot.slot_key}
                        onClick={() => save(slot.slot_key, { enabled: slot.enabled !== 1 })}
                      >
                        {slot.enabled === 1 ? 'Disable' : 'Enable'}
                      </button>
                    </div>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </section>
  );
}

function RunsTab() {
  const { data, error } = useData<AdminRun[]>('/admin/runs?limit=40');

  if (error) return <p className="admin-banner">{error}</p>;
  if (!data) return <p className="admin-empty">Loading…</p>;
  if (data.length === 0) return <p className="admin-empty">No runs recorded yet.</p>;

  return (
    <section className="admin-section">
      <div className="admin-section-head">
        <h2 className="admin-h2">Pipeline runs</h2>
      </div>

      <div className="table-scroll">
        <table className="admin-table">
          <thead>
            <tr>
              <th>Started</th>
              <th>Status</th>
              <th className="num">Fetched</th>
              <th className="num">Kept</th>
              <th className="num">Published</th>
              <th className="num">Held</th>
              <th className="num">Cost</th>
            </tr>
          </thead>
          <tbody>
            {data.map((run) => (
              <tr key={run.id}>
                <td className="num muted">{formatTime(run.started_at)}</td>
                <td>
                  <span
                    className={
                      run.status === 'ok'
                        ? 'pill ok'
                        : run.status === 'failed'
                          ? 'pill bad'
                          : 'pill warn'
                    }
                  >
                    {run.status}
                  </span>
                </td>
                <td className="num">{Number(run.fetched).toLocaleString()}</td>
                <td className="num">{run.prefilter_kept}</td>
                <td className="num">{run.published}</td>
                <td className="num">{run.held}</td>
                <td className="num">${Number(run.cost_usd).toFixed(4)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </section>
  );
}

// --------------------------------------------------------- helpers

function hostOf(url: string): string {
  try {
    return new URL(url).hostname.replace(/^www\./, '');
  } catch {
    return 'source';
  }
}

function formatTime(value: string): string {
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return '—';
  return date.toLocaleString('en-GB', {
    month: 'short',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
  });
}

interface Audience {
  accounts: Record<string, number>;
  subscribers: Record<string, number>;
  devices: Record<string, number>;
  filters: Record<string, number>;
  growth: Array<{ day: string; count: number }>;
}

/**
 * Audience.
 *
 * Counts and shapes, never a list of addresses. Knowing how many people
 * are reachable and what they asked for is what the dashboard is for;
 * being able to read the list is not, and not building that is cheaper
 * than protecting it.
 */
function AudienceTab() {
  const { data, error } = useData<Audience>('/admin/audience');

  if (error) return <p className="admin-banner">{error}</p>;
  if (!data) return <p className="admin-empty">Loading…</p>;

  const { accounts, subscribers, devices, filters } = data;
  const reachable =
    subscribers.email_active + subscribers.telegram_active + devices.active;

  return (
    <>
      <div className="admin-stats">
        <Stat label="Reachable" value={reachable} tone={reachable > 0 ? 'good' : ''} />
        <Stat label="Accounts" value={accounts.active} />
        <Stat label="Active 30d" value={accounts.active_30d} />
        <Stat label="Telegram" value={subscribers.telegram_active} />
        <Stat label="Email" value={subscribers.email_active} />
        <Stat label="Devices" value={devices.active} />
      </div>

      <h2 className="admin-h2">Accounts</h2>
      <table className="admin-table">
        <tbody>
          <tr>
            <td>Verified</td>
            <td>{accounts.verified} of {accounts.active}</td>
          </tr>
          <tr>
            <td>Social login only</td>
            <td>{accounts.social_only}</td>
          </tr>
          {/* Linked vs anonymous matters: anonymous channels keep working
              and must never be broken by an account change. */}
          <tr>
            <td>Channels linked to an account</td>
            <td>{subscribers.linked + devices.linked}</td>
          </tr>
        </tbody>
      </table>

      <h2 className="admin-h2">Channel health</h2>
      <table className="admin-table">
        <tbody>
          <tr>
            <td>Email awaiting confirmation</td>
            <td>{subscribers.email_pending}</td>
          </tr>
          <tr>
            <td>Unsubscribed</td>
            <td>{subscribers.unsubscribed}</td>
          </tr>
          <tr>
            {/* Revoked devices are deleted apps, reported by the push
                service. A rising number here is churn, not an error. */}
            <td>Devices revoked (app deleted)</td>
            <td>{devices.revoked}</td>
          </tr>
          <tr>
            <td>iOS / Android</td>
            <td>
              {devices.ios} / {devices.android}
            </td>
          </tr>
        </tbody>
      </table>

      {/* Editorial feedback, not vanity: a category nobody subscribes to
          is worth knowing about. */}
      <h2 className="admin-h2">What people filter on</h2>
      <table className="admin-table">
        <tbody>
          <tr>
            <td>Everything (no filter)</td>
            <td>{filters.unfiltered}</td>
          </tr>
          <tr>
            <td>By topic</td>
            <td>{filters.by_category}</td>
          </tr>
          <tr>
            <td>By company</td>
            <td>{filters.by_company}</td>
          </tr>
          <tr>
            <td>By model</td>
            <td>{filters.by_product}</td>
          </tr>
        </tbody>
      </table>

      <h2 className="admin-h2">Signups, last 30 days</h2>
      {data.growth.length === 0 ? (
        <p className="admin-empty">No signups yet.</p>
      ) : (
        <table className="admin-table">
          <tbody>
            {data.growth.map((row) => (
              <tr key={row.day}>
                <td>{row.day}</td>
                <td>{row.count}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </>
  );
}

/**
 * A one-line summary of a pipeline run, for the admin buttons.
 *
 * Returns the numbers that matter and, crucially, the first failure note.
 * The API reports per-item failures in `notes` while still returning 200 —
 * so a run where every item failed looks like a success unless the notes
 * are read. That is exactly how an unsupported model parameter went
 * unnoticed until the response was inspected by hand.
 */
function summarise(data: Record<string, unknown> | null): string {
  if (!data) return 'Done.';

  const n = (key: string): number | null =>
    typeof data[key] === 'number' ? (data[key] as number) : null;

  const parts: string[] = [];
  const add = (key: string, label: string) => {
    const value = n(key);
    if (value !== null && value > 0) parts.push(`${value.toLocaleString()} ${label}`);
  };

  add('fetched', 'fetched');
  add('kept', 'kept');
  add('published', 'published');
  add('held', 'held');
  add('written', 'written');
  add('translated', 'translated');

  const failed = n('failed');
  if (failed !== null && failed > 0) parts.push(`${failed} failed`);

  const cost = data.costUsd;
  if (typeof cost === 'number' && cost > 0) parts.push(`$${cost.toFixed(4)}`);

  // Deduplicated: fifty items failing the same way produce fifty identical
  // notes, and repeating one message fifty times hides everything else.
  const notes = Array.isArray(data.notes) ? (data.notes as unknown[]).map(String) : [];
  const unique = [...new Set(notes)];
  if (unique.length > 0) {
    parts.push(unique[0] + (unique.length > 1 ? ` (+${unique.length - 1} more)` : ''));
  }

  return parts.length > 0 ? parts.join(', ') : 'Nothing to do.';
}
