// ForecastPanel — window.ForecastPanel
//
// One component, mounted by page context on every forward-looking page. No
// per-page logic; the page key selects which series it shows, and everything else
// comes from the engine.
//
// The design principle: a forecast is only worth as much as its track record, so
// the track record is never more than one glance away. Every projected line
// carries the method that produced it and that method's MEASURED out-of-sample
// error on this company's own history — not a vague confidence blurb, a number,
// with the full backtest scorecard one click behind it.
//
// Three honest states, and the panel will happily show any of them:
//   forecastable        → point line + P10/P90 band + accuracy badge
//   below_accuracy_bar  → NO LINE. We say the best method was off by X% on this
//                         company's history and that we won't project it.
//   insufficient_history→ NO LINE. We say how many months exist and how many are needed.
//
// Owner assumptions are visually distinct from the statistical baseline, because
// they are a different kind of claim: one is evidence, the other is judgment.
//
// Props: { tenantId, pageType, series?, title? }

(function () {
  const R = window.React;
  if (!R) return;
  const { useState, useEffect, useCallback } = R;
  const h = R.createElement;

  const INK = "#0F1520", MUTE = "#4B5563", LINE = "#E4E8F0";
  const POS = "#059669", NEG = "#DC2626", BLUE = "#1C4ED8", GOLD = "#b8921e";
  const MONO = "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace";

  // Which series each page cares about. PAGE-conditional, never tenant-conditional.
  const PAGE_SERIES = {
    forecast: ["revenue", "opex", "net_income", "net_cash"],
    cash_forecast: ["net_cash"],
    cash: ["net_cash"],
    overview: ["revenue", "net_cash"],
    income_statement: ["revenue", "opex", "net_income"],
    revenue_intelligence: ["revenue"],
    sales: ["revenue"],
    expenses: ["opex"],
    opex_intelligence: ["opex"],
    fpna: ["revenue", "opex", "net_income"],
  };

  const money = (v) => {
    const F = window.PerduraFormat;
    if (F && F.money) return F.money(v, { compact: true });
    const a = Math.abs(Math.round(v || 0));
    return (v < 0 ? "−$" : "$") + (a >= 1000 ? (a / 1000).toFixed(0) + "K" : a.toLocaleString());
  };
  const pct = (v) => (v == null ? "—" : (v * 100).toFixed(1) + "%");

  // Accuracy badge. The colour is driven by the MEASURED error, so a badge can
  // never look better than the method actually performed.
  function accuracyTone(mape) {
    if (mape == null) return { tone: "#6B7280", word: "untested" };
    if (mape <= 0.05) return { tone: POS, word: "strong" };
    if (mape <= 0.15) return { tone: "#0f9d76", word: "good" };
    if (mape <= 0.25) return { tone: GOLD, word: "rough" };
    return { tone: NEG, word: "poor" };
  }

  // ── Chart: actuals (solid) → forecast (dashed) with the P10/P90 band ───────
  function Chart({ s, onDrillActual }) {
    const W = 720, H = 220, padL = 56, padR = 12, padT = 12, padB = 26;
    const hist = s.history.slice(-12);
    const histMonths = s.history_months.slice(-12);
    const pt = s.point || [];
    const band = s.band;
    const hasBand = !!(band && band.residual_source === "backtest_out_of_sample");
    const p10 = hasBand ? band.p10 : [];
    const p90 = hasBand ? band.p90 : [];

    const all = hist.concat(pt, p10, p90).filter((v) => Number.isFinite(v));
    if (!all.length) return null;
    let lo = Math.min(...all, 0), hi = Math.max(...all);
    if (hi === lo) hi = lo + 1;
    const n = hist.length + pt.length;
    const x = (i) => padL + (i * (W - padL - padR)) / Math.max(1, n - 1);
    const y = (v) => padT + (1 - (v - lo) / (hi - lo)) * (H - padT - padB);

    const histPath = hist.map((v, i) => `${i ? "L" : "M"}${x(i)},${y(v)}`).join(" ");
    // Join the forecast to the last actual so the line doesn't float.
    const fx = (i) => x(hist.length - 1 + 1 + i);
    const fcPath = pt.length
      ? `M${x(hist.length - 1)},${y(hist[hist.length - 1])} ` +
        pt.map((v, i) => `L${fx(i)},${y(v)}`).join(" ")
      : "";
    const bandPath = hasBand && p10.length
      ? `M${x(hist.length - 1)},${y(hist[hist.length - 1])} ` +
        p90.map((v, i) => `L${fx(i)},${y(v)}`).join(" ") + " " +
        p10.slice().reverse().map((v, i) => `L${fx(p10.length - 1 - i)},${y(v)}`).join(" ") + " Z"
      : "";

    return h("svg", { viewBox: `0 0 ${W} ${H}`, style: { width: "100%", height: 220 } },
      // The band is drawn FIRST and faintly — it is context, not the message.
      hasBand && h("path", { d: bandPath, fill: BLUE, opacity: 0.10 }),
      h("path", { d: histPath, fill: "none", stroke: INK, strokeWidth: 2 }),
      pt.length && h("path", { d: fcPath, fill: "none", stroke: BLUE, strokeWidth: 2, strokeDasharray: "5 3" }),
      // The boundary between what happened and what is guessed. This line is the
      // whole honesty of the chart: everything right of it is a projection.
      pt.length && h("line", {
        x1: x(hist.length - 1), x2: x(hist.length - 1), y1: padT, y2: H - padB,
        stroke: "#9AA3B2", strokeWidth: 1, strokeDasharray: "2 2",
      }),
      pt.length && h("text", {
        x: x(hist.length - 1) + 4, y: padT + 9, fontSize: 8.5, fill: "#9AA3B2", fontWeight: 700,
      }, "FORECAST →"),
      // Actual points are drillable — every forecast figure traces to the actuals
      // it was built from.
      hist.map((v, i) => h("circle", {
        key: "h" + i, cx: x(i), cy: y(v), r: 2.5, fill: INK,
        style: { cursor: onDrillActual ? "pointer" : "default" },
        onClick: onDrillActual ? () => onDrillActual(histMonths[i]) : undefined,
      }, h("title", null, `${histMonths[i]} · ${money(v)} (actual)`))),
      pt.map((v, i) => h("circle", { key: "f" + i, cx: fx(i), cy: y(v), r: 2.5, fill: BLUE },
        h("title", null,
          `${s.forecast_months[i]} · ${money(v)} (forecast)` +
          (hasBand ? `\nrange ${money(p10[i])} – ${money(p90[i])}` : "")))),
      [lo, (lo + hi) / 2, hi].map((v, i) =>
        h("text", { key: "y" + i, x: padL - 6, y: y(v) + 3, textAnchor: "end", fontSize: 9, fill: "#9AA3B2", fontFamily: MONO },
          money(v))),
      h("text", { x: padL, y: H - 8, fontSize: 9, fill: "#9AA3B2" }, histMonths[0] || ""),
      pt.length && h("text", { x: W - padR, y: H - 8, textAnchor: "end", fontSize: 9, fill: "#9AA3B2" },
        s.forecast_months[s.forecast_months.length - 1]),
    );
  }

  // ── The backtest scorecard. This is Forecast Accuracy. ────────────────────
  function Scorecard({ s }) {
    const rows = (s.scorecard.scores || []).slice().sort((a, b) => {
      if (a.mape == null) return 1;
      if (b.mape == null) return -1;
      return a.mape - b.mape;
    });
    return h("div", { style: { marginTop: 10, border: "1px solid " + LINE, borderRadius: 8, overflow: "hidden" } },
      h("div", { style: { padding: "8px 12px", background: "#F7F8FB", fontSize: 11, color: MUTE } },
        h("b", { style: { color: INK } }, "How each method actually performed on your history"),
        " — each was re-fitted at every point in time and asked to predict months it had never seen. ",
        "Lower error is better. The method with the lowest error is the one used."),
      h("table", { className: "pc-table", style: { width: "100%", fontSize: 11.5 } },
        h("thead", null, h("tr", null,
          h("th", { style: { textAlign: "left" } }, "Method"),
          h("th", { className: "num" }, "Avg error"),
          h("th", { className: "num" }, "Avg $ off"),
          h("th", { className: "num" }, "Tests"),
          h("th", { style: { textAlign: "left" } }, ""))),
        h("tbody", null, rows.map((sc) => {
          const sel = sc.method === s.method;
          const t = accuracyTone(sc.mape);
          return h("tr", {
            key: sc.method,
            style: { background: sel ? "rgba(28,78,216,.05)" : undefined },
          },
            h("td", { style: { fontWeight: sel ? 800 : 600, color: INK } },
              sc.label, sel && h("span", { style: { color: BLUE, fontSize: 10, fontWeight: 800 } }, "  ◀ USED")),
            h("td", { className: "num", style: { fontFamily: MONO, color: sc.mape == null ? "#9AA3B2" : t.tone, fontWeight: 700 } },
              sc.mape == null ? "—" : pct(sc.mape)),
            h("td", { className: "num", style: { fontFamily: MONO, color: MUTE } },
              sc.mae == null ? "—" : money(sc.mae)),
            h("td", { className: "num", style: { fontFamily: MONO, color: MUTE } }, sc.predictions || "—"),
            h("td", { style: { fontSize: 10.5, color: "#9AA3B2" } },
              !sc.available ? sc.unavailable_reason
                : !sc.enough_tests ? sc.not_selectable_reason
                : !sc.meets_bar ? "Above the " + pct(s.accuracy_bar) + " reliability bar"
                : ""),
          );
        }))),
    );
  }

  function SeriesCard({ s, ex, onDrillActual }) {
    const [openCard, setOpenCard] = useState(false);
    const t = accuracyTone(s.mape);
    const band = s.band;
    const hasBand = !!(band && band.residual_source === "backtest_out_of_sample");

    // Honest refusal states. No chart, no number, no hedged guess.
    if (s.status !== "forecastable") {
      return h("div", { style: { border: "1px solid " + LINE, borderRadius: 10, padding: 16, background: "#fff" } },
        h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, marginBottom: 6 } },
          h("div", { style: { fontSize: 14, fontWeight: 800, color: INK } }, s.label),
          h("span", {
            style: { fontSize: 10, fontWeight: 700, color: "#6B7280", background: "rgba(107,114,128,.08)",
              border: "1px solid rgba(107,114,128,.2)", borderRadius: 20, padding: "3px 9px" },
          }, "⚪ Not forecastable")),
        h("div", { style: { fontSize: 12.5, color: MUTE, lineHeight: 1.55 } },
          s.status === "insufficient_history"
            ? `Not enough history to forecast ${s.label.toLowerCase()} reliably yet. ${s.status_reason}`
            : s.status_reason),
        ex && ex.headline && h("div", { style: { marginTop: 8, fontSize: 12.5, color: "#374151" } }, ex.headline),
        s.scorecard.backtestable && h("div", null,
          h("button", {
            onClick: () => setOpenCard(!openCard),
            style: { marginTop: 10, background: "none", border: "none", padding: 0, color: BLUE, fontSize: 11.5, fontWeight: 700, cursor: "pointer" },
          }, openCard ? "Hide what we tried ▲" : "See what we tried and how it scored ▼"),
          openCard && h(Scorecard, { s })),
      );
    }

    const lastPt = s.point[s.point.length - 1];
    const lastLo = hasBand ? band.p10[band.p10.length - 1] : null;
    const lastHi = hasBand ? band.p90[band.p90.length - 1] : null;

    return h("div", { style: { border: "1px solid " + LINE, borderRadius: 10, padding: 16, background: "#fff" } },
      h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12, marginBottom: 8, flexWrap: "wrap" } },
        h("div", null,
          h("div", { style: { fontSize: 14, fontWeight: 800, color: INK } },
            (ex && ex.headline) || s.label),
          h("div", { style: { fontSize: 11.5, color: MUTE, marginTop: 2 } },
            s.method_label, " · ", s.method_describe)),
        // The badge IS the measured accuracy. Not a vibe.
        h("span", {
          title: s.status_reason,
          style: { fontSize: 10, fontWeight: 700, color: t.tone, background: t.tone + "14",
            border: "1px solid " + t.tone + "33", borderRadius: 20, padding: "3px 9px",
            whiteSpace: "nowrap", cursor: "help" },
        }, `±${pct(s.mape)} avg error on your history`)),

      h(Chart, { s, onDrillActual }),

      h("div", { style: { display: "flex", gap: 18, flexWrap: "wrap", fontSize: 12, color: MUTE, marginTop: 8 } },
        h("span", null, s.forecast_months[s.forecast_months.length - 1], " point ",
          h("b", { style: { fontFamily: MONO, color: INK } }, money(lastPt))),
        hasBand && h("span", {
          title: "A simulation of this method's past errors and your assumptions — NOT a guarantee. " +
            band.sims + " simulated paths.",
          style: { cursor: "help" },
        }, "Simulated range ",
          h("b", { style: { fontFamily: MONO, color: INK } }, money(lastLo) + " – " + money(lastHi)),
          h("span", { style: { fontSize: 10, color: "#9AA3B2" } }, "  (P10–P90)")),
        !hasBand && h("span", { style: { color: GOLD } },
          "No range — too few past errors to simulate one honestly"),
        s.applied_assumptions && s.applied_assumptions.length > 0 && h("span", {
          style: { color: GOLD, fontWeight: 700 },
          title: "Your assumption, not the statistical baseline. The baseline is what the data says; this is what you told us to expect.",
        }, "⚙ Includes your assumptions"),
      ),

      ex && h("div", { style: { marginTop: 10, fontSize: 12.5, color: "#374151", lineHeight: 1.55 } },
        ex.method_note && h("div", { style: { marginBottom: 4 } }, ex.method_note),
        ex.range_note && h("div", { style: { marginBottom: 6 } }, ex.range_note),
        ex.what_to_consider && h("div", {
          style: { fontSize: 12, color: INK, background: "#F7F8FB", borderRadius: 6, padding: "7px 10px" },
        }, h("b", null, "Worth considering: "), ex.what_to_consider)),

      h("button", {
        onClick: () => setOpenCard(!openCard),
        style: { marginTop: 10, background: "none", border: "none", padding: 0, color: BLUE, fontSize: 11.5, fontWeight: 700, cursor: "pointer" },
      }, openCard ? "Hide forecast accuracy ▲" : "See forecast accuracy — how every method scored ▼"),
      openCard && h(Scorecard, { s }),
    );
  }

  // ── Owner assumptions. Labelled as judgment, never as evidence. ───────────
  function Assumptions({ tenantId, assumptions, onChange, busy }) {
    const [draft, setDraft] = useState({});
    const val = (a) => (draft[a.key] != null ? draft[a.key] : (a.owner_set ? a.value : ""));

    const commit = (a, raw) => {
      const v = raw === "" ? null : Number(raw);
      if (raw !== "" && !Number.isFinite(v)) return;
      onChange(a.key, v);
    };

    return h("div", { style: { border: "1px solid " + GOLD + "44", background: "rgba(184,146,30,.04)", borderRadius: 10, padding: 14, marginBottom: 14 } },
      h("div", { style: { fontSize: 12, fontWeight: 800, color: INK, marginBottom: 3 } }, "⚙ Your assumptions"),
      h("div", { style: { fontSize: 11, color: MUTE, marginBottom: 10, lineHeight: 1.5 } },
        "These are ", h("b", null, "your judgment, not the data's"), ". The forecast baseline comes from the ",
        "method that predicted your history best; anything you set here is applied on top of it and labelled ",
        "as yours. Leave a field blank to take no view — blank is not the same as zero."),
      h("div", { style: { display: "flex", gap: 14, flexWrap: "wrap" } },
        assumptions.map((a) =>
          h("label", { key: a.key, style: { display: "flex", flexDirection: "column", gap: 3, fontSize: 11, color: MUTE, maxWidth: 230 } },
            h("span", null, a.label,
              h("span", { style: { color: "#9AA3B2" } }, "  (", a.unit, ")")),
            h("input", {
              type: "number", step: "any", disabled: busy,
              value: draft[a.key] != null ? draft[a.key] : (a.owner_set ? a.value : ""),
              placeholder: "no view",
              onChange: (e) => setDraft(Object.assign({}, draft, { [a.key]: e.target.value })),
              onBlur: (e) => commit(a, e.target.value),
              style: { width: 110, padding: "5px 7px", borderRadius: 6, border: "1px solid " + LINE, fontFamily: MONO, fontSize: 12 },
            }),
            // An assumption that changes nothing MUST say so. Setting a number and
            // watching the chart not move, with no explanation, is a quiet lie.
            a.owner_set && a.effective === false && h("span", {
              style: { fontSize: 10, color: NEG, lineHeight: 1.4, marginTop: 2 },
            }, "⚠ Not affecting the forecast — ", a.effect_note),
            a.owner_set && a.effective === true && h("span", {
              style: { fontSize: 10, color: POS, lineHeight: 1.4, marginTop: 2 },
            }, "✓ ", a.effect_note),
          )),
      ),
      busy && h("div", { style: { fontSize: 11, color: MUTE, marginTop: 8 } }, "Re-running the forecast…"),
    );
  }

  function Skeleton() {
    return h("div", { style: { display: "flex", flexDirection: "column", gap: 12 } },
      [0, 1].map((i) => h("div", {
        key: i,
        style: { border: "1px solid " + LINE, borderRadius: 10, padding: 16, background: "#fff", opacity: 1 - i * 0.2 },
      },
        h("div", { style: { height: 12, width: "44%", borderRadius: 4, background: "#EEF1F6", marginBottom: 12 } }),
        h("div", { style: { height: 150, borderRadius: 6, background: "#F3F5F9" } }))));
  }

  function ForecastPanel(props) {
    const tenantId = props.tenantId;
    const pageType = props.pageType;
    const [phase, setPhase] = useState("loading");
    const [res, setRes] = useState(null);
    const [busy, setBusy] = useState(false);
    const [liveAssumptions, setLiveAssumptions] = useState(null);

    const load = useCallback((assumptionOverrides) => {
      if (!tenantId || !window.PerduraSynthesis || !window.PerduraSynthesis.fetchForecast) {
        setPhase("hidden"); return;
      }
      window.PerduraSynthesis.fetchForecast(tenantId, {
        assumptions: assumptionOverrides || undefined,
      }).then((r) => {
        if (!r || r.error || !r.forecast) { setPhase("hidden"); return; }
        setRes(r);
        setPhase("done");
        setBusy(false);
      }).catch(() => { setPhase("hidden"); setBusy(false); });
    }, [tenantId]);

    useEffect(() => { setPhase("loading"); load(liveAssumptions); }, [tenantId, pageType]);

    // Changing an assumption re-runs the forecast live, against the same engine.
    const onAssumptionChange = (key, value) => {
      const next = Object.assign({}, liveAssumptions || {});
      if (value == null) delete next[key];
      else next[key] = { value, low: value, high: value };
      setLiveAssumptions(next);
      setBusy(true);
      load(next);
      // Persist, so the owner's view survives a reload. Failure to persist must not
      // break the live forecast they're looking at.
      const db = window.supabaseClient;
      if (!db) return;
      if (value == null) {
        db.from("forecast_assumptions").delete()
          .eq("company_id", tenantId).eq("assumption_key", key).then(() => {}, () => {});
      } else {
        db.from("forecast_assumptions").upsert({
          company_id: tenantId, assumption_key: key, value, low: value, high: value,
          updated_at: new Date().toISOString(),
        }, { onConflict: "company_id,assumption_key" }).then(() => {}, () => {});
      }
    };

    if (phase === "hidden") return null;

    const shell = (body, sub) => h("div", {
      style: { border: "1px solid " + LINE, borderRadius: 12, background: "#fff",
        boxShadow: "0 1px 6px rgba(13,32,64,0.06)", padding: 16, marginTop: 16 },
    },
      h("div", { style: { marginBottom: 12 } },
        h("div", { style: { fontSize: 10, fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "#6475a0" } },
          "Forecast"),
        h("div", { style: { fontSize: 15, fontWeight: 800, color: INK, marginTop: 2 } },
          props.title || "What's ahead — and how much to trust it"),
        sub && h("div", { style: { fontSize: 11.5, color: MUTE, marginTop: 3 } }, sub)),
      body);

    if (phase === "loading") return shell(h(Skeleton), "Backtesting every method against your own history…");

    const fc = res.forecast;
    const want = PAGE_SERIES[pageType] || PAGE_SERIES.forecast;
    const shown = (fc.series || []).filter((s) => want.indexOf(s.series) >= 0);
    if (!shown.length) return null;

    const exBy = {};
    (res.explanations || []).forEach((e) => { exBy[e.series] = e; });

    const drillActual = (month) => {
      if (!window.openDrillPanel || !month) return;
      const [y, m] = month.split("-").map(Number);
      const end = new Date(y, m, 0);
      window.openDrillPanel({
        name: "Actuals · " + month, category: "Forecast base", pageType: "forecast",
        codes: [], start: `${month}-01`,
        end: `${month}-${String(end.getDate()).padStart(2, "0")}`,
      });
    };

    const rw = fc.runway;
    const sub = `${fc.generated_from_months} months of history · ${fc.horizon}-month horizon · `
      + `methods chosen by backtesting, not by reputation`;

    return shell(
      h("div", { style: { display: "flex", flexDirection: "column", gap: 12 } },
        res.summary && h("div", { style: { fontSize: 12.5, color: "#374151", lineHeight: 1.55 } }, res.summary),

        fc.assumptions && fc.assumptions.length > 0 && h(Assumptions, {
          tenantId, assumptions: fc.assumptions, onChange: onAssumptionChange, busy,
        }),

        // Runway as a RANGE. A single date would be false precision.
        rw && rw.p50_months != null && want.indexOf("net_cash") >= 0 && h("div", {
          style: { border: "1px solid " + LINE, borderRadius: 10, padding: 14, background: "#fff" },
        },
          h("div", { style: { fontSize: 12, fontWeight: 800, color: INK, marginBottom: 4 } }, "Cash runway"),
          h("div", { style: { fontSize: 13, color: INK } },
            "Cash runs out somewhere between ",
            h("b", { style: { fontFamily: MONO } }, Math.round(rw.p10_months) + " and " + Math.round(rw.p90_months) + " months"),
            ", most likely around ",
            h("b", { style: { fontFamily: MONO } }, Math.round(rw.p50_months) + " months"), "."),
          h("div", { style: { fontSize: 11, color: MUTE, marginTop: 4 } },
            "A range, not a date — the exact month depends on how the next few months actually land. ",
            rw.never_runs_out_pct > 0
              ? rw.never_runs_out_pct + "% of simulated paths never run out within the horizon."
              : "")),

        rw && rw.p50_months == null && rw.never_runs_out_pct === 100 && want.indexOf("net_cash") >= 0 && h("div", {
          style: { border: "1px solid " + LINE, borderRadius: 10, padding: 14, background: "#fff", fontSize: 13, color: INK },
        },
          h("b", null, "Cash runway: "), "no simulated path runs out of cash within the ",
          fc.horizon, "-month horizon."),

        shown.map((s) => h(SeriesCard, { key: s.series, s, ex: exBy[s.series], onDrillActual: drillActual })),
      ),
      sub);
  }

  window.ForecastPanel = ForecastPanel;
})();
