#!/usr/bin/env python3
"""Extract policy and geometry from local MTPro2 sources.

Keep source identities public; evaluate source-dependent values during build.
"""
from __future__ import annotations

from dataclasses import dataclass, asdict
from fractions import Fraction
from pathlib import Path
from build_config import EDITIONS

import argparse
import json
import math
import os
import re
import subprocess
import tempfile
from typing import Dict, Iterable, Mapping, Optional


# Structural source registry.  These are filenames/roles, not evaluated metrics.
SOURCE_FONTS: Dict[str, str] = {
    'mit': 'mt2mit', 'syt': 'mt2syt', 'exa': 'mt2exa', 'xl': 'mt2xl', 'xxxl': 'mt2xxxl',
    'ams': 'mt2syat', 'bb': 'mt2bbt', 'script': 'mt2mst', 'frak': 'mt2mft',
    'curly': 'mt2mct', 'bold': 'mt2mbt',
    'exe': 'mt2exe', 'exf': 'mt2exf', 'exg': 'mt2exg',
    'mit_s': 'mt2mis', 'mit_ss': 'mt2mif', 'syt_s': 'mt2sys', 'syt_ss': 'mt2syf',
    'ams_s': 'mt2syas', 'ams_ss': 'mt2syaf',
    'bb_s': 'mt2bbs', 'bb_ss': 'mt2bbf', 'script_s': 'mt2mss', 'script_ss': 'mt2msf',
    'frak_s': 'mt2mfs', 'frak_ss': 'mt2mff',
    'bold_s': 'mt2mbs', 'bold_ss': 'mt2mbf', 'curly_s': 'mt2mcs', 'curly_ss': 'mt2mcf',
    'bbi': 'mt2bbit', 'bbi_s': 'mt2bbis', 'bbi_ss': 'mt2bbif',
    'bbd': 'mt2bbdt', 'bbd_s': 'mt2bbds', 'bbd_ss': 'mt2bbdf',
    'hrb': 'mt2hrbt', 'hrb_s': 'mt2hrbs', 'hrb_ss': 'mt2hrbf',
    'hrbd': 'mt2hrbdt', 'hrbd_s': 'mt2hrbds', 'hrbd_ss': 'mt2hrbdf',
    'hbi': 'mt2hbit', 'hbi_s': 'mt2hbis', 'hbi_ss': 'mt2hbif',
    'bmit': 'mt2bmit', 'bmit_s': 'mt2bmis', 'bmit_ss': 'mt2bmif',
    'bsyt': 'mt2bsyt', 'bsyt_s': 'mt2bsys', 'bsyt_ss': 'mt2bsyf',
}

LITE_TAGS = frozenset((
    'mit', 'mit_s', 'mit_ss', 'syt', 'syt_s', 'syt_ss',
    'exa', 'exe', 'exf', 'exg', 'xl', 'xxxl',
    'bold', 'bold_s', 'bold_ss',
))

# Full-only .fd declarations that define option-alphabet source identity and
# optical-size ordering.  These are public structural names only.
OPTICAL_FD_GROUPS = {
    'umt2ms.fd': (
        ('curly option family', ('curly_ss', 'curly_s', 'curly')),
        ('script option family', ('script_ss', 'script_s', 'script')),
    ),
    'umt2mf.fd': (
        ('Fraktur family', ('frak_ss', 'frak_s', 'frak')),
    ),
    'umt2bb.fd': (
        ('blackboard family', ('bb_ss', 'bb_s', 'bb')),
        ('italic blackboard family', ('bbi_ss', 'bbi_s', 'bbi')),
        ('bold blackboard family', ('bbd_ss', 'bbd_s', 'bbd')),
    ),
    'umt2hrb.fd': (
        ('holey Roman family', ('hrb_ss', 'hrb_s', 'hrb')),
        ('holey italic family', ('hbi_ss', 'hbi_s', 'hbi')),
        ('holey bold family', ('hrbd_ss', 'hrbd_s', 'hrbd')),
    ),
}

# Tags whose source glyphs are ordinary/simple math characters by construction.
# Symbol-family exceptions are separate because source class, not alphabet role,
# decides whether an italic correction participates in ordinary adjacency.
ORDINARY_ALL_TAGS = frozenset((
    'mit', 'mit_s', 'mit_ss', 'bmit', 'bmit_s', 'bmit_ss',
    'bb', 'bb_s', 'bb_ss', 'script', 'script_s', 'script_ss',
    'frak', 'frak_s', 'frak_ss', 'curly', 'curly_s', 'curly_ss',
    'bold', 'bold_s', 'bold_ss',
    'bbi', 'bbi_s', 'bbi_ss', 'bbd', 'bbd_s', 'bbd_ss',
    'hrb', 'hrb_s', 'hrb_ss', 'hrbd', 'hrbd_s', 'hrbd_ss', 'hbi', 'hbi_s', 'hbi_ss',
))

# The symbol-family ordinary slots are stable TeX identities.  Their *metrics*
# and skew/kern values are always read from local TFM/package sources.
SYMBOL_ORDINARY_SLOTS = frozenset((114, 177))
SYMBOL_ORDINARY_TAGS = frozenset(('syt', 'syt_s', 'syt_ss', 'bsyt', 'bsyt_s', 'bsyt_ss'))

# Map builder tags to the LaTeX declaration that owns their skewchar policy.
# The selector itself is structural; the numeric skewchar is parsed at runtime.
_SKEW_ROLE = {}
for _t in ('mit', 'mit_s', 'mit_ss', 'bmit', 'bmit_s', 'bmit_ss'):
    _SKEW_ROLE[_t] = ('family', 'sty', 'LMP1', 'mtt')
for _t in ('syt', 'syt_s', 'syt_ss'):
    _SKEW_ROLE[_t] = ('shape', 'sty', 'LMP2', 'mtt', 'm', 'n')
for _t in ('bsyt', 'bsyt_s', 'bsyt_ss'):
    _SKEW_ROLE[_t] = ('shape', 'sty', 'LMP2', 'mtt', 'b', 'n')
for _t in ('bold', 'bold_s', 'bold_ss'):
    _SKEW_ROLE[_t] = ('family', 'sty', 'U', 'mtt')
for _t in ('script', 'script_s', 'script_ss', 'curly', 'curly_s', 'curly_ss'):
    _SKEW_ROLE[_t] = ('family', 'umt2ms.fd', 'U', 'mt2ms')
for _t in ('frak', 'frak_s', 'frak_ss'):
    _SKEW_ROLE[_t] = None
for _t in ('bb', 'bb_s', 'bb_ss', 'bbi', 'bbi_s', 'bbi_ss', 'bbd', 'bbd_s', 'bbd_ss'):
    _SKEW_ROLE[_t] = ('family', 'umt2bb.fd', 'U', 'mt2bb')
for _t in ('hrb', 'hrb_s', 'hrb_ss', 'hrbd', 'hrbd_s', 'hrbd_ss', 'hbi', 'hbi_s', 'hbi_ss'):
    _SKEW_ROLE[_t] = ('family', 'umt2hrb.fd', 'U', 'mt2hrb')


@dataclass(frozen=True)
class AccentDeclaration:
    macro: str
    math_class: str
    symbol_family: str
    slot: int


@dataclass(frozen=True)
class DotMacroPolicy:
    vdots_step_num: int
    vdots_step_den: int
    ddots_raise: tuple[tuple[int, int], ...]
    ddots_inner_mu_num: int
    ddots_inner_mu_den: int

    @property
    def vdots_step_pt(self) -> Fraction:
        return Fraction(self.vdots_step_num, self.vdots_step_den)

    @property
    def ddots_raises_pt(self) -> tuple[Fraction, ...]:
        return tuple(Fraction(n, d) for n, d in self.ddots_raise)

    @property
    def ddots_inner_mu(self) -> Fraction:
        return Fraction(self.ddots_inner_mu_num, self.ddots_inner_mu_den)


@dataclass(frozen=True)
class SourcePolicy:
    script_ratio_num: int
    script_ratio_den: int
    scriptscript_ratio_num: int
    scriptscript_ratio_den: int
    skewchar_by_tag: Mapping[str, Optional[int]]
    accents: Mapping[str, AccentDeclaration]
    dot_macros: DotMacroPolicy

    @property
    def script_ratio(self) -> Fraction:
        return Fraction(self.script_ratio_num, self.script_ratio_den)

    @property
    def scriptscript_ratio(self) -> Fraction:
        return Fraction(self.scriptscript_ratio_num, self.scriptscript_ratio_den)

    def to_jsonable(self) -> dict:
        return {
            'script_ratio': {'numerator': self.script_ratio_num, 'denominator': self.script_ratio_den},
            'scriptscript_ratio': {'numerator': self.scriptscript_ratio_num, 'denominator': self.scriptscript_ratio_den},
            'skewchar_by_tag': dict(self.skewchar_by_tag),
            'accents': {k: asdict(v) for k, v in sorted(self.accents.items())},
            'dot_macros': asdict(self.dot_macros),
            'source_font_by_tag': dict(SOURCE_FONTS),
        }


def _strip_comments(text: str) -> str:
    return re.sub(r'(?<!\\)%.*$', '', text, flags=re.MULTILINE)


def _tex_int(token: str) -> int:
    token = token.strip()
    if token.startswith('"'):
        return int(token[1:], 16)
    if token.startswith("'"):
        return int(token[1:], 8)
    return int(token, 10)


def _tex_fraction(token: str) -> Fraction:
    token = token.strip()
    if token.startswith('.'):
        token = '0' + token
    if '.' in token:
        sign = -1 if token.startswith('-') else 1
        if token[0] in '+-':
            token = token[1:]
        a, b = token.split('.', 1)
        n = int((a or '0') + b)
        return Fraction(sign * n, 10 ** len(b))
    return Fraction(int(token), 1)


def _require_one(pattern: str, text: str, label: str, flags: int = 0) -> re.Match[str]:
    hits = list(re.finditer(pattern, text, flags))
    if len(hits) != 1:
        raise ValueError(f'{label}: expected exactly one declaration, found {len(hits)}')
    return hits[0]


def _family_skew(text: str, encoding: str, family: str) -> Optional[int]:
    pat = (r'\\DeclareFontFamily\s*\{' + re.escape(encoding) + r'\}\s*\{' +
           re.escape(family) + r'\}\s*\{([^}]*)\}')
    m = _require_one(pat, text, f'font family {encoding}/{family}', re.DOTALL)
    body = m.group(1)
    sm = re.search(r'\\skewchar\s*\\font\s*([^\s}]+)', body)
    return _tex_int(sm.group(1)) if sm else None


def _shape_skew(text: str, encoding: str, family: str, series: str, shape: str) -> Optional[int]:
    pat = (r'\\DeclareFontShape\s*\{' + re.escape(encoding) + r'\}\s*\{' +
           re.escape(family) + r'\}\s*\{' + re.escape(series) + r'\}\s*\{' +
           re.escape(shape) + r'\}\s*\{.*?\}\s*\{([^}]*)\}')
    m = _require_one(pat, text, f'font shape {encoding}/{family}/{series}/{shape}', re.DOTALL)
    body = m.group(1)
    sm = re.search(r'\\skewchar\s*\\font\s*([^\s}]+)', body)
    return _tex_int(sm.group(1)) if sm else None


def _parse_accents(sty: str) -> Dict[str, AccentDeclaration]:
    # MTPro2 declarations are simple braced tokens; whitespace/newlines are allowed.
    pat = re.compile(
        r'\\DeclareMathAccent\s*\{\\([A-Za-z@]+)\}\s*'
        r'\{\\([A-Za-z@]+)\}\s*\{([^}]+)\}\s*\{([^}]+)\}',
        re.DOTALL,
    )
    out: Dict[str, AccentDeclaration] = {}
    for m in pat.finditer(sty):
        macro, math_class, family, slot_token = m.groups()
        slot = _tex_int(slot_token)
        rec = AccentDeclaration(macro, math_class, family.strip(), slot)
        old = out.get(macro)
        if old is not None and old != rec:
            raise ValueError(f'conflicting \\DeclareMathAccent for \\{macro}')
        out[macro] = rec
    if not out:
        raise ValueError('no \\DeclareMathAccent declarations found in mtpro2.sty')
    return out


def _extract_braced_def_body(text: str, macro: str) -> str:
    r"""Return the balanced body of one ``\def\macro{...}`` declaration."""
    m = _require_one(r'\\def\s*\\' + re.escape(macro) + r'\s*\{',
                     text, macro + ' macro')
    start = m.end() - 1
    depth = 0
    for i in range(start, len(text)):
        ch = text[i]
        escaped = i > 0 and text[i - 1] == '\\'
        if escaped:
            continue
        if ch == '{':
            depth += 1
        elif ch == '}':
            depth -= 1
            if depth == 0:
                return text[start + 1:i]
            if depth < 0:
                break
    raise ValueError(f'{macro}: unbalanced macro body')


def _parse_dot_macros(sty: str) -> DotMacroPolicy:
    """Extract the MTPro2 dot-macro geometry that affects synthesized glyphs."""
    vm = _require_one(
        r'\\def\s*\\vdots.*?\\baselineskip\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*\\p@',
        sty, 'vdots baseline step', re.DOTALL)
    vdots_step = _tex_fraction(vm.group(1))

    body = _extract_braced_def_body(sty, 'ddots')
    raises = []
    for token in re.findall(r'\\raise\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)?)\s*\\p@', body):
        raises.append(_tex_fraction(token or '1'))
    mkerns = [_tex_fraction(x) for x in re.findall(
        r'\\mkern\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*mu', body)]
    if len(raises) != 3 or len(mkerns) < 3:
        raise ValueError('ddots macro geometry is not the expected three-dot structure')
    # The internal spacing is the first kern between two successive dot boxes;
    # edge kerns are math-class padding and are not baked into the glyph.
    inner = mkerns[1]
    if mkerns[-2] != inner:
        raise ValueError('ddots internal spacing is asymmetric')
    return DotMacroPolicy(
        vdots_step.numerator, vdots_step.denominator,
        tuple((x.numerator, x.denominator) for x in raises),
        inner.numerator, inner.denominator,
    )


def required_policy_files(active_tags: Iterable[str]) -> tuple[str, ...]:
    """Return all numeric and structural policy files required by the edition."""
    tags = set(active_tags)
    files = {'mtpro2.sty'}
    for tag in tags:
        role = _SKEW_ROLE.get(tag)
        if role is not None and role[1] != 'sty':
            files.add(role[1])
    for filename, groups in OPTICAL_FD_GROUPS.items():
        if any(any(tag in tags for tag in group_tags) for _, group_tags in groups):
            files.add(filename)
    return tuple(sorted(files))


def active_tags(edition):
    if edition not in EDITIONS:
        raise ValueError(f'unknown edition: {edition}')
    return tuple(tag for tag in SOURCE_FONTS if edition == 'full' or tag in LITE_TAGS)


def required_source_files(tags):
    tags = tuple(tags)
    return tuple(sorted({SOURCE_FONTS[tag] + suffix for tag in tags
                         for suffix in ('.pfb', '.tfm')} | set(required_policy_files(tags))))


def validate_source_files(mtpro2_dir: str | Path, active_tags: Iterable[str]) -> None:
    """Require the selected edition's fonts and policy declarations."""
    root = Path(mtpro2_dir)
    missing = [name for name in required_source_files(active_tags)
               if not (root / name).is_file()]
    if missing:
        raise FileNotFoundError('incomplete MTPro2 source set: ' + ', '.join(missing))


def extract_policy(mtpro2_dir: str | Path, active_tags: Optional[Iterable[str]] = None) -> SourcePolicy:
    root = Path(mtpro2_dir)
    sty_path = root / 'mtpro2.sty'
    if not sty_path.is_file():
        raise FileNotFoundError(sty_path)
    texts = {'sty': _strip_comments(sty_path.read_text(encoding='latin-1'))}

    tags = set(active_tags) if active_tags is not None else set(_SKEW_ROLE)
    # Only require .fd files that are actually referenced by the active edition.
    # Lite must not accidentally depend on Full-only option-family declarations.
    required_text_sources = [name for name in required_policy_files(tags)
                             if name != 'mtpro2.sty']
    for name in required_text_sources:
        p = root / name
        if not p.is_file():
            raise FileNotFoundError(p)
        texts[name] = _strip_comments(p.read_text(encoding='latin-1'))

    sty = texts['sty']
    sr = _tex_fraction(_require_one(
        r'\\def\s*\\defaultscriptratio\s*\{([^}]+)\}', sty,
        'defaultscriptratio').group(1))
    ssr = _tex_fraction(_require_one(
        r'\\def\s*\\defaultscriptscriptratio\s*\{([^}]+)\}', sty,
        'defaultscriptscriptratio').group(1))
    if not (0 < sr <= 1 and 0 < ssr <= sr):
        raise ValueError('invalid MTPro2 script ratios')

    skew: Dict[str, Optional[int]] = {}
    for tag in sorted(tags):
        role = _SKEW_ROLE.get(tag)
        if tag not in _SKEW_ROLE:
            continue
        if role is None:
            skew[tag] = None
            continue
        kind, source, enc, fam, *rest = role
        text = texts[source]
        if kind == 'family':
            skew[tag] = _family_skew(text, enc, fam)
        elif kind == 'shape':
            skew[tag] = _shape_skew(text, enc, fam, rest[0], rest[1])
        else:
            raise AssertionError(role)

    return SourcePolicy(
        sr.numerator, sr.denominator,
        ssr.numerator, ssr.denominator,
        skew,
        _parse_accents(sty),
        _parse_dot_macros(sty),
    )


def is_ordinary_left(tag: str, slot: int) -> bool:
    return tag in ORDINARY_ALL_TAGS or (tag in SYMBOL_ORDINARY_TAGS and slot in SYMBOL_ORDINARY_SLOTS)


def load_policy_json(path: str | Path) -> SourcePolicy:
    data = json.loads(Path(path).read_text(encoding='utf-8'))
    sr = data['script_ratio']
    ssr = data['scriptscript_ratio']
    accents = {k: AccentDeclaration(**v) for k, v in data['accents'].items()}
    dm = data['dot_macros']
    dots = DotMacroPolicy(
        int(dm['vdots_step_num']), int(dm['vdots_step_den']),
        tuple((int(n), int(d)) for n, d in dm['ddots_raise']),
        int(dm['ddots_inner_mu_num']), int(dm['ddots_inner_mu_den']),
    )
    return SourcePolicy(
        int(sr['numerator']), int(sr['denominator']),
        int(ssr['numerator']), int(ssr['denominator']),
        {k: (None if v is None else int(v)) for k, v in data['skewchar_by_tag'].items()},
        accents,
        dots,
    )


def _type1_slot_path(pfb_path: Path, slot: int) -> dict:
    """Return one local Type1 slot as a JSON-safe cubic path description."""
    from fontTools import t1Lib
    from fontTools.pens.recordingPen import RecordingPen
    from fontTools.pens.boundsPen import BoundsPen

    font = t1Lib.T1Font(str(pfb_path))
    font.parse()
    encoding = font.font.get('Encoding')
    if not isinstance(encoding, list) or not (0 <= slot < len(encoding)):
        raise ValueError(f'{pfb_path.name}: no usable Type1 encoding for slot {slot}')
    glyph_name = encoding[slot]
    glyphs = font.getGlyphSet()
    if glyph_name not in glyphs:
        raise ValueError(f'{pfb_path.name}: slot {slot} glyph {glyph_name!r} missing')
    rec = RecordingPen()
    glyphs[glyph_name].draw(rec)
    bounds = BoundsPen(glyphs)
    glyphs[glyph_name].draw(bounds)
    ops = []
    for op, args in rec.value:
        ops.append({'op': op, 'points': [[float(x), float(y)] for (x, y) in args]})
    return {
        'source_file': pfb_path.name,
        'slot': int(slot),
        'glyph_name': glyph_name,
        'bounds': [float(v) for v in (bounds.bounds or (0, 0, 0, 0))],
        'path': ops,
    }


def _load_tfm_file(path: Path):
    """Parse one local TFM through the independent tftopl -> PL path."""
    from tfmpl import TFM
    fd, pl = tempfile.mkstemp(suffix='.pl')
    os.close(fd)
    try:
        subprocess.run(
            ['tftopl', str(path), pl],
            check=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
            text=True,
        )
        return TFM(pl)
    finally:
        try:
            os.unlink(pl)
        except OSError:
            pass


def _type1_upm(font) -> int:
    matrix = font.font.get('FontMatrix')
    if not matrix or not matrix[0]:
        raise ValueError('Type1 font has no usable FontMatrix')
    upm = round(abs(1.0 / float(matrix[0])))
    if upm <= 0:
        raise ValueError('Type1 font has an invalid FontMatrix scale')
    return upm


def _extract_prime_padding(mtpro2_dir: str | Path, script_ratio: Fraction) -> dict:
    """Derive prime padding from local italic overhang and script-prime ink.

    Use the minimum em-quantized shift plus conversion-policy clearance.
    """
    from fontTools import t1Lib
    from fontTools.pens.boundsPen import BoundsPen
    import uni_map as U

    root = Path(mtpro2_dir)
    italic_tag = 'mit'
    italic_base = SOURCE_FONTS[italic_tag]
    italic_pfb = root / (italic_base + '.pfb')
    italic_tfm = root / (italic_base + '.tfm')
    font = t1Lib.T1Font(str(italic_pfb))
    font.parse()
    encoding = font.font.get('Encoding')
    if not isinstance(encoding, list):
        raise ValueError(f'{italic_pfb.name}: no usable Type1 encoding')
    source_upm = _type1_upm(font)
    tfm = _load_tfm_file(italic_tfm)
    glyphs = font.getGlyphSet()
    overhangs = []
    for slot in tuple(range(ord('A'), ord('Z') + 1)) + tuple(range(ord('a'), ord('z') + 1)):
        if slot not in tfm.chars or slot >= len(encoding):
            continue
        glyph_name = encoding[slot]
        if glyph_name not in glyphs:
            continue
        pen = BoundsPen(glyphs)
        glyphs[glyph_name].draw(pen)
        if not pen.bounds:
            continue
        ink_right = float(pen.bounds[2]) * 1000.0 / source_upm
        advance = float(tfm.chars[slot]['wd']) * 1000.0
        overhangs.append(ink_right - advance)
    if not overhangs:
        raise ValueError('cannot derive raw-prime padding from local math italic source')
    max_overhang = max(0.0, max(overhangs))

    prime_slot = next((slot for slot, (u, _name) in U.SYT.items() if u == 0x2032), None)
    if prime_slot is None:
        raise ValueError('public source registry has no prime identity')
    prime_tag = 'syt_s'
    prime_pfb = root / (SOURCE_FONTS[prime_tag] + '.pfb')
    pfont = t1Lib.T1Font(str(prime_pfb))
    pfont.parse()
    penc = pfont.font.get('Encoding')
    if not isinstance(penc, list) or prime_slot >= len(penc):
        raise ValueError(f'{prime_pfb.name}: no usable prime encoding')
    pglyphs = pfont.getGlyphSet()
    pglyph_name = penc[prime_slot]
    if pglyph_name not in pglyphs:
        raise ValueError(f'{prime_pfb.name}: prime source glyph is missing')
    ppen = BoundsPen(pglyphs)
    pglyphs[pglyph_name].draw(ppen)
    if not ppen.bounds:
        raise ValueError(f'{prime_pfb.name}: prime source glyph has no ink bounds')
    prime_upm = _type1_upm(pfont)
    prime_left = float(ppen.bounds[0]) * 1000.0 / prime_upm * float(script_ratio)

    quantum = max(1, round(float(Fraction(1, 100)) * 1000))
    clearance = quantum
    required_shift = max(0.0, max_overhang + clearance - prime_left)
    padding = int(math.ceil(required_shift / quantum) * quantum)
    return {
        'italic_source_tag': italic_tag,
        'prime_source_tag': prime_tag,
        'max_math_italic_overhang': max_overhang,
        'scaled_prime_ink_left': prime_left,
        'clearance': clearance,
        'quantum': quantum,
        'padding': padding,
    }


def extract_geometry(mtpro2_dir: str | Path, policy: SourcePolicy | None = None) -> dict:
    """Extract geometry for synthesized forms from local PFB slots."""
    import uni_map as U
    chain = U.H_CHAINS.get(0x23DC)
    if not chain:
        raise ValueError('missing TOP PARENTHESIS source chain')
    tag, slot = chain[-1]
    base = SOURCE_FONTS[tag]
    return {
        'overparen_template': _type1_slot_path(Path(mtpro2_dir) / (base + '.pfb'), int(slot)),
        'prime_padding': _extract_prime_padding(mtpro2_dir, (policy or extract_policy(mtpro2_dir)).script_ratio),
    }


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument('--mtpro2-dir', required=True)
    ap.add_argument('--edition', choices=EDITIONS, default='full')
    ap.add_argument('-o', '--output')
    ap.add_argument('--include-geometry', action='store_true')
    ns = ap.parse_args()
    active = active_tags(ns.edition)
    validate_source_files(ns.mtpro2_dir, active)
    policy = extract_policy(ns.mtpro2_dir, active)
    payload = policy.to_jsonable()
    payload['source_font_by_tag'] = {tag: SOURCE_FONTS[tag] for tag in sorted(active)}
    payload['policy_source_files'] = list(required_policy_files(active))
    if ns.include_geometry:
        payload['geometry'] = extract_geometry(ns.mtpro2_dir, policy)
    text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + '\n'
    if ns.output:
        Path(ns.output).parent.mkdir(parents=True, exist_ok=True)
        Path(ns.output).write_text(text, encoding='utf-8')
    else:
        print(text, end='')


if __name__ == '__main__':
    main()
