from __future__ import annotations

from collections.abc import Mapping, Sequence

import pandas as pd

CANONICAL_COLUMNS = ("id", "parameter_name", "parameter_value", "parameter_unit")


def parameter_units_from_table(parameter_table: pd.DataFrame) -> dict[str, str]:
    required_columns = {"parameter", "unit"}
    missing_columns = required_columns - set(parameter_table.columns)
    if missing_columns:
        missing = ", ".join(sorted(missing_columns))
        raise ValueError(f"parameter_table must include columns: {missing}")

    units = (
        parameter_table.loc[:, ["parameter", "unit"]]
        .drop_duplicates()
        .sort_values(["parameter", "unit"], kind="stable")
    )
    duplicated = units.duplicated(subset=["parameter"], keep=False)
    if duplicated.any():
        duplicates = ", ".join(sorted(units.loc[duplicated, "parameter"].astype(str).unique()))
        raise ValueError(f"parameter_table contains parameters with conflicting units: {duplicates}")

    return dict(zip(units["parameter"], units["unit"], strict=True))


def rebase_group_ids(
    dataframe: pd.DataFrame,
    *,
    start_id: int = 0,
    id_column: str = "id",
) -> tuple[pd.DataFrame, int]:
    if id_column not in dataframe.columns:
        raise ValueError(f"dataframe is missing required id column '{id_column}'")

    rebased = dataframe.copy()
    unique_ids = pd.Index(rebased[id_column]).drop_duplicates()
    mapping = {old_id: start_id + index for index, old_id in enumerate(unique_ids)}
    rebased[id_column] = rebased[id_column].map(mapping).astype("int64")
    return rebased, start_id + len(unique_ids)


def canonicalize_proposal_dataframe(
    dataframe: pd.DataFrame,
    *,
    id_column: str = "id",
    parameter_name_column: str = "parameter_name",
    parameter_value_column: str = "parameter_value",
    parameter_unit_column: str = "parameter_unit",
    keep_extra_columns: bool = False,
    rebase_ids: bool = False,
    id_start: int = 0,
    source_name: str = "proposal dataframe",
) -> pd.DataFrame:
    rename_map = {
        id_column: "id",
        parameter_name_column: "parameter_name",
        parameter_value_column: "parameter_value",
        parameter_unit_column: "parameter_unit",
    }
    missing_columns = [source for source in rename_map if source not in dataframe.columns]
    if missing_columns:
        missing = ", ".join(missing_columns)
        raise ValueError(f"{source_name} is missing required columns: {missing}")

    canonical = dataframe.rename(columns=rename_map).copy()
    if not keep_extra_columns:
        canonical = canonical.loc[:, list(CANONICAL_COLUMNS)]

    for column in ("parameter_name", "parameter_unit"):
        canonical[column] = canonical[column].astype("string").str.strip()
        if canonical[column].isna().any() or (canonical[column] == "").any():
            raise ValueError(f"{source_name} contains blank values in '{column}'")

    canonical["parameter_value"] = pd.to_numeric(canonical["parameter_value"], errors="raise")
    if canonical["parameter_value"].isna().any():
        raise ValueError(f"{source_name} contains missing values in 'parameter_value'")

    if canonical["id"].isna().any():
        raise ValueError(f"{source_name} contains missing values in 'id'")

    if rebase_ids:
        canonical, _ = rebase_group_ids(canonical, start_id=id_start)
    else:
        try:
            canonical["id"] = pd.to_numeric(canonical["id"], errors="raise").astype("int64")
        except Exception as error:  # pragma: no cover - pandas raises different concrete exceptions
            raise ValueError(
                f"{source_name} requires integer-like ids or rebase_ids=True for non-numeric ids"
            ) from error

    duplicated = canonical.duplicated(subset=["id", "parameter_name"], keep=False)
    if duplicated.any():
        duplicate_rows = canonical.loc[duplicated, ["id", "parameter_name"]].drop_duplicates()
        preview = ", ".join(
            f"(id={row.id}, parameter_name={row.parameter_name})" for row in duplicate_rows.itertuples(index=False)
        )
        raise ValueError(f"{source_name} contains duplicate (id, parameter_name) rows: {preview}")

    return canonical.sort_values(["id", "parameter_name"], kind="stable").reset_index(drop=True)


def wide_to_canonical_dataframe(
    wide_dataframe: pd.DataFrame,
    *,
    parameter_units: Mapping[str, str] | pd.DataFrame,
    id_column: str | None = None,
    parameter_columns: Sequence[str] | None = None,
    drop_missing_values: bool = False,
    source_name: str = "wide dataframe",
) -> pd.DataFrame:
    if isinstance(parameter_units, pd.DataFrame):
        parameter_units_map = parameter_units_from_table(parameter_units)
    else:
        parameter_units_map = {str(name): str(unit) for name, unit in parameter_units.items()}

    if id_column is None:
        prepared = wide_dataframe.copy()
        prepared.insert(0, "__row_id__", range(len(prepared)))
        id_column = "__row_id__"
    else:
        if id_column not in wide_dataframe.columns:
            raise ValueError(f"{source_name} is missing id column '{id_column}'")
        prepared = wide_dataframe.copy()

    if parameter_columns is None:
        parameter_columns = [
            column for column in prepared.columns if column != id_column and column in parameter_units_map
        ]
    else:
        parameter_columns = list(parameter_columns)

    if not parameter_columns:
        raise ValueError(
            f"{source_name} did not resolve any parameter columns. "
            "Provide parameter_columns explicitly or include matching keys in parameter_units."
        )

    missing_units = [column for column in parameter_columns if column not in parameter_units_map]
    if missing_units:
        missing = ", ".join(missing_units)
        raise ValueError(f"{source_name} is missing unit metadata for parameter columns: {missing}")

    melted = prepared.loc[:, [id_column, *parameter_columns]].melt(
        id_vars=[id_column],
        value_vars=parameter_columns,
        var_name="parameter_name",
        value_name="parameter_value",
    )
    if drop_missing_values:
        melted = melted.dropna(subset=["parameter_value"])
    elif melted["parameter_value"].isna().any():
        raise ValueError(f"{source_name} contains missing parameter values. Set drop_missing_values=True to omit them.")

    melted["parameter_unit"] = melted["parameter_name"].map(parameter_units_map)

    return canonicalize_proposal_dataframe(
        melted.rename(columns={id_column: "id"}),
        rebase_ids=True,
        source_name=source_name,
    )
