"""Histogram sheet implementation for numeric columns."""

from __future__ import annotations

import contextlib
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import ClassVar

import polars as pl

from ..core.engine.contracts import EnginePayloadHandle, TableSlice
from ..core.engine.polars_adapter import (
    table_slice_from_dataframe,
    unwrap_lazyframe_handle,
)
from ..core.formatting import _format_number_with_thousands_separator
from ..core.interfaces import JobRunnerProtocol
from ..core.sheet import (
    SHEET_FEATURE_SLICE,
    SHEET_FEATURE_VALUE_AT,
    Sheet,
    SheetFeature,
)
from ..render.braille import FILL_CHAR, SPROUT_CHAR


def _fit_text(text: str, width: int) -> str:
    """Left-justify text within a fixed width, adding ellipsis when needed."""

    if width <= 0:
        return ""
    if len(text) <= width:
        return text.ljust(width)
    if width == 1:
        return text[:1]
    return text[: width - 1] + "…"


@dataclass(frozen=True)
class HistogramStats:
    """Summary statistics required for histogram rendering."""

    n: int
    nulls: int
    minimum: float | None
    q1: float | None
    median: float | None
    q3: float | None
    maximum: float | None


def _format_edge(value: float | None) -> str:
    """Format a histogram bucket edge with compact numeric formatting."""

    if value is None or (isinstance(value, float) and math.isnan(value)):
        return "NA"

    if isinstance(value, float):
        if math.isfinite(value):
            if abs(value) >= 1_000_000 or (0 < abs(value) < 0.001):
                return f"{value:.6g}"
            if float(int(value)) == value:
                return str(int(value))
            return f"{value:.6g}"
        return "inf" if value > 0 else "-inf"

    return str(value)


class HistogramSheet:
    """Sheet that materializes histogram buckets for a numeric column."""

    _CAPABILITIES: ClassVar[frozenset[SheetFeature]] = frozenset(
        {
            SHEET_FEATURE_SLICE,
            SHEET_FEATURE_VALUE_AT,
        }
    )

    def __init__(
        self,
        base_sheet: Sheet,
        column_name: str,
        *,
        preferred_height: int | None = None,
        preferred_width: int | None = None,
        runner: JobRunnerProtocol,
    ) -> None:
        self.source_sheet = base_sheet
        self.source_column = column_name
        if runner is None:  # pragma: no cover - defensive guard
            msg = "HistogramSheet requires a JobRunner instance"
            raise ValueError(msg)
        self._runner: JobRunnerProtocol = runner

        self.filter_text: str | None = None
        self.sort_col: str | None = None
        self.sort_asc: bool = True

        base_lf_candidate = getattr(base_sheet, "lf", None)
        if base_lf_candidate is None:
            base_lf_candidate = getattr(base_sheet, "lf0", None)
        if base_lf_candidate is None:
            raise ValueError("Histogram view requires a lazy frame source")
        if isinstance(base_lf_candidate, EnginePayloadHandle):
            base_lf = unwrap_lazyframe_handle(base_lf_candidate)
        else:
            base_lf = base_lf_candidate

        stats_df = base_lf.select(
            [
                pl.col(column_name).count().alias("n"),
                pl.col(column_name)
                .filter(pl.col(column_name).is_not_null())
                .n_unique()
                .alias("distinct"),
                pl.col(column_name).min().alias("min"),
                pl.col(column_name).quantile(0.25, interpolation="nearest").alias("q1"),
                pl.col(column_name).median().alias("median"),
                pl.col(column_name).quantile(0.75, interpolation="nearest").alias("q3"),
                pl.col(column_name).max().alias("max"),
                pl.col(column_name).is_null().sum().alias("nulls"),
            ]
        ).collect()

        if stats_df.is_empty():
            n = 0
            distinct = 0
            minimum = q1 = median = q3 = maximum = None
            nulls = 0
        else:
            row = stats_df.row(0, named=True)
            n = int(row.get("n", 0) or 0)
            distinct = row.get("distinct")
            minimum = row.get("min")
            q1 = row.get("q1")
            median = row.get("median")
            q3 = row.get("q3")
            maximum = row.get("max")
            nulls = int(row.get("nulls", 0) or 0)

        # Keep a clean float column for statistics + binning
        value_expr = pl.col(column_name).cast(pl.Float64, strict=False)
        self._clean_lf = (
            base_lf.select(value_expr.alias("__value"))
            .drop_nulls()
            .rename({"__value": column_name})
        )

        self.distinct_count = int(distinct or 0) if n else 0

        self.stats = HistogramStats(
            n=n,
            nulls=nulls,
            minimum=float(minimum) if minimum is not None else None,
            q1=float(q1) if q1 is not None else None,
            median=float(median) if median is not None else None,
            q3=float(q3) if q3 is not None else None,
            maximum=float(maximum) if maximum is not None else None,
        )

        self._value_range = (
            0.0
            if self.stats.minimum is None or self.stats.maximum is None
            else float(self.stats.maximum - self.stats.minimum)
        )

        self.log_scale = False
        self._preferred_height = (
            max(1, int(preferred_height)) if preferred_height is not None else None
        )
        self._preferred_width = (
            max(20, int(preferred_width)) if preferred_width is not None else None
        )

        self.columns = ["from", "to", "hist", "count"]
        self.schema = {
            "from": pl.Utf8,
            "to": pl.Utf8,
            "hist": pl.Utf8,
            "count": pl.Int64,
        }

        self._bin_edges: list[float] = []
        self._counts: list[int] = []
        self._bucket_left_labels: list[str] = []
        self._bucket_right_labels: list[str] = []
        self._display_df: pl.DataFrame = pl.DataFrame(
            {col: pl.Series(col, [], dtype=self.schema[col]) for col in self.columns}
        )
        self._export_df: pl.DataFrame = pl.DataFrame(
            {
                "bin_left": pl.Series("bin_left", [], dtype=pl.Float64),
                "bin_right": pl.Series("bin_right", [], dtype=pl.Float64),
                "count": pl.Series("count", [], dtype=pl.Int64),
            }
        )

        self._column_widths: dict[str, int] = {col: max(4, len(col)) for col in self.columns}
        self._view_width_override = self._preferred_width
        self.lf0 = self._display_df.lazy()
        self.lf = self.lf0

        self.bin_count = self._initial_bin_count()
        self._recompute_table()

    @property
    def job_runner(self) -> JobRunnerProtocol:
        return self._runner

    # Sheet protocol -----------------------------------------------------
    def __len__(self) -> int:  # pragma: no cover - simple delegation
        return int(self._display_df.height)

    def fetch_slice(self, row_start: int, row_count: int, columns: Sequence[str]) -> TableSlice:
        if row_start < 0 or row_count < 0:
            raise ValueError("row_start and row_count must be non-negative")
        available = [col for col in columns if col in self.columns]
        if not available:
            return TableSlice.empty(columns, self.schema)
        sliced = self._display_df.select(available).slice(row_start, row_count)
        return table_slice_from_dataframe(sliced, sliced.schema)

    def get_value_at(self, row_index: int, column_name: str | None = None) -> object:
        if row_index < 0 or row_index >= len(self):
            raise IndexError("row index out of range")
        column = column_name or self.columns[0]
        if column not in self.columns:
            raise KeyError(f"unknown column: {column}")
        return self._display_df[column][row_index]

    def supports(self, feature: SheetFeature, /) -> bool:
        return feature in self._CAPABILITIES

    def get_column_widths(self) -> dict[str, int]:
        """Expose the current column width allocation used for rendering."""

        return dict(self._column_widths)

    def preferred_fill_column(self) -> str | None:  # pragma: no cover - simple hint
        return "hist"

    # Histogram specific helpers ----------------------------------------
    @property
    def counts(self) -> list[int]:
        return list(self._counts)

    def bucket_left_labels(self) -> list[str]:
        return list(self._bucket_left_labels)

    @property
    def bucket_right_labels(self) -> list[str]:
        return list(self._bucket_right_labels)

    def adjust_bins(self, delta: int) -> bool:
        if not self._counts or self._value_range == 0:
            return False
        target = self.bin_count + delta
        target = self._clamp_bins(target, allow_single=True)
        if target == self.bin_count:
            return False
        self.bin_count = target
        self._recompute_table()
        return True

    def toggle_log_scale(self) -> None:
        self.log_scale = not self.log_scale
        self._refresh_display_table()

    def export_bins(self) -> pl.DataFrame:
        return self._export_df.clone()

    def update_layout_for_view(
        self,
        *,
        view_width: int | None = None,
        view_height: int | None = None,
        viewer: object | None = None,
    ) -> None:
        """Refresh the rendered histogram layout for the active viewport."""

        if view_height is not None:
            with contextlib.suppress(Exception):  # pragma: no cover - defensive
                self._preferred_height = max(1, int(view_height))

        width_changed = False
        width_candidate: int | None = None
        if view_width is not None:
            with contextlib.suppress(Exception):  # pragma: no cover - defensive
                width_candidate = max(20, int(view_width))
        if width_candidate is not None and width_candidate != self._view_width_override:
            self._view_width_override = width_candidate
            width_changed = True

        if width_changed:
            self._refresh_display_table()
            if viewer is not None and hasattr(viewer, "invalidate_row_cache"):
                with contextlib.suppress(Exception):  # pragma: no cover - defensive
                    viewer.invalidate_row_cache()

        if viewer is not None and hasattr(viewer, "__dict__"):
            total_rows: int | None = None
            with contextlib.suppress(Exception):  # pragma: no cover - defensive
                total_rows = len(self)
            if total_rows is not None:
                with contextlib.suppress(Exception):  # pragma: no cover - defensive
                    viewer._total_rows = int(total_rows)
                    viewer._row_count_stale = False
                    if hasattr(viewer, "clamp") and callable(viewer.clamp):
                        viewer.clamp()

    def status_text(self) -> str:
        parts: list[str] = []
        parts.append(self.source_column)
        parts.append(f"n={self.stats.n}")
        parts.append(f"nulls={self.stats.nulls}")
        parts.append(f"min={_format_edge(self.stats.minimum)}")
        parts.append(f"q1={_format_edge(self.stats.q1)}")
        parts.append(f"median={_format_edge(self.stats.median)}")
        parts.append(f"q3={_format_edge(self.stats.q3)}")
        parts.append(f"max={_format_edge(self.stats.maximum)}")
        parts.append(f"bins={self.bin_count}")
        parts.append(f"log={'on' if self.log_scale else 'off'}")
        return " \u00b7 ".join(parts)

    # Internal helpers ---------------------------------------------------
    def _initial_bin_count(self) -> int:
        if self.stats.n <= 0 or self.stats.minimum is None or self.stats.maximum is None:
            return 0

        if self.stats.maximum == self.stats.minimum:
            return 1

        if self.stats.n < 2:
            return self._clamp_bins(self._sturges(self.stats.n), allow_single=False)

        q1 = self.stats.q1
        q3 = self.stats.q3
        iqr = None
        if q1 is not None and q3 is not None:
            iqr = q3 - q1
        if iqr is None or not math.isfinite(iqr):
            iqr = 0.0

        if iqr <= 0:
            return self._clamp_bins(self._sturges(self.stats.n), allow_single=False)

        width = 2 * iqr / (self.stats.n ** (1 / 3))
        if not math.isfinite(width) or width <= 0:
            return self._clamp_bins(self._sturges(self.stats.n), allow_single=False)

        approx = int(math.ceil(self._value_range / width))
        if approx < 1:
            return self._clamp_bins(self._sturges(self.stats.n), allow_single=False)

        return self._clamp_bins(approx, allow_single=False)

    @staticmethod
    def _sturges(n: int) -> int:
        if n <= 0:
            return 1
        return 1 + math.ceil(math.log2(n))

    def _clamp_bins(self, bins: int, *, allow_single: bool) -> int:
        bins = int(math.ceil(bins))
        if bins < 1:
            bins = 1

        minimum = 1 if allow_single else 4
        view_cap: int | None = None
        if self._preferred_height is not None:
            view_cap = max(1, min(100, self._preferred_height))
            if view_cap < minimum:
                minimum = view_cap if allow_single else max(1, view_cap)

        distinct_cap: int | None = None
        if getattr(self, "distinct_count", 0) > 0:
            distinct_cap = max(1, min(100, self.distinct_count))
            bins = min(bins, distinct_cap)
            if distinct_cap < minimum:
                minimum = distinct_cap if allow_single else max(1, distinct_cap)

        if bins < minimum:
            bins = minimum
        bins = min(100, bins)

        if not allow_single and view_cap is not None:
            bins = min(bins, view_cap)

        if distinct_cap is not None:
            bins = min(bins, distinct_cap)

        return bins

    def _recompute_table(self) -> None:
        if self.stats.n <= 0 or self.stats.minimum is None or self.stats.maximum is None:
            self._counts = []
            self._bin_edges = []
            self._bucket_left_labels = []
            self._bucket_right_labels = []
            self._export_df = pl.DataFrame(
                {
                    "bin_left": pl.Series("bin_left", [], dtype=pl.Float64),
                    "bin_right": pl.Series("bin_right", [], dtype=pl.Float64),
                    "count": pl.Series("count", [], dtype=pl.Int64),
                }
            )
            self._refresh_display_table()
            return

        if self.stats.maximum == self.stats.minimum:
            counts = [self.stats.n]
            edges = [self.stats.minimum, self.stats.maximum]
        else:
            width = self._value_range / self.bin_count
            edges = [self.stats.minimum + i * width for i in range(self.bin_count)]
            edges.append(self.stats.maximum)

            bin_expr = ((pl.col(self.source_column) - self.stats.minimum) / width).floor()
            grouped = (
                self._clean_lf.with_columns(
                    pl.when(bin_expr < 0)
                    .then(0)
                    .when(bin_expr >= self.bin_count)
                    .then(self.bin_count - 1)
                    .otherwise(bin_expr)
                    .cast(pl.Int64)
                    .alias("__bin")
                )
                .group_by("__bin")
                .agg(pl.len().alias("count"))
                .collect()
            )

            counts = [0] * self.bin_count
            for bin_index, count in grouped.iter_rows():
                idx = int(bin_index)
                if 0 <= idx < self.bin_count:
                    counts[idx] = int(count)

        bin_left = edges[:-1]
        bin_right = edges[1:]
        self._counts = counts
        self._bin_edges = [float(x) for x in edges]
        left_labels: list[str] = []
        right_labels: list[str] = []
        for idx, left in enumerate(bin_left):
            right = bin_right[idx]
            left_txt = _format_edge(left)
            right_txt = _format_edge(right)
            closing = "]" if idx == len(bin_left) - 1 else ")"
            left_labels.append(f"[{left_txt}")
            right_labels.append(f"{right_txt}{closing}")
        self._bucket_left_labels = left_labels
        self._bucket_right_labels = right_labels

        self._export_df = pl.DataFrame(
            {
                "bin_left": bin_left,
                "bin_right": bin_right,
                "count": counts,
            }
        )
        self._refresh_display_table()

    def _refresh_display_table(self) -> None:
        view_width = max(20, self._view_width_override or 80)

        if not self._counts:
            self._set_display_df(
                {col: pl.Series(col, [], dtype=self.schema[col]) for col in self.columns}
            )
            self._column_widths = {col: max(len(col) + 4, 6) for col in self.columns}
            return

        widths, inner_widths = self._compute_column_widths(view_width)
        self._column_widths = widths

        left_inner = inner_widths["from"]
        right_inner = inner_widths["to"]
        hist_inner = inner_widths["hist"]

        if self.log_scale:
            scaled = [math.log1p(count) for count in self._counts]
            max_scaled = max(scaled) if scaled else 0.0
            scale_denominator = max_scaled if max_scaled > 0 else 1.0
        else:
            scaled = [float(count) for count in self._counts]
            max_count = max(scaled) if scaled else 0.0
            scale_denominator = max_count if max_count > 0 else 1.0

        hist_strings: list[str] = []
        left_texts: list[str] = []
        right_texts: list[str] = []

        for idx, count in enumerate(self._counts):
            left_label = self._bucket_left_labels[idx]
            right_label = self._bucket_right_labels[idx]
            left_texts.append(_fit_text(left_label, left_inner))
            right_texts.append(_fit_text(right_label, right_inner))

            if hist_inner <= 0:
                hist_strings.append("")
                continue

            raw_value = scaled[idx]
            fraction = raw_value / scale_denominator if scale_denominator > 0 else 0.0
            bar_len = int(round(fraction * hist_inner))
            bar_len = max(0, min(hist_inner, bar_len))

            if count > 0 and bar_len == 0:
                bar = (SPROUT_CHAR + (" " * (hist_inner - 1)))[:hist_inner]
            else:
                filled = FILL_CHAR * bar_len
                padding = " " * max(0, hist_inner - bar_len)
                bar = (filled + padding)[:hist_inner]

            hist_strings.append(bar)

        display_data = {
            "from": left_texts,
            "to": right_texts,
            "hist": [s.ljust(hist_inner) if hist_inner > 0 else s for s in hist_strings],
            "count": self._counts,
        }

        self._set_display_df(display_data)

    def _compute_column_widths(self, view_width: int) -> tuple[dict[str, int], dict[str, int]]:
        column_count = len(self.columns)
        pad = 1
        border_overhead = column_count + 1

        left_labels = self._bucket_left_labels
        right_labels = self._bucket_right_labels

        desired_left_inner = (
            max([len("from")] + [len(label) for label in left_labels])
            if left_labels
            else len("from")
        )
        desired_right_inner = (
            max([len("to")] + [len(label) for label in right_labels]) if right_labels else len("to")
        )
        count_texts = [_format_number_with_thousands_separator(count) for count in self._counts]
        desired_count_inner = (
            max([len("count")] + [len(text) for text in count_texts])
            if count_texts
            else len("count")
        )

        left_min = 2
        right_min = 2
        hist_min = 2
        count_min = 1

        available_inner = view_width - border_overhead - (2 * pad * column_count)
        available_inner = max(
            left_min + right_min + hist_min + count_min,
            available_inner,
        )

        left_cap = max(left_min, available_inner // 6)
        if desired_left_inner < left_cap:
            left_cap = desired_left_inner
        left_inner = min(
            left_cap,
            available_inner - (right_min + hist_min + count_min),
        )
        left_inner = max(left_min, left_inner)

        remaining_for_right = available_inner - (left_inner + hist_min + count_min)
        right_cap = max(right_min, available_inner // 6)
        if desired_right_inner < right_cap:
            right_cap = desired_right_inner
        right_inner = min(right_cap, remaining_for_right)
        right_inner = max(right_min, right_inner)

        count_inner = min(
            desired_count_inner,
            available_inner - (left_inner + right_inner + hist_min),
        )
        count_inner = max(count_min, count_inner)

        hist_inner = available_inner - left_inner - right_inner - count_inner

        if hist_inner < hist_min:
            deficit = hist_min - hist_inner

            reducible_count = max(0, count_inner - count_min)
            reduce_count = min(deficit, reducible_count)
            count_inner -= reduce_count
            deficit -= reduce_count

            reducible_right = max(0, right_inner - right_min)
            reduce_right = min(deficit, reducible_right)
            right_inner -= reduce_right
            deficit -= reduce_right

            reducible_left = max(0, left_inner - left_min)
            reduce_left = min(deficit, reducible_left)
            left_inner -= reduce_left
            deficit -= reduce_left

            hist_inner = available_inner - left_inner - right_inner - count_inner

        hist_inner = max(hist_min, hist_inner)

        inner_widths = {
            "from": left_inner,
            "to": right_inner,
            "hist": hist_inner,
            "count": count_inner,
        }

        widths = {col: max(inner + 2 * pad, 4) for col, inner in inner_widths.items()}
        return widths, inner_widths

    def _set_display_df(self, data: Mapping[str, Sequence[object] | pl.Series]) -> None:
        self._display_df = pl.DataFrame(data)
        self.lf0 = self._display_df.lazy()
        self.lf = self.lf0
        self.schema = self._display_df.schema
        self.columns = list(self._display_df.columns)
