#!/usr/bin/env python3
"""Check Roman donor inputs and normalized bounding boxes and advances."""
from __future__ import annotations

import argparse
from dataclasses import dataclass
from pathlib import Path
import re

import sys
from fontTools.pens.boundsPen import BoundsPen
from fontTools.ttLib import TTFont


REGULAR_CODEPOINTS = tuple(range(0x41, 0x5B)) + tuple(range(0x61, 0x7B))
BOLD_CODEPOINTS = REGULAR_CODEPOINTS + tuple(range(0x30, 0x3A))

# Keep this independent copy of the builder's permitted text-donor domain.
# The output audit probes overlapping code points outside this domain using
# normalized bounding boxes and advances, not outline equality.
ROMAN_TEXT_EXTRA_RANGES = (
    (0x0020, 0x036F),
    (0x1E00, 0x1EFF),
    (0x2000, 0x218F),
    (0x2500, 0x266F),
    (0x301A, 0x301B),
    (0xFB00, 0xFB06),
)


def _is_roman_text_extra(codepoint: int) -> bool:
    return any(first <= codepoint <= last
               for first, last in ROMAN_TEXT_EXTRA_RANGES)


@dataclass(frozen=True)
class DonorInfo:
    path: Path
    family: str
    upm: int
    weight: int
    outline: str


def _name_values(font: TTFont, name_id: int) -> set[str]:
    values: set[str] = set()
    for record in font['name'].names:
        if record.nameID != name_id:
            continue
        try:
            value = record.toUnicode().strip()
        except Exception:
            continue
        if value:
            values.add(value)
    return values


def _family(font: TTFont) -> str:
    values = _name_values(font, 16) or _name_values(font, 1)
    if not values:
        raise ValueError('missing family name (name ID 16/1)')
    return sorted(values, key=lambda value: (len(value), value.casefold()))[0]


def _outline_format(font: TTFont) -> str:
    if 'CFF ' in font:
        return 'CFF'
    if 'glyf' in font:
        return 'TrueType'
    raise ValueError('unsupported outline format (expected static CFF or TrueType)')


def _inspect(path: str | Path, kind: str) -> DonorInfo:
    p = Path(path)
    try:
        font = TTFont(p)
    except Exception as exc:
        raise ValueError(f'cannot open {kind} donor {p}: {exc}') from exc
    for tag in ('head', 'hmtx', 'cmap', 'name', 'OS/2', 'post'):
        if tag not in font:
            raise ValueError(f'{kind} donor {p} is missing required {tag} table')
    if 'fvar' in font:
        raise ValueError(f'{kind} donor {p} is variable; select a static instance')

    outline = _outline_format(font)
    upm = int(font['head'].unitsPerEm)
    if not 16 <= upm <= 16384:
        raise ValueError(f'{kind} donor {p} has invalid unitsPerEm={upm}')

    os2 = font['OS/2']
    post = font['post']
    weight = int(os2.usWeightClass)
    italic = bool(os2.fsSelection & 0x01)
    oblique = bool(os2.fsSelection & 0x200)
    if abs(float(post.italicAngle)) > 0.01 or italic or oblique:
        raise ValueError(
            f'{kind} donor must be upright: {p} '
            f'(italicAngle={post.italicAngle}, fsSelection={os2.fsSelection:#x})')
    if kind == 'Regular' and not 300 <= weight < 600:
        raise ValueError(f'Regular donor has non-Regular weight {weight}: {p}')
    if kind == 'Bold' and not 600 <= weight <= 900:
        raise ValueError(f'Bold donor has non-Bold weight {weight}: {p}')
    if int(getattr(post, 'isFixedPitch', 0) or 0):
        raise ValueError(f'{kind} donor is fixed-pitch, not a proportional Roman: {p}')

    cmap = font.getBestCmap() or {}
    required = REGULAR_CODEPOINTS if kind == 'Regular' else BOLD_CODEPOINTS
    missing = [cp for cp in required if cp not in cmap]
    if missing:
        sample = ', '.join(f'U+{cp:04X}' for cp in missing[:12])
        more = '' if len(missing) <= 12 else f' (+{len(missing) - 12} more)'
        raise ValueError(f'{kind} donor lacks required characters: {sample}{more}')

    return DonorInfo(p, _family(font), upm, weight, outline)


def validate_inputs(regular: str | Path, bold: str | Path) -> tuple[DonorInfo, DonorInfo]:
    reg = _inspect(regular, 'Regular')
    bld = _inspect(bold, 'Bold')
    if reg.family.casefold() != bld.family.casefold():
        raise ValueError(
            f'Regular/Bold donor families do not match: {reg.family!r} != {bld.family!r}')
    return reg, bld


def _bbox(font: TTFont, glyph_name: str):
    pen = BoundsPen(font.getGlyphSet())
    font.getGlyphSet()[glyph_name].draw(pen)
    return pen.bounds


def _bbox_close(actual, source, scale: float, tolerance: float = 2.1) -> bool:
    if actual is None or source is None:
        return actual is None and source is None
    expected = tuple(float(value) * scale for value in source)
    return all(abs(float(a) - e) <= tolerance for a, e in zip(actual, expected))


def audit_output(
        output_path: str | Path,
        regular_path: str | Path,
        bold_path: str | Path,
        sty_path: str | Path | None = None) -> None:
    validate_inputs(regular_path, bold_path)
    if sty_path:
        source = Path(sty_path).read_text(encoding='latin-1', errors='ignore')
        compact = re.sub(r'\s+', '', source)
        mathbf_patterns = (
            r'\\DeclareMathAlphabet\{\\mathbf\}\{\\encodingdefault\}\{\\rmdefault\}\{b\}\{n\}',
            r'\\DeclareMathAlphabet\{\\mathbf\}\{\\encodingdefault\}\{\\rmdefault\}\{\\bfdefault\}\{\\updefault\}',
        )
        mbf_patterns = (
            r'\\DeclareMathAlphabet\{\\mbf\}\{U\}\{mtt\}\{b\}\{n\}',
            r'\\DeclareMathAlphabet\{\\mbf\}\{U\}\{mtt\}\{\\bfdefault\}\{\\updefault\}',
        )
        if not any(re.search(pattern, compact) for pattern in mathbf_patterns):
            raise SystemExit(r'mtpro2.sty contract missing: \mathbf -> rmdefault bold upright')
        if not any(re.search(pattern, compact) for pattern in mbf_patterns):
            raise SystemExit(r'mtpro2.sty contract missing: \mbf -> U/mtt bold upright')

    out = TTFont(output_path)
    reg = TTFont(regular_path)
    bold = TTFont(bold_path)
    output_upm = int(out['head'].unitsPerEm)
    reg_scale = output_upm / int(reg['head'].unitsPerEm)
    bold_scale = output_upm / int(bold['head'].unitsPerEm)
    output_cmap = out.getBestCmap() or {}
    regular_cmap = reg.getBestCmap() or {}
    bold_cmap = bold.getBestCmap() or {}
    output_hmtx = out['hmtx'].metrics
    regular_hmtx = reg['hmtx'].metrics
    bold_hmtx = bold['hmtx'].metrics
    errors: list[str] = []

    def one(source_font, source_cmap, source_hmtx, scale,
            source_cp, destination_cp, label):
        source_glyph = source_cmap.get(source_cp)
        destination_glyph = output_cmap.get(destination_cp)
        if not source_glyph or not destination_glyph:
            errors.append(
                f'{label}: missing src/dst U+{source_cp:04X}->U+{destination_cp:04X}')
            return
        expected_advance = round(source_hmtx[source_glyph][0] * scale)
        actual_advance = output_hmtx[destination_glyph][0]
        if actual_advance != expected_advance:
            errors.append(
                f'{label}: advance {actual_advance} != normalized donor {expected_advance}')
        if not _bbox_close(
                _bbox(out, destination_glyph), _bbox(source_font, source_glyph), scale):
            errors.append(
                f'{label}: outline bbox {_bbox(out, destination_glyph)} != '
                f'normalized donor {_bbox(source_font, source_glyph)} scale={scale:g}')

    for cp in REGULAR_CODEPOINTS:
        one(reg, regular_cmap, regular_hmtx, reg_scale, cp, cp, f'Regular U+{cp:04X}')
    for index, cp in enumerate(range(0x41, 0x5B)):
        one(bold, bold_cmap, bold_hmtx, bold_scale,
            cp, 0x1D400 + index, f'Bold {chr(cp)}')
    for index, cp in enumerate(range(0x61, 0x7B)):
        one(bold, bold_cmap, bold_hmtx, bold_scale,
            cp, 0x1D41A + index, f'Bold {chr(cp)}')
    for index, cp in enumerate(range(0x30, 0x3A)):
        one(bold, bold_cmap, bold_hmtx, bold_scale,
            cp, 0x1D7CE + index, f'Bold digit {chr(cp)}')

    # Compare normalized bounding boxes and advances at overlapping code points.
    # This is a heuristic for unintended donor imports, not an outline comparison.
    leaked: list[int] = []
    permitted_output_glyphs = {
        glyph_name for cp, glyph_name in output_cmap.items()
        if _is_roman_text_extra(cp)
    }
    for cp, source_glyph in regular_cmap.items():
        if _is_roman_text_extra(cp):
            continue
        destination_glyph = output_cmap.get(cp)
        if not destination_glyph:
            continue
        # MTPro2 intentionally gives several Latin-shaped Greek characters a
        # same-GID alias to Basic Latin.  That is not an imported extra glyph.
        if destination_glyph in permitted_output_glyphs:
            continue
        source_bbox = _bbox(reg, source_glyph)
        if source_bbox is None:
            continue
        expected_advance = round(regular_hmtx[source_glyph][0] * reg_scale)
        if (output_hmtx[destination_glyph][0] == expected_advance and
                _bbox_close(_bbox(out, destination_glyph), source_bbox, reg_scale)):
            leaked.append(cp)
    if leaked:
        sample = ', '.join(f'U+{cp:04X}' for cp in leaked[:20])
        more = '' if len(leaked) <= 20 else f' (+{len(leaked) - 20} more)'
        errors.append(
            f'Possible Regular-donor import outside text domain '
            f'(bbox/advance match): {sample}{more}')

    if errors:
        print('ROMAN DONOR CONTRACT FAILED', file=sys.stderr)
        for error in errors[:40]:
            print('  -', error, file=sys.stderr)
        if len(errors) > 40:
            print(f'  ... {len(errors) - 40} more', file=sys.stderr)
        raise SystemExit(1)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument('font', nargs='?')
    ap.add_argument('--regular', required=True)
    ap.add_argument('--bold', required=True)
    ap.add_argument('--sty')
    ap.add_argument('--check-inputs-only', action='store_true')
    ns = ap.parse_args()
    try:
        if ns.check_inputs_only:
            if ns.font:
                ap.error('FONT is not used with --check-inputs-only')
            validate_inputs(ns.regular, ns.bold)
            return
        if not ns.font:
            ap.error('FONT is required unless --check-inputs-only is used')
        audit_output(ns.font, ns.regular, ns.bold, ns.sty)
    except ValueError as exc:
        raise SystemExit(f'ROMAN DONOR CONTRACT FAIL: {exc}') from exc


if __name__ == '__main__':
    main()
