from __future__ import annotations

from collections.abc import Generator
import json
from pathlib import Path
from typing import Literal, TypeVar

from pydantic import BaseModel, ConfigDict, Field


def _default_merged_cells_schema() -> list[Literal["r1", "c1", "r2", "c2", "v"]]:
    """Return default schema for merged cell items."""
    return ["r1", "c1", "r2", "c2", "v"]


class BaseShape(BaseModel):
    """Common shape metadata (position, size, text, and styling)."""

    id: int | None = Field(
        default=None,
        description="Sequential shape id within the sheet (if applicable).",
    )
    text: str = Field(description="Visible text content of the shape.")
    l: int = Field(description="Left offset (Excel units).")  # noqa: E741
    t: int = Field(description="Top offset (Excel units).")
    w: int | None = Field(default=None, description="Shape width (None if unknown).")
    h: int | None = Field(default=None, description="Shape height (None if unknown).")
    rotation: float | None = Field(
        default=None, description="Rotation angle in degrees."
    )


class Shape(BaseShape):
    """Normal shape metadata."""

    kind: Literal["shape"] = Field(default="shape", description="Shape kind.")
    type: str | None = Field(default=None, description="Excel shape type name.")


class Arrow(BaseShape):
    """Connector shape metadata."""

    kind: Literal["arrow"] = Field(default="arrow", description="Shape kind.")
    begin_arrow_style: int | None = Field(
        default=None, description="Arrow style enum for the start of a connector."
    )
    end_arrow_style: int | None = Field(
        default=None, description="Arrow style enum for the end of a connector."
    )
    begin_id: int | None = Field(
        default=None,
        description=(
            "Shape id at the start of a connector (ConnectorFormat.BeginConnectedShape)."
        ),
    )
    end_id: int | None = Field(
        default=None,
        description=(
            "Shape id at the end of a connector (ConnectorFormat.EndConnectedShape)."
        ),
    )
    direction: Literal["E", "SE", "S", "SW", "W", "NW", "N", "NE"] | None = Field(
        default=None, description="Connector direction (compass heading)."
    )


class SmartArtNode(BaseModel):
    """Node of SmartArt hierarchy."""

    text: str = Field(description="Visible text for the node.")
    kids: list[SmartArtNode] = Field(default_factory=list, description="Child nodes.")


class SmartArt(BaseShape):
    """SmartArt shape metadata with nested nodes."""

    kind: Literal["smartart"] = Field(default="smartart", description="Shape kind.")
    layout: str = Field(description="SmartArt layout name.")
    nodes: list[SmartArtNode] = Field(
        default_factory=list, description="Root nodes of SmartArt tree."
    )


class MergedCells(BaseModel):
    """Compressed merged cell ranges using schema + items."""

    model_config = ConfigDict(populate_by_name=True)

    schema_: list[Literal["r1", "c1", "r2", "c2", "v"]] = Field(
        default_factory=_default_merged_cells_schema,
        alias="schema",
        description="Ordered field names for each item.",
    )
    items: list[tuple[int, int, int, int, str]] = Field(
        default_factory=list,
        description=(
            "Merged cell items as (r1, c1, r2, c2, v) tuples where rows are 1-based "
            "and columns are 0-based."
        ),
    )


class CellRow(BaseModel):
    """A single row of cells with optional hyperlinks."""

    r: int = Field(description="Row index (1-based).")
    c: dict[str, int | float | str] = Field(
        description="Column index (string) to cell value map."
    )
    links: dict[str, str] | None = Field(
        default=None, description="Optional hyperlinks per column index."
    )


class ChartSeries(BaseModel):
    """Series metadata for a chart."""

    name: str = Field(description="Series display name.")
    name_range: str | None = Field(
        default=None, description="Range reference for the series name."
    )
    x_range: str | None = Field(
        default=None, description="Range reference for X axis values."
    )
    y_range: str | None = Field(
        default=None, description="Range reference for Y axis values."
    )


class Chart(BaseModel):
    """Chart metadata including series and layout."""

    name: str = Field(description="Chart name.")
    chart_type: str = Field(description="Chart type (e.g., Column, Line).")
    title: str | None = Field(default=None, description="Chart title.")
    y_axis_title: str = Field(description="Y-axis title.")
    y_axis_range: list[float] = Field(
        default_factory=list, description="Y-axis range [min, max] when available."
    )
    w: int | None = Field(default=None, description="Chart width (None if unknown).")
    h: int | None = Field(default=None, description="Chart height (None if unknown).")
    series: list[ChartSeries] = Field(description="Series included in the chart.")
    l: int = Field(description="Left offset (Excel units).")  # noqa: E741
    t: int = Field(description="Top offset (Excel units).")
    error: str | None = Field(
        default=None, description="Extraction error detail if any."
    )


class PrintArea(BaseModel):
    """Cell coordinate bounds for a print area."""

    r1: int = Field(description="Start row (1-based).")
    c1: int = Field(description="Start column (0-based).")
    r2: int = Field(description="End row (1-based, inclusive).")
    c2: int = Field(description="End column (0-based, inclusive).")


class SheetData(BaseModel):
    """Structured data for a single sheet."""

    rows: list[CellRow] = Field(
        default_factory=list, description="Extracted rows with cell values and links."
    )
    shapes: list[Shape | Arrow | SmartArt] = Field(
        default_factory=list, description="Shapes detected on the sheet."
    )
    charts: list[Chart] = Field(
        default_factory=list, description="Charts detected on the sheet."
    )
    table_candidates: list[str] = Field(
        default_factory=list, description="Cell ranges likely representing tables."
    )
    print_areas: list[PrintArea] = Field(
        default_factory=list, description="User-defined print areas."
    )
    auto_print_areas: list[PrintArea] = Field(
        default_factory=list, description="COM-computed auto page-break areas."
    )
    formulas_map: dict[str, list[tuple[int, int]]] = Field(
        default_factory=dict,
        description=(
            "Mapping of formula strings to lists of (row, column) tuples "
            "where row is 1-based and column is 0-based."
        ),
    )
    colors_map: dict[str, list[tuple[int, int]]] = Field(
        default_factory=dict,
        description=(
            "Mapping of hex color codes to lists of (row, column) tuples "
            "where row is 1-based and column is 0-based."
        ),
    )
    merged_cells: MergedCells | None = Field(
        default=None, description="Merged cell ranges on the sheet."
    )
    merged_ranges: list[str] = Field(
        default_factory=list,
        description=(
            "Merged ranges in A1 notation (e.g., 'A1:C3'). "
            "Used in alpha_col-oriented output."
        ),
    )

    def _as_payload(self) -> dict[str, object]:
        from ..io import dict_without_empty_values

        return dict_without_empty_values(
            self.model_dump(exclude_none=True, by_alias=True)
        )  # type: ignore

    def to_json(self, *, pretty: bool = False, indent: int | None = None) -> str:
        """
        Serialize the sheet into JSON text.
        """
        indent_val = 2 if pretty and indent is None else indent
        return json.dumps(self._as_payload(), ensure_ascii=False, indent=indent_val)

    def to_yaml(self) -> str:
        """
        Serialize the sheet into YAML text (requires pyyaml).
        """
        from ..io import _require_yaml

        yaml = _require_yaml()
        return str(
            yaml.safe_dump(
                self._as_payload(),
                allow_unicode=True,
                sort_keys=False,
                indent=2,
            )
        )

    def to_toon(self) -> str:
        """
        Serialize the sheet into TOON text (requires python-toon).
        """
        from ..io import _require_toon

        toon = _require_toon()
        return str(toon.encode(self._as_payload()))

    def save(
        self, path: str | Path, *, pretty: bool = False, indent: int | None = None
    ) -> Path:
        """
        Save the sheet to a file, inferring format from the extension.

        - .json → JSON
        - .yaml/.yml → YAML
        - .toon → TOON
        """
        dest = Path(path)
        fmt = (dest.suffix.lstrip(".") or "json").lower()
        match fmt:
            case "json":
                dest.write_text(
                    self.to_json(pretty=pretty, indent=indent), encoding="utf-8"
                )
            case "yaml" | "yml":
                dest.write_text(self.to_yaml(), encoding="utf-8")
            case "toon":
                dest.write_text(self.to_toon(), encoding="utf-8")
            case _:
                raise ValueError(f"Unsupported export format: {fmt}")
        return dest


class WorkbookData(BaseModel):
    """Workbook-level container with per-sheet data."""

    book_name: str = Field(description="Workbook file name (no path).")
    sheets: dict[str, SheetData] = Field(
        description="Mapping of sheet name to SheetData."
    )

    def to_json(self, *, pretty: bool = False, indent: int | None = None) -> str:
        """
        Serialize the workbook into JSON text.
        """
        from ..io import serialize_workbook

        return serialize_workbook(self, fmt="json", pretty=pretty, indent=indent)

    def to_yaml(self) -> str:
        """
        Serialize the workbook into YAML text (requires pyyaml).
        """
        from ..io import serialize_workbook

        return serialize_workbook(self, fmt="yaml")

    def to_toon(self) -> str:
        """
        Serialize the workbook into TOON text (requires python-toon).
        """
        from ..io import serialize_workbook

        return serialize_workbook(self, fmt="toon")

    def save(
        self, path: str | Path, *, pretty: bool = False, indent: int | None = None
    ) -> Path:
        """
        Save the workbook to a file, inferring format from the extension.

        - .json → JSON
        - .yaml/.yml → YAML
        - .toon → TOON
        """
        from ..io import save_as_json, save_as_toon, save_as_yaml

        dest = Path(path)
        fmt = (dest.suffix.lstrip(".") or "json").lower()
        match fmt:
            case "json":
                save_as_json(self, dest, pretty=pretty, indent=indent)
            case "yaml" | "yml":
                save_as_yaml(self, dest)
            case "toon":
                save_as_toon(self, dest)
            case _:
                raise ValueError(f"Unsupported export format: {fmt}")
        return dest

    def __getitem__(self, sheet_name: str) -> SheetData:
        """Return the SheetData for the given sheet name."""
        return self.sheets[sheet_name]

    def __iter__(self) -> Generator[tuple[str, SheetData], None, None]:
        """Iterate over (sheet_name, SheetData) pairs in order."""
        yield from self.sheets.items()


class PrintAreaView(BaseModel):
    """Slice of a sheet restricted to a print area (manual or auto)."""

    book_name: str = Field(description="Workbook name owning the area.")
    sheet_name: str = Field(description="Sheet name owning the area.")
    area: PrintArea = Field(description="Print area bounds.")
    shapes: list[Shape | Arrow | SmartArt] = Field(
        default_factory=list, description="Shapes overlapping the area."
    )
    charts: list[Chart] = Field(
        default_factory=list, description="Charts overlapping the area."
    )
    rows: list[CellRow] = Field(
        default_factory=list, description="Rows within the area bounds."
    )
    table_candidates: list[str] = Field(
        default_factory=list, description="Table candidates intersecting the area."
    )

    def _as_payload(self) -> dict[str, object]:
        from ..io import dict_without_empty_values

        return dict_without_empty_values(
            self.model_dump(exclude_none=True, by_alias=True)
        )  # type: ignore

    def to_json(self, *, pretty: bool = False, indent: int | None = None) -> str:
        """
        Serialize the print-area view into JSON text.
        """
        indent_val = 2 if pretty and indent is None else indent
        return json.dumps(self._as_payload(), ensure_ascii=False, indent=indent_val)

    def to_yaml(self) -> str:
        """
        Serialize the print-area view into YAML text (requires pyyaml).
        """
        from ..io import _require_yaml

        yaml = _require_yaml()
        return str(
            yaml.safe_dump(
                self._as_payload(),
                allow_unicode=True,
                sort_keys=False,
                indent=2,
            )
        )

    def to_toon(self) -> str:
        """
        Serialize the print-area view into TOON text (requires python-toon).
        """
        from ..io import _require_toon

        toon = _require_toon()
        return str(toon.encode(self._as_payload()))

    def save(
        self, path: str | Path, *, pretty: bool = False, indent: int | None = None
    ) -> Path:
        """
        Save the print-area view to a file, inferring format from the extension.

        - .json JSON
        - .yaml/.yml YAML
        - .toon TOON
        """
        dest = Path(path)
        fmt = (dest.suffix.lstrip(".") or "json").lower()
        match fmt:
            case "json":
                dest.write_text(
                    self.to_json(pretty=pretty, indent=indent), encoding="utf-8"
                )
            case "yaml" | "yml":
                dest.write_text(self.to_yaml(), encoding="utf-8")
            case "toon":
                dest.write_text(self.to_toon(), encoding="utf-8")
            case _:
                raise ValueError(f"Unsupported export format: {fmt}")
        return dest


# ---------------------------------------------------------------------------
# Column index ↔ Excel alpha-style column name conversion
# ---------------------------------------------------------------------------


def col_index_to_alpha(index: int) -> str:
    """Convert a 0-based column index to an Excel-style column name.

    Args:
        index: 0-based column index (0 → A, 25 → Z, 26 → AA, …).

    Returns:
        Excel column name string.

    Raises:
        ValueError: If index is negative.

    Examples:
        >>> col_index_to_alpha(0)
        'A'
        >>> col_index_to_alpha(25)
        'Z'
        >>> col_index_to_alpha(26)
        'AA'
    """
    if index < 0:
        raise ValueError(f"Column index must be non-negative, got {index}")
    result: list[str] = []
    num = index
    while True:
        num, remainder = divmod(num, 26)
        result.append(chr(65 + remainder))
        if num == 0:
            break
        num -= 1
    result.reverse()
    return "".join(result)


def convert_row_keys_to_alpha(row: CellRow) -> CellRow:
    """Return a new CellRow with numeric column keys converted to ABC-style.

    Non-numeric keys are preserved unchanged.

    Args:
        row: Original CellRow with 0-based numeric string keys.

    Returns:
        New CellRow with alpha column keys.
    """
    new_c = _convert_mapping_keys_to_alpha(row.c, row_index=row.r, field_name="c")

    new_links: dict[str, str] | None = None
    if row.links:
        new_links = _convert_mapping_keys_to_alpha(
            row.links, row_index=row.r, field_name="links"
        )

    return CellRow(r=row.r, c=new_c, links=new_links)


def convert_sheet_keys_to_alpha(sheet: SheetData) -> SheetData:
    """Return a new SheetData with all CellRow column keys converted to ABC-style.

    Args:
        sheet: Original SheetData.

    Returns:
        New SheetData with alpha column keys in every row.
    """
    new_rows = [convert_row_keys_to_alpha(row) for row in sheet.rows]
    updated_fields: dict[str, object] = {"rows": new_rows}
    if sheet.merged_cells is not None:
        updated_fields["merged_ranges"] = _merged_items_to_a1_ranges(
            sheet.merged_cells.items
        )
        updated_fields["merged_cells"] = None
    return sheet.model_copy(update=updated_fields)


def convert_workbook_keys_to_alpha(workbook: WorkbookData) -> WorkbookData:
    """Return a new WorkbookData with all CellRow column keys converted to ABC-style.

    Args:
        workbook: Original WorkbookData.

    Returns:
        New WorkbookData with alpha column keys in every sheet.
    """
    new_sheets = {
        name: convert_sheet_keys_to_alpha(sheet)
        for name, sheet in workbook.sheets.items()
    }
    return workbook.model_copy(update={"sheets": new_sheets})


def _alpha_key(key: str) -> str:
    """Convert a numeric string key to alpha, passing non-numeric keys through."""
    try:
        return col_index_to_alpha(int(key))
    except ValueError:
        return key


MapValue = TypeVar("MapValue")


def _convert_mapping_keys_to_alpha(
    source: dict[str, MapValue], *, row_index: int, field_name: str
) -> dict[str, MapValue]:
    """Convert mapping keys to alpha-style and reject collisions.

    Args:
        source: Original key-value mapping.
        row_index: 1-based row index for error context.
        field_name: Field name ("c" or "links") for error context.

    Returns:
        Converted mapping with alpha-style keys.

    Raises:
        ValueError: If multiple source keys map to the same alpha key.
    """
    converted: dict[str, MapValue] = {}
    for original_key, value in source.items():
        alpha_key = _alpha_key(original_key)
        if alpha_key in converted:
            raise ValueError(
                "Column key collision after alpha conversion in "
                f"row {row_index} ({field_name}): "
                f"{original_key!r} -> {alpha_key!r}"
            )
        converted[alpha_key] = value
    return converted


def _merged_items_to_a1_ranges(
    items: list[tuple[int, int, int, int, str]],
) -> list[str]:
    """Convert merged cell tuples to A1 range strings."""
    ranges: list[str] = []
    for r1, c1, r2, c2, _value in items:
        start = f"{col_index_to_alpha(c1)}{r1}"
        end = f"{col_index_to_alpha(c2)}{r2}"
        ranges.append(f"{start}:{end}")
    return ranges
