// Health panel — Task 12 (AR Task 6 adds item (d); AP Task 5 adds items (e)
// and (f); Bank Rec Task 5 adds items (g) and (h)). Placed on the Dashboard
// after the tiles. Checks, each with its own status tone and (where the
// design brief calls for it) a link to the place that fixes it:
//   (a) books balanced          — from the dashboard payload's own health
//   (b) years not yet locked    — from GET /api/c/close/status, links #close
//   (c) wrong-sign balances     — from the dashboard payload, links #account/:id
//   (d) oldest overdue receivable — from the dashboard payload's health.oldestReceivable, links #invoice/:id
//   (e) overdue bills / bills due soon — from health.overdueBills/billsDueSoon, links #bills
//   (f) last backup age         — from GET /api/backup/status, links #settings
//   (g) accounts never reconciled — from health.neverReconciled, links #home
//   (h) reconciliations 30+ days stale — from health.staleReconciliation, links #home
//   (j) over-received purchase orders, and purchase orders past their expected
//       date — derived from GET /api/c/purchase-orders (Inventory Task 4).
//       Silent for a company with no purchase orders.
//   (k) inventory out of tie — from health.inventoryOutOfTie, the inventory
//       design spec's decision 7 ("the single number for the health panel and
//       for tests"), links #reports. Silent at 0.0000, which is what a company
//       with no stock at all reports.
// CLAUDE.md §9: no debit/credit language, errors in English, every number
// drillable. When every check is clean, this collapses to a single quiet
// green line rather than a checklist of checkmarks nobody needs to read.

import type { ReactNode } from "react";
import { useApiGet } from "../lib/useApiGet";
import { absMoney, fmtMoney, isZeroMoney } from "../lib/money";
import { fmtDate, fmtHoursAgo, todayIso } from "../lib/date";
import { PRODUCT_NAME } from "../lib/brand";
import type { NavTarget } from "../lib/useHashView";
import { isPastExpected, type PoListResponse } from "../purchaseOrders/poTypes";

export interface WrongSignRow {
  accountId: string;
  name: string;
  balance: string;
}

// AR Task 6: the single oldest open receivable (GET /api/c/reports/dashboard's
// health.oldestReceivable, server/src/reports.ts) — null when there are no
// open invoices at all. Only rendered as its own row when it's actually
// overdue (daysOverdue > 0); an oldest receivable that isn't due yet is
// unremarkable and the "Books balanced" line already covers "everything
// looks right" for a company with no problems to flag.
export interface OldestReceivable {
  invoiceId: string;
  invoiceNo: string | null;
  customerName: string;
  dueDate: string;
  daysOverdue: number;
  open: string;
}

// AP Task 5: overdue/due-soon bill counts (GET /api/c/reports/dashboard's
// health.overdueBills/health.billsDueSoon, server/src/reports.ts) — both
// {count: 0, total: "0.0000"} when there's nothing to flag, in which case
// neither row renders (mirrors the oldest-receivable row's "nothing to show"
// convention above).
export interface BillCountSummary {
  count: number;
  total: string;
}

// Bank Rec Task 5: GET /api/c/reports/dashboard's health.neverReconciled and
// health.staleReconciliation (server/src/reports.ts) — see that file's own
// comment for exactly which accounts qualify (active asset accounts, "money
// accounts" in this system's vocabulary, same universe GET /api/c/bank/overview
// uses). Both are plain arrays, empty when there's nothing to flag, in which
// case (mirroring the oldest-receivable/bill-count rows above) nothing
// renders for that check.
export interface NeverReconciledRow {
  accountId: string;
  name: string;
}
export interface StaleReconciliationRow {
  accountId: string;
  name: string;
  reconciledThrough: string;
}

interface CloseStatusYear {
  year: number;
  postedEntries: number;
  locked: boolean;
}
interface CloseStatusResponse {
  lockedThrough: string | null;
  years: CloseStatusYear[];
}

interface BackupStatusResponse {
  dir: string;
  lastBackup: { file: string; ageHours: number } | null;
  count: number;
}

// Task 1 of docs/superpowers/plans/2026-08-01-launch.md: GET
// /api/update-status (server/src/updateRoutes.ts) — whole-app state, not
// company data, same non-scoped-but-gated-on-companyId fetch pattern
// backupStatus above uses. Only "update-available" produces a row here;
// every other status ("disabled", "unreachable", "current") is silent —
// this app must never look broken just because an update check didn't run.
interface UpdateStatusResponse {
  current: string;
  latest: string | null;
  notes: string | null;
  downloadUrl: string | null;
  checkedAt: string | null;
  status: "disabled" | "unreachable" | "current" | "update-available";
}

type Tone = "good" | "info" | "warn" | "bad";

const BACKUP_GOOD_HOURS = 26;
const BACKUP_WARN_HOURS = 24 * 8; // 8 days

interface HealthRow {
  tone: Tone;
  content: ReactNode;
}

export function HealthPanel({
  companyId,
  balanced,
  outOfBalance,
  wrongSign,
  oldestReceivable,
  overdueBills,
  billsDueSoon,
  neverReconciled,
  staleReconciliation,
  inventoryOutOfTie,
  navigate,
  onSelectAccount,
}: {
  companyId: string | null;
  balanced: boolean;
  outOfBalance: string;
  wrongSign: WrongSignRow[];
  oldestReceivable: OldestReceivable | null;
  overdueBills: BillCountSummary;
  billsDueSoon: BillCountSummary;
  neverReconciled: NeverReconciledRow[];
  staleReconciliation: StaleReconciliationRow[];
  inventoryOutOfTie: string;
  navigate: (target: NavTarget) => void;
  onSelectAccount: (accountId: string) => void;
}) {
  const { data: closeStatus } = useApiGet<CloseStatusResponse>("/api/c/close/status", companyId);
  // /api/backup/status isn't scoped to a company (it's a whole-database
  // backup) — X-Company-Id is simply ignored server-side — but useApiGet
  // only fetches once its companyId arg is truthy, so `companyId` (the
  // Dashboard's already-selected company) doubles as that gate here too.
  const { data: backupStatus } = useApiGet<BackupStatusResponse>("/api/backup/status", companyId);
  // Deliberately NOT part of the "still loading" gate below — this fetch
  // failing, or the server-side check never having run (no UPDATE_FEED_URL
  // configured, or the site being unreachable), must never delay or blank
  // out the rest of the health panel. "No item" is the correct rendering
  // for every status except "update-available".
  const { data: updateStatus } = useApiGet<UpdateStatusResponse>("/api/update-status", companyId);
  // Inventory Task 4's two purchase-order health items. The spec's own bullet
  // reads "negative-stock-would-have-been items, POs overdue" — quoted
  // correctly here, because the first half of it is NOT what shipped and a
  // comment that pretended otherwise was hiding the swap. Negative stock is
  // impossible by construction: record_movement() refuses to take an item
  // below zero, so there is no "would have been" to warn about and never can
  // be. Over-received POs took its place as the second check — a real
  // paperwork question the schema deliberately does allow. The spec has been
  // amended to say so rather than leave a bullet nobody implemented.
  //
  // Derived here from the ordinary purchase-order list rather than
  // added to the dashboard payload, exactly as the checks-queue badge is a
  // best-effort client-side sum: both facts are already on the wire (`state`,
  // `expectedDate`, `overReceivedLines`), so a second server-side derivation
  // of them would be a second thing that could disagree. Deliberately NOT part
  // of the loading gate below — a company with no purchase orders must never
  // see this panel wait on, or mention, a feature it doesn't use.
  //
  // 500 is the API's own maximum page size, and `total` says how many there
  // really are. Review finding F6: this used to ask for 200, swallow any
  // failure, and go quietly wrong past the cap — a health panel that hides
  // warnings when it can't do its job is worse than no health panel. Both are
  // now said out loud below.
  const { data: purchaseOrders, error: poError, retry: retryPos } =
    useApiGet<PoListResponse>("/api/c/purchase-orders?limit=500", companyId);

  // Nothing to show yet (still loading either check) — render nothing rather
  // than a flash of "Everything looks right." that then changes its mind.
  if (!closeStatus || !backupStatus) {
    return <p className="msg-inline" style={{ padding: "8px 0" }}>Checking the health of your books&hellip;</p>;
  }

  const rows: HealthRow[] = [];

  // (a) Books balanced
  rows.push({
    tone: balanced ? "good" : "bad",
    content: balanced
      ? "Books balanced"
      : `Out of balance by ${fmtMoney(absMoney(outOfBalance))} — call your accountant`,
  });

  // (b) Years not yet locked
  const unlockedCount = closeStatus.years.filter((y) => !y.locked).length;
  if (unlockedCount > 0) {
    rows.push({
      tone: "info",
      content: (
        <button type="button" className="acct-link" onClick={() => navigate("close")}>
          {unlockedCount} {unlockedCount === 1 ? "year" : "years"} not yet locked
        </button>
      ),
    });
  }

  // (c) Wrong-sign balances — one row per account
  for (const w of wrongSign) {
    rows.push({
      tone: "bad",
      content: (
        <button type="button" className="acct-link" onClick={() => onSelectAccount(w.accountId)}>
          {w.name} has a balance that looks backwards ({fmtMoney(w.balance)})
        </button>
      ),
    });
  }

  // (d) Oldest overdue receivable — see the OldestReceivable doc comment
  // above for why a non-overdue oldest receivable renders nothing here.
  if (oldestReceivable && oldestReceivable.daysOverdue > 0) {
    const days = oldestReceivable.daysOverdue;
    rows.push({
      tone: "warn",
      content: (
        <button
          type="button"
          className="acct-link"
          onClick={() => navigate({ view: "invoice", id: oldestReceivable.invoiceId })}
        >
          {oldestReceivable.customerName} owes {fmtMoney(oldestReceivable.open)} — due{" "}
          {fmtDate(oldestReceivable.dueDate)}, {days} day{days === 1 ? "" : "s"} ago
        </button>
      ),
    });
  }

  // (e) Overdue bills / bills due soon — AP Task 5, mirrors (d) above but for
  // what the company owes rather than what it's owed. Each renders only when
  // its count is actually non-zero.
  if (overdueBills.count > 0) {
    rows.push({
      tone: "bad",
      content: (
        <button type="button" className="acct-link" onClick={() => navigate("bills")}>
          {overdueBills.count} bill{overdueBills.count === 1 ? "" : "s"} overdue — {fmtMoney(overdueBills.total)}
        </button>
      ),
    });
  }
  if (billsDueSoon.count > 0) {
    rows.push({
      tone: "warn",
      content: (
        <button type="button" className="acct-link" onClick={() => navigate("bills")}>
          {billsDueSoon.count} bill{billsDueSoon.count === 1 ? "" : "s"} due this week — {fmtMoney(billsDueSoon.total)}
        </button>
      ),
    });
  }

  // (f) Last backup age
  const ageHours = backupStatus.lastBackup?.ageHours;
  const backupTone: Tone =
    ageHours === undefined ? "bad" : ageHours < BACKUP_GOOD_HOURS ? "good" : ageHours < BACKUP_WARN_HOURS ? "warn" : "bad";
  rows.push({
    tone: backupTone,
    content: (
      <button type="button" className="acct-link" onClick={() => navigate("settings")}>
        {backupStatus.lastBackup ? `Last backup ${fmtHoursAgo(ageHours!)} ago` : "No backup yet"}
      </button>
    ),
  });

  // (g) Accounts never reconciled — Bank Rec Task 5, one row per account.
  // Quiet ("info"), not "bad": a money account with activity but no
  // statement imported yet isn't wrong, just not yet started.
  for (const a of neverReconciled) {
    rows.push({
      tone: "info",
      content: (
        <button type="button" className="acct-link" onClick={() => navigate("home")}>
          {a.name} has never been reconciled
        </button>
      ),
    });
  }

  // (h) Reconciliations 30+ days stale — one row per account. "warn": this
  // account WAS being kept current and has since fallen behind, which is
  // more actionable than "never started".
  for (const a of staleReconciliation) {
    rows.push({
      tone: "warn",
      content: (
        <button type="button" className="acct-link" onClick={() => navigate("home")}>
          {a.name} hasn't been reconciled since {fmtDate(a.reconciledThrough)}
        </button>
      ),
    });
  }

  // (j) Purchase orders that need a human look. Two separate facts, kept
  // separate because their answers are different: an over-receipt is a
  // paperwork question (did the vendor really send extra, or was the bill
  // keyed wrong?), while a late order is a chase-the-vendor question. Neither
  // is an error — over-receipts are allowed, and a late delivery isn't a
  // bookkeeping problem — so both are "warn"/"info", never "bad".
  //
  // A failed fetch is reported rather than read as "nothing to flag" — the
  // only honest thing a check can say when it didn't run.
  if (poError) {
    rows.push({
      tone: "warn",
      content: (
        <button type="button" className="acct-link" onClick={retryPos}>
          Couldn&rsquo;t check your purchase orders just now — try again
        </button>
      ),
    });
  }
  const posSeen = purchaseOrders?.purchaseOrders ?? [];
  const posUnseen = Math.max((purchaseOrders?.total ?? 0) - posSeen.length, 0);
  const overReceivedPos = posSeen.filter(
    (po) => po.overReceivedLines > 0 && po.state !== "cancelled",
  );
  for (const po of overReceivedPos.slice(0, 3)) {
    rows.push({
      tone: "warn",
      content: (
        <button
          type="button"
          className="acct-link"
          onClick={() => navigate({ view: "purchase-order", id: po.id })}
        >
          More arrived than was ordered on purchase order {po.poNo ? `#${po.poNo}` : ""} to{" "}
          {po.vendorName}
        </button>
      ),
    });
  }
  const today = todayIso();
  const latePos = posSeen.filter((po) => isPastExpected(po, today));
  if (latePos.length > 0) {
    rows.push({
      tone: "info",
      content: (
        <button type="button" className="acct-link" onClick={() => navigate("purchase-orders")}>
          {latePos.length} purchase order{latePos.length === 1 ? "" : "s"} past the date
          {latePos.length === 1 ? " it was" : " they were"} expected
        </button>
      ),
    });
  }
  // Says what it DIDN'T look at, rather than implying it looked at everything.
  // Open and partly-received orders sort first (server/src/purchaseOrders.ts's
  // ORDER BY), so the overdue check is complete either way; it is the
  // over-receipt scan, which can sit on a long-closed order, that this bounds.
  if (posUnseen > 0 && (overReceivedPos.length > 0 || latePos.length > 0)) {
    rows.push({
      tone: "info",
      content: (
        <button type="button" className="acct-link" onClick={() => navigate("purchase-orders")}>
          Checked your {posSeen.length} most recent purchase orders — {posUnseen} older{" "}
          {posUnseen === 1 ? "one wasn't" : "ones weren't"} looked at
        </button>
      ),
    });
  }

  // (k) Inventory doesn't tie to the ledger. The inventory milestone's headline
  // invariant, said out loud on the one screen the spec named for it. This is
  // the inventory equivalent of (a): two independent derivations of the same
  // money — what the stock records say is on the shelf, and what the Inventory
  // account on the balance sheet says — disagreeing. "bad", same as (a), for
  // the same reason: it means a number in the financial statements is wrong.
  //
  // No direction is offered because inventory_out_of_tie() sums ABSOLUTE
  // differences across accounts (so two accounts wrong in opposite directions
  // cannot cancel), and an absolute sum genuinely cannot say "over" or
  // "short". The valuation report shows the per-account rows that can.
  if (!isZeroMoney(inventoryOutOfTie)) {
    rows.push({
      tone: "bad",
      content: (
        <button type="button" className="acct-link" onClick={() => navigate("reports")}>
          Your stock records and your books disagree by {fmtMoney(absMoney(inventoryOutOfTie))} —
          open the Inventory valuation report to see which account
        </button>
      ),
    });
  }

  // (i) An update is available — quiet ("info"): nothing is wrong, there's
  // just something new. Links to Settings' "Updates" section rather than
  // opening the download link directly from here (CLAUDE.md §9/the design
  // brief's "no auto-download" posture: the user reviews notes and decides
  // for themselves).
  if (updateStatus?.status === "update-available" && updateStatus.latest) {
    rows.push({
      tone: "info",
      content: (
        <button type="button" className="acct-link" onClick={() => navigate("settings")}>
          {PRODUCT_NAME} {updateStatus.latest} is available — see what's new
        </button>
      ),
    });
  }

  const allGood = rows.every((r) => r.tone === "good");

  return (
    <div className="card health-panel" style={{ marginBottom: 12 }}>
      <h2>Health check</h2>
      {allGood ? (
        <div className="health-item health-item-good">
          <span className="health-dot" aria-hidden="true" />
          <span className="health-text">Everything looks right.</span>
        </div>
      ) : (
        <div className="health-list">
          {rows.map((row, i) => (
            <div key={i} className={`health-item health-item-${row.tone}`}>
              <span className="health-dot" aria-hidden="true" />
              <span className="health-text">{row.content}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
