"""Frontend page configuration loader and schema."""

from __future__ import annotations

import logging
from functools import lru_cache
from pathlib import Path
from typing import Annotated, Any, Literal

import yaml
from astropy import units as u
from astropy.units import UnitConversionError
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    TypeAdapter,
    ValidationError,
    model_validator,
)

logger = logging.getLogger(__name__)


class LayoutConfig(BaseModel):
    """Layout configuration for a page."""

    rows: int = 1
    cols: int = 1
    max_per_row: int = 1


class PageConfig(BaseModel):
    """Configuration for a single page."""

    name: str
    label: str
    plots: list[dict[str, Any]] = Field(default_factory=list)
    layout: LayoutConfig = Field(
        default_factory=lambda: LayoutConfig(rows=2, cols=2, max_per_row=2)
    )
    css: list[str] = Field(default_factory=list)
    js: list[str] = Field(default_factory=list)
    template: str = "pages/array_element_type/generic_page.html"

    @model_validator(mode="after")
    def _validate_plots(self) -> PageConfig:
        if not self.plots:
            raise ValueError("Page config must define plots.")
        for plot in self.plots:
            title = plot.get("title")
            if not title:
                raise ValueError("Plot title is required for every plot entry.")
        return self

    def plot_titles(self) -> list[str]:
        """Return plot titles in the order defined by the page config."""
        return [plot.get("title") or "" for plot in self.plots]

    def plot_keys(self) -> list[str]:
        """Return generated internal plot keys in the order defined by the page config."""
        keys: list[str] = []
        seen: dict[str, int] = {}

        for idx, plot in enumerate(self.plots):
            metric = plot.get("metric")
            plot_type = plot.get("plotType")
            base_seed = (
                f"{metric}_{plot_type}" if metric and plot_type else plot.get("title")
            )
            base = self._slugify(base_seed or f"plot_{idx + 1}")
            if not base:
                base = f"plot_{idx + 1}"

            count = seen.get(base, 0) + 1
            seen[base] = count
            suffix = "" if count == 1 else f"_{count}"
            keys.append(f"{base}{suffix}")

        return keys

    def _slugify(self, value: str) -> str:
        return "".join(ch if ch.isalnum() else "_" for ch in value.lower()).strip("_")


class AxisConfigBase(BaseModel):
    """Base axis configuration for plots."""

    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    label: str
    domain: list[float] | None = None
    unit: str | None = None
    field: str | None = None

    def values_from_columns(
        self,
        columns: dict[str, dict[str, Any]],
    ) -> list[Any] | None:
        """Extract and convert axis values from backend columns using axis field/unit."""
        if not self.field:
            return None

        column = columns.get(self.field)
        if not isinstance(column, dict):
            return None

        values = column.get("data")
        if not isinstance(values, list):
            return None

        source_unit = column.get("unit")
        if source_unit is not None and not isinstance(source_unit, str):
            source_unit = str(source_unit)

        converted = _convert_values(values, source_unit, self.unit)
        return _flatten_numeric_values(converted)


class ScatterAxisConfig(AxisConfigBase):
    """Axis configuration for scatter plots."""

    scale: Literal["linear", "log", "time", "ordinal", "band", "point"] | None = None


class HistogramAxisConfig(AxisConfigBase):
    """Axis configuration for histogram plots."""

    scale: Literal["linear", "log"] | None = None


class CameraAxisConfig(AxisConfigBase):
    """Axis configuration for camera views."""

    label: str | None = None
    scale: Literal["linear"] | None = None


class ScatterMarksConfig(BaseModel):
    """Marks configuration for scatter plots."""

    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    type: (
        Literal[
            "circle",
            "triangle",
            "cross",
            "wye",
            "star",
            "square",
            "diamond",
        ]
        | None
    ) = None
    fill: str | None = None
    stroke: str | None = None
    stroke_width: float | None = Field(default=None, alias="strokeWidth")
    opacity: float | None = None
    size: float | None = None


class HistogramBarConfig(BaseModel):
    """Bar style configuration for histogram plots."""

    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    fill: str | None = None
    stroke: str | None = None
    stroke_width: float | None = Field(default=None, alias="strokeWidth")
    opacity: float | None = None


class BasePlotConfig(BaseModel):
    """Shared configuration for plot types."""

    model_config = ConfigDict(extra="forbid", populate_by_name=True)

    metric: str
    plot_type: str = Field(alias="plotType")
    title: str
    subtitle: str | None = None


class ScatterPlotConfig(BasePlotConfig):
    """Configuration for scatter plots."""

    plot_type: Literal["scatterplot"] = Field(alias="plotType")
    x: ScatterAxisConfig
    y: ScatterAxisConfig
    marks: ScatterMarksConfig | None = None
    line: (
        Literal[
            "none",
            "solid",
            "dashed",
            "dotted",
            "dot-dashed",
            "dot-dashed-dashed",
        ]
        | None
    ) = None


class CameraViewConfig(BasePlotConfig):
    """Configuration for camera views."""

    plot_type: Literal["cameraview"] = Field(alias="plotType")
    x: CameraAxisConfig | None = None
    y: CameraAxisConfig | None = None


class Histogram1DConfig(BasePlotConfig):
    """Configuration for 1D histogram plots."""

    plot_type: Literal["histogram1d"] = Field(alias="plotType")
    x: HistogramAxisConfig
    y: HistogramAxisConfig
    bins: int | None = None
    marks: HistogramBarConfig | None = None


PlotConfig = Annotated[
    ScatterPlotConfig | CameraViewConfig | Histogram1DConfig,
    Field(discriminator="plot_type"),
]

PLOT_CONFIG_ADAPTER = TypeAdapter(PlotConfig)


def _convert_values(
    values: list[Any], source_unit: str | None, target_unit: str | None
) -> list[Any]:
    if not source_unit or not target_unit or source_unit == target_unit:
        return values

    try:
        quantity = u.Quantity(values, unit=source_unit)
        return quantity.to(target_unit).value.tolist()
    except (UnitConversionError, ValueError, TypeError):
        return values


def _flatten_numeric_values(values: Any) -> list[float | int] | None:
    if not isinstance(values, list):
        return None

    flat: list[float | int] = []

    def _walk(item: Any) -> bool:
        if isinstance(item, list):
            for sub in item:
                if not _walk(sub):
                    return False
            return True

        if hasattr(item, "item"):
            try:
                item = item.item()
            except (ValueError, TypeError):
                return False

        if isinstance(item, bool):
            return False

        if isinstance(item, int | float):
            flat.append(item)
            return True

        return False

    if not _walk(values):
        return None

    return flat


def _validated_plot_config(plot_config: dict[str, Any]) -> PlotConfig | None:
    try:
        return PLOT_CONFIG_ADAPTER.validate_python(plot_config)
    except ValidationError:
        return None


def adapt_backend_payload(
    payload: dict[str, Any],
    plot_configurations: dict[str, dict[str, Any]],
) -> dict[str, Any]:
    """Adapt backend metric payload into plot-source keyed payload with unit conversion."""
    if not isinstance(payload, dict):
        return payload

    adapted: dict[str, Any] = {}

    for source, plot_config in plot_configurations.items():
        validated = _validated_plot_config(plot_config)
        if validated is None:
            continue

        metric_raw = payload.get(validated.metric)
        if not isinstance(metric_raw, dict):
            continue

        fetched_data = metric_raw.get("fetchedData")
        if not isinstance(fetched_data, dict):
            continue

        columns = fetched_data.get("columns")
        if not isinstance(columns, dict):
            continue

        fetched_metadata = metric_raw.get("fetchedMetadata")
        if not isinstance(fetched_metadata, dict):
            fetched_metadata = {}

        if isinstance(validated, ScatterPlotConfig):
            y_values = validated.y.values_from_columns(columns)
            if y_values is None:
                continue

            if validated.x.field:
                x_values = validated.x.values_from_columns(columns)
                if x_values is None:
                    continue
            else:
                x_values = list(range(len(y_values)))

            adapted[source] = {
                "fetchedData": {"x": x_values, "y": y_values},
                "fetchedMetadata": fetched_metadata,
            }
            continue

        if isinstance(validated, Histogram1DConfig):
            x_values = validated.x.values_from_columns(columns)
            if x_values is None:
                continue

            adapted[source] = {
                "fetchedData": {"x": x_values},
                "fetchedMetadata": fetched_metadata,
            }
            continue

        if isinstance(validated, CameraViewConfig):
            x_values = validated.x.values_from_columns(columns) if validated.x else None
            if x_values is None:
                continue

            gain_column = columns.get("gain")
            gain_values: list[float | int] = []
            if isinstance(gain_column, dict) and isinstance(
                gain_column.get("data"), list
            ):
                flattened_gain = _flatten_numeric_values(gain_column.get("data"))
                if flattened_gain is not None:
                    gain_values = flattened_gain

            camera_data: dict[str, list[float | int]] = {"x": x_values}
            if gain_values:
                camera_data["gain"] = gain_values

            adapted[source] = {
                "fetchedData": camera_data,
                "fetchedMetadata": fetched_metadata,
            }

    return adapted if adapted else payload


class PageIndexEntry(BaseModel):
    """Reference to a page config file."""

    name: str
    source: str


class BaseFrontendConfig(BaseModel):
    """Shared frontend configuration fields."""

    array_element_types: list[str] = Field(
        default_factory=lambda: ["LSTs", "MSTs", "SSTs"]
    )
    auxiliary_subitems: list[str] = Field(
        default_factory=lambda: ["Lidar", "FRAM", "Weather Station"]
    )


class FrontendIndexConfig(BaseFrontendConfig):
    """Index configuration listing available pages."""

    pages: list[PageIndexEntry]


class UserIndexConfig(BaseModel):
    """Optional user overrides for pages and auxiliary subitems."""

    pages: list[PageIndexEntry] | None = None
    auxiliary_subitems: list[str] | None = None


def _merge_page_entries(
    default_entries: list[PageIndexEntry], user_entries: list[PageIndexEntry]
) -> list[PageIndexEntry]:
    """Merge user page entries into defaults without allowing name collisions.

    User entries are appended preserving user order. If a user entry has the same
    name as a default entry, the user index is considered invalid.
    """
    merged = list(default_entries)
    default_names = {entry.name for entry in default_entries}

    for user_entry in user_entries:
        if user_entry.name in default_names:
            raise ValueError(
                f"User page '{user_entry.name}' conflicts with an existing default page."
            )
        merged.append(user_entry)

    return merged


class FrontendConfig(BaseFrontendConfig):
    """Resolved frontend configuration."""

    pages: list[PageConfig]
    page_entries: list[PageIndexEntry] = Field(default_factory=list)
    page_errors: dict[str, str] = Field(default_factory=dict)
    user_index_error: str | None = None

    def page_by_name(self, name: str) -> PageConfig | None:
        """Return a page configuration by name."""
        return next((page for page in self.pages if page.name == name), None)

    def page_error(self, name: str) -> str | None:
        """Return a page validation error by name, if any."""
        return self.page_errors.get(name)

    def page_label(self, name: str) -> str:
        """Return a label for a page, falling back to the name."""
        page = self.page_by_name(name)
        if page:
            return page.label
        return name.replace("_", " ")


def _default_config_path() -> Path:
    return Path(__file__).resolve().parent / "view" / "pages" / "pages_index.yaml"


def _user_index_path() -> Path:
    return (
        Path(__file__).resolve().parent
        / "view"
        / "pages"
        / "user_pages"
        / "pages_index.yaml"
    )


def _load_page_config(config_path: Path) -> PageConfig:
    raw: Any = yaml.safe_load(config_path.read_text())
    page = PageConfig.model_validate(raw)
    return page


@lru_cache(maxsize=1)
def load_frontend_config(config_path: Path | None = None) -> FrontendConfig:
    """Load and validate the frontend configuration file."""
    path = config_path or _default_config_path()
    try:
        raw: Any = yaml.safe_load(path.read_text())
    except FileNotFoundError as exc:
        logger.error("Frontend config not found at %s", path)
        raise FileNotFoundError(f"Frontend config not found at {path}.") from exc

    index = FrontendIndexConfig.model_validate(raw)

    user_index_error: str | None = None
    user_index_path = _user_index_path()
    if user_index_path.exists():
        try:
            user_raw: Any = yaml.safe_load(user_index_path.read_text())
            user_index = UserIndexConfig.model_validate(user_raw)
            if user_index.pages is not None:
                index.pages = _merge_page_entries(index.pages, user_index.pages)
            if user_index.auxiliary_subitems is not None:
                index.auxiliary_subitems = user_index.auxiliary_subitems
        except Exception as exc:
            logger.error("Invalid user index config: %s", exc)
            user_index_error = str(exc)

    base_dir = path.parent
    pages: list[PageConfig] = []
    page_errors: dict[str, str] = {}
    for entry in index.pages:
        page_path = (base_dir / entry.source).resolve()
        try:
            page = _load_page_config(page_path)
            if page.name != entry.name:
                raise ValueError(
                    "Page name mismatch for "
                    f"{entry.source}: expected {entry.name}, got {page.name}"
                )
            pages.append(page)
        except Exception as exc:
            logger.error("Invalid page config for %s: %s", entry.name, exc)
            page_errors[entry.name] = str(exc)

    return FrontendConfig(
        array_element_types=index.array_element_types,
        auxiliary_subitems=index.auxiliary_subitems,
        pages=pages,
        page_entries=index.pages,
        page_errors=page_errors,
        user_index_error=user_index_error,
    )


def build_nav_pages(config: FrontendConfig) -> list[dict[str, str]]:
    """Build navigation entries from config pages."""
    if config.page_entries:
        return [
            {"name": entry.name, "label": config.page_label(entry.name)}
            for entry in config.page_entries
        ]
    return [{"name": page.name, "label": page.label} for page in config.pages]


def build_plot_state(
    page: PageConfig,
) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
    """Build plot configurations and collect validation errors per plot."""
    plot_configs: dict[str, dict[str, Any]] = {}
    plot_errors: dict[str, str] = {}

    for plot_key, plot in zip(page.plot_keys(), page.plots):
        if not plot.get("plotType"):
            # Title-only placeholder plots are allowed without config validation.
            continue
        try:
            validated = PLOT_CONFIG_ADAPTER.validate_python(plot)
        except ValidationError as exc:
            logger.error("Invalid plot configuration for %s: %s", plot_key, exc)
            plot_errors[plot_key] = str(exc)
            continue

        plot_configs[plot_key] = validated.model_dump(exclude_none=True, by_alias=True)

    return plot_configs, plot_errors
