"""CLI command for comparing two datasets."""

from __future__ import annotations

import argparse
import sys
import termios
import tty
from collections.abc import Iterable, Sequence
from pathlib import Path

import polars as pl
from polars import datatypes as pl_datatypes

from ..api import Runtime
from ..core.engine.polars_adapter import collect_lazyframe, unwrap_physical_plan
from ..data.db import is_db_uri
from ..data.export import write_view_to_path
from ..logging import Recorder, RecorderConfig
from ..utils import _boot_trace, lazy_imports
from .progress import file_write_feedback


def compare_main(argv: Sequence[str]) -> int:
    _boot_trace("cli:compare start")
    parser = _build_parser()
    args = parser.parse_args(argv)

    runtime = Runtime()
    try:
        left_path = _validate_path(parser, args.left_path)
        right_path = _validate_path(parser, args.right_path)

        left_lf, left_schema = _load_lazyframe(runtime, left_path)
        right_lf, right_schema = _load_lazyframe(runtime, right_path)

        keys = _parse_name_list(args.keys)
        cols = _parse_name_list(args.cols)

        left_cols = list(left_schema.keys())
        right_cols = list(right_schema.keys())

        if keys:
            _assert_columns_present(keys, left_schema, "left", parser)
            _assert_columns_present(keys, right_schema, "right", parser)
        else:
            row_col = _unique_column_name("__pulka_row", left_cols, right_cols)
            keys = [row_col]
            left_lf = left_lf.with_row_index(name=row_col)
            right_lf = right_lf.with_row_index(name=row_col)

        left_extra, right_extra, compare_cols = _resolve_compare_columns(
            cols,
            left_cols,
            right_cols,
            parser,
        )

        if args.rtol is not None and args.rtol < 0:
            parser.error("--rtol must be non-negative")
        if args.atol is not None and args.atol < 0:
            parser.error("--atol must be non-negative")

        if not compare_cols:
            parser.error("No comparable columns; --cols is required for disjoint schemas.")

        dtype_mismatches = _dtype_mismatches(compare_cols, left_schema, right_schema)
        if dtype_mismatches:
            mismatch_text = "; ".join(dtype_mismatches)
            parser.error(f"dtype mismatch: {mismatch_text}")

        if args.keys:
            _warn_null_keys(left_lf, keys, "left")
            _warn_null_keys(right_lf, keys, "right")
            _assert_unique_keys(left_lf, keys, "left", parser)
            _assert_unique_keys(right_lf, keys, "right", parser)

        diff_lf = _build_diff_lazyframe(
            left_lf,
            right_lf,
            keys=keys,
            compare_cols=compare_cols,
            dtype_map=left_schema,
            rtol=args.rtol,
            atol=args.atol,
        )

        left_rows = _row_count(left_lf)
        right_rows = _row_count(right_lf)
        summary = _summarize_diff(
            diff_lf,
            keys=keys,
            compare_cols=compare_cols,
            dtype_map=left_schema,
            left_extra=left_extra,
            right_extra=right_extra,
            rtol=args.rtol,
            atol=args.atol,
            left_path=left_path,
            right_path=right_path,
            row_mode=not args.keys,
            left_rows=left_rows,
            right_rows=right_rows,
        )

        if args.out:
            exit_code = _write_diff_output(diff_lf, args.out)
            _print_summary(summary)
            return _final_exit_code(exit_code, summary)

        _print_summary(summary)
        if _should_open_tui():
            return _open_diff_tui(runtime, diff_lf, summary)
        return _final_exit_code(0, summary)
    finally:
        runtime.close()


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="pulka compare",
        description="Compare two datasets and open a diff view",
    )
    parser.add_argument("left_path", help="Left-hand dataset path")
    parser.add_argument("right_path", help="Right-hand dataset path")
    parser.add_argument(
        "--keys",
        help="Comma-separated key columns (default: row position)",
    )
    parser.add_argument(
        "--cols",
        help="Comma-separated columns to compare (default: all common columns)",
    )
    parser.add_argument(
        "--rtol",
        type=float,
        default=None,
        help="Relative tolerance for float comparisons",
    )
    parser.add_argument(
        "--atol",
        type=float,
        default=None,
        help="Absolute tolerance for float comparisons",
    )
    parser.add_argument(
        "--out",
        type=Path,
        help="Write diff output to PATH and exit",
    )
    return parser


def _validate_path(parser: argparse.ArgumentParser, value: str) -> Path:
    if is_db_uri(value):
        parser.error("Database URIs are not supported by pulka compare.")
    path = Path(value).expanduser()
    if not path.exists():
        parser.error(f"Path not found: {value}")
    return path


def _load_lazyframe(runtime: Runtime, path: Path) -> tuple[pl.LazyFrame, pl.Schema]:
    physical_plan = runtime.scanners.scan(path)
    lazyframe = unwrap_physical_plan(physical_plan).to_lazyframe()
    try:
        schema = lazyframe.collect_schema()
    except Exception:
        schema = lazyframe.schema
    return lazyframe, schema


def _parse_name_list(raw: str | None) -> list[str]:
    if not raw:
        return []
    values = []
    for chunk in raw.split(","):
        item = chunk.strip()
        if not item:
            continue
        values.append(item)
    return list(dict.fromkeys(values))


def _assert_columns_present(
    columns: Iterable[str],
    schema: pl.Schema,
    label: str,
    parser: argparse.ArgumentParser,
) -> None:
    missing = [name for name in columns if name not in schema]
    if missing:
        missing_text = ", ".join(missing)
        parser.error(f"Missing columns in {label} dataset: {missing_text}")


def _resolve_compare_columns(
    columns: list[str],
    left_cols: list[str],
    right_cols: list[str],
    parser: argparse.ArgumentParser,
) -> tuple[list[str], list[str], list[str]]:
    left_set = set(left_cols)
    right_set = set(right_cols)
    left_extra = sorted(left_set - right_set)
    right_extra = sorted(right_set - left_set)

    if columns:
        left_missing = [name for name in columns if name not in left_set]
        right_missing = [name for name in columns if name not in right_set]
        missing = sorted(set(left_missing + right_missing))
        if missing:
            parser.error(f"Unknown columns for comparison: {', '.join(missing)}")
        return left_extra, right_extra, columns

    compare_cols = sorted(left_set & right_set)
    return left_extra, right_extra, compare_cols


def _dtype_mismatches(
    columns: Iterable[str],
    left_schema: pl.Schema,
    right_schema: pl.Schema,
) -> list[str]:
    mismatches: list[str] = []
    for name in columns:
        left_dtype = left_schema.get(name)
        right_dtype = right_schema.get(name)
        if left_dtype != right_dtype:
            mismatches.append(f"{name} ({left_dtype} != {right_dtype})")
    return mismatches


def _assert_unique_keys(
    lf: pl.LazyFrame,
    keys: list[str],
    label: str,
    parser: argparse.ArgumentParser,
) -> None:
    dupes = collect_lazyframe(
        lf.select(keys).group_by(keys).len().filter(pl.col("len") > 1).limit(1)
    )
    if dupes.height:
        key_text = ", ".join(keys)
        parser.error(f"Duplicate keys detected in {label} dataset for [{key_text}].")


def _unique_column_name(base: str, *columns: Iterable[str]) -> str:
    taken = set()
    for col_group in columns:
        taken.update(col_group)
    if base not in taken:
        return base
    idx = 1
    while f"{base}_{idx}" in taken:
        idx += 1
    return f"{base}_{idx}"


def _warn_null_keys(lf: pl.LazyFrame, keys: list[str], label: str) -> None:
    exprs = [pl.col(name).is_null().any().alias(name) for name in keys]
    result = collect_lazyframe(lf.select(exprs))
    if result.height == 0:
        return
    row = result.row(0)
    null_keys = [name for name, flag in zip(keys, row, strict=True) if bool(flag)]
    if not null_keys:
        return
    joined = ", ".join(null_keys)
    print(
        (
            f"warning: {label} key columns contain nulls ({joined}); "
            "null keys may not match across files"
        ),
        file=sys.stderr,
    )


def _is_float_dtype(dtype: pl.DataType) -> bool:
    is_float = getattr(pl_datatypes, "is_float", None)
    if callable(is_float):
        return bool(is_float(dtype))
    return dtype in (pl.Float32, pl.Float64)


def _float_diff_expr(
    left_col: pl.Expr,
    right_col: pl.Expr,
    *,
    rtol: float | None,
    atol: float | None,
) -> pl.Expr:
    """Return a diff expression for float columns with null==null semantics."""

    rtol_value = float(rtol or 0.0)
    atol_value = float(atol or 0.0)
    both_null = left_col.is_null() & right_col.is_null()
    both_value = left_col.is_not_null() & right_col.is_not_null()
    max_abs = pl.max_horizontal(left_col.abs(), right_col.abs())
    tol = pl.max_horizontal(pl.lit(atol_value), max_abs * rtol_value)
    approx_equal = (left_col - right_col).abs() <= tol
    equal_expr = both_null | (both_value & approx_equal)
    return equal_expr.not_().fill_null(True)


def _build_diff_lazyframe(
    left_lf: pl.LazyFrame,
    right_lf: pl.LazyFrame,
    *,
    keys: list[str],
    compare_cols: list[str],
    dtype_map: pl.Schema,
    rtol: float | None,
    atol: float | None,
) -> pl.LazyFrame:
    left_renames = [pl.col(name).alias(f"{name}__left") for name in compare_cols]
    right_renames = [pl.col(name).alias(f"{name}__right") for name in compare_cols]

    left_sel = [*map(pl.col, keys), *left_renames, pl.lit(True).alias("__present_left")]
    right_sel = [*map(pl.col, keys), *right_renames, pl.lit(True).alias("__present_right")]

    joined = left_lf.select(left_sel).join(
        right_lf.select(right_sel),
        on=keys,
        how="full",
        coalesce=True,
    )

    diff_exprs: list[pl.Expr] = []
    for name in compare_cols:
        left_name = f"{name}__left"
        right_name = f"{name}__right"
        left_col = pl.col(left_name)
        right_col = pl.col(right_name)
        if (rtol is not None or atol is not None) and _is_float_dtype(dtype_map.get(name)):
            diff_exprs.append(
                _float_diff_expr(
                    left_col,
                    right_col,
                    rtol=rtol,
                    atol=atol,
                )
            )
        else:
            diff_exprs.append(left_col.eq_missing(right_col).not_())

    any_diff = pl.any_horizontal(*diff_exprs) if diff_exprs else pl.lit(False)

    kind_expr = (
        pl.when(pl.col("__present_left").is_null() & pl.col("__present_right").is_not_null())
        .then(pl.lit("right_only"))
        .when(pl.col("__present_right").is_null() & pl.col("__present_left").is_not_null())
        .then(pl.lit("left_only"))
        .when(any_diff)
        .then(pl.lit("changed"))
        .otherwise(pl.lit("same"))
    )

    ordered_cols = [*keys, "__kind"]
    for name in compare_cols:
        ordered_cols.append(f"{name}__left")
        ordered_cols.append(f"{name}__right")

    return (
        joined.with_columns(__kind=kind_expr)
        .filter(pl.col("__kind") != "same")
        .select(ordered_cols)
        .sort(by=keys, nulls_last=True)
    )


def _summarize_diff(
    diff_lf: pl.LazyFrame,
    *,
    keys: list[str],
    compare_cols: list[str],
    dtype_map: pl.Schema,
    left_extra: list[str],
    right_extra: list[str],
    rtol: float | None,
    atol: float | None,
    left_path: Path,
    right_path: Path,
    row_mode: bool,
    left_rows: int,
    right_rows: int,
) -> dict[str, object]:
    counts_df = collect_lazyframe(diff_lf.group_by("__kind").len())
    counts = {row["__kind"]: int(row["len"]) for row in counts_df.iter_rows(named=True)}
    for key in ("changed", "left_only", "right_only"):
        counts.setdefault(key, 0)
    total = counts["changed"] + counts["left_only"] + counts["right_only"]

    top_changed = _top_changed_columns(
        diff_lf,
        compare_cols=compare_cols,
        dtype_map=dtype_map,
        rtol=rtol,
        atol=atol,
        limit=3,
        changed_rows=counts["changed"],
    )

    return {
        "keys": keys,
        "compare_cols": compare_cols,
        "left_extra": left_extra,
        "right_extra": right_extra,
        "rtol": rtol,
        "counts": counts,
        "total": total,
        "top_changed": top_changed,
        "left_path": left_path,
        "right_path": right_path,
        "row_mode": row_mode,
        "left_rows": left_rows,
        "right_rows": right_rows,
    }


def _write_diff_output(diff_lf: pl.LazyFrame, destination: Path) -> int:
    try:
        with file_write_feedback(destination, noun="diff file"):
            write_view_to_path(diff_lf, destination)
    except Exception as exc:
        print(f"Export failed: {exc}", file=sys.stderr)
        return 2
    return 0


def _should_open_tui() -> bool:
    if not sys.stdin.isatty():
        return False
    return _prompt_yes_no("Open row-level diff sheet in Pulka? [y/n] ")


def _prompt_yes_no(prompt: str) -> bool:
    fd = sys.stdin.fileno()
    try:
        old_settings = termios.tcgetattr(fd)
    except Exception:
        try:
            response = input(prompt).strip().lower()
        except EOFError:
            return False
        return response in {"", "y", "yes"}

    sys.stdout.write("\n")
    sys.stdout.write(prompt)
    sys.stdout.flush()
    try:
        tty.setraw(fd)
        char = sys.stdin.read(1)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

    sys.stdout.write("\n")
    sys.stdout.flush()
    if not char:
        return False
    return char.lower() in {"y", "\r", "\n"}


def _open_diff_tui(runtime: Runtime, diff_lf: pl.LazyFrame, summary: dict[str, object]) -> int:
    from ..tui.app import run_tui_app

    source_label = f"compare: {summary['left_path']} vs {summary['right_path']}"
    recorder = Recorder(RecorderConfig(enabled=False))
    session = runtime.open(
        None,
        recorder=recorder,
        lazyframe=diff_lf,
        source_label=source_label,
    )
    counts = summary["counts"]
    session.viewer.status_message = (
        f"diffs: changed={counts['changed']} left_only={counts['left_only']} "
        f"right_only={counts['right_only']}"
    )
    try:
        run_tui_app(session.viewer, recorder=session.recorder)
    finally:
        session.close()
    return _final_exit_code(0, summary)


def _final_exit_code(base: int, summary: dict[str, object]) -> int:
    if base:
        return base
    counts = summary["counts"]
    total = counts["changed"] + counts["left_only"] + counts["right_only"]
    return 0 if total == 0 else 1


def _print_summary(summary: dict[str, object]) -> None:
    left_path = summary["left_path"]
    right_path = summary["right_path"]
    compare_cols = summary["compare_cols"]
    left_extra = summary["left_extra"]
    right_extra = summary["right_extra"]
    counts = summary["counts"]
    total = summary["total"]
    top_changed = summary.get("top_changed") or []

    left_rows = summary["left_rows"]
    right_rows = summary["right_rows"]

    overlap_rows = min(left_rows - counts["left_only"], right_rows - counts["right_only"])
    overlap_rows = max(overlap_rows, 0)
    left_cols_total = len(compare_cols) + len(left_extra)
    right_cols_total = len(compare_cols) + len(right_extra)

    shape_rows: list[tuple[str, str, str]] = [
        ("Rows", str(left_rows), str(right_rows)),
        ("Rows matched", str(overlap_rows), str(overlap_rows)),
        ("Rows only", str(counts["left_only"]), str(counts["right_only"])),
        ("Columns", str(left_cols_total), str(right_cols_total)),
        ("Columns only", str(len(left_extra)), str(len(right_extra))),
    ]

    breakdown_rows: list[tuple[str, str, str]] = []
    if total:
        changed_pct = counts["changed"] / total * 100
        left_pct = counts["left_only"] / total * 100
        right_pct = counts["right_only"] / total * 100
        breakdown_rows = [
            ("Value changes", str(counts["changed"]), f"{changed_pct:.1f}%"),
            ("Only in left", str(counts["left_only"]), f"{left_pct:.1f}%"),
            ("Only in right", str(counts["right_only"]), f"{right_pct:.1f}%"),
            ("Total diff rows", str(total), "100.0%"),
        ]
    else:
        breakdown_rows = [
            ("Value changes", "0", "0.0%"),
            ("Only in left", "0", "0.0%"),
            ("Only in right", "0", "0.0%"),
            ("Total diff rows", "0", "0.0%"),
        ]

    if sys.stdout.isatty() and _print_rich_summary(
        left=str(left_path),
        right=str(right_path),
        shape_rows=shape_rows,
        breakdown_rows=breakdown_rows,
        top_changed=top_changed,
    ):
        return

    print("Compare summary")
    print(f"Left:  {left_path}")
    print(f"Right: {right_path}")
    print()
    print("Shape")
    for metric, left_val, right_val in shape_rows:
        print(f"  {metric}: left={left_val} right={right_val}")
    print()
    print("Diff breakdown")
    for label, count, pct in breakdown_rows:
        print(f"  {label}: {count} ({pct})")
    if top_changed:
        print()
        print("Top changed columns")
        for name, count, pct in top_changed:
            print(f"  {name}: {count} ({pct:.1f}%)")


def _print_rich_summary(
    *,
    left: str,
    right: str,
    shape_rows: list[tuple[str, str, str]],
    breakdown_rows: list[tuple[str, str, str]],
    top_changed: list[tuple[str, int, float]],
) -> bool:
    try:
        console_cls = lazy_imports.rich_console_class()
        table_cls = lazy_imports.rich_table_class()
    except Exception:
        return False

    console = console_cls()

    console.print("Compare summary", style="bold")
    console.print(f"Left:  {left}", style="dim")
    console.print(f"Right: {right}", style="dim")

    shape_table = table_cls(show_lines=False, header_style="bold", border_style="dim")
    shape_table.add_column("", style="bold")
    shape_table.add_column("Left")
    shape_table.add_column("Right")
    for metric, left_val, right_val in shape_rows:
        shape_table.add_row(metric, left_val, right_val)

    console.print()
    console.print("Shape", style="dim")
    console.print(shape_table)

    breakdown_table = table_cls(show_lines=False, header_style="bold", border_style="dim")
    breakdown_table.add_column("Type", style="bold")
    breakdown_table.add_column("Rows", justify="right")
    breakdown_table.add_column("Share", justify="right")
    for label, count, pct in breakdown_rows:
        breakdown_table.add_row(label, count, pct)

    console.print()
    console.print("Diff breakdown", style="dim")
    console.print(breakdown_table)

    if top_changed:
        console.print()
        console.print("Top changed columns", style="dim")
        top_table = table_cls(show_lines=False, header_style="bold", border_style="dim")
        top_table.add_column("Column", style="bold")
        top_table.add_column("Changed rows", justify="right")
        top_table.add_column("Rate", justify="right")
        for name, count, pct in top_changed:
            top_table.add_row(name, str(count), f"{pct:.1f}%")
        console.print(top_table)
    return True


def _diff_expr_for_column(
    name: str,
    *,
    dtype_map: pl.Schema,
    rtol: float | None,
    atol: float | None,
) -> pl.Expr:
    left_col = pl.col(f"{name}__left")
    right_col = pl.col(f"{name}__right")
    dtype = dtype_map.get(name)
    if (rtol is not None or atol is not None) and dtype is not None and _is_float_dtype(dtype):
        return _float_diff_expr(left_col, right_col, rtol=rtol, atol=atol)
    return left_col.eq_missing(right_col).not_()


def _top_changed_columns(
    diff_lf: pl.LazyFrame,
    *,
    compare_cols: list[str],
    dtype_map: pl.Schema,
    rtol: float | None,
    atol: float | None,
    limit: int,
    changed_rows: int,
) -> list[tuple[str, int, float]]:
    if not compare_cols or changed_rows <= 0 or limit <= 0:
        return []

    exprs = [
        _diff_expr_for_column(name, dtype_map=dtype_map, rtol=rtol, atol=atol).sum().alias(name)
        for name in compare_cols
    ]
    counts_df = collect_lazyframe(
        diff_lf.filter(pl.col("__kind") == "changed").select(exprs).limit(1)
    )
    if counts_df.height == 0:
        return []

    row = counts_df.row(0)
    results: list[tuple[str, int, float]] = []
    for name, value in zip(counts_df.columns, row, strict=True):
        count = int(value or 0)
        if count <= 0:
            continue
        pct = count / changed_rows * 100 if changed_rows else 0.0
        results.append((name, count, pct))
    results.sort(key=lambda item: (-item[1], item[0]))
    return results[:limit]


def _row_count(lf: pl.LazyFrame) -> int:
    result = collect_lazyframe(lf.select(pl.len().alias("len")))
    if result.height == 0:
        return 0
    return int(result[0, 0])


__all__ = ["compare_main"]
