"""ASCII chart rendering for terminal environments.

Renders frozen dataclasses from ``horizon.plotting`` as text art that
displays cleanly in terminals, Claude Code, and other CLI tools.
No external dependencies - stdlib only.
"""

from __future__ import annotations

import math
from typing import Sequence


# ---------------------------------------------------------------------------
# Canvas - shared 2-D character grid
# ---------------------------------------------------------------------------

class _Canvas:
    """Mutable character grid with coordinate mapping."""

    __slots__ = ("w", "h", "grid", "x_min", "x_max", "y_min", "y_max")

    def __init__(
        self,
        width: int,
        height: int,
        x_min: float,
        x_max: float,
        y_min: float,
        y_max: float,
    ) -> None:
        self.w = width
        self.h = height
        self.grid: list[list[str]] = [[" "] * width for _ in range(height)]
        self.x_min = x_min
        self.x_max = x_max
        self.y_min = y_min
        self.y_max = y_max

    # -- coordinate helpers --------------------------------------------------

    def col(self, x: float) -> int:
        if self.x_max == self.x_min:
            return self.w // 2
        return max(0, min(self.w - 1, round((x - self.x_min) / (self.x_max - self.x_min) * (self.w - 1))))

    def row(self, y: float) -> int:
        if self.y_max == self.y_min:
            return self.h // 2
        # Row 0 = top = y_max
        return max(0, min(self.h - 1, round((self.y_max - y) / (self.y_max - self.y_min) * (self.h - 1))))

    # -- drawing primitives --------------------------------------------------

    def put(self, x: float, y: float, ch: str) -> None:
        r, c = self.row(y), self.col(x)
        self.grid[r][c] = ch

    def line(self, x0: float, y0: float, x1: float, y1: float, ch: str = "·") -> None:
        """Bresenham between two data-space points."""
        c0, r0 = self.col(x0), self.row(y0)
        c1, r1 = self.col(x1), self.row(y1)
        dc = abs(c1 - c0)
        dr = abs(r1 - r0)
        sc = 1 if c0 < c1 else -1
        sr = 1 if r0 < r1 else -1
        err = dc - dr
        while True:
            if 0 <= r0 < self.h and 0 <= c0 < self.w:
                self.grid[r0][c0] = ch
            if r0 == r1 and c0 == c1:
                break
            e2 = 2 * err
            if e2 > -dr:
                err -= dr
                c0 += sc
            if e2 < dc:
                err += dc
                r0 += sr

    def fill_down(self, x: float, y: float, ch: str = "░") -> None:
        """Fill from (x, y) down to y_min."""
        c = self.col(x)
        r_top = self.row(y)
        for r in range(r_top, self.h):
            if self.grid[r][c] == " ":
                self.grid[r][c] = ch

    # -- render with axis labels ---------------------------------------------

    def render(
        self,
        title: str = "",
        y_labels: bool = True,
        x_label_left: str = "",
        x_label_right: str = "",
    ) -> str:
        label_w = 0
        y_strs: list[str] = []
        if y_labels:
            label_w = 10
            for r in range(self.h):
                y_val = self.y_max - (r / max(self.h - 1, 1)) * (self.y_max - self.y_min)
                y_strs.append(f"{y_val:>9.4g}")

        lines: list[str] = []
        if title:
            lines.append(title)
            lines.append("")

        for r in range(self.h):
            row_str = "".join(self.grid[r])
            if y_labels:
                lines.append(f"{y_strs[r]} │{row_str}")
            else:
                lines.append(f"│{row_str}")

        # x-axis
        if y_labels:
            lines.append(" " * label_w + "└" + "─" * self.w)
        else:
            lines.append("└" + "─" * self.w)

        if x_label_left or x_label_right:
            pad = " " * (label_w + 1) if y_labels else " "
            right = x_label_right.rjust(self.w - len(x_label_left))
            lines.append(pad + x_label_left + right)

        return "\n".join(lines)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _fmt_num(v: float) -> str:
    """Compact number formatting."""
    if abs(v) >= 1e6:
        return f"{v:.1e}"
    if abs(v) >= 1000:
        return f"{v:,.0f}"
    if abs(v) >= 1:
        return f"{v:.2f}"
    if abs(v) >= 0.01:
        return f"{v:.4f}"
    return f"{v:.6f}"


def _auto_range(vals: Sequence[float]) -> tuple[float, float]:
    """Min/max with small padding, filtering non-finite."""
    clean = [v for v in vals if math.isfinite(v)]
    if not clean:
        return 0.0, 1.0
    lo, hi = min(clean), max(clean)
    if lo == hi:
        return lo - 1.0, hi + 1.0
    margin = (hi - lo) * 0.05
    return lo - margin, hi + margin


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def line_chart(
    timestamps: Sequence[float],
    values: Sequence[float],
    *,
    width: int = 72,
    height: int = 18,
    title: str = "",
    marker: str = "·",
) -> str:
    """Render a time-series as an ASCII line chart.

    Works with any (timestamps, values) pair or a ``CurveData``.
    """
    if not timestamps or not values:
        return title + "\n(no data)" if title else "(no data)"

    y_min, y_max = _auto_range(values)
    x_min, x_max = float(timestamps[0]), float(timestamps[-1])
    c = _Canvas(width, height, x_min, x_max, y_min, y_max)

    for i in range(len(timestamps) - 1):
        v0, v1 = values[i], values[i + 1]
        if math.isfinite(v0) and math.isfinite(v1):
            c.line(timestamps[i], v0, timestamps[i + 1], v1, marker)

    return c.render(
        title=title,
        x_label_left=_fmt_num(x_min),
        x_label_right=_fmt_num(x_max),
    )


def multi_line(
    series: list[tuple[Sequence[float], Sequence[float], str, str]],
    *,
    width: int = 72,
    height: int = 18,
    title: str = "",
) -> str:
    """Overlay multiple time-series. Each item: (timestamps, values, label, marker).

    Example markers: ``·`` ``×`` ``○`` ``+`` ``*``
    """
    if not series:
        return "(no data)"

    all_x: list[float] = []
    all_y: list[float] = []
    for ts, vs, _, _ in series:
        all_x.extend(ts)
        all_y.extend(v for v in vs if math.isfinite(v))

    if not all_x or not all_y:
        return "(no data)"

    y_min, y_max = _auto_range(all_y)
    x_min, x_max = min(all_x), max(all_x)
    c = _Canvas(width, height, x_min, x_max, y_min, y_max)

    for ts, vs, _, marker in series:
        for i in range(len(ts) - 1):
            v0, v1 = vs[i], vs[i + 1]
            if math.isfinite(v0) and math.isfinite(v1):
                c.line(ts[i], v0, ts[i + 1], v1, marker)

    legend = "  ".join(f"{m} {label}" for _, _, label, m in series)
    out = c.render(
        title=title,
        x_label_left=_fmt_num(x_min),
        x_label_right=_fmt_num(x_max),
    )
    return out + "\n" + legend


def area_chart(
    timestamps: Sequence[float],
    values: Sequence[float],
    *,
    width: int = 72,
    height: int = 18,
    title: str = "",
    fill: str = "░",
    edge: str = "▓",
) -> str:
    """Render a filled area chart (e.g. underwater drawdown)."""
    if not timestamps or not values:
        return title + "\n(no data)" if title else "(no data)"

    y_min, y_max = _auto_range(values)
    x_min, x_max = float(timestamps[0]), float(timestamps[-1])
    c = _Canvas(width, height, x_min, x_max, y_min, y_max)

    for i in range(len(timestamps)):
        v = values[i]
        if math.isfinite(v):
            c.fill_down(timestamps[i], v, fill)
            c.put(timestamps[i], v, edge)

    return c.render(
        title=title,
        x_label_left=_fmt_num(x_min),
        x_label_right=_fmt_num(x_max),
    )


def bar_chart(
    labels: Sequence[str],
    values: Sequence[float],
    *,
    width: int = 50,
    title: str = "",
    bar_char: str = "█",
    neg_char: str = "░",
) -> str:
    """Horizontal bar chart with labels on the left."""
    if not labels or not values:
        return title + "\n(no data)" if title else "(no data)"

    max_label = max(len(str(l)) for l in labels)
    max_val = max(abs(v) for v in values if math.isfinite(v)) if values else 1.0
    if max_val == 0:
        max_val = 1.0

    bar_width = width - max_label - 14  # room for label + value
    bar_width = max(bar_width, 10)

    lines: list[str] = []
    if title:
        lines.append(title)
        lines.append("")

    for label, val in zip(labels, values):
        v = val if math.isfinite(val) else 0.0
        n = int(abs(v) / max_val * bar_width)
        ch = bar_char if v >= 0 else neg_char
        bar = ch * n
        lines.append(f"{str(label):>{max_label}} │{bar} {_fmt_num(v)}")

    return "\n".join(lines)


def histogram_chart(
    hist,
    *,
    width: int = 60,
    title: str = "",
) -> str:
    """Render a ``HistogramData`` as a horizontal bar chart.

    Shows bin ranges on the left and counts on the right.
    """
    if not hist.bin_centers:
        return title + "\n(no data)" if title else "(no data)"

    max_count = max(hist.counts) if hist.counts else 1
    if max_count == 0:
        max_count = 1

    bar_width = max(width - 30, 10)

    lines: list[str] = []
    if title:
        lines.append(title)
    else:
        lines.append(f"  mean={_fmt_num(hist.mean)}  std={_fmt_num(hist.std)}  skew={_fmt_num(hist.skew)}")
    lines.append("")

    for i, (center, count) in enumerate(zip(hist.bin_centers, hist.counts)):
        n = int(count / max_count * bar_width)
        bar = "█" * n
        lines.append(f"  {_fmt_num(center):>10} │{bar} {count}")

    return "\n".join(lines)


def heatmap(
    data,
    *,
    width: int = 0,
) -> str:
    """Render a ``HeatmapData`` using shaded block characters.

    Shade scale: ``·  ░  ▒  ▓  █`` (5 levels, light→dark for positive).
    """
    if not data.values:
        return data.title + "\n(no data)" if data.title else "(no data)"

    shades_pos = " ░▒▓█"
    shades_neg = " ░▒▓█"

    # Flatten to find range
    flat = [v for row in data.values for v in row if math.isfinite(v)]
    if not flat:
        return "(no data)"
    abs_max = max(abs(v) for v in flat) or 1.0

    col_w = max(len(str(c)) for c in data.col_labels) + 1 if data.col_labels else 4
    col_w = max(col_w, 4)
    row_label_w = max(len(str(r)) for r in data.row_labels) + 1 if data.row_labels else 0

    lines: list[str] = []
    if data.title:
        lines.append(data.title)
        lines.append("")

    # Column header
    header = " " * row_label_w + "│"
    for cl in data.col_labels:
        header += f"{str(cl):^{col_w}}"
    lines.append(header)
    lines.append(" " * row_label_w + "│" + "─" * (col_w * len(data.col_labels)))

    for ri, row in enumerate(data.values):
        rl = str(data.row_labels[ri]) if ri < len(data.row_labels) else ""
        s = f"{rl:>{row_label_w}}│"
        for v in row:
            if not math.isfinite(v):
                cell = " · "
            else:
                level = min(4, int(abs(v) / abs_max * 4.99))
                shade = shades_pos[level] if v >= 0 else shades_neg[level]
                val_str = f"{v:+.0f}" if abs(v) >= 1 else f"{v:+.2f}"
                # Combine shade + value
                cell = f"{shade}{val_str}"
            s += f"{cell:^{col_w}}"
        lines.append(s)

    # Legend
    lines.append("")
    lines.append(f"  Scale: {_fmt_num(-abs_max)} ← · ░ ▒ ▓ █ → {_fmt_num(abs_max)}")

    return "\n".join(lines)


def scatter(
    data,
    *,
    width: int = 72,
    height: int = 18,
    title: str = "Trade Scatter",
) -> str:
    """Render a ``TradeScatterData`` with buy (▲) and sell (▼) markers."""
    if not data.points:
        return title + "\n(no trades)"

    all_ts = [p.timestamp for p in data.points]
    all_prices = [p.price for p in data.points]

    y_min, y_max = _auto_range(all_prices)
    x_min, x_max = min(all_ts), max(all_ts)
    c = _Canvas(width, height, x_min, x_max, y_min, y_max)

    for p in data.sells:
        c.put(p.timestamp, p.price, "▼")
    for p in data.buys:
        c.put(p.timestamp, p.price, "▲")

    out = c.render(
        title=title,
        x_label_left=_fmt_num(x_min),
        x_label_right=_fmt_num(x_max),
    )
    return out + f"\n  ▲ buy ({len(data.buys)})  ▼ sell ({len(data.sells)})"


def calibration(
    data,
    *,
    width: int = 40,
    height: int = 20,
    title: str = "",
) -> str:
    """Render a ``CalibrationPlotData`` - predicted vs actual with y=x reference.

    Dots (·) = perfect calibration line, circles (●) = actual frequency.
    """
    if not data.bin_centers:
        return (title or "Calibration") + "\n(no data)"

    if not title:
        title = f"Calibration  ECE={_fmt_num(data.ece)}  Brier={_fmt_num(data.brier_score)}"

    c = _Canvas(width, height, 0.0, 1.0, 0.0, 1.0)

    # Perfect line y=x
    for i in range(width):
        x = i / max(width - 1, 1)
        c.put(x, x, "·")

    # Actual calibration points
    for bc, af in zip(data.bin_centers, data.actual_freq):
        if math.isfinite(bc) and math.isfinite(af):
            c.put(bc, af, "●")

    out = c.render(
        title=title,
        x_label_left="0.0",
        x_label_right="1.0",
    )
    return out + "\n  · perfect  ● actual"


def sparkline(values: Sequence[float], width: int = 40) -> str:
    """Compact single-row sparkline using Unicode block elements."""
    chars = "▁▂▃▄▅▆▇█"
    clean = [v for v in values if math.isfinite(v)]
    if not clean:
        return ""

    # Sample down if needed
    if len(clean) > width:
        step = len(clean) / width
        sampled = [clean[int(i * step)] for i in range(width)]
    else:
        sampled = list(clean)

    lo, hi = min(sampled), max(sampled)
    rng = hi - lo if hi != lo else 1.0

    return "".join(
        chars[min(len(chars) - 1, int((v - lo) / rng * (len(chars) - 1)))]
        for v in sampled
    )


# ---------------------------------------------------------------------------
# High-level: render a PlotBundle
# ---------------------------------------------------------------------------

def dashboard(
    bundle,
    *,
    width: int = 72,
    height: int = 16,
) -> str:
    """Render an entire ``PlotBundle`` as an ASCII dashboard.

    Produces a multi-section text report suitable for terminal display.
    """
    sections: list[str] = []
    sep = "═" * width

    # -- Equity curve --
    if bundle.equity.timestamps:
        sections.append(sep)
        sections.append(line_chart(
            bundle.equity.timestamps,
            bundle.equity.equity,
            width=width,
            height=height,
            title="  EQUITY (normalized to 1.0)",
        ))

    # -- Underwater --
    if bundle.underwater.timestamps and any(d > 0 for d in bundle.underwater.drawdown_pct):
        sections.append(sep)
        # Negate drawdown so it fills downward from 0
        neg_dd = tuple(-d for d in bundle.underwater.drawdown_pct)
        sections.append(area_chart(
            bundle.underwater.timestamps,
            neg_dd,
            width=width,
            height=max(height // 2, 8),
            title="  DRAWDOWN (%)",
        ))

    # -- Return distribution --
    if bundle.return_hist.bin_centers:
        sections.append(sep)
        sections.append(histogram_chart(
            bundle.return_hist,
            width=width,
            title="  RETURN DISTRIBUTION",
        ))

    # -- PnL distribution --
    if bundle.pnl_hist and bundle.pnl_hist.bin_centers:
        sections.append(sep)
        sections.append(histogram_chart(
            bundle.pnl_hist,
            width=width,
            title="  PnL DISTRIBUTION",
        ))

    # -- Rolling stats --
    if bundle.rolling and bundle.rolling.sharpe.timestamps:
        sections.append(sep)
        sections.append(multi_line(
            [
                (bundle.rolling.sharpe.timestamps, bundle.rolling.sharpe.values, "Sharpe", "·"),
                (bundle.rolling.sortino.timestamps, bundle.rolling.sortino.values, "Sortino", "×"),
            ],
            width=width,
            height=max(height // 2, 8),
            title="  ROLLING RISK METRICS",
        ))

    # -- Monthly heatmap --
    if bundle.monthly_heatmap and bundle.monthly_heatmap.values:
        sections.append(sep)
        sections.append(heatmap(bundle.monthly_heatmap, width=width))

    # -- Trade scatter --
    if bundle.trades.points:
        sections.append(sep)
        sections.append(scatter(
            bundle.trades,
            width=width,
            height=max(height // 2, 8),
        ))

    # -- Calibration --
    if bundle.calibration and bundle.calibration.bin_centers:
        sections.append(sep)
        sections.append(calibration(
            bundle.calibration,
            width=min(width, 40),
            height=max(height // 2, 10),
        ))

    sections.append(sep)
    return "\n\n".join(sections)
