// PerduraFinancingEvidence — window.PerduraFinancingEvidence
//
// ONE reader for the `hidden_financing_detected` signal, shared by every surface
// that speaks about identified financing:
//
//   • the P&L distortion banner        (PLHiddenFinancingFlag, pages-finance.jsx)
//   • the Debt & Financing schedule    (DebtSchedulePage, pages-debt-schedule.jsx)
//   • the Revenue page's inline flag   (pages-revenue.jsx)
//
// It exists so those three cannot drift apart. Each used to be free to re-derive
// "what was advanced" from the ledger in its own way, and three surfaces quoting
// three different numbers for the same finding is worse than one surface quoting
// none. Every figure any of them shows comes from the signal's supporting_evidence
// via this module, which computes NOTHING of its own beyond regrouping those rows.
//
// The only gate anywhere is whether the signal fired. There are no tenant, funder
// or archetype conditionals in this file, and there is no fallback: if the signal
// is silent, every consumer gets `signal: null` and must say so honestly rather
// than render an empty schedule that implies a clean opinion was formed.
//
// WHAT THIS MODULE REFUSES TO DERIVE — repayments, remaining balance, effective
// rate, total cost of the financing. The evidence carries
// `repayment_terms_available: 0` precisely so a surface can SEE that the
// repayment stream is absent from the ledger instead of inferring it. Any of
// those four numbers would have to be invented here, so none of them exists here.

(function () {
  const { useState, useEffect } = React;

  // ── catalog fetch (module-level cache — three surfaces, one fetch) ─────────
  let catalogText = null;
  let catalogPromise = null;
  function loadCatalogs() {
    if (catalogText) return Promise.resolve(catalogText);
    if (catalogPromise) return catalogPromise;
    const urls = {
      metrics: "/src/intelligence/catalogs/metrics.yaml",
      exceptions: "/src/intelligence/catalogs/exceptions.yaml",
      opportunities: "/src/intelligence/catalogs/opportunities.yaml",
    };
    catalogPromise = Promise.all(Object.entries(urls).map(async ([k, u]) => {
      const r = await fetch(u);
      if (!r.ok) throw new Error("catalog " + k + " " + r.status);
      return [k, await r.text()];
    })).then((entries) => {
      const out = {};
      entries.forEach(([k, v]) => (out[k] = v));
      catalogText = out;
      return out;
    }).catch((e) => { catalogPromise = null; throw e; });
    return catalogPromise;
  }

  const SIGNAL_ID = "hidden_financing_detected";
  const COST_SIGNAL_ID = "financing_cost_in_overhead";

  // ── shaping ───────────────────────────────────────────────────────────────
  // Regroup the evidence rows into the views the surfaces need. No arithmetic
  // beyond summing rows the evidence already separated, and the two portions are
  // never added together — see `booked_as_revenue` / `booked_elsewhere` below.
  function shape(sig, costSig) {
    if (!sig) return null;
    const ev = sig.supporting_evidence || {};
    const values = ev.values || {};
    const labels = ev.labels || {};
    const num = (x) => (typeof x === "number" && isFinite(x) ? x : null);

    // Structured rows. Older evidence (before the schedule existed) carried only
    // the display string; a surface must render nothing rather than parse it, so
    // an absent array is an empty array and the tables simply do not populate.
    const advances = Array.isArray(ev.advances) ? ev.advances : [];
    const relationships = Array.isArray(ev.funder_relationships) ? ev.funder_relationships : [];
    // Per-funder repayment search. Present even when it found nothing — "we
    // searched this funder and there is no stream" is the finding, and a surface
    // can only make that claim credibly if it can show the search ran.
    const repayments = Array.isArray(ev.funder_repayments) ? ev.funder_repayments : [];
    const repayByFunder = {};
    repayments.forEach((r) => { if (r && r.counterparty) repayByFunder[r.counterparty] = r; });

    // Per-funder rollup. Several advances from one counterparty roll into one
    // funder row; the individual advances stay addressable underneath.
    const byFunder = [];
    const funderIdx = {};
    advances.forEach((a) => {
      const k = a.counterparty || "—";
      if (!funderIdx[k]) {
        funderIdx[k] = { counterparty: k, counterparty_raw: a.counterparty_raw || null,
                         advanced: 0, count: 0, first_date: a.date, last_date: a.date,
                         tokens: [], offsets: [], rows: [] };
        byFunder.push(funderIdx[k]);
      }
      const f = funderIdx[k];
      f.advanced += a.amount || 0;
      f.count++;
      f.rows.push(a);
      if (a.date && a.date < f.first_date) f.first_date = a.date;
      if (a.date && a.date > f.last_date) f.last_date = a.date;
      if (a.memo_token && f.tokens.indexOf(a.memo_token) === -1) f.tokens.push(a.memo_token);
      const off = a.offset_account_name || "—";
      if (f.offsets.indexOf(off) === -1) f.offsets.push(off);
    });
    byFunder.sort((a, b) => b.advanced - a.advanced);
    // Attach each funder's repayment search result to its row.
    byFunder.forEach((f) => { f.repayment = repayByFunder[f.counterparty] || null; });

    // Which REVENUE accounts carry advances, and how much — this is what lets the
    // Revenue page flag a line in place instead of restating it. Keyed by account
    // code when the ledger has one, by name otherwise.
    const revenueAccounts = [];
    const revIdx = {};
    advances.filter((a) => a.offset_is_revenue).forEach((a) => {
      const k = a.offset_account_code || a.offset_account_name || "—";
      if (!revIdx[k]) {
        revIdx[k] = { code: a.offset_account_code || null, name: a.offset_account_name || "—",
                      category: a.offset_category || null, amount: 0, count: 0, funders: [] };
        revenueAccounts.push(revIdx[k]);
      }
      const r = revIdx[k];
      r.amount += a.amount || 0;
      r.count++;
      if (a.counterparty && r.funders.indexOf(a.counterparty) === -1) r.funders.push(a.counterparty);
    });
    revenueAccounts.sort((a, b) => b.amount - a.amount);

    return {
      signal: sig,
      evidence: ev,
      values: values,
      labels: labels,
      advances: advances,
      byFunder: byFunder,
      relationships: relationships,
      repayments: repayments,
      revenueAccounts: revenueAccounts,

      // How many identified funders have a repayment cadence in the postings.
      // 0 is the expected answer on a ledger whose repayments were never booked,
      // and consumers must render that as "no repayment stream in this ledger"
      // rather than as a zero balance.
      repaymentStreamsIdentified: num(values.repayment_streams_identified),
      fundersSearchedForRepayment: num(values.funders_searched_for_repayment),
      // Total cash that actually left to identified funders. This is EVIDENCE
      // FOR the "no stream" statement, not a repaid-to-date figure: on the one
      // ledger measured it is a rounding error against the advances, which is
      // what a fee looks like and what a repayment stream does not.
      outflowToFunders: repayments.reduce((s, r) => s + (r.outflow_total || 0), 0),

      // The two portions, deliberately named so they read as separate facts and
      // never as addends. `booked_as_revenue` is the provable P&L distortion and
      // the catalog's dollar_impact_estimation; `booked_elsewhere` never touched
      // revenue. `total_advanced` is the gross the funders sent — it is NOT their
      // sum-as-an-impact, and no surface may present it as one.
      totalAdvanced: num(values.total_advanced),
      bookedAsRevenue: num(values.advances_booked_as_revenue),
      bookedElsewhere: num(values.advances_booked_as_expense_offset),
      funderCount: num(values.funder_count),
      windowDays: num(values.window_days),
      annualRevenue: num(values.annual_revenue),
      recordedDebt: num(values.recorded_debt_balance),

      // 0 = the repayment stream is NOT in this ledger. Everything downstream of
      // that fact — repaid to date, remaining balance, effective rate, total cost
      // — is not computable and is rendered as such, never omitted and never
      // estimated. This flag is the reason those columns exist as words.
      repaymentTermsAvailable: num(values.repayment_terms_available),

      firstAdvance: labels.first_advance || null,
      lastAdvance: labels.last_advance || null,
      limits: labels.limits || null,

      // A second, independent signal about where financing-NAMED accounts are
      // classified. Carried through because the schedule should show it next to
      // the advances, but it measures classification, not repayment, and the
      // consumers label it that way.
      financingNamedSpend: costSig
        ? num((((costSig.supporting_evidence || {}).values) || {}).financing_named_operating_spend)
        : null,
    };
  }

  // ── hook ──────────────────────────────────────────────────────────────────
  // Returns { loading, error, financing }. `financing` is null when the signal
  // did not fire — which is the clean, correct answer for most tenants, not an
  // error and not a data gap.
  function useFinancingEvidence(data) {
    const [state, setState] = useState({ loading: true, error: null, financing: null, noLedger: false });

    useEffect(() => {
      let cancelled = false;
      (async () => {
        try {
          const PI = window.PerduraIntelligence;
          if (!PI || !PI.run || !window.jsyaml) {
            // The engine has not loaded. We have formed no opinion, so we hold
            // the loading state rather than reporting an absence of findings.
            if (!cancelled) setState({ loading: true, error: null, financing: null, noLedger: false });
            return;
          }
          const live = data || window.__perduraLiveData;

          // A failed read is an ERROR, never an all-clear. This is the
          // blank ≠ broken ≠ zero rule: rendering "no financing found" off a
          // failed fetch would be the single most damaging thing this module
          // could do, because the reader would take it as an opinion.
          if (live && live.loadError) {
            if (!cancelled) setState({ loading: false, error: new Error(String(live.loadError)), financing: null, noLedger: false });
            return;
          }

          // No ledger in hand yet — either still loading, or this company has no
          // transactions at all. Either way NO CHECK HAS RUN, so consumers must
          // not print the clean state. They get `noLedger` and say that instead.
          const hasLedger = !!(live && ((live.txns && live.txns.length) || (live.plHistory && (live.plHistory.revenue || []).length)));
          if (!hasLedger) {
            if (!cancelled) setState({ loading: false, error: null, financing: null, noLedger: true });
            return;
          }
          const text = await loadCatalogs();
          const set = PI.run(text, live, window.__perduraProfile, (s) => window.jsyaml.load(s));
          if (cancelled) return;
          const sigs = set.signals || [];
          const sig = sigs.find((s) => s.id === SIGNAL_ID) || null;
          const cost = sigs.find((s) => s.id === COST_SIGNAL_ID) || null;
          setState({ loading: false, error: null, financing: shape(sig, cost), noLedger: false });
        } catch (e) {
          // A catalog fetch or engine failure must surface as an error state, not
          // as "clean books" — silently rendering the all-clear on a failed read
          // is the one outcome this module must never produce.
          if (!cancelled) setState({ loading: false, error: e, financing: null, noLedger: false });
        }
      })();
      return () => { cancelled = true; };
    }, [data]);

    return state;
  }

  // ── row matching, for flagging in place ───────────────────────────────────
  // A surface that lists revenue by account, or by the counterparty text on the
  // posting, needs to know which of ITS OWN rows are actually identified
  // financing. It must not recompute that — it asks here.
  //
  // The matching is deliberately conservative in both directions. It compares
  // normalized text and requires a containment match on a counterparty name of
  // real length, because the cost of a false match is labelling a genuine
  // customer as a funder — the exact failure the whole detector is built to
  // avoid. A missed match leaves a row unflagged, which is the safer error: the
  // page-level banner still states the total, so nothing is hidden either way.
  function normText(s) {
    return String(s || "").toUpperCase().replace(/[^A-Z0-9 ]/g, " ").replace(/\s+/g, " ").trim();
  }

  // Which identified advances land on this account? Matched on the account CODE
  // when both sides have one (exact, unambiguous), on the name otherwise.
  function advancesForAccount(financing, account) {
    if (!financing || !account) return [];
    const code = account.code == null ? null : String(account.code);
    const name = normText(account.name);
    return financing.advances.filter((a) => {
      if (code && a.offset_account_code) return String(a.offset_account_code) === code;
      if (!name) return false;
      return normText(a.offset_account_name) === name;
    });
  }

  // Which identified advances does this free-text row represent? Used where a
  // page groups postings by their memo or counterparty text — a funder's bank
  // memo lands in such a list looking exactly like a customer name.
  function advancesForText(financing, text) {
    if (!financing || !text) return [];
    const hay = normText(text);
    if (hay.length < 4) return [];
    return financing.advances.filter((a) => {
      const cands = [a.counterparty, a.counterparty_raw].filter(Boolean).map(normText);
      return cands.some((c) => c.length >= 4 && hay.indexOf(c) !== -1);
    });
  }

  const sumAmt = (rows) => rows.reduce((s, a) => s + (a.amount || 0), 0);

  window.PerduraFinancingEvidence = {
    useFinancingEvidence: useFinancingEvidence,
    loadCatalogs: loadCatalogs,
    shape: shape,
    advancesForAccount: advancesForAccount,
    advancesForText: advancesForText,
    sumAdvances: sumAmt,
    normText: normText,
    SIGNAL_ID: SIGNAL_ID,
  };
})();
