"""Reusable dataset duplicate-detection helpers shared by CLI and TUI commands."""

from __future__ import annotations

from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any

import polars as pl

from .compare import parse_name_list
from .core.engine.polars_adapter import collect_lazyframe, unwrap_physical_plan
from .data.db import is_db_uri

if TYPE_CHECKING:  # pragma: no cover - import for typing only
    from .api.runtime import Runtime


@dataclass(frozen=True, slots=True)
class DuplicatesResult:
    """Result payload returned by :func:`duplicates_paths`."""

    duplicates_lf: pl.LazyFrame
    summary: dict[str, object]
    warnings: tuple[str, ...] = ()


def duplicates_paths(
    runtime: Runtime | Any,
    path: str | Path,
    *,
    keys: Sequence[str] | None = None,
    cols: Sequence[str] | None = None,
    exclude: Sequence[str] | None = None,
    rows: bool = True,
) -> DuplicatesResult:
    """Detect duplicate groups in a dataset path using the runtime's scanners."""

    dataset_path = _validate_path(path)
    lazyframe, schema = _load_lazyframe(runtime, dataset_path)
    return duplicates_lazyframe(
        lazyframe,
        schema=schema,
        dataset_path=dataset_path,
        keys=list(keys or ()),
        cols=list(cols or ()),
        exclude=list(exclude or ()),
        rows=rows,
    )


def duplicates_lazyframe(
    lf: pl.LazyFrame,
    *,
    schema: pl.Schema,
    dataset_path: Path,
    keys: list[str],
    cols: list[str],
    exclude: list[str],
    rows: bool,
) -> DuplicatesResult:
    """Detect duplicates and return the requested duplicates lazyframe + summary."""

    all_columns = list(schema.keys())
    if not all_columns:
        raise ValueError("No columns available for duplicate detection.")

    if keys:
        _assert_columns_present(keys, schema, "dataset")

    if exclude:
        _assert_columns_present(exclude, schema, "dataset")
    exclude_set = set(exclude)

    base_cols = cols if cols else all_columns
    _assert_columns_present(base_cols, schema, "dataset")
    detection_cols = [name for name in base_cols if name not in exclude_set]

    # When both keys and cols are provided, detect duplicates on keys + cols.
    detection_cols = list(dict.fromkeys([*keys, *detection_cols]))
    if not detection_cols:
        raise ValueError("No columns selected for duplicate detection.")

    warnings: list[str] = []
    if keys:
        null_keys = _null_key_columns(lf, keys)
        if null_keys:
            joined = ", ".join(null_keys)
            warnings.append(
                f"key columns contain nulls ({joined}); null keys may form surprising groups"
            )

    duplicates_groups_lf = _build_duplicates_groups_lazyframe(lf, detection_cols)
    duplicates_lf = (
        _build_duplicates_rows_lazyframe(
            lf,
            detection_cols,
            duplicates_groups_lf,
            all_columns=all_columns,
            exclude=exclude,
        )
        if rows
        else duplicates_groups_lf
    )
    total_rows = _row_count(lf)
    summary = _summarize_duplicates(
        duplicates_groups_lf,
        detection_cols=detection_cols,
        keys=keys,
        cols=cols,
        exclude=exclude,
        dataset_path=dataset_path,
        total_rows=total_rows,
        rows_mode=rows,
    )
    if warnings:
        summary = dict(summary)
        summary["warnings"] = tuple(warnings)
    return DuplicatesResult(
        duplicates_lf=duplicates_lf,
        summary=summary,
        warnings=tuple(warnings),
    )


def _validate_path(value: str | Path) -> Path:
    text = str(value)
    if is_db_uri(text):
        raise ValueError("Database URIs are not supported by pulka duplicates.")
    path = Path(value).expanduser()
    if not path.exists():
        raise ValueError(f"Path not found: {value}")
    return path


def _load_lazyframe(runtime: Any, 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 _assert_columns_present(columns: Iterable[str], schema: pl.Schema, label: str) -> None:
    missing = [name for name in columns if name not in schema]
    if missing:
        missing_text = ", ".join(missing)
        raise ValueError(f"Missing columns in {label}: {missing_text}")


def _null_key_columns(lf: pl.LazyFrame, keys: list[str]) -> list[str]:
    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)
    return [name for name, flag in zip(keys, row, strict=True) if bool(flag)]


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


def _build_duplicates_groups_lazyframe(lf: pl.LazyFrame, detection_cols: list[str]) -> pl.LazyFrame:
    sort_by = ["dupe__count", *detection_cols]
    descending = [True, *([False] * len(detection_cols))]
    return (
        lf.group_by(detection_cols)
        .len()
        .rename({"len": "dupe__count"})
        .filter(pl.col("dupe__count") > 1)
        .sort(by=sort_by, descending=descending, nulls_last=True)
    )


def _build_duplicates_rows_lazyframe(
    lf: pl.LazyFrame,
    detection_cols: list[str],
    duplicates_groups_lf: pl.LazyFrame,
    *,
    all_columns: list[str],
    exclude: list[str],
) -> pl.LazyFrame:
    row_col = _unique_column_name("__pulka_row", all_columns)
    lf_with_row = lf.with_row_index(name=row_col)
    groups_sel = duplicates_groups_lf.select([*map(pl.col, detection_cols), pl.col("dupe__count")])
    sort_by = ["dupe__count", *detection_cols, row_col]
    descending = [True, *([False] * len(detection_cols)), False]
    return (
        lf_with_row.join(groups_sel, on=detection_cols, how="inner")
        .sort(by=sort_by, descending=descending, nulls_last=True)
        .drop(row_col, *exclude)
    )


def _summarize_duplicates(
    duplicates_lf: pl.LazyFrame,
    *,
    detection_cols: list[str],
    keys: list[str],
    cols: list[str],
    exclude: list[str],
    dataset_path: Path,
    total_rows: int,
    rows_mode: bool,
) -> dict[str, object]:
    counts_df = collect_lazyframe(
        duplicates_lf.select(
            pl.len().alias("dupe_groups"),
            pl.col("dupe__count").sum().alias("dupe_rows"),
        )
    )
    dupe_groups = 0
    dupe_rows = 0
    if counts_df.height:
        dupe_groups = int(counts_df[0, "dupe_groups"] or 0)
        dupe_rows = int(counts_df[0, "dupe_rows"] or 0)
    dupe_extra_rows = max(dupe_rows - dupe_groups, 0)

    return {
        "dataset_path": dataset_path,
        "keys": keys,
        "cols": cols,
        "exclude": exclude,
        "detection_cols": detection_cols,
        "total_rows": total_rows,
        "dupe_groups": dupe_groups,
        "dupe_rows": dupe_rows,
        "dupe_extra_rows": dupe_extra_rows,
        "rows_mode": rows_mode,
    }


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__ = [
    "DuplicatesResult",
    "duplicates_paths",
    "duplicates_lazyframe",
    "parse_name_list",
]
