#!/usr/bin/env python3
"""Audit source widths, italic corrections, TopAccent, and cumulative IC/kern.

Check transformed prime metrics through their separate compatibility path.
"""
from __future__ import annotations
from build_config import EDITIONS

import argparse, json, os, subprocess, tempfile
import sys
from fontTools.ttLib import TTFont
from fontTools.pens.boundsPen import BoundsPen
from tfmpl import TFM
from add_ordinary_ic_gpos import _right_domain
import source_policy


# The ordinary-left domain and source identities are structural interoperability
# rules shared with the builder. Evaluated skew/metric values are parsed from the
# licensee's local source tree below.

# The ordinary-left domain is structural: alphabet families are ordinary by
# construction, while symbol-family exceptions are explicit TeX identities.
def expected_ordinary_left(tag, slot):
    return source_policy.is_ordinary_left(tag, slot)


def load_tfm(tfm_dir, base):
    path = os.path.join(tfm_dir, base + '.tfm')
    if not os.path.isfile(path):
        raise SystemExit('missing TFM: ' + path)
    with tempfile.NamedTemporaryFile(suffix='.pl', delete=False) as t:
        pl = t.name
    try:
        subprocess.run(['tftopl', path, pl], check=True,
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        return TFM(pl)
    finally:
        try:
            os.unlink(pl)
        except OSError:
            pass


def pair_xadvance(font, left, right):
    if 'GPOS' not in font or not font['GPOS'].table.LookupList:
        return 0
    total = 0
    for lk in font['GPOS'].table.LookupList.Lookup:
        sts = []
        if lk.LookupType == 2:
            sts = list(lk.SubTable)
        elif lk.LookupType == 9:
            sts = [s.ExtSubTable for s in lk.SubTable if getattr(
                s, 'ExtensionLookupType', None) == 2]
        for st in sts:
            fmt = getattr(st, 'Format', None)
            if fmt == 1:
                if left not in st.Coverage.glyphs:
                    continue
                ps = st.PairSet[st.Coverage.glyphs.index(left)]
                for rec in ps.PairValueRecord:
                    if rec.SecondGlyph == right:
                        v = getattr(rec, 'Value1', None)
                        total += int(getattr(v, 'XAdvance', 0) or 0) if v else 0
            elif fmt == 2:
                if left not in st.Coverage.glyphs:
                    continue
                c1 = st.ClassDef1.classDefs.get(left, 0)
                c2 = st.ClassDef2.classDefs.get(right, 0)
                rec = st.Class1Record[c1].Class2Record[c2]
                v = getattr(rec, 'Value1', None)
                total += int(getattr(v, 'XAdvance', 0) or 0) if v else 0
    return total


def math_ic(font):
    gi = font['MATH'].table.MathGlyphInfo
    info = gi.MathItalicsCorrectionInfo
    return {g: int(v.Value) for g, v in zip(info.Coverage.glyphs, info.ItalicsCorrection)}


def topaccent(font):
    gi = font['MATH'].table.MathGlyphInfo
    ta = gi.MathTopAccentAttachment
    return {g: int(v.Value) for g, v in zip(ta.TopAccentCoverage.glyphs, ta.TopAccentAttachment)}


def _cmap_gid_map(font):
    order = font.getGlyphOrder()
    gid = {g: i for i, g in enumerate(order)}
    out = {}
    for st in font['cmap'].tables:
        if not st.isUnicode():
            continue
        for cp, g in st.cmap.items():
            out[int(cp)] = gid[g]
    return out


def _reference_name_map(target, reference):
    """Resolve canonical OTF names by unchanged GID; TTF post format 3 drops names."""
    ro = reference.getGlyphOrder()
    to = target.getGlyphOrder()
    if len(ro) != len(to):
        raise SystemExit('reference/target glyph count differs')
    if _cmap_gid_map(reference) != _cmap_gid_map(target):
        raise SystemExit('reference/target Unicode cmap -> GID mapping differs')
    return dict(zip(ro, to))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('font')
    ap.add_argument('--contract', required=True)
    ap.add_argument('--tfm-dir', required=True)
    ap.add_argument('--edition', choices=EDITIONS, default='full')
    ap.add_argument('--reference-font',
                    help='check converted accent geometry using canonical OTF names; '
                         'requires prior OTF source and cross-format audits')
    ns = ap.parse_args()
    con = json.load(open(ns.contract, encoding='utf-8'))
    f = TTFont(ns.font)
    ref = TTFont(ns.reference_font) if ns.reference_font else None
    if ref is None and 'glyf' in f and 'post' in f and float(f['post'].formatType) == 3.0:
        raise SystemExit(
            'post format 3 TrueType target requires --reference-font for canonical glyph-name resolution')
    name_map = _reference_name_map(f, ref) if ref is not None else {
        g: g for g in f.getGlyphOrder()}

    def target_name(canonical):
        return name_map.get(canonical)
    canonical_cmap = (ref.getBestCmap() if ref is not None else f.getBestCmap())
    if ref is not None:
        # Table invariants transfer source semantics, but not outline geometry.
        accents = topaccent(f)
        glyphs = f.getGlyphSet()
        for record in con['records']:
            if not record.get('normalized_combining_accent'):
                continue
            name = target_name(record['output_glyph'])
            if name not in glyphs:
                raise SystemExit(f'normalized accent missing: {name}')
            pen = BoundsPen(glyphs)
            glyphs[name].draw(pen)
            bounds = pen.bounds or (0, 0, 0, 0)
            expected = round((bounds[0] + bounds[2]) / 2)
            if accents.get(name) != expected:
                raise SystemExit(f'{name}: TopAccent differs from normalized outline center')
        return
    hm = f['hmtx'].metrics
    icm = math_ic(f)
    tam = topaccent(f)
    font_by_tag = con['source_font_by_tag']
    skew = con['skewchar_by_tag']
    errors = []

    # Reparse source policy independently of the builder-generated contract.
    # This catches stale/tampered contract data without duplicating proprietary
    # skewchar values in the public auditor.
    active = source_policy.active_tags(ns.edition)
    try:
        local_policy = source_policy.extract_policy(ns.tfm_dir, active)
    except Exception as exc:
        raise SystemExit(f'cannot reconstruct local MTPro2 source policy: {exc}') from exc
    expected_skew = dict(local_policy.skewchar_by_tag)
    expected_source_font = {tag: source_policy.SOURCE_FONTS[tag] for tag in expected_skew}
    if skew != expected_skew:
        errors.append('skewchar_by_tag differs from independently parsed local source policy')
    if font_by_tag != expected_source_font:
        errors.append('source_font_by_tag differs from the public structural source registry')
    tfms = {tag: load_tfm(ns.tfm_dir, base) for tag, base in expected_source_font.items()}

    # Identify the transformed public prime base from cmap and contract identity.
    # Script alternates retain source advances, including the scriptscript form.
    _prime = canonical_cmap.get(0x2032)
    _prime_metric_transforms = set()
    if _prime:
        for rec in con.get('records', []):
            if rec.get('output_glyph') == _prime:
                _prime_metric_transforms.add((rec['tag'], int(rec['slot']), rec['output_glyph']))

    rec_by_key = {}
    for r in con['records']:
        tag = r['tag']
        slot = int(r['slot'])
        cg = r['output_glyph']
        rec_by_key[(tag, slot)] = r
        g = target_name(cg)
        if tag not in tfms:
            continue
        if g is None or g not in hm:
            errors.append(f'{cg}: canonical glyph cannot be resolved in target font')
            continue
        t = tfms[tag]
        if slot not in t.chars:
            errors.append(f'{tag}:{slot}: missing source char')
            continue
        wd = round((t.chars[slot].get('wd') or 0) * 1000)
        ic = round((t.chars[slot].get('ic') or 0) * 1000)
        normalized = bool(r.get('normalized_combining_accent'))
        fixed_accent = bool(r.get('fixed_source_accent'))
        want_hmtx = 0 if normalized else wd
        _prime_transformed = (tag, slot, cg) in _prime_metric_transforms
        if not _prime_transformed and hm[g][0] != want_hmtx:
            errors.append(
                f'{cg}: hmtx {hm[g][0]} != source/transform target {want_hmtx} ({tag}:{slot})')
        have_ic = icm.get(g, 0)
        if have_ic != ic:
            errors.append(f'{cg}: MATH IC {have_ic} != source IC {ic} ({tag}:{slot})')
        sk = skew.get(tag)
        skern = round(((t.kerns.get((slot, int(sk)), 0) or 0) if sk is not None else 0) * 1000)
        if normalized:
            gs = f.getGlyphSet()
            pen = BoundsPen(gs)
            gs[g].draw(pen)
            bbx = pen.bounds or (0, 0, 0, 0)
            want_ta = round((bbx[0] + bbx[2]) / 2)
        elif fixed_accent:
            want_ta = wd // 2
        else:
            want_ta = round((((t.chars[slot].get('wd') or 0) + (t.chars[slot].get('ic') or 0)) / 2
                             + ((t.kerns.get((slot, int(sk)), 0) or 0) if sk is not None else 0)) * 1000)
        have_ta = tam.get(g)
        if have_ta is None:
            errors.append(f'{cg}: TopAccent missing ({tag}:{slot})')
        elif have_ta != want_ta:
            errors.append(
                f'{cg}: TopAccent {have_ta} != {want_ta} ({tag}:{slot}, skew={sk}, skern={skern})')

    # Independently reconstruct every explicit ordinary TFM pair from imported source slots.
    expected_kern = {}
    for tag, t in tfms.items():
        sk = skew.get(tag)
        slots = {slot: r['output_glyph'] for (tg, slot), r in rec_by_key.items() if tg == tag}
        for (l, r), v in t.kerns.items():
            if sk is not None and int(r) == int(sk):
                continue
            if l not in slots or r not in slots:
                continue
            k = round((v or 0) * 1000)
            if not k:
                continue
            key = (slots[l], slots[r])
            prev = expected_kern.get(key)
            if prev is not None and prev != k:
                errors.append(f'{key[0]}/{key[1]}: conflicting independent TFM kern {prev} vs {k}')
            expected_kern[key] = k

    contract_kern = {(r['left'], r['right']): int(r['xadvance'])
                     for r in con.get('tfm_kern_pairs', [])}
    if contract_kern != expected_kern:
        miss = [(k, v) for k, v in expected_kern.items() if contract_kern.get(k) != v]
        extra = [(k, v) for k, v in contract_kern.items() if expected_kern.get(k) != v]
        if miss:
            errors.append('contract missing/wrong TFM kern: ' + \
                          ', '.join(f'{a}/{b}:{v:+d}' for (a, b), v in miss[:12]))
        if extra:
            errors.append('contract extra/wrong TFM kern: ' + \
                          ', '.join(f'{a}/{b}:{v:+d}' for (a, b), v in extra[:12]))

    # Reconstruct the expected ordinary-IC left set from source records and the
    # independently stated TeX-family domain above; do not trust the builder JSON
    # to define its own coverage.
    expected_left_ic = {}
    for r in con['records']:
        tag = r['tag']
        slot = int(r['slot'])
        g = r['output_glyph']
        if tag not in tfms or not expected_ordinary_left(tag, slot):
            continue
        t = tfms[tag]
        if slot not in t.chars:
            continue
        val = round((t.chars[slot].get('ic') or 0) * 1000)
        if val:
            prev = expected_left_ic.get(g)
            if prev is not None and prev != val:
                errors.append(
                    f'{g}: independently reconstructed ordinary IC conflict {prev} vs {val}')
            expected_left_ic[g] = val

    left_ic = {r['glyph']: int(r['italic_correction']) for r in con.get('ordinary_ic_lefts', [])}
    if left_ic != expected_left_ic:
        miss = [(g, v) for g, v in expected_left_ic.items() if left_ic.get(g) != v]
        extra = [(g, v) for g, v in left_ic.items() if expected_left_ic.get(g) != v]
        if miss:
            errors.append('ordinary IC contract missing/wrong: ' + \
                          ', '.join(f'{g}:{v:+d}' for g, v in miss[:16]))
        if extra:
            errors.append('ordinary IC contract extra/wrong: ' + \
                          ', '.join(f'{g}:{v:+d}' for g, v in extra[:16]))
    mapped_lefts = {target_name(g) for g in left_ic if target_name(g) is not None}
    rights = _right_domain(f, mapped_lefts)

    # Every ordinary IC left must show its IC against a broad neutral right.
    # Canonical contract names are resolved to target names by glyph ID when the
    # target is a post-format-3 TTF.
    neutral_canon = canonical_cmap.get(0x30) or canonical_cmap.get(0x31)
    neutral = target_name(neutral_canon) if neutral_canon else None
    if not neutral_canon or not neutral:
        errors.append('no neutral digit glyph for IC bridge audit')
    else:
        for cg, ic in left_ic.items():
            g = target_name(cg)
            if g is None:
                errors.append(f'{cg}: ordinary IC glyph cannot be resolved in target font')
                continue
            want = ic + expected_kern.get((cg, neutral_canon), 0)
            got = pair_xadvance(f, g, neutral)
            if got != want:
                errors.append(
                    f'{cg}/{neutral_canon}: combined PairPos {got:+d} != IC+kern {want:+d}')

    # Every explicit source kern must accumulate IC if its left glyph is an
    # ordinary IC left and the mapped right glyph belongs to the ordinary bridge
    # domain.
    for (cl, cr), k in expected_kern.items():
        l = target_name(cl)
        r = target_name(cr)
        if l is None or r is None:
            errors.append(f'{cl}/{cr}: source pair cannot be resolved in target font')
            continue
        want = k + (left_ic.get(cl, 0) if r in rights else 0)
        got = pair_xadvance(f, l, r)
        if got != want:
            errors.append(f'{cl}/{cr}: serialized PairPos {got:+d} != source total {want:+d}')

    if errors:
        for e in errors[:60]:
            print('source-contract:', e, file=sys.stderr)
        raise SystemExit(f'SOURCE CONTRACT FAIL: {len(errors)} errors')


if __name__ == '__main__':
    main()
