"""Build one edition, check its serialized pair, then publish to out/.
"""

from __future__ import annotations

import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import traceback
from copy import deepcopy
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path

import catalog
import donors
import plan
from config import FONT_VERSION, identity, parse_options, resolve_inputs
from diagnostics import write_contract

ALTERNATE_PATH = Path(__file__).with_name("source_alternates.json")


def publish_fonts(root, fonts, checked_sha):
    """Replace each checked file intact; this is not a pair transaction.
    """
    output = root / "out"
    output.mkdir(parents=True, exist_ok=True)
    for font in fonts:
        destination = output / font.name
        fd, name = tempfile.mkstemp(
            prefix=font.name + ".", suffix=".tmp", dir=output
        )
        os.close(fd)
        temporary = Path(name)
        try:
            if (
                hashlib.sha256(font.read_bytes()).hexdigest()
                != checked_sha[font]
            ):
                raise ValueError("staging font changed before publication")
            shutil.copy2(font, temporary)
            if (
                hashlib.sha256(temporary.read_bytes()).hexdigest()
                != checked_sha[font]
            ):
                raise ValueError("publication copy differs from checked font")
            os.replace(temporary, destination)
        finally:
            temporary.unlink(missing_ok=True)


def condition_snapshot(snapshot, policy):
    # Each consumer owns its copy, including nested
    # geometry/metric dictionaries.
    payload = deepcopy(snapshot)
    payload["large_variants"] = policy
    return payload


def measure_compatibility(
    root, edition, regular, bold, coverage, snapshot, run
):
    """Construct legacy glyphs only to their Windows-assignment
    boundary.
    """
    from windows_metrics import validate

    work = root / "build" / edition / "compatibility"
    # Remove only owned small results; active PL files are
    # regenerated by native.
    work.mkdir(parents=True, exist_ok=True)
    for name in ("metrics.json", "source-policy.json"):
        path = work / name
        if path.is_file():
            path.unlink()
    policy_path = work / "source-policy.json"
    result_path = work / "metrics.json"
    write_contract(policy_path, condition_snapshot(snapshot, "legacy"))
    env = backend_environment(
        edition, regular, bold, coverage, work, policy_path, "legacy"
    )
    run(
        "FontForge compatibility metrics",
        [
            "fontforge",
            "-lang=py",
            "-script",
            root / "tools/build_font.py",
            "--purpose",
            "windows",
            "--metrics-output",
            result_path,
        ],
        environment=env,
    )
    if not result_path.is_file():
        raise ValueError(
            "compatibility metrics producer did not write current result"
        )
    result = json.loads(result_path.read_text(encoding="utf-8"))
    validate(result, snapshot)
    return result


def backend_environment(
    edition, regular, bold, coverage, work, policy_path, policy
):
    env = donors.child_environment(coverage, os.environ)
    env.update(
        MTP2_EDITION=edition,
        MTP2_BUILD_DIR=str(work),
        MTP2_LARGE_VARIANTS=policy,
        MTP2_SOURCE_POLICY_JSON=str(policy_path),
        MTP2_ROMAN_REGULAR=str(regular),
        MTP2_ROMAN_BOLD=str(bold),
        PYTHONDONTWRITEBYTECODE="1",
    )
    return env


def build_condition(
    root, edition, regular, bold, coverage, log, stage, execute, hashes
):
    """Generate one product pair; the companion returns only run-owned
    numbers.
    """
    from validation import check_pair
    from windows_metrics import derive as derive_windows
    from write_fonts import process as write_otf

    source = root / "mtpro2"
    basename = identity(edition)[1]
    condition = root / "build" / edition
    work, final = condition / "native", condition / "final"
    work.mkdir(parents=True, exist_ok=True)
    final.mkdir(parents=True, exist_ok=True)
    policy_path = work / "source-policy.json"
    otf = work / (basename + ".otf")
    final_otf, final_ttf = (
        final / (basename + ext) for ext in (".otf", ".ttf")
    )
    # No earlier final candidate can survive a
    # failed producer in this run.
    for directory in (work, final):
        for candidate in directory.iterdir():
            if candidate.is_file() and candidate.name in {
                basename + ".otf",
                basename + ".ttf",
                "source-policy.json",
                "math-constants.json",
                "large-variant-build.json",
                "native-result.json",
                "windows-policy.json",
            }:
                candidate.unlink()
    env = backend_environment(
        edition, regular, bold, coverage, work, policy_path, "source"
    )
    tools = root / "tools"

    def run(label, command, *, environment=None):
        return execute(
            label,
            command,
            environment=env if environment is None else environment,
        )

    def tool(name, *arguments):
        run(name, [sys.executable, tools / name, *arguments])

    stage("read declarations, metrics, outlines and donor supply into catalog")
    inputs = catalog.read_inputs(
        source,
        work / "pl",
        edition,
        regular,
        bold,
        run=run,
        hashes=hashes,
        stage=stage,
    )
    stage("apply mappings and plan construction dependencies")
    generation = plan.create(inputs, coverage)
    stage("preflight required source slots and donor supply")
    from validation import check_sources

    check_sources(inputs, generation, coverage, log=log)
    log.flush()
    inputs.bind_plan(generation)
    snapshot = inputs.snapshot(coverage, generation)
    write_contract(policy_path, condition_snapshot(snapshot, "source"))
    run(
        "FontForge build",
        [
            "fontforge",
            "-lang=py",
            "-script",
            tools / "build_font.py",
            "--purpose",
            "font",
        ],
    )
    for artifact in (otf, work / "native-result.json"):
        if not artifact.is_file():
            raise ValueError(
                "producer did not write " + str(artifact.relative_to(root))
            )
    stage("OpenType postprocessing")
    log.flush()
    write_otf(
        otf,
        final_otf,
        work=work,
        catalog=inputs,
        snapshot=condition_snapshot(snapshot, "source"),
        alternate_path=ALTERNATE_PATH,
    )
    if not final_otf.is_file():
        raise ValueError("postprocessor did not write final OTF")
    source_sha = hashlib.sha256(final_otf.read_bytes()).hexdigest()
    metrics = measure_compatibility(
        root, edition, regular, bold, coverage, snapshot, run
    )
    if hashlib.sha256(final_otf.read_bytes()).hexdigest() != source_sha:
        raise ValueError("source OTF changed during compatibility measurement")
    windows_path = final / "windows-policy.json"
    write_contract(
        windows_path,
        derive_windows(final_otf, metrics, snapshot, source_sha),
    )
    tool(
        "otf2ttf.py",
        final_otf,
        final_ttf,
        "--windows-policy",
        windows_path,
        "--edition",
        edition,
        "--input-manifest",
        policy_path,
    )
    stage("final serialized pair check")
    log.flush()
    check_pair(final_otf, final_ttf, edition, windows_path, policy_path)
    checked_sha = {
        font: hashlib.sha256(font.read_bytes()).hexdigest()
        for font in (final_otf, final_ttf)
    }
    return final_otf, final_ttf, checked_sha


def main(argv=None):
    _, args = parse_options(argv)
    root = Path(__file__).resolve().parents[1]
    log_path = root / "build" / args.edition / "build.log"
    try:
        log_path.parent.mkdir(parents=True, exist_ok=True)
        log = log_path.open("w", encoding="utf-8")
    except OSError as exc:
        print(f"error: cannot open {log_path}: {exc}", file=sys.stderr)
        return 1
    phase = "log header"
    with log:

        def stage(label):
            nonlocal phase
            phase = label
            log.write("\nphase: " + label + "\n")
            log.flush()

        def run(label, command, *, environment=None, check=True):
            stage(label)
            log.write("command: " + repr([str(x) for x in command]) + "\n")
            log.flush()
            result = subprocess.run(
                [str(x) for x in command],
                env=environment,
                stdout=log,
                stderr=subprocess.STDOUT,
            )
            log.write(f"\nexit code: {result.returncode}\n")
            log.flush()
            if result.returncode in (130, -2):
                raise KeyboardInterrupt
            if check and result.returncode:
                raise ValueError(f"{label}: exit code {result.returncode}")
            return result

        try:
            try:
                fonttools = version("fonttools")
            except PackageNotFoundError:
                fonttools = "unavailable"
            except Exception as exc:
                fonttools = f"unretrievable ({type(exc).__name__}: {exc})"
            log.write(
                f"mtp2otf {FONT_VERSION}\n"
                f"datetime: {datetime.now(timezone.utc).isoformat()}\n"
                f"edition: {args.edition}\n"
                f"donor coverage: {args.donor_coverage}\n"
                f"argv: {repr(sys.argv if argv is None else argv)}\n"
                f"Python path: {sys.executable}\n"
                f"Python version: {sys.version}\n"
                f"fontTools version: {fonttools}\n"
            )
            stage("input identities and dependency checks")
            hashes = {}
            regular, bold = resolve_inputs(args, root, log, stage, run, hashes)
            print("building " + identity(args.edition)[0], flush=True)
            stage("prepare current build candidates")
            otf, ttf, checked_sha = build_condition(
                root,
                args.edition,
                regular,
                bold,
                args.donor_coverage,
                log,
                stage,
                run,
                hashes,
            )
            stage("publish checked fonts")
            publish_fonts(root, (otf, ttf), checked_sha)
            stage("success")
            return 0
        except (Exception, KeyboardInterrupt) as exc:
            log.write("\nfailure phase: " + phase + "\n")
            traceback.print_exc(file=log)
            log.flush()
            cause = (
                "interrupted"
                if isinstance(exc, KeyboardInterrupt)
                else (
                    str(exc).splitlines()[0][:400]
                    if str(exc)
                    else type(exc).__name__
                )
            )
            print(
                f"error: {phase}: {cause}; see {log_path.relative_to(root)}",
                file=sys.stderr,
            )
            return 130 if isinstance(exc, KeyboardInterrupt) else 1


if __name__ == "__main__":
    raise SystemExit(main())
