"use client";

/**
 * ForecastChart — §22 deficit-index trajectory across horizons × scenarios.
 *
 * Renders ABOVE the 6.1 horizons table (chart-then-table) on the Site Finder
 * analysis screen. Visualises what the 6.1 / 6.2 tables show as numbers:
 *
 *   • Primary (left y-axis «Индекс дефицита», ~[-1,1]): one line per scenario
 *     (base / aggressive / conservative), deficit_index at each horizon.
 *     Colours from --viz-1..3 IN ORDER (base = viz-1). Falls back to a single
 *     «Дефицит» line from future_market.forecasts_by_horizon if no scenarios.
 *   • Secondary (right y-axis «Ставка, % годовых»): a dashed key-rate path
 *     (prediction → dashed per ui-conventions). Single line (base scenario, or
 *     the fallback forecast) to keep the chart readable — 3 rate lines clutter.
 *   • markLine (vertical) at the target horizon, labelled «целевой».
 *   • markLine (horizontal) at deficit = 0 («баланс») so the sign reads:
 *     >0 недонасыщенность, <0 затоварка.
 *
 * Lazy-mount + echarts import are handled by ChartShell — we only build a typed
 * `option` and hand it over (no echarts import in this file).
 *
 * Deficit semantics (HARD, see forecast-helpers): >0 недонасыщенность,
 * <0 затоварка, ≈0 баланс.
 */

import { useMemo } from "react";

import { ChartShell } from "@/components/analytics/ChartShell";
import type {
  DemandSupplyForecast,
  ForecastReport,
  ScenarioKey,
} from "@/types/forecast";

import {
  CONFIDENCE_RU,
  confidenceBandHalfWidth,
  fmtNum,
} from "./forecast-helpers";

interface Props {
  report: ForecastReport;
  /** Horizon (мес) to emphasise — the user's HorizonSelector choice wins. */
  selectedHorizon?: number;
}

// Scenario render order + RU labels. viz-1 is always the focused/own series, so
// base (the default expectation) takes viz-1, then aggressive, then conservative.
const SCENARIO_ORDER: ScenarioKey[] = ["base", "aggressive", "conservative"];

const SCENARIO_RU: Record<ScenarioKey, string> = {
  base: "База",
  aggressive: "Агрессивный",
  conservative: "Консервативный",
};

// Series colours — ONLY from --viz-1..3 in order (documented hardcode exception
// for ECharts series colours; values mirror ui-tokens.md).
const VIZ: Record<ScenarioKey, string> = {
  base: "#1D4ED8", // --viz-1
  aggressive: "#0EA5E9", // --viz-2
  conservative: "#14B8A6", // --viz-3
};

// Single-line fallback colour (ui-tokens: одиночная линия — #374151).
const SINGLE_LINE = "#374151";
// Chrome colours below are intentionally raw hex, NOT var(--token), for the same
// reason as VIZ above: ChartShell renders via echarts-for-react with the default
// CANVAS renderer (no opts.renderer:"svg"), and the canvas 2D context does not
// resolve CSS custom properties — `var(--x)` would paint as transparent/black.
// Values mirror ui-tokens.md exactly: --prediction-line, --fg-secondary,
// --border-strong. Keep in sync with ui-tokens.md by hand.
const PREDICTION_LINE = "#0EA5E9"; // --prediction-line
const PREDICTION_BAND = "rgba(14, 165, 233, 0.18)"; // --prediction-band
const FG_SECONDARY = "#5B6066"; // --fg-secondary
const BORDER_STRONG = "#D1D5DB"; // --border-strong

// #1926 — deficit axis bounds; band lower/upper are clamped to these so a wide
// low-confidence interval never paints past the scale.
const DEFICIT_MIN = -1;
const DEFICIT_MAX = 1;

const clampDeficit = (v: number): number =>
  Math.max(DEFICIT_MIN, Math.min(DEFICIT_MAX, v));

// ── Tooltip row type (echarts-for-react gives loose params; narrow ourselves) ──
interface TooltipParam {
  seriesName?: string;
  value?: number | null;
  axisValueLabel?: string;
  marker?: string;
  seriesType?: string;
  dataIndex?: number;
}

function sortedUniqueHorizons(report: ForecastReport): number[] {
  const set = new Set<number>();
  for (const f of report.future_market.forecasts_by_horizon) {
    set.add(f.horizon_months);
  }
  for (const sc of Object.values(report.scenarios.by_scenario)) {
    for (const f of sc?.forecasts ?? []) set.add(f.horizon_months);
  }
  return Array.from(set).sort((a, b) => a - b);
}

/** deficit_index per horizon for a forecast list, aligned to the horizon axis. */
function deficitByHorizon(
  forecasts: DemandSupplyForecast[],
  horizons: number[],
): (number | null)[] {
  const byH = new Map<number, number>();
  for (const f of forecasts) byH.set(f.horizon_months, f.deficit_index);
  return horizons.map((h) => byH.get(h) ?? null);
}

/** rate_future per horizon for a forecast list, aligned to the horizon axis. */
function rateByHorizon(
  forecasts: DemandSupplyForecast[],
  horizons: number[],
): (number | null)[] {
  const byH = new Map<number, number>();
  for (const f of forecasts) byH.set(f.horizon_months, f.rate_future);
  return horizons.map((h) => byH.get(h) ?? null);
}

/**
 * #1926 — qualitative confidence band around a deficit line, aligned to the
 * horizon axis. The §22 payload carries NO numeric quantiles (p25/p75) — only a
 * per-horizon `confidence` level — so the band width is derived from that level
 * (lower confidence → wider), NOT from fabricated quantiles. Rendered as two
 * stacked series (lower bound + width) so ECharts fills between them; the caller
 * uses `stackStrategy: "all"` so a negative lower bound + positive width stack
 * correctly on the signed [−1, 1] axis.
 *
 * Returns `{ lower, width }` aligned to `horizons` (null where the deficit or its
 * confidence is missing — the band is simply absent at that horizon, never faked).
 */
interface DeficitBand {
  lower: (number | null)[];
  width: (number | null)[];
}

function bandByHorizon(
  forecasts: DemandSupplyForecast[],
  horizons: number[],
): DeficitBand {
  const byH = new Map<number, DemandSupplyForecast>();
  for (const f of forecasts) byH.set(f.horizon_months, f);
  const lower: (number | null)[] = [];
  const width: (number | null)[] = [];
  for (const h of horizons) {
    const f = byH.get(h);
    if (f == null || f.deficit_index == null) {
      lower.push(null);
      width.push(null);
      continue;
    }
    const half = confidenceBandHalfWidth(f.confidence);
    const lo = clampDeficit(f.deficit_index - half);
    const hi = clampDeficit(f.deficit_index + half);
    lower.push(lo);
    width.push(hi - lo);
  }
  return { lower, width };
}

/** Per-horizon confidence level for a forecast list (for the band tooltip). */
function confidenceByHorizon(
  forecasts: DemandSupplyForecast[],
  horizons: number[],
): (string | null)[] {
  const byH = new Map<number, DemandSupplyForecast>();
  for (const f of forecasts) byH.set(f.horizon_months, f);
  return horizons.map((h) => {
    const f = byH.get(h);
    return f == null ? null : CONFIDENCE_RU[f.confidence];
  });
}

// #1958 — близость к ±1 трактуем как clamp-floor: backend подрезает deficit_index
// к [−1,1], и вырожденный (недифференцированный) прогноз садится на границу.
const DEGENERATE_CLAMP_EPS = 0.02;

/**
 * #1958 — вырожден ли deficit-ряд? Линия «плоская и неинформативная», когда:
 *   (a) backend схлопнул сценарии (scenarios_collapsed) — 3 линии идентичны; ИЛИ
 *   (b) все ненулевые точки дефицита РАВНЫ между собой И сидят у clamp-границы
 *       (|value| ≈ 1) — дифференциации сегментов нет, линия почти невидима у края.
 *
 * Чистая функция (без ECharts) — переиспользуется тестами. Реальный фикс
 * дифференциации — backend #1959; здесь только честный flat-state callout.
 */
export function isDeficitDegenerate(
  values: (number | null)[],
  scenariosCollapsed: boolean,
): boolean {
  if (scenariosCollapsed) return true;
  const present = values.filter((v): v is number => v != null);
  if (present.length === 0) return false;
  const first = present[0];
  const allEqual = present.every((v) => Math.abs(v - first) < 1e-9);
  if (!allEqual) return false;
  // Все точки равны — вырождено только если они у clamp-floor (|value| ≈ 1).
  return Math.abs(Math.abs(first) - 1) < DEGENERATE_CLAMP_EPS;
}

export function ForecastChart({ report, selectedHorizon }: Props) {
  const fm = report.future_market;
  const byScenario = report.scenarios.by_scenario;

  const presentScenarios = SCENARIO_ORDER.filter(
    (k) => (byScenario[k]?.forecasts.length ?? 0) > 0,
  );
  // #1871 P1 — when backend collapses 3 scenarios into one (failed §9.6 β
  // rate-sensitivity gate), conservative/base/aggressive deficit_index lines
  // are identical and just stack on top of each other. Render the base line
  // only — the caveat above (Section6Forecast) explains why. Band / rate-line
  // / markLine remain untouched (still derived from the base / fallback row).
  const collapsed = report.scenarios.scenarios_collapsed === true;
  const effectiveScenarios: ScenarioKey[] = collapsed
    ? presentScenarios.filter((k) => k === "base")
    : presentScenarios;
  const hasScenarios = effectiveScenarios.length > 0;
  const hasHorizons = fm.forecasts_by_horizon.length > 0;

  // Target horizon: user's selector wins, else future_supply, else 12.
  const targetHorizon =
    selectedHorizon ?? fm.future_supply?.horizon_months ?? 12;

  const option = useMemo<Record<string, unknown>>(() => {
    const horizons = sortedUniqueHorizons(report);
    const xLabels = horizons.map((h) => `${h} мес`);

    // ── Deficit series ──────────────────────────────────────────────────────
    // Per-scenario lines when available, else a single fallback line. Forecast →
    // dashed line per ui-conventions (prediction never a solid point-line).
    const deficitSeries: Record<string, unknown>[] = hasScenarios
      ? effectiveScenarios.map((key) => ({
          name: SCENARIO_RU[key],
          type: "line",
          yAxisIndex: 0,
          data: deficitByHorizon(byScenario[key]?.forecasts ?? [], horizons),
          symbol: "circle",
          symbolSize: 6,
          smooth: false,
          connectNulls: true,
          lineStyle: { color: VIZ[key], width: 2, type: "dashed" },
          itemStyle: { color: VIZ[key] },
          emphasis: { focus: "series" },
          z: 3,
        }))
      : [
          {
            name: "Дефицит",
            type: "line",
            yAxisIndex: 0,
            data: deficitByHorizon(fm.forecasts_by_horizon, horizons),
            symbol: "circle",
            symbolSize: 6,
            smooth: false,
            connectNulls: true,
            lineStyle: { color: SINGLE_LINE, width: 2, type: "dashed" },
            itemStyle: { color: SINGLE_LINE },
            z: 3,
          },
        ];

    // ── Confidence band (#1926) ──────────────────────────────────────────────
    // Qualitative interval around the FOCUSED deficit line (base scenario, else
    // the single fallback line) — banding all 3 scenario lines clutters. Width
    // reflects the per-horizon confidence level (low → wider), NOT fabricated
    // p25/p75; labelled «качественный» in legend + tooltip. Two stacked series
    // (transparent lower bound + filled width) with `stackStrategy: "all"` so the
    // fill sits between lower and upper on the signed [−1, 1] axis.
    const bandKey: ScenarioKey | null = hasScenarios
      ? (byScenario.base?.forecasts.length ?? 0) > 0
        ? "base"
        : effectiveScenarios[0]
      : null;
    const bandSource: DemandSupplyForecast[] =
      bandKey != null
        ? (byScenario[bandKey]?.forecasts ?? [])
        : fm.forecasts_by_horizon;
    const band = bandByHorizon(bandSource, horizons);
    const bandConf = confidenceByHorizon(bandSource, horizons);
    const hasBand = band.width.some((v) => v != null && v > 0);
    const BAND_STACK = "deficit-band";

    const bandSeries: Record<string, unknown>[] = hasBand
      ? [
          {
            // Lower bound — invisible line, just the stack base for the fill.
            name: "band-lower",
            type: "line",
            yAxisIndex: 0,
            data: band.lower,
            stack: BAND_STACK,
            stackStrategy: "all",
            symbol: "none",
            lineStyle: { opacity: 0 },
            areaStyle: { opacity: 0 },
            silent: true,
            // Excluded from legend/tooltip (chrome-only helper series).
            tooltip: { show: false },
            z: 1,
          },
          {
            // Width (upper − lower) — the visible filled band.
            name: "Интервал уверенности (качественный)",
            type: "line",
            yAxisIndex: 0,
            data: band.width,
            stack: BAND_STACK,
            stackStrategy: "all",
            symbol: "none",
            lineStyle: { opacity: 0 },
            areaStyle: { color: PREDICTION_BAND },
            silent: true,
            z: 1,
          },
        ]
      : [];

    // ── Rate path (secondary axis, dashed = prediction) ─────────────────────
    // One line only (base scenario preferred, else fallback forecast) — three
    // rate lines clutter the deficit story.
    const rateSource: DemandSupplyForecast[] =
      byScenario.base?.forecasts ??
      (hasScenarios
        ? (byScenario[effectiveScenarios[0]]?.forecasts ?? [])
        : fm.forecasts_by_horizon);
    const rateData = rateByHorizon(rateSource, horizons);
    const hasRate = rateData.some((v) => v != null);

    const rateSeries: Record<string, unknown>[] = hasRate
      ? [
          {
            name: "Ставка",
            type: "line",
            yAxisIndex: 1,
            data: rateData,
            symbol: "emptyCircle",
            symbolSize: 5,
            smooth: false,
            connectNulls: true,
            // Forecast / prediction → dashed (ui-conventions HARD).
            lineStyle: { color: PREDICTION_LINE, width: 2, type: "dashed" },
            itemStyle: { color: PREDICTION_LINE },
            z: 2,
          },
        ]
      : [];

    // ── markLines: vertical «целевой» + horizontal «баланс» (deficit = 0) ────
    // Attached to the first deficit series so they paint once.
    const targetLabel = xLabels[horizons.indexOf(targetHorizon)];
    const markLineData: Record<string, unknown>[] = [
      {
        yAxis: 0,
        label: {
          formatter: "баланс",
          position: "insideEndTop",
          color: FG_SECONDARY,
          fontSize: 11,
        },
        lineStyle: { color: BORDER_STRONG, type: "solid", width: 1 },
      },
    ];
    if (targetLabel != null) {
      markLineData.push({
        xAxis: targetLabel,
        label: {
          formatter: "целевой",
          color: FG_SECONDARY,
          fontSize: 11,
        },
        lineStyle: { color: BORDER_STRONG, type: "dashed", width: 1 },
      });
    }
    const firstDeficit = deficitSeries[0] as Record<string, unknown>;
    firstDeficit.markLine = {
      silent: true,
      symbol: "none",
      data: markLineData,
    };

    const yDeficit: Record<string, unknown> = {
      type: "value",
      name: "Индекс дефицита",
      position: "left",
      min: -1,
      max: 1,
      interval: 0.5,
      // #1958 — раньше nameLocation по умолчанию ("end") ставил подпись оси в
      // верхний угол, где она перекрывалась с легендой. Кладём подпись ВДОЛЬ оси
      // (middle, повёрнута на 90°) с зазором nameGap, чтобы она не лезла в
      // легенду и не наезжала на axisLabel'ы.
      nameLocation: "middle",
      nameGap: 40,
      nameRotate: 90,
      nameTextStyle: { color: FG_SECONDARY, fontSize: 11 },
      axisLabel: {
        color: FG_SECONDARY,
        formatter: (v: number) => fmtNum(v, 1),
      },
    };

    const yAxis: Record<string, unknown>[] = [yDeficit];
    if (hasRate) {
      yAxis.push({
        type: "value",
        name: "Ставка, % годовых",
        position: "right",
        scale: true,
        // #1958 — то же, что для левой оси: подпись вдоль правой оси (повёрнута
        // на −90°), nameGap чуть больше под формат «14 %», не в легенду.
        nameLocation: "middle",
        nameGap: 48,
        nameRotate: -90,
        nameTextStyle: { color: FG_SECONDARY, fontSize: 11 },
        axisLabel: {
          color: FG_SECONDARY,
          formatter: (v: number) => `${fmtNum(v, 0)} %`,
        },
        splitLine: { show: false },
      });
    }

    // Helper band series are excluded from the tooltip body; instead the band's
    // per-horizon confidence level is appended as a qualitative note (#1926).
    const BAND_WIDTH_NAME = "Интервал уверенности (качественный)";
    const BAND_SERIES_NAMES = new Set(["band-lower", BAND_WIDTH_NAME]);

    return {
      tooltip: {
        trigger: "axis",
        axisPointer: { type: "cross" },
        // Units MANDATORY: «−0.42» for deficit, «14.5 %» for ставка.
        formatter: (raw: unknown) => {
          const params = Array.isArray(raw) ? (raw as TooltipParam[]) : [];
          if (params.length === 0) return "";
          const header = params[0]?.axisValueLabel ?? "";
          const lines = params
            .filter(
              (p) =>
                p.value != null && !BAND_SERIES_NAMES.has(p.seriesName ?? ""),
            )
            .map((p) => {
              const v = p.value as number;
              const isRate = p.seriesName === "Ставка";
              const text = isRate ? `${fmtNum(v, 2)} %` : fmtNum(v, 2);
              return `${p.marker ?? ""}${p.seriesName}: <b>${text}</b>`;
            });
          // Qualitative confidence note for the band (no fabricated quantiles):
          // interval width reflects the horizon's confidence level.
          const idx = params[0]?.dataIndex;
          const conf =
            hasBand && typeof idx === "number" ? bandConf[idx] : null;
          const note =
            conf != null
              ? `Интервал: уверенность ${conf} (качественный, не p25/p75)`
              : null;
          return [header, ...lines, ...(note != null ? [note] : [])].join(
            "<br/>",
          );
        },
        textStyle: { fontSize: 12 },
      },
      legend: {
        top: 0,
        // The invisible lower-bound helper series is hidden from the legend; the
        // filled «Интервал уверенности (качественный)» stays as the band key.
        data: [...deficitSeries, ...rateSeries, ...bandSeries]
          .map((s) => (s as { name?: string }).name)
          .filter((n): n is string => n != null && n !== "band-lower"),
        textStyle: { color: FG_SECONDARY, fontSize: 12 },
      },
      grid: { left: 56, right: hasRate ? 64 : 24, top: 40, bottom: 32 },
      xAxis: {
        type: "category",
        data: xLabels,
        boundaryGap: false,
        axisLabel: { color: FG_SECONDARY },
        axisLine: { lineStyle: { color: BORDER_STRONG } },
      },
      yAxis,
      // Band first (paints under), then deficit lines, then the rate path.
      series: [...bandSeries, ...deficitSeries, ...rateSeries],
    };
  }, [
    report,
    fm,
    byScenario,
    hasScenarios,
    effectiveScenarios,
    // `collapsed` intentionally omitted: it's already encoded in
    // `effectiveScenarios` (collapse filters the array down to ["base"]), and
    // exhaustive-deps flags it as an unnecessary dep when included separately.
    targetHorizon,
  ]);

  // #1958 — детектим вырожденный (плоский) deficit-ряд на тех же данных, что
  // кормят график: per-scenario forecasts либо fallback forecasts_by_horizon.
  const isDegenerate = useMemo(() => {
    const horizons = sortedUniqueHorizons(report);
    const values: (number | null)[] = hasScenarios
      ? effectiveScenarios.flatMap((key) =>
          deficitByHorizon(byScenario[key]?.forecasts ?? [], horizons),
        )
      : deficitByHorizon(fm.forecasts_by_horizon, horizons);
    return isDeficitDegenerate(values, collapsed);
  }, [report, fm, byScenario, hasScenarios, effectiveScenarios, collapsed]);

  // Thin reports (no horizons AND no scenarios) → render nothing.
  if (!hasHorizons && !hasScenarios) return null;

  return (
    <div style={{ position: "relative" }}>
      <ChartShell option={option} height={300} notMerge />
      {isDegenerate && (
        <div
          // #1958 — плоская/вырожденная линия почти невидима у края шкалы;
          // честная серая плашка поверх центра графика объясняет, почему
          // дифференциации сегментов нет (реальный фикс — backend #1959).
          style={{
            position: "absolute",
            top: "50%",
            left: "50%",
            transform: "translate(-50%, -50%)",
            maxWidth: 360,
            padding: "10px 14px",
            background: "var(--bg-card-alt)",
            border: "1px solid var(--border-strong)",
            borderRadius: 8,
            fontSize: 12,
            lineHeight: 1.4,
            color: "var(--fg-secondary)",
            textAlign: "center",
            pointerEvents: "none",
          }}
        >
          Прогноз дефицита плоский — данных недостаточно для дифференциации
          сегментов.
        </div>
      )}
    </div>
  );
}
