"""Embedded OpenType (EOT) helpers for PresentationML font packaging.

PowerPoint stores embedded fonts as `.fntdata` parts that wrap OpenType payloads
in the EOT container defined by ECMA-376 §21.1.7.6. This module converts the
subsetted OpenType bytes produced by :mod:`fontTools` into spec-compliant EOT
streams and exposes a thin metadata wrapper that downstream packaging code can
use when emitting `<p:embeddedFontLst>` entries.

The layout uses inline length-prefixed strings (compatible with PowerPoint's
expected format) rather than offset-based strings.
"""

from __future__ import annotations

from dataclasses import dataclass
from io import BytesIO
import struct
from typing import TYPE_CHECKING, Final
import uuid

try:  # pragma: no cover - optional dependency guard
    from fontTools.ttLib import TTFont as _TTFont
except Exception:  # pragma: no cover
    _TTFont = None

if TYPE_CHECKING:
    from fontTools.ttLib import TTFont


EOT_VERSION: Final[int] = 0x00020002
EOT_MAGIC: Final[int] = 0x504C

# Fixed header size (before inline strings): 82 bytes
# Fields: EOTSize(4) + FontDataSize(4) + Version(4) + Flags(4) + PANOSE(10) +
#         Charset(1) + Italic(1) + Weight(4) + fsType(2) + MagicNumber(2) +
#         UnicodeRange1-4(16) + CodePageRange1-2(8) + ChecksumAdjustment(4) +
#         Reserved1-4(16) + Padding1(2)
FIXED_HEADER_SIZE: Final[int] = 82


class EOTConversionError(RuntimeError):
    """Raised when an Embedded OpenType payload cannot be produced."""


@dataclass(frozen=True)
class EOTResult:
    """Encapsulate the converted EOT payload plus derived metadata."""

    data: bytes
    family_name: str
    style_name: str
    full_name: str
    version_name: str
    weight: int
    italic: bool
    charset: int
    panose: bytes
    fs_type: int
    unicode_ranges: tuple[int, int, int, int]
    codepage_ranges: tuple[int, int]
    root_string: str
    guid: uuid.UUID | None


@dataclass(frozen=True)
class _FontNames:
    family: str
    style: str
    full: str
    version: str


@dataclass(frozen=True)
class _FontMetrics:
    panose: bytes
    charset: int
    italic: bool
    weight: int
    fs_type: int
    unicode_ranges: tuple[int, int, int, int]
    codepage_ranges: tuple[int, int]
    checksum_adjustment: int


def build_eot(
    font_bytes: bytes,
    *,
    resolved_family: str | None = None,
    resolved_style: str | None = None,
    root_string: str | None = None,
    guid: uuid.UUID | str | None = None,
    obfuscate: bool = False,
) -> EOTResult:
    """
    Convert subsetted OpenType bytes into an EOT payload suitable for PPTX packaging.

    Parameters
    ----------
    font_bytes:
        Raw TTF/OTF stream (typically produced by fontTools subsetting).
    resolved_family / resolved_style:
        Optional overrides from the font resolver; fall back to name table entries.
    root_string:
        Optional root string (ECMA terminology) used when Office records the font
        origin. If omitted and ``guid`` is supplied, we default to the GUID string.
    guid:
        Font GUID recorded elsewhere in the PPTX/OOXML package. When provided it is
        returned alongside the payload and can optionally drive obfuscation.
    obfuscate:
        Apply ECMA's GUID-based XOR obfuscation to the font bytes (mirrors ODTTF).
        PowerPoint does not need this, but DOCX embedding does.
    """

    if not isinstance(font_bytes, (bytes, bytearray)):
        raise EOTConversionError("font_bytes must be a byte buffer")
    if _TTFont is None:  # pragma: no cover - environments without fontTools
        raise EOTConversionError("fontTools is required for EOT conversion")

    normalized_root = _normalize_root_string(root_string, guid)
    normalized_guid = _normalize_guid(guid)

    font: TTFont | None = None
    try:
        font = _load_font(font_bytes)
        names = _extract_font_names(font, resolved_family, resolved_style)
        metrics = _extract_font_metrics(font)
    finally:
        if font is not None:
            try:
                font.close()
            except Exception:  # pragma: no cover - cleanup best effort
                pass

    # Build inline string table
    string_blob = _build_inline_strings(names, normalized_root)

    # Build fixed header
    header = _build_fixed_header(metrics, len(string_blob), len(font_bytes))

    payload = bytearray(header + string_blob + bytes(font_bytes))
    if obfuscate:
        if normalized_guid is None:
            raise EOTConversionError("obfuscation requested but no GUID supplied")
        _apply_guid_obfuscation(
            payload, normalized_guid, len(header) + len(string_blob)
        )

    final_bytes = bytes(payload)
    return EOTResult(
        data=final_bytes,
        family_name=names.family,
        style_name=names.style,
        full_name=names.full,
        version_name=names.version,
        weight=metrics.weight,
        italic=metrics.italic,
        charset=metrics.charset,
        panose=metrics.panose,
        fs_type=metrics.fs_type,
        unicode_ranges=metrics.unicode_ranges,
        codepage_ranges=metrics.codepage_ranges,
        root_string=normalized_root,
        guid=normalized_guid,
    )


def _load_font(font_bytes: bytes) -> TTFont:
    try:
        return _TTFont(BytesIO(bytes(font_bytes)), lazy=False)  # type: ignore[misc]
    except Exception as exc:  # pragma: no cover - defensive guard
        raise EOTConversionError(f"failed to parse font: {exc}") from exc


def _extract_font_names(
    font: "TTFont", resolved_family: str | None, resolved_style: str | None
) -> _FontNames:
    try:
        name_table = font["name"]
    except (
        KeyError
    ) as exc:  # pragma: no cover - OpenType fonts must include the name table
        raise EOTConversionError(f"font missing required table: {exc}") from exc

    family = resolved_family or _get_name(name_table, 1) or "EmbeddedFont"
    style = resolved_style or _get_name(name_table, 2) or "Regular"
    version = _get_name(name_table, 5) or "1.0"
    full = _get_name(name_table, 4) or f"{family} {style}".strip()
    return _FontNames(family=family, style=style, full=full, version=version)


def _extract_font_metrics(font: "TTFont") -> _FontMetrics:
    try:
        os2 = font["OS/2"]
        head = font["head"]
    except KeyError as exc:
        raise EOTConversionError(f"font missing required table: {exc}") from exc

    panose = _extract_panose(os2)
    charset = _guess_charset(os2)
    italic = _is_italic(os2, font)
    weight = int(getattr(os2, "usWeightClass", 400))
    fs_type = int(getattr(os2, "fsType", 0)) & 0xFFFF
    unicode_ranges = (
        int(getattr(os2, "ulUnicodeRange1", 0)),
        int(getattr(os2, "ulUnicodeRange2", 0)),
        int(getattr(os2, "ulUnicodeRange3", 0)),
        int(getattr(os2, "ulUnicodeRange4", 0)),
    )
    codepage_ranges = (
        int(getattr(os2, "ulCodePageRange1", 0)),
        int(getattr(os2, "ulCodePageRange2", 0)),
    )
    checksum_adjustment = int(getattr(head, "checkSumAdjustment", 0)) & 0xFFFFFFFF

    return _FontMetrics(
        panose=panose,
        charset=charset,
        italic=italic,
        weight=weight,
        fs_type=fs_type,
        unicode_ranges=unicode_ranges,
        codepage_ranges=codepage_ranges,
        checksum_adjustment=checksum_adjustment,
    )


def _encode_utf16le_with_null(value: str) -> bytes:
    """Encode string as UTF-16LE with null terminator."""
    if not value:
        return b"\x00\x00"
    encoded = value.encode("utf-16le")
    if not encoded.endswith(b"\x00\x00"):
        encoded += b"\x00\x00"
    return encoded


def _build_inline_strings(names: _FontNames, root_string: str) -> bytes:
    """Build inline string table with 2-byte length prefixes.

    Format: [len1:u16][str1][len2:u16][str2][len3:u16][str3][len4:u16][str4][rootlen:u16][root][padding:u16]
    Each length is the byte length of the string (including null terminator).
    """
    blob = bytearray()

    # FamilyName
    family_bytes = _encode_utf16le_with_null(names.family)
    blob.extend(struct.pack("<H", len(family_bytes)))
    blob.extend(family_bytes)

    # StyleName
    style_bytes = _encode_utf16le_with_null(names.style)
    blob.extend(struct.pack("<H", len(style_bytes)))
    blob.extend(style_bytes)

    # VersionName
    version_bytes = _encode_utf16le_with_null(names.version)
    blob.extend(struct.pack("<H", len(version_bytes)))
    blob.extend(version_bytes)

    # FullName
    full_bytes = _encode_utf16le_with_null(names.full)
    blob.extend(struct.pack("<H", len(full_bytes)))
    blob.extend(full_bytes)

    # RootString (optional, usually empty)
    if root_string:
        root_bytes = _encode_utf16le_with_null(root_string)
        blob.extend(struct.pack("<H", len(root_bytes)))
        blob.extend(root_bytes)
    else:
        blob.extend(struct.pack("<H", 0))  # Zero-length root string

    # Padding to align to 4 bytes (u16 padding field)
    blob.extend(struct.pack("<H", 0))

    return bytes(blob)


def _build_fixed_header(
    metrics: _FontMetrics, string_blob_len: int, font_data_len: int
) -> bytes:
    """Build the 82-byte fixed header."""
    eot_size = FIXED_HEADER_SIZE + string_blob_len + font_data_len

    header = bytearray()

    # EOTSize (u32)
    header.extend(struct.pack("<L", eot_size))
    # FontDataSize (u32)
    header.extend(struct.pack("<L", font_data_len))
    # Version (u32)
    header.extend(struct.pack("<L", EOT_VERSION))
    # Flags (u32) - TTEMBED_SUBSET
    header.extend(struct.pack("<L", 0x0004))
    # FontPANOSE (10 bytes)
    panose = metrics.panose[:10].ljust(10, b"\x00")
    header.extend(panose)
    # Charset (u8)
    header.extend(struct.pack("<B", metrics.charset))
    # Italic (u8)
    header.extend(struct.pack("<B", 1 if metrics.italic else 0))
    # Weight (u32)
    header.extend(struct.pack("<L", metrics.weight))
    # fsType (u16)
    header.extend(struct.pack("<H", metrics.fs_type))
    # MagicNumber (u16)
    header.extend(struct.pack("<H", EOT_MAGIC))
    # UnicodeRange1-4 (4 x u32)
    for ur in metrics.unicode_ranges:
        header.extend(struct.pack("<L", ur))
    # CodePageRange1-2 (2 x u32)
    for cpr in metrics.codepage_ranges:
        header.extend(struct.pack("<L", cpr))
    # ChecksumAdjustment (u32)
    header.extend(struct.pack("<L", metrics.checksum_adjustment))
    # Reserved1-4 (4 x u32)
    header.extend(b"\x00" * 16)
    # Padding1 (u16) - NOT u32!
    header.extend(struct.pack("<H", 0))

    assert len(header) == FIXED_HEADER_SIZE, (
        f"Header size mismatch: {len(header)} != {FIXED_HEADER_SIZE}"
    )

    return bytes(header)


def _get_name(name_table, name_id: int) -> str:
    try:
        value = name_table.getDebugName(name_id)
    except Exception:
        value = None
    if isinstance(value, str):
        return value.strip()
    return ""


def _extract_panose(os2) -> bytes:
    panose = getattr(os2, "panose", None)
    if panose is None:
        return b"\x00" * 10
    try:
        return bytes(
            int(getattr(panose, attr, 0)) & 0xFF
            for attr in (
                "bFamilyType",
                "bSerifStyle",
                "bWeight",
                "bProportion",
                "bContrast",
                "bStrokeVariation",
                "bArmStyle",
                "bLetterForm",
                "bMidline",
                "bXHeight",
            )
        )
    except Exception:  # pragma: no cover - defensive
        return bytes(getattr(panose, "data", b"\x00" * 10))[:10].ljust(10, b"\x00")


def _guess_charset(os2) -> int:
    code_page_range1 = int(getattr(os2, "ulCodePageRange1", 0))
    if code_page_range1 & 0x20000000:
        return 2  # SYMBOL_CHARSET
    return 0  # ANSI_CHARSET (not DEFAULT_CHARSET=1)


def _is_italic(os2, font: "TTFont") -> bool:
    selection = int(getattr(os2, "fsSelection", 0))
    if selection & 0x01:
        return True
    try:
        post_table = font["post"]
        return bool(getattr(post_table, "italicAngle", 0))
    except Exception:
        return False


def _normalize_root_string(value: str | None, guid: uuid.UUID | str | None) -> str:
    if isinstance(value, str) and value:
        return value
    if isinstance(guid, uuid.UUID):
        return str(guid)
    if isinstance(guid, str) and guid:
        try:
            return str(uuid.UUID(guid))
        except Exception:
            return guid
    return ""


def _normalize_guid(value: uuid.UUID | str | None) -> uuid.UUID | None:
    if isinstance(value, uuid.UUID):
        return value
    if isinstance(value, str) and value:
        try:
            return uuid.UUID(value)
        except Exception:
            return None
    return None


def _apply_guid_obfuscation(
    payload: bytearray, guid: uuid.UUID, font_offset: int
) -> None:
    """XOR the first 32 bytes of the font data with the GUID (ODTTF-style)."""

    key = guid.bytes_le
    limit = min(32, len(payload) - font_offset)
    for idx in range(limit):
        payload[font_offset + idx] ^= key[idx % 16]


__all__ = ["EOTConversionError", "EOTResult", "build_eot"]
