"""Base class for OOXML emitters (Word, PowerPoint, Excel).

SSOT Architecture:
==================
All XML structure comes from xml-structures/ as the Single Source of Truth.
Emitters load templates and modify attribute values - they never construct
new XML elements programmatically (no etree.Element() or etree.SubElement()).

For dynamic content (e.g., paragraphs, table rows), template fragments are
loaded, values substituted, parsed, and inserted into the document.

Consolidates common functionality:
- ZIP package building from SSOT xml-structures
- Font embedding infrastructure (ODTTF, EOT, OTF)
- Theme customization (color scheme, font scheme)
"""

from __future__ import annotations

import logging
from abc import abstractmethod
from datetime import datetime, timezone
from pathlib import Path

# TYPE_CHECKING imports to avoid circular dependencies
from typing import TYPE_CHECKING, Literal

from lxml import etree

from tokenmoulds.emitters import TemplateResolver
from tokenmoulds.emitters.base import BaseEmitter
from tokenmoulds.emitters.xml_helpers import (
    A_NS,
    create_effect_style,
    find_element,
    parse_xml,
    replace_placeholders,
    serialize_tree,
    set_color_slot,
    set_color_value,
    set_font_typeface,
)
from tokenmoulds.fonts.embedding import DEFAULT_FONT_MAPPING, FontEmbeddingService
from tokenmoulds.fonts.types import EmbeddedFont
from tokenmoulds.ir import ColorTheme, DocumentIR
from tokenmoulds.packaging import PackageManifest, PackageWriter, PackagedEntry
from tokenmoulds.validation.formats import Format
from tokenmoulds.validation.model import ValidationReport
from tokenmoulds.validation.ooxml import validate_ooxml_bytes
from tokenmoulds.validation.structural import validate_package_structure

if TYPE_CHECKING:
    from tokenmoulds.ir.style_primitives import Effects

logger = logging.getLogger(__name__)


# =============================================================================
# PATH CONSTANTS
# =============================================================================


def get_vendored_fonts_path() -> Path | None:
    """Get the path to vendored fonts directory.

    Returns None — callers must pass an explicit fonts_dir.
    """
    return None


# =============================================================================
# OOXML BASE EMITTER
# =============================================================================


class OOXMLBaseEmitter(BaseEmitter):
    """Base class for OOXML format emitters (Word, PowerPoint, Excel).

    Provides shared functionality for:
    - ZIP package building with customization hooks
    - Font embedding preparation
    - Theme customization (colors, fonts)

    Subclasses must implement abstract methods to specify format-specific behavior.
    """

    # Default font mapping for embedding — sourced from fonts/embedding.py
    FONT_MAPPING = DEFAULT_FONT_MAPPING

    def __init__(
        self,
        document: DocumentIR,
        template_root: Path | str | None = None,
        embed_fonts: bool = False,
        fonts_dir: Path | str | None = None,
        use_pure_text_colors: bool = True,
    ) -> None:
        """Initialize the OOXML emitter.

        Args:
            document: Document IR with style information
            template_root: Optional override for template resolution
            embed_fonts: Whether to embed fonts in the package
            fonts_dir: Directory containing vendored fonts
            use_pure_text_colors: If True (default), use pure black/white for dk1/lt1
                                  text colors to ensure readability. If False, use
                                  the actual theme colors from the Document IR.
        """
        super().__init__(document, template_root)
        self.embed_fonts = embed_fonts
        self.fonts_dir = Path(fonts_dir) if fonts_dir else get_vendored_fonts_path()
        self.embedded_fonts: list[EmbeddedFont] = []
        self.use_pure_text_colors = use_pure_text_colors
        self.templates = TemplateResolver(self._get_format_key(), template_root)

    # =========================================================================
    # ABSTRACT METHODS (must be implemented by subclasses)
    # =========================================================================

    @abstractmethod
    def _get_format_key(self) -> str:
        """Return the format key for template resolution.

        Returns:
            One of 'word', 'powerpoint', 'excel'
        """
        pass

    def _get_base_template_path(self) -> Path | None:
        """Return the path to the base template file (deprecated).

        Returns None for SSOT-based emitters that build from xml-structures.
        Subclasses using legacy base template approach should override.

        Returns:
            Path to .docx, .pptx, or .xlsx base template, or None for SSOT
        """
        return None

    def _get_package_structure(self) -> dict[str, str]:
        """Return the package structure mapping archive paths to template names.

        This defines which templates to load from xml-structures/ and where
        they should be placed in the ZIP archive.

        Override in subclasses to define format-specific package structure.

        Returns:
            Dict mapping archive path to template name (relative to spec folder)
        """
        return {}

    @abstractmethod
    def _get_document_title(self) -> str:
        """Return the title for document metadata.

        Returns:
            Title string for core properties
        """
        pass

    @staticmethod
    def _utc_timestamp() -> str:
        """Return current UTC time as ISO 8601 string for document metadata."""
        return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

    @abstractmethod
    def _get_font_embedding_format(self) -> Literal["odttf", "eot", "none"]:
        """Return the font embedding format for this emitter.

        Returns:
            'odttf' for Word, 'eot' for PowerPoint, 'none' for Excel
        """
        pass

    @abstractmethod
    def _should_customize_entry(self, entry_path: str) -> bool:
        """Check if a ZIP entry should be customized.

        Args:
            entry_path: Path within the ZIP package

        Returns:
            True if this entry should be passed to _customize_entry()
        """
        pass

    @abstractmethod
    def _customize_entry(self, entry_path: str, data: bytes) -> bytes | None:
        """Customize a specific ZIP entry.

        Args:
            entry_path: Path within the ZIP package
            data: Original entry data

        Returns:
            Modified data, or None to skip this entry
        """
        pass

    @abstractmethod
    def _get_font_entries_path(self) -> str:
        """Return the path prefix for embedded font files.

        Returns:
            Path like 'word/fonts/' or 'ppt/fonts/'
        """
        pass

    @abstractmethod
    def _get_font_metadata_entries(self) -> list[PackagedEntry]:
        """Return font-related metadata entries (relationships, font table, etc.).

        Returns:
            List of PackagedEntry objects for font metadata files.
        """
        pass

    # =========================================================================
    # CONCRETE METHODS (shared implementation)
    # =========================================================================

    def build_package(
        self,
        *,
        validate: bool = False,
        validation_strict: bool = True,
    ) -> bytes:
        """Build the OOXML package from SSOT xml-structures.

        This method:
        1. Prepares fonts for embedding (if enabled)
        2. Builds a PackageManifest from the package structure
        3. Delegates ZIP assembly to PackageWriter
        4. Optionally validates the generated package

        Returns:
            Complete package as bytes
        """
        # Prepare fonts if embedding
        extra_entries: list[PackagedEntry] = []
        if self.embed_fonts:
            service = FontEmbeddingService()
            embedded, font_entries = service.prepare(
                document_ir=self.document,
                fonts_dir=self.fonts_dir,
                font_format=self._get_font_embedding_format(),
                font_mapping=self.FONT_MAPPING,
                fonts_path=self._get_font_entries_path(),
                initial_rel_id=self._get_initial_rel_id(),
            )
            self.embedded_fonts = embedded
            extra_entries.extend(font_entries)
            extra_entries.extend(self._get_font_metadata_entries())

        package_structure = self._get_package_structure()
        if not package_structure:
            raise ValueError(
                "No package structure defined. "
                "Override _get_package_structure() for SSOT approach."
            )

        # Build manifest from structure dict
        customizable_paths = {
            path for path in package_structure if self._should_customize_entry(path)
        }
        manifest = PackageManifest.from_structure_dict(
            package_structure, customizable_paths
        )

        # Delegate to PackageWriter
        writer = PackageWriter(compress_level=9)
        package = writer.build(
            manifest,
            self.templates,  # type: ignore[arg-type]  # TemplateResolver satisfies TemplateLoader
            customize=self._customize_entry,
            extra_entries=extra_entries or None,
        )

        return self._finalize_built_package(
            package,
            validate=validate,
            validation_strict=validation_strict,
        )

    def _finalize_built_package(
        self,
        package: bytes,
        *,
        validate: bool,
        validation_strict: bool,
    ) -> bytes:
        """Optionally validate a generated OOXML package before returning it."""
        if not validate:
            return package

        validation_format = self._get_validation_format()
        structural_report = validate_package_structure(package, validation_format)
        combined_report = self._merge_validation_reports(structural_report)

        if structural_report.is_valid:
            deep_outcome = validate_ooxml_bytes(
                package,
                validation_format,
                include_binary=False,
            )
            combined_report = self._merge_validation_reports(
                structural_report,
                deep_outcome.report,
            )

        if combined_report.errors and validation_strict:
            raise ValueError(self._format_validation_failure(combined_report))

        self._log_validation_report(combined_report, strict=validation_strict)
        return package

    def _get_validation_format(self) -> str:
        """Return the TokenMoulds validation format for the emitter output."""
        return {
            "word": Format.WORD_TEMPLATE,
            "powerpoint": Format.POWERPOINT_TEMPLATE,
            "excel": Format.EXCEL_TEMPLATE,
        }[self._get_format_key()]

    def _merge_validation_reports(self, *reports: ValidationReport) -> ValidationReport:
        """Merge one or more validation reports into a single report."""
        merged = ValidationReport()
        for report in reports:
            for issue in report.issues:
                merged.add_issue(issue)
        return merged

    def _format_validation_failure(self, report: ValidationReport) -> str:
        """Format validation errors for an exception message."""
        lines = ["Generated OOXML package failed validation:"]
        for issue in report.errors[:10]:
            location = f"{issue.location}: " if issue.location else ""
            lines.append(f"  - {location}{issue.message}")
        if len(report.errors) > 10:
            lines.append(f"  - ... and {len(report.errors) - 10} more")
        return "\n".join(lines)

    def _log_validation_report(self, report: ValidationReport, *, strict: bool) -> None:
        """Log warnings and informational validation output."""
        for issue in report.warnings:
            location = f"{issue.location}: " if issue.location else ""
            logger.warning("%s%s", location, issue.message)
        for issue in report.infos:
            location = f"{issue.location}: " if issue.location else ""
            logger.info("%s%s", location, issue.message)
        if not strict:
            for issue in report.errors:
                location = f"{issue.location}: " if issue.location else ""
                logger.warning("%s%s", location, issue.message)

    def _render_tree(
        self,
        template_name: str,
        context: dict[str, str],
        spec_rel_path: str | None = None,
    ) -> bytes:
        """Render a template using cached lxml tree and DOM-based replacement.

        Uses read_tree() for cached parsing and replace_placeholders() for
        DOM-based variable substitution. This is more efficient for templates
        that are loaded repeatedly.

        Args:
            template_name: Name of the template file
            context: Dict of placeholder values to substitute
            spec_rel_path: Alternative path for spec folder resolution

        Returns:
            Serialized XML bytes
        """
        root = self.templates.read_tree(template_name, spec_rel_path=spec_rel_path)
        replace_placeholders(root, context)
        return serialize_tree(root)

    def _get_initial_rel_id(self) -> int:
        """Get the starting relationship ID for fonts.

        Override in subclass if a different starting ID is needed
        (e.g., PowerPoint uses 20 to avoid conflicts).

        Returns:
            Starting relationship ID number
        """
        return 1

    # =========================================================================
    # LXML HELPER METHODS (delegate to xml_helpers module)
    # =========================================================================

    def _find_element(
        self,
        root: etree._Element,
        xpath: str,
        namespaces: dict[str, str] | None = None,
    ) -> etree._Element | None:
        """Find a single element using XPath. Delegates to xml_helpers.find_element."""
        return find_element(root, xpath, namespaces)

    def _set_color_value(
        self,
        root: etree._Element,
        color_name: str,
        hex_color: str,
    ) -> bool:
        """Set OOXML color value. Delegates to xml_helpers.set_color_value."""
        return set_color_value(root, color_name, hex_color)

    def _set_font_typeface(
        self,
        root: etree._Element,
        font_type: str,
        script: str,
        typeface: str,
    ) -> bool:
        """Set font typeface. Delegates to xml_helpers.set_font_typeface."""
        return set_font_typeface(root, font_type, script, typeface)

    def _serialize_tree(
        self,
        root: etree._Element,
        xml_declaration: bool = True,
        encoding: str = "UTF-8",
    ) -> bytes:
        """Serialize lxml tree. Delegates to xml_helpers.serialize_tree."""
        return serialize_tree(root, xml_declaration, encoding)

    # =========================================================================
    # THEME CUSTOMIZATION (shared color/font scheme logic)
    # =========================================================================

    def _customize_theme(self, data: bytes) -> bytes:
        """Customize theme XML with Document IR colors and fonts.

        This applies the color scheme and font scheme from the document.

        Args:
            data: Original theme XML bytes

        Returns:
            Modified theme XML bytes
        """
        root = parse_xml(data)
        return self._customize_theme_tree(root)

    def _customize_theme_tree(self, root: etree._Element) -> bytes:
        """Apply theme customizations to a parsed tree.

        This applies the color scheme and font scheme from the document.
        Subclasses can override to add format-specific customizations.

        Args:
            root: Parsed theme XML root element

        Returns:
            Modified theme XML bytes
        """
        # Apply color scheme
        clr_scheme = find_element(root, ".//a:clrScheme")
        if clr_scheme is not None:
            self._apply_color_scheme(
                clr_scheme,
                self.document.color_theme,
                enforce_pure_text_colors=self.use_pure_text_colors,
            )

        # Apply font scheme
        font_scheme = find_element(root, ".//a:fontScheme")
        if font_scheme is not None:
            self._apply_font_scheme(font_scheme)

        return serialize_tree(root)

    def _apply_color_scheme(
        self,
        clr_scheme: etree._Element,
        theme: ColorTheme,
        enforce_pure_text_colors: bool = True,
    ) -> None:
        """Apply ColorTheme to a clrScheme element.

        Args:
            clr_scheme: The clrScheme XML element
            theme: ColorTheme with color values
            enforce_pure_text_colors: If True, use pure black/white for dk1/lt1
        """
        # Build color mapping
        if enforce_pure_text_colors:
            # Use pure black/white for dk1/lt1 to ensure text readability
            from tokenmoulds.design.color_defaults import PURE_BLACK, PURE_WHITE

            color_map = {
                "dk1": PURE_BLACK,
                "lt1": PURE_WHITE,
                "dk2": theme.dk2,
                "lt2": theme.lt2,
            }
        else:
            color_map = {
                "dk1": theme.dk1,
                "lt1": theme.lt1,
                "dk2": theme.dk2,
                "lt2": theme.lt2,
            }

        # Add accent colors
        color_map.update(
            {
                "accent1": theme.accent1,
                "accent2": theme.accent2,
                "accent3": theme.accent3,
                "accent4": theme.accent4,
                "accent5": theme.accent5,
                "accent6": theme.accent6,
                "hlink": theme.hyperlink,
                "folHlink": theme.hyperlink_followed,
            }
        )

        # Apply colors to scheme using xml_helpers
        for color_name, color_value in color_map.items():
            slot = clr_scheme.find(f"{{{A_NS}}}{color_name}")
            if slot is not None:
                set_color_slot(slot, color_value)

    def _apply_font_scheme(self, font_scheme: etree._Element) -> None:
        """Apply font scheme (heading/body fonts) to a fontScheme element.

        Args:
            font_scheme: The fontScheme XML element
        """
        # Use generic name to avoid variable font issues in Office font dropdowns
        font_scheme.set("name", "Custom")

        # Major font (headings)
        major = font_scheme.find(f"{{{A_NS}}}majorFont/{{{A_NS}}}latin")
        if major is not None:
            major.set("typeface", self.document.typography.heading_font)

        # Minor font (body)
        minor = font_scheme.find(f"{{{A_NS}}}minorFont/{{{A_NS}}}latin")
        if minor is not None:
            minor.set("typeface", self.document.typography.body_font)

    def _apply_effect_styles(
        self,
        root: etree._Element,
        effect_styles: "list[Effects]",
    ) -> None:
        """Apply effect styles to theme fmtScheme effectStyleLst.

        Replaces the existing effectStyleLst with new effect styles.
        OOXML requires exactly 3 effect styles (subtle, moderate, intense).

        Args:
            root: Theme root element
            effect_styles: List of 3 Effects primitives
        """

        if not effect_styles or len(effect_styles) != 3:
            return

        # Find effectStyleLst
        effect_lst = find_element(root, ".//a:effectStyleLst")
        if effect_lst is None:
            return

        # Clear existing effect styles
        for child in list(effect_lst):
            effect_lst.remove(child)

        # Add new effect styles
        for effects in effect_styles:
            create_effect_style(effect_lst, effects)
