"""Theme typography contracts shared by SVG conversion and PPTX assembly."""

from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path
from xml.etree import ElementTree as ET

from .utils import font_px_to_hpt, parse_font_family


DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
_LOCK_ROW_RE = re.compile(r"^-\s+([A-Za-z0-9_]+)\s*:\s*(.+?)\s*$")
_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})


class ThemeFontError(RuntimeError):
    """Raised when a project theme-font contract cannot be loaded or applied."""


@dataclass(frozen=True)
class ThemeFontFace:
    """Concrete Latin, East Asian, and complex-script theme faces."""

    latin: str
    ea: str
    cs: str

    def matches(self, fonts: dict[str, str]) -> bool:
        """Return whether resolved SVG fonts represent this theme face."""
        return fonts.get("latin") == self.latin and fonts.get("ea") == self.ea


@dataclass(frozen=True)
class ThemeFontSpec:
    """Major/minor theme fonts derived from one project's typography lock."""

    major: ThemeFontFace
    minor: ThemeFontFace
    major_family: str
    minor_family: str


@dataclass(frozen=True)
class MasterTextStyleSpec:
    """Title/body defaults written to one generated slide master's txStyles."""

    title_hpt: int
    body_hpt: int

    @property
    def body_levels_hpt(self) -> tuple[int, ...]:
        """Return a deterministic nine-level PowerPoint body-size hierarchy."""
        factors = (16, 15, 14, 13, 12, 11, 10, 9, 8)
        minimum = min(self.body_hpt, 800)
        sizes = []
        for factor in factors:
            scaled = round((self.body_hpt * factor / 16) / 50) * 50
            sizes.append(max(minimum, scaled))
        return tuple(sizes)


def _font_face(font_family: str) -> ThemeFontFace:
    fonts = parse_font_family(font_family)
    return ThemeFontFace(
        latin=fonts["latin"],
        ea=fonts["ea"],
        cs=fonts["latin"],
    )


def _typography_rows(lock_path: Path) -> dict[str, str]:
    rows: dict[str, str] = {}
    current_section: str | None = None
    try:
        lines = lock_path.read_text(encoding="utf-8").splitlines()
    except OSError as exc:
        raise ThemeFontError(f"Cannot read {lock_path}: {exc}") from exc

    for raw_line in lines:
        line = raw_line.strip()
        if line.startswith("## "):
            current_section = line[3:].strip()
            continue
        if current_section != "typography":
            continue
        match = _LOCK_ROW_RE.fullmatch(line)
        if match:
            rows[match.group(1)] = match.group(2)
    return rows


def load_theme_font_spec(project_path: Path) -> ThemeFontSpec | None:
    """Load major/minor theme fonts from ``spec_lock.md`` typography rows."""
    lock_path = project_path / "spec_lock.md"
    if not lock_path.is_file():
        return None
    rows = _typography_rows(lock_path)
    default_family = rows.get("font_family")
    major_family = rows.get("title_family") or default_family
    minor_family = rows.get("body_family") or default_family
    if not major_family or not minor_family:
        return None
    return ThemeFontSpec(
        major=_font_face(major_family),
        minor=_font_face(minor_family),
        major_family=major_family,
        minor_family=minor_family,
    )


def _font_size_hpt(raw: str, field: str) -> int:
    try:
        px = float(raw)
    except (TypeError, ValueError, OverflowError) as exc:
        raise ThemeFontError(
            f"spec_lock.md typography {field} must be a numeric px value: {raw!r}"
        ) from exc
    try:
        size = font_px_to_hpt(px)
    except ValueError as exc:
        raise ThemeFontError(
            f"spec_lock.md typography {field} is outside the PowerPoint "
            f"font-size range: {raw!r}"
        ) from exc
    return size


def load_master_text_style_spec(project_path: Path) -> MasterTextStyleSpec:
    """Load required title/body defaults for generated Master text styles."""
    lock_path = project_path / "spec_lock.md"
    if not lock_path.is_file():
        raise ThemeFontError(
            "Master export requires spec_lock.md typography title and body rows"
        )
    rows = _typography_rows(lock_path)
    missing = [field for field in ("title", "body") if field not in rows]
    if missing:
        raise ThemeFontError(
            "Master export requires spec_lock.md typography rows: "
            + ", ".join(missing)
        )
    return MasterTextStyleSpec(
        title_hpt=_font_size_hpt(rows["title"], "title"),
        body_hpt=_font_size_hpt(rows["body"], "body"),
    )


def theme_font_tokens(
    fonts: dict[str, str],
    spec: ThemeFontSpec | None,
) -> dict[str, str] | None:
    """Return DrawingML major/minor tokens for a locked SVG font face."""
    if spec is None:
        return None
    major_match = spec.major.matches(fonts)
    minor_match = spec.minor.matches(fonts)
    if major_match and not minor_match:
        prefix = "+mj"
    elif minor_match:
        # When title/body use the same family, minor is the least surprising
        # default for ordinary text boxes. Template assembly forces semantic
        # title placeholders to the major role after SVG conversion.
        prefix = "+mn"
    else:
        return None
    return {
        "latin": f"{prefix}-lt",
        "ea": f"{prefix}-ea",
        "cs": f"{prefix}-cs",
    }


def _patch_font_collection(collection: ET.Element, face: ThemeFontFace) -> None:
    for tag, value in (("latin", face.latin), ("ea", face.ea), ("cs", face.cs)):
        elem = collection.find(f"{{{DML_NS}}}{tag}")
        if elem is None:
            elem = ET.SubElement(collection, f"{{{DML_NS}}}{tag}")
        elem.set("typeface", value)
    for supplemental in collection.findall(f"{{{DML_NS}}}font"):
        if supplemental.get("script") in _CJK_THEME_SCRIPTS:
            supplemental.set("typeface", face.ea)


def apply_theme_font_spec(extract_dir: Path, spec: ThemeFontSpec) -> None:
    """Install locked major/minor fonts into every existing PPTX theme part."""
    theme_dir = extract_dir / "ppt" / "theme"
    theme_paths = sorted(theme_dir.glob("theme*.xml"))
    if not theme_paths:
        raise ThemeFontError(f"PPTX package has no theme part under {theme_dir}")

    ET.register_namespace("a", DML_NS)
    for theme_path in theme_paths:
        try:
            tree = ET.parse(theme_path)
        except (OSError, ET.ParseError) as exc:
            raise ThemeFontError(f"Cannot parse {theme_path}: {exc}") from exc
        font_scheme = tree.getroot().find(f".//{{{DML_NS}}}fontScheme")
        if font_scheme is None:
            raise ThemeFontError(f"Theme has no fontScheme: {theme_path}")
        major = font_scheme.find(f"{{{DML_NS}}}majorFont")
        minor = font_scheme.find(f"{{{DML_NS}}}minorFont")
        if major is None or minor is None:
            raise ThemeFontError(f"Theme has no major/minor font collection: {theme_path}")
        font_scheme.set("name", "PPT Master")
        _patch_font_collection(major, spec.major)
        _patch_font_collection(minor, spec.minor)
        tree.write(theme_path, encoding="utf-8", xml_declaration=True)


def _style_run_properties(style: ET.Element, label: str) -> list[ET.Element]:
    run_properties = list(style.iter(f"{{{DML_NS}}}defRPr"))
    if not run_properties:
        raise ThemeFontError(f"slide master {label} has no a:defRPr entries")
    return run_properties


def _style_level_run_properties(
    style: ET.Element,
    label: str,
) -> tuple[ET.Element, ...]:
    """Return direct level 1-9 defaults from one Master text style."""
    levels: list[ET.Element] = []
    for level in range(1, 10):
        run_properties = style.find(
            f"{{{DML_NS}}}lvl{level}pPr/{{{DML_NS}}}defRPr"
        )
        if run_properties is None:
            raise ThemeFontError(
                f"slide master {label} has no level-{level} a:defRPr"
            )
        levels.append(run_properties)
    return tuple(levels)


def apply_master_text_style_spec(
    extract_dir: Path,
    spec: MasterTextStyleSpec,
) -> int:
    """Install declared title/body anchors into generated slide-master txStyles."""
    master_dir = extract_dir / "ppt" / "slideMasters"
    master_paths = sorted(master_dir.glob("slideMaster*.xml"))
    if not master_paths:
        raise ThemeFontError(f"PPTX package has no slide master under {master_dir}")

    ET.register_namespace("a", DML_NS)
    ET.register_namespace("p", PML_NS)
    for master_path in master_paths:
        try:
            tree = ET.parse(master_path)
        except (OSError, ET.ParseError) as exc:
            raise ThemeFontError(f"Cannot parse {master_path}: {exc}") from exc
        text_styles = tree.getroot().find(f"{{{PML_NS}}}txStyles")
        if text_styles is None:
            raise ThemeFontError(f"Slide master has no p:txStyles: {master_path}")

        title_style = text_styles.find(f"{{{PML_NS}}}titleStyle")
        if title_style is None:
            raise ThemeFontError(
                f"Slide master has no p:titleStyle: {master_path}"
            )
        for run_properties in _style_run_properties(
            title_style,
            "p:titleStyle",
        ):
            run_properties.set("sz", str(spec.title_hpt))

        body_levels = spec.body_levels_hpt
        for style_name in ("bodyStyle", "otherStyle"):
            style = text_styles.find(f"{{{PML_NS}}}{style_name}")
            if style is None:
                raise ThemeFontError(
                    f"Slide master has no p:{style_name}: {master_path}"
                )
            for run_properties, size in zip(
                _style_level_run_properties(
                    style,
                    f"p:{style_name}",
                ),
                body_levels,
            ):
                run_properties.set("sz", str(size))

        style_sizes = (
            ("titleStyle", (spec.title_hpt,)),
            ("bodyStyle", body_levels),
            ("otherStyle", body_levels),
        )

        tree.write(master_path, encoding="utf-8", xml_declaration=True)

        try:
            read_back = ET.parse(master_path).getroot()
        except (OSError, ET.ParseError) as exc:
            raise ThemeFontError(
                f"Cannot read back slide master {master_path}: {exc}"
            ) from exc
        read_back_styles = read_back.find(f"{{{PML_NS}}}txStyles")
        if read_back_styles is None:
            raise ThemeFontError(
                f"Slide master lost p:txStyles after update: {master_path}"
            )
        for style_name, expected_sizes in style_sizes:
            style = read_back_styles.find(f"{{{PML_NS}}}{style_name}")
            if style is None:
                actual_sizes: tuple[str | None, ...] = ()
            elif style_name == "titleStyle":
                actual_sizes = tuple(
                    item.get("sz")
                    for item in _style_run_properties(
                        style,
                        f"p:{style_name}",
                    )
                )
            else:
                actual_sizes = tuple(
                    item.get("sz")
                    for item in _style_level_run_properties(
                        style,
                        f"p:{style_name}",
                    )
                )
            if actual_sizes != tuple(str(size) for size in expected_sizes):
                raise ThemeFontError(
                    f"Slide master p:{style_name} size read-back failed: "
                    f"{master_path}"
                )
    return len(master_paths)
