"""PlotSpec dataclass and validators for declarative chart definitions."""

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import yaml

from abench_speckz.expr import validate_expr_syntax
from abench_speckz.model import RunError

ALLOWED_TYPES = {"bar", "stacked-bar", "line", "scatter"}
BLOCK_KINDS = ("text", "plot_id", "html", "include_html", "kpi", "headline")
ALLOWED_PALETTES = {"tableau", "vivid", "pastel", "paired", "muted", "accessible"}
ALLOWED_DARK_MODES = {True, False, "auto"}
_KPI_STATS = {"max", "min", "mean"}


@dataclass
class NormalizeSpec:
    against: str  # group_by variable whose reference value pins to 1.0×
    reference: str  # the variable value that becomes the denominator


@dataclass
class KpiCompare:
    where: list[str] = field(default_factory=list)  # "k=v" filter expressions


@dataclass
class KpiCard:
    label: str
    metric: str
    stat: str = "mean"  # max | min | mean across matching combos
    where: list[str] = field(default_factory=list)
    unit: str = ""
    precision: int = 2
    compare: KpiCompare | None = None


@dataclass
class HeadlineDef:
    metric: str
    compare: str  # variable whose values are ranked
    template: str
    where: list[str] = field(default_factory=list)


@dataclass
class PlotSpec:
    id: str
    type: str
    x: str
    y: list[str]
    title: str | None = None
    group_by: list[str] | None = None
    smooth: bool = False
    fixed_vars: bool = True
    series_label: str | None = None
    data_table: bool = True
    x_title: str | None = None
    y_title: str | None = None
    normalize: NormalizeSpec | None = None


@dataclass
class Block:
    kind: str  # one of BLOCK_KINDS
    value: str  # markdown/html/plot_id text; empty for structured blocks (kpi, headline)
    data: Any = None  # structured payload for kpi (list[KpiCard]) / headline (HeadlineDef)


@dataclass
class Section:
    title: str
    description: str | None = None  # markdown source
    blocks: list[Block] = field(default_factory=list)


@dataclass
class ThemeConfig:
    palette: str = "tableau"
    dark_mode: bool | str = "auto"  # True, False, or "auto"
    switcher: bool = False


@dataclass
class PlotsDoc:
    plots: list[PlotSpec] = field(default_factory=list)
    sections: list[Section] = field(default_factory=list)
    report_title: str | None = None
    report_description: str | None = None
    theme: ThemeConfig = field(default_factory=ThemeConfig)


def parse_theme(raw: Any, source: str) -> ThemeConfig:
    if raw is None:
        return ThemeConfig()
    if not isinstance(raw, dict):
        raise RunError(f"{source}: theme must be a mapping")

    palette = raw.get("palette", "tableau")
    if palette not in ALLOWED_PALETTES:
        raise RunError(f"{source}: theme.palette must be one of {sorted(ALLOWED_PALETTES)}, got {palette!r}")

    dark_mode_raw = raw.get("dark_mode", "auto")
    if dark_mode_raw not in ALLOWED_DARK_MODES:
        raise RunError(f"{source}: theme.dark_mode must be true, false, or 'auto', got {dark_mode_raw!r}")
    dark_mode: bool | str = dark_mode_raw

    switcher = raw.get("switcher", False)
    if not isinstance(switcher, bool):
        raise RunError(f"{source}: theme.switcher must be a boolean")

    unknown = set(raw) - {"palette", "dark_mode", "switcher"}
    if unknown:
        raise RunError(f"{source}: theme: unknown fields {sorted(unknown)}")

    return ThemeConfig(palette=palette, dark_mode=dark_mode, switcher=switcher)


def parse_plots(raw: Any, source: str) -> list[PlotSpec]:
    if raw is None:
        return []
    if not isinstance(raw, list):
        raise RunError(f"{source}: plots must be a list")

    out: list[PlotSpec] = []
    seen_ids: set[str] = set()
    for i, item in enumerate(raw):
        if not isinstance(item, dict):
            raise RunError(f"{source}: plots[{i}] must be a mapping")

        plot_id = item.get("id")
        if not isinstance(plot_id, str) or not plot_id:
            raise RunError(f"{source}: plots[{i}].id is required (string)")
        if plot_id in seen_ids:
            raise RunError(f"{source}: duplicate plot id {plot_id!r}")
        seen_ids.add(plot_id)

        ptype = item.get("type")
        if ptype not in ALLOWED_TYPES:
            raise RunError(
                f"{source}: plot {plot_id!r}: type must be one of {sorted(ALLOWED_TYPES)}, got {ptype!r}"
            )

        x = item.get("x")
        if not isinstance(x, str) or not x:
            raise RunError(f"{source}: plot {plot_id!r}: x is required (string)")
        if not x.strip().isidentifier():
            validate_expr_syntax(x, f"{source}: plot {plot_id!r}: x")

        y_raw = item.get("y")
        if y_raw is None:
            raise RunError(f"{source}: plot {plot_id!r}: y is required")
        if isinstance(y_raw, str):
            y = [y_raw]
        elif isinstance(y_raw, list) and all(isinstance(m, str) and m for m in y_raw):
            y = list(y_raw)
        else:
            raise RunError(f"{source}: plot {plot_id!r}: y must be a string or list of strings")
        if not y:
            raise RunError(f"{source}: plot {plot_id!r}: y must not be empty")
        for j, yi in enumerate(y):
            if not yi.strip().isidentifier():
                validate_expr_syntax(yi, f"{source}: plot {plot_id!r}: y[{j}]")

        if ptype == "scatter" and len(y) != 1:
            raise RunError(f"{source}: plot {plot_id!r}: scatter requires exactly one y metric")
        if ptype == "stacked-bar" and len(y) < 2:
            raise RunError(f"{source}: plot {plot_id!r}: stacked-bar requires at least two y metrics")

        title = item.get("title")
        if title is not None and not isinstance(title, str):
            raise RunError(f"{source}: plot {plot_id!r}: title must be a string")

        group_by_raw = item.get("group_by")
        if group_by_raw is None:
            group_by = None
        else:
            if isinstance(group_by_raw, str):
                entries = [group_by_raw]
            elif isinstance(group_by_raw, list):
                entries = group_by_raw
            else:
                raise RunError(f"{source}: plot {plot_id!r}: group_by must be a string or list of strings")
            if not entries:
                raise RunError(f"{source}: plot {plot_id!r}: group_by list must not be empty")
            for j, k in enumerate(entries):
                if not isinstance(k, str) or not k:
                    raise RunError(f"{source}: plot {plot_id!r}: group_by[{j}] must be a non-empty string")
                if k.startswith("!") and not k[1:]:
                    raise RunError(
                        f"{source}: plot {plot_id!r}: group_by[{j}] '!' must be followed by a variable name"
                    )
            group_by = list(entries)

        smooth_raw = item.get("smooth", False)
        if not isinstance(smooth_raw, bool):
            raise RunError(f"{source}: plot {plot_id!r}: smooth must be a boolean")
        if smooth_raw and ptype != "line":
            raise RunError(f"{source}: plot {plot_id!r}: smooth is only valid for line plots")
        smooth = smooth_raw

        fixed_vars_raw = item.get("fixed_vars", True)
        if not isinstance(fixed_vars_raw, bool):
            raise RunError(f"{source}: plot {plot_id!r}: fixed_vars must be a boolean")
        fixed_vars = fixed_vars_raw

        series_label_raw = item.get("series_label")
        if series_label_raw is not None and not isinstance(series_label_raw, str):
            raise RunError(f"{source}: plot {plot_id!r}: series_label must be a string")
        series_label = series_label_raw or None

        data_table_raw = item.get("data_table", True)
        if not isinstance(data_table_raw, bool):
            raise RunError(f"{source}: plot {plot_id!r}: data_table must be a boolean")
        data_table = data_table_raw

        x_title_raw = item.get("x_title")
        if x_title_raw is not None and not isinstance(x_title_raw, str):
            raise RunError(f"{source}: plot {plot_id!r}: x_title must be a string")
        x_title = x_title_raw or None

        y_title_raw = item.get("y_title")
        if y_title_raw is not None and not isinstance(y_title_raw, str):
            raise RunError(f"{source}: plot {plot_id!r}: y_title must be a string")
        y_title = y_title_raw or None

        normalize_raw = item.get("normalize")
        if normalize_raw is None:
            normalize: NormalizeSpec | None = None
        else:
            if ptype in ("scatter", "stacked-bar"):
                raise RunError(f"{source}: plot {plot_id!r}: normalize is not allowed on {ptype!r} plots")
            if not isinstance(normalize_raw, dict):
                raise RunError(f"{source}: plot {plot_id!r}: normalize must be a mapping")
            against = normalize_raw.get("against")
            reference = normalize_raw.get("reference")
            if not isinstance(against, str) or not against:
                raise RunError(f"{source}: plot {plot_id!r}: normalize.against must be a non-empty string")
            if reference is None or str(reference) == "":
                raise RunError(f"{source}: plot {plot_id!r}: normalize.reference must be a non-empty value")
            unknown_norm = set(normalize_raw) - {"against", "reference"}
            if unknown_norm:
                raise RunError(
                    f"{source}: plot {plot_id!r}: normalize: unknown fields {sorted(unknown_norm)}"
                )
            normalize = NormalizeSpec(against=str(against), reference=str(reference))

        unknown = set(item) - {
            "id",
            "type",
            "x",
            "y",
            "title",
            "group_by",
            "smooth",
            "fixed_vars",
            "series_label",
            "data_table",
            "x_title",
            "y_title",
            "normalize",
        }
        if unknown:
            raise RunError(f"{source}: plot {plot_id!r}: unknown fields {sorted(unknown)}")

        out.append(
            PlotSpec(
                id=plot_id,
                type=ptype,
                x=x,
                y=y,
                title=title,
                group_by=group_by,
                smooth=smooth,
                fixed_vars=fixed_vars,
                series_label=series_label,
                data_table=data_table,
                x_title=x_title,
                y_title=y_title,
                normalize=normalize,
            )
        )
    return out


def parse_sections(
    raw: Any,
    *,
    source: str,
    plot_ids: set[str],
    base_dir: Path,
) -> list[Section]:
    if raw is None:
        return []
    if not isinstance(raw, list):
        raise RunError(f"{source}: sections must be a list")

    out: list[Section] = []
    for i, item in enumerate(raw):
        if not isinstance(item, dict):
            raise RunError(f"{source}: sections[{i}] must be a mapping")

        title = item.get("title")
        if not isinstance(title, str) or not title:
            raise RunError(f"{source}: sections[{i}].title is required (string)")

        description = item.get("description")
        if description is not None and not isinstance(description, str):
            raise RunError(f"{source}: section {title!r}: description must be a string")

        blocks_raw = item.get("blocks")
        if blocks_raw is None:
            blocks: list[Block] = []
        elif not isinstance(blocks_raw, list):
            raise RunError(f"{source}: section {title!r}: blocks must be a list")
        else:
            blocks = [
                _parse_block(b, j, source=source, section_title=title, plot_ids=plot_ids, base_dir=base_dir)
                for j, b in enumerate(blocks_raw)
            ]

        unknown = set(item) - {"title", "description", "blocks"}
        if unknown:
            raise RunError(f"{source}: section {title!r}: unknown fields {sorted(unknown)}")

        out.append(Section(title=title, description=description, blocks=blocks))
    return out


def _where_dict_to_list(raw: Any, *, source: str, ctx: str) -> list[str]:
    """Convert a YAML where-dict {k: v, ...} to ["k=v", ...] filter strings."""
    if raw is None:
        return []
    if not isinstance(raw, dict):
        raise RunError(f"{source}: {ctx}: where must be a mapping")
    return [f"{k}={v}" for k, v in raw.items()]


def _parse_kpi_card(item: Any, index: int, *, source: str, ctx: str) -> KpiCard:
    loc = f"{ctx} kpi[{index}]"
    if not isinstance(item, dict):
        raise RunError(f"{source}: {loc} must be a mapping")

    label = item.get("label")
    if not isinstance(label, str) or not label:
        raise RunError(f"{source}: {loc}: label must be a non-empty string")

    metric = item.get("metric")
    if not isinstance(metric, str) or not metric:
        raise RunError(f"{source}: {loc}: metric must be a non-empty string")

    stat = item.get("stat", "mean")
    if stat not in _KPI_STATS:
        raise RunError(f"{source}: {loc}: stat must be one of {sorted(_KPI_STATS)}, got {stat!r}")

    where = _where_dict_to_list(item.get("where"), source=source, ctx=loc)

    unit = item.get("unit", "")
    if not isinstance(unit, str):
        raise RunError(f"{source}: {loc}: unit must be a string")

    precision_raw = item.get("precision", 2)
    if not isinstance(precision_raw, int) or isinstance(precision_raw, bool) or precision_raw < 0:
        raise RunError(f"{source}: {loc}: precision must be a non-negative integer")
    precision = precision_raw

    compare_raw = item.get("compare")
    if compare_raw is None:
        compare = None
    else:
        if not isinstance(compare_raw, dict):
            raise RunError(f"{source}: {loc}: compare must be a mapping")
        cmp_where = _where_dict_to_list(compare_raw.get("where"), source=source, ctx=f"{loc} compare")
        unknown_cmp = set(compare_raw) - {"where"}
        if unknown_cmp:
            raise RunError(f"{source}: {loc}: compare: unknown fields {sorted(unknown_cmp)}")
        compare = KpiCompare(where=cmp_where)

    unknown = set(item) - {"label", "metric", "stat", "where", "unit", "precision", "compare"}
    if unknown:
        raise RunError(f"{source}: {loc}: unknown fields {sorted(unknown)}")

    return KpiCard(
        label=label, metric=metric, stat=stat, where=where, unit=unit, precision=precision, compare=compare
    )


def _parse_headline(item: Any, *, source: str, ctx: str) -> HeadlineDef:
    if not isinstance(item, dict):
        raise RunError(f"{source}: {ctx}: headline must be a mapping")

    metric = item.get("metric")
    if not isinstance(metric, str) or not metric:
        raise RunError(f"{source}: {ctx}: headline.metric must be a non-empty string")

    compare = item.get("compare")
    if not isinstance(compare, str) or not compare:
        raise RunError(f"{source}: {ctx}: headline.compare must be a non-empty string (variable name)")

    template = item.get("template", "{best} is {ratio} ahead of {worst}")
    if not isinstance(template, str):
        raise RunError(f"{source}: {ctx}: headline.template must be a string")

    where = _where_dict_to_list(item.get("where"), source=source, ctx=ctx)

    unknown = set(item) - {"metric", "compare", "template", "where"}
    if unknown:
        raise RunError(f"{source}: {ctx}: headline: unknown fields {sorted(unknown)}")

    return HeadlineDef(metric=metric, compare=compare, template=template, where=where)


def _parse_block(
    item: Any,
    index: int,
    *,
    source: str,
    section_title: str,
    plot_ids: set[str],
    base_dir: Path,
) -> Block:
    where = f"section {section_title!r} blocks[{index}]"
    if not isinstance(item, dict):
        raise RunError(f"{source}: {where} must be a mapping")

    present = [k for k in BLOCK_KINDS if k in item]
    if not present:
        raise RunError(f"{source}: {where} must have exactly one of {list(BLOCK_KINDS)}")
    if len(present) > 1:
        raise RunError(f"{source}: {where} has multiple kinds {present}; pick exactly one")

    unknown = set(item) - set(BLOCK_KINDS)
    if unknown:
        raise RunError(f"{source}: {where} unknown fields {sorted(unknown)}")

    kind = present[0]

    if kind == "kpi":
        cards_raw = item[kind]
        if not isinstance(cards_raw, list):
            raise RunError(f"{source}: {where}: kpi must be a list of card definitions")
        cards = [_parse_kpi_card(c, j, source=source, ctx=where) for j, c in enumerate(cards_raw)]
        return Block(kind=kind, value="", data=cards)

    if kind == "headline":
        headline_raw = item[kind]
        headline = _parse_headline(headline_raw, source=source, ctx=where)
        return Block(kind=kind, value="", data=headline)

    value = item[kind]
    if not isinstance(value, str) or not value:
        raise RunError(f"{source}: {where} {kind!r} must be a non-empty string")

    if kind == "plot_id":
        if value not in plot_ids:
            raise RunError(f"{source}: {where} plot_id {value!r} not found in top-level plots")
        return Block(kind=kind, value=value)

    if kind == "include_html":
        path = (base_dir / value).resolve()
        if not path.exists():
            raise RunError(f"{source}: {where} include_html: file not found: {path}")
        try:
            content = path.read_text(encoding="utf-8")
        except OSError as e:
            raise RunError(f"{source}: {where} include_html: cannot read {path}: {e}") from e
        return Block(kind=kind, value=content)

    return Block(kind=kind, value=value)


def load_plots_file(path: Path) -> PlotsDoc:
    if not path.exists():
        raise RunError(f"{path}: plots file not found")
    raw = yaml.safe_load(path.read_text())
    return _build_doc(raw, source=str(path), base_dir=path.parent)


def _build_doc(raw: Any, *, source: str, base_dir: Path) -> PlotsDoc:
    if raw is None:
        return PlotsDoc()
    if isinstance(raw, list):
        return PlotsDoc(plots=parse_plots(raw, source=source))
    if not isinstance(raw, dict):
        raise RunError(f"{source}: top-level must be a list or mapping")

    report_title = raw.get("report_title") or None
    report_description = raw.get("report_description") or None
    if report_title is not None and not isinstance(report_title, str):
        raise RunError(f"{source}: report_title must be a string")
    if report_description is not None and not isinstance(report_description, str):
        raise RunError(f"{source}: report_description must be a string")

    theme = parse_theme(raw.get("theme"), source=source)
    plots_specs = parse_plots(raw.get("plots"), source=source)
    plot_ids = {p.id for p in plots_specs}
    sections = parse_sections(
        raw.get("sections"),
        source=source,
        plot_ids=plot_ids,
        base_dir=base_dir,
    )

    return PlotsDoc(
        plots=plots_specs,
        sections=sections,
        report_title=report_title,
        report_description=report_description,
        theme=theme,
    )


def discover_from_tool_snapshots(results_dir: Path) -> PlotsDoc:
    tools_dir = results_dir / "tools"
    if not tools_dir.is_dir():
        return PlotsDoc()

    merged_plots: list[PlotSpec] = []
    seen_ids: set[str] = set()
    merged_sections: list[Section] = []
    report_title: str | None = None
    report_description: str | None = None
    theme: ThemeConfig | None = None

    for tool_yaml in sorted(tools_dir.glob("*.yaml")):
        raw = yaml.safe_load(tool_yaml.read_text())
        if not isinstance(raw, dict):
            continue
        if report_title is None:
            report_title = raw.get("report_title") or None
        if report_description is None:
            report_description = raw.get("report_description") or None
        if theme is None and "theme" in raw:
            theme = parse_theme(raw.get("theme"), source=str(tool_yaml))

        for spec in parse_plots(raw.get("plots"), source=str(tool_yaml)):
            if spec.id in seen_ids:
                continue
            seen_ids.add(spec.id)
            merged_plots.append(spec)

        merged_sections.extend(
            parse_sections(
                raw.get("sections"),
                source=str(tool_yaml),
                plot_ids=seen_ids,
                base_dir=tool_yaml.parent,
            )
        )

    return PlotsDoc(
        plots=merged_plots,
        sections=merged_sections,
        report_title=report_title,
        report_description=report_description,
        theme=theme or ThemeConfig(),
    )
