#!/usr/bin/env python3

# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
#
# SPDX-License-Identifier: LicenseRef-MutuaL-1.2

"""Compute Golden Ratio Typography values for one font and one geometry.

Metric source, choose one:
    --font path/to/font.woff2
    --mu 2.04 [--x-ratio 0.72]

Geometry, choose one:
    --size 16 --width 640
    --size 16 --cpl 65

Examples:
    python3 scripts/compute_grt.py --font fonts/Inter-Regular.woff2 --size 16 --width 640
    python3 scripts/compute_grt.py --font fonts/Inter-Regular.woff2 --size 16 --cpl 65
    python3 scripts/compute_grt.py --font fonts/Mono.woff2 --sample-file prose.txt --size 15 --width 720
    python3 scripts/compute_grt.py --mu 2.04 --x-height 545 --cap-height 730 --size 16 --cpl 65

The script intentionally handles one font or metric set per invocation. Run it
again for another font, size, or measure.
"""

from __future__ import annotations

import argparse
import math
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path

# Golden ratio and GRT constants.
PHI = (1 + 5**0.5) / 2
Q_LOWER = 1 + (PHI - 1) / PHI
GOLDEN_X_RATIO = 1 / PHI
DEFAULT_WIDTH_FACTOR = 34.0
ADVISORY_RATIO = 1.9

# English-like prose weights. The values are only weights; they do not need to
# sum to exactly 100. Space is included because prose rhythm depends on it.
DEFAULT_PROSE_WEIGHTS: dict[str, float] = {
    " ": 17.0,
    "e": 10.2,
    "t": 7.7,
    "a": 6.6,
    "o": 6.3,
    "i": 5.7,
    "n": 5.7,
    "s": 5.3,
    "r": 5.0,
    "h": 5.0,
    "l": 3.3,
    "d": 3.3,
    "u": 2.3,
    "c": 2.2,
    "m": 2.0,
    "f": 1.8,
    "w": 1.7,
    "g": 1.6,
    "y": 1.6,
    "p": 1.5,
    "b": 1.2,
    "v": 0.8,
    "k": 0.5,
    "j": 0.1,
    "x": 0.1,
    "q": 0.1,
    "z": 0.07,
    ",": 1.0,
    ".": 0.7,
    "'": 0.3,
    "-": 0.2,
}


@dataclass(frozen=True)
class Coverage:
    source: str
    matched_weight: float
    total_weight: float
    missing_characters: tuple[str, ...]

    @property
    def matched_percent(self) -> float:
        if self.total_weight == 0:
            return 0.0
        return self.matched_weight * 100 / self.total_weight


@dataclass(frozen=True)
class FontMetrics:
    source: str
    mu: float
    units_per_em: int | None
    avg_advance: float | None
    x_height: float | None
    cap_height: float | None
    x_ratio: float | None
    x_ratio_source: str
    coverage: Coverage | None


@dataclass(frozen=True)
class Geometry:
    size: float
    width: float
    cpl: float


def positive_float(raw: str) -> float:
    try:
        value = float(raw)
    except ValueError as error:
        raise argparse.ArgumentTypeError("must be a number") from error
    if not math.isfinite(value) or value <= 0:
        raise argparse.ArgumentTypeError(
            "must be a finite number greater than 0"
        )
    return value


def ratio_float(raw: str) -> float:
    value = positive_float(raw)
    if value <= 1:
        raise argparse.ArgumentTypeError("must be greater than 1")
    return value


def non_negative_int(raw: str) -> int:
    try:
        value = int(raw, 10)
    except ValueError as error:
        raise argparse.ArgumentTypeError("must be an integer") from error
    if value < 0:
        raise argparse.ArgumentTypeError("must be 0 or greater")
    return value


def normalise_sample_text(text: str) -> str:
    return "".join(" " if ch.isspace() else ch for ch in text)


def weights_from_text(text: str, source: str) -> dict[str, float]:
    counts = Counter(normalise_sample_text(text))
    if not counts:
        raise ValueError(f"{source} is empty")
    return {ch: float(count) for ch, count in counts.items()}


def load_weights(args: argparse.Namespace) -> tuple[str, dict[str, float]]:
    if args.sample_text is not None:
        return "sample text", weights_from_text(
            args.sample_text, "--sample-text"
        )
    if args.sample_file is not None:
        try:
            text = args.sample_file.read_text(encoding="utf-8")
        except OSError as error:
            raise ValueError(
                f"cannot read --sample-file {args.sample_file}: {error}"
            ) from error
        except UnicodeDecodeError as error:
            raise ValueError(
                f"--sample-file {args.sample_file} is not valid UTF-8: {error}"
            ) from error
        return f"sample file {args.sample_file}", weights_from_text(
            text, str(args.sample_file)
        )
    return "default English prose", dict(DEFAULT_PROSE_WEIGHTS)


def glyph_name(font: object, codepoint: int) -> str | None:
    cmap = font.getBestCmap()
    if cmap is None:
        return None
    glyph = cmap.get(codepoint)
    if glyph is None:
        return None
    if isinstance(glyph, int):
        glyph_order = font.getGlyphOrder()
        if 0 <= glyph < len(glyph_order):
            return glyph_order[glyph]
        return None
    return glyph


def weighted_average_advance(
    font: object,
    weights: dict[str, float],
    weight_source: str,
) -> tuple[float, Coverage]:
    metrics = font["hmtx"].metrics
    total_width = 0.0
    matched_weight = 0.0
    total_weight = 0.0
    missing_characters: list[str] = []

    for ch, weight in weights.items():
        if weight <= 0:
            continue
        total_weight += weight
        name = glyph_name(font, ord(ch))
        if name is None or name not in metrics:
            missing_characters.append(ch)
            continue
        total_width += float(metrics[name][0]) * weight
        matched_weight += weight

    if total_weight == 0:
        raise ValueError(f"{weight_source} has no positive weights")
    if matched_weight == 0:
        raise ValueError(
            f"no weighted characters from {weight_source} exist in the font"
        )

    coverage = Coverage(
        source=weight_source,
        matched_weight=matched_weight,
        total_weight=total_weight,
        missing_characters=tuple(missing_characters),
    )
    return total_width / matched_weight, coverage


def os2_heights(
    font: object,
) -> tuple[float | None, float | None, str | None, str | None]:
    os2 = font.get("OS/2")
    if os2 is None:
        return None, None, None, None

    sx_height = float(getattr(os2, "sxHeight", 0) or 0)
    cap_height = float(getattr(os2, "sCapHeight", 0) or 0)
    x_value = sx_height if sx_height > 0 else None
    cap_value = cap_height if cap_height > 0 else None
    x_source = "OS/2 sxHeight" if x_value is not None else None
    cap_source = "OS/2 sCapHeight" if cap_value is not None else None
    return x_value, cap_value, x_source, cap_source


def glyph_y_max(
    font: object, candidates: tuple[str, ...]
) -> tuple[float | None, str | None]:
    from fontTools.pens.boundsPen import BoundsPen

    glyph_set = font.getGlyphSet()
    for ch in candidates:
        name = glyph_name(font, ord(ch))
        if name is None:
            continue
        try:
            glyph = glyph_set[name]
        except KeyError:
            continue
        pen = BoundsPen(glyph_set)
        glyph.draw(pen)
        if pen.bounds is None:
            continue
        _x_min, _y_min, _x_max, y_max = pen.bounds
        if y_max > 0:
            return float(y_max), f"glyph bounds for {ch!r}"
    return None, None


def measured_x_ratio(
    font: object,
) -> tuple[float | None, float | None, float | None, str]:
    x_height, cap_height, x_source, cap_source = os2_heights(font)

    if x_height is None:
        x_height, x_source = glyph_y_max(font, ("x", "o"))
    if cap_height is None:
        cap_height, cap_source = glyph_y_max(font, ("H", "T"))

    if x_height is None or cap_height is None:
        return x_height, cap_height, None, "not available"
    return (
        x_height,
        cap_height,
        x_height / cap_height,
        f"{x_source} / {cap_source}",
    )


def measure_font(
    path: Path, weights: dict[str, float], weight_source: str
) -> FontMetrics:
    try:
        from fontTools.ttLib import TTFont, TTLibError
    except ImportError as error:
        raise ValueError(
            "reading --font requires fontTools; install it with "
            "`python3 -m pip install fonttools brotli`"
        ) from error

    try:
        font = TTFont(str(path))
    except (OSError, TTLibError) as error:
        raise ValueError(f"cannot read font {path}: {error}") from error

    try:
        try:
            units_per_em = int(font["head"].unitsPerEm)
            _ = font["hmtx"].metrics
        except KeyError as error:
            raise ValueError(
                f"font {path} is missing required table {error}"
            ) from error

        avg_advance, coverage = weighted_average_advance(
            font, weights, weight_source
        )
        x_height, cap_height, x_ratio, x_ratio_source = measured_x_ratio(font)
    finally:
        font.close()

    return FontMetrics(
        source=f"font {path}",
        mu=units_per_em / avg_advance,
        units_per_em=units_per_em,
        avg_advance=avg_advance,
        x_height=x_height,
        cap_height=cap_height,
        x_ratio=x_ratio,
        x_ratio_source=x_ratio_source,
        coverage=coverage,
    )


def with_manual_x_metrics(
    metrics: FontMetrics,
    x_ratio: float | None,
    x_height: float | None,
    cap_height: float | None,
) -> FontMetrics:
    if x_ratio is None and x_height is None and cap_height is None:
        return metrics

    if x_ratio is not None:
        return FontMetrics(
            source=metrics.source,
            mu=metrics.mu,
            units_per_em=metrics.units_per_em,
            avg_advance=metrics.avg_advance,
            x_height=metrics.x_height,
            cap_height=metrics.cap_height,
            x_ratio=x_ratio,
            x_ratio_source="manual --x-ratio",
            coverage=metrics.coverage,
        )

    assert x_height is not None
    assert cap_height is not None
    return FontMetrics(
        source=metrics.source,
        mu=metrics.mu,
        units_per_em=metrics.units_per_em,
        avg_advance=metrics.avg_advance,
        x_height=x_height,
        cap_height=cap_height,
        x_ratio=x_height / cap_height,
        x_ratio_source="manual --x-height/--cap-height",
        coverage=metrics.coverage,
    )


def manual_metrics(
    mu: float,
    x_ratio: float | None,
    x_height: float | None,
    cap_height: float | None,
) -> FontMetrics:
    if x_ratio is None and x_height is not None and cap_height is not None:
        x_ratio = x_height / cap_height
        x_ratio_source = "manual --x-height/--cap-height"
    elif x_ratio is not None:
        x_ratio_source = "manual --x-ratio"
    else:
        x_ratio_source = "not supplied"

    return FontMetrics(
        source="manual --mu",
        mu=mu,
        units_per_em=None,
        avg_advance=None,
        x_height=x_height,
        cap_height=cap_height,
        x_ratio=x_ratio,
        x_ratio_source=x_ratio_source,
        coverage=None,
    )


def cpl_for_width(width: float, size: float, mu: float) -> float:
    assert width > 0
    assert size > 0
    assert mu > 0
    return width * mu / size


def width_for_cpl(cpl: float, size: float, mu: float) -> float:
    assert cpl > 0
    assert size > 0
    assert mu > 0
    return cpl * size / mu


def geometry_from_args(args: argparse.Namespace, mu: float) -> Geometry:
    if args.width is not None:
        width = args.width
        cpl = cpl_for_width(width, args.size, mu)
    else:
        assert args.cpl is not None
        cpl = args.cpl
        width = width_for_cpl(cpl, args.size, mu)
    return Geometry(size=args.size, width=width, cpl=cpl)


def grt_line_height(
    size: float,
    width: float,
    mu: float,
    x_ratio: float | None,
    width_factor: float,
) -> tuple[float, float, float]:
    """Return base line height, corrected line height, and correction factor.

    From https://grtcalculator.com/math/:
        width factor x_w = CPL / mu
        content width w  = CPL * f / mu, therefore CPL = w * mu / f
        h_base = f * (q_lower + (phi - q_lower) * (CPL / x_w))

    The default width factor is 34, matching the public GRT calculator.
    """
    assert size > 0
    assert width > 0
    assert mu > 0
    assert width_factor > 0

    cpl = cpl_for_width(width, size, mu)
    h_base = size * (Q_LOWER + (PHI - Q_LOWER) * (cpl / width_factor))
    correction = (x_ratio / GOLDEN_X_RATIO) if x_ratio is not None else 1.0
    h_corrected = h_base * correction
    return h_base, h_corrected, correction


def describe_character(ch: str) -> str:
    if ch == " ":
        return "space"
    if ch.isprintable():
        return ch
    return f"U+{ord(ch):04X}"


def describe_missing(characters: tuple[str, ...]) -> str:
    if not characters:
        return "none"
    limit = 12
    shown = ", ".join(describe_character(ch) for ch in characters[:limit])
    remaining = len(characters) - limit
    if remaining > 0:
        return f"{shown}, … ({remaining} more)"
    return shown


def fmt_optional(value: float | int | None, suffix: str = "") -> str:
    if value is None:
        return "N/A"
    if isinstance(value, int):
        return f"{value}{suffix}"
    return f"{value:.4f}{suffix}"


def print_report(
    args: argparse.Namespace, metrics: FontMetrics, geometry: Geometry
) -> tuple[float, float]:
    h_base, h_corrected, correction = grt_line_height(
        geometry.size,
        geometry.width,
        metrics.mu,
        metrics.x_ratio,
        args.width_factor,
    )
    spacing_h = h_corrected if args.spacing_source == "corrected" else h_base

    print("Golden Ratio Typography")
    print("=======================")
    print(f"metric source: {metrics.source}")
    print(f"mu:            {metrics.mu:.4f}  (character constant)")
    print(f"units/em:      {fmt_optional(metrics.units_per_em)}")
    print(f"avg advance:   {fmt_optional(metrics.avg_advance)}")
    if metrics.coverage is not None:
        print(f"weights:       {metrics.coverage.source}")
        print(
            f"coverage:      {metrics.coverage.matched_percent:.1f}% weight matched; "
            f"missing: {describe_missing(metrics.coverage.missing_characters)}"
        )
    print(f"x-height:      {fmt_optional(metrics.x_height)}")
    print(f"cap-height:    {fmt_optional(metrics.cap_height)}")
    print(
        f"x-ratio:       {fmt_optional(metrics.x_ratio)}  ({metrics.x_ratio_source})"
    )
    print(f"correction:    {correction:.4f}  (x-ratio / 1/phi)")
    print()

    print("Geometry")
    print("--------")
    print(f"font size:     {geometry.size:g}px")
    print(f"measure:       {geometry.width:.3f}px")
    print(f"cpl:           {geometry.cpl:.1f}")
    print(f"width factor:  {args.width_factor:g}")
    print()

    print("Line height")
    print("-----------")
    print(f"h_base:        {h_base:.3f}px  ratio {h_base / geometry.size:.4f}")
    print(
        f"h_corrected:   {h_corrected:.3f}px  ratio {h_corrected / geometry.size:.4f}"
    )
    print()

    print(f"Spacing units (from h_{args.spacing_source})")
    print("-----------------------------")
    print(f"h/phi^2:       {spacing_h / PHI**2:.3f}px")
    print(f"h/phi:         {spacing_h / PHI:.3f}px")
    print(f"h:             {spacing_h:.3f}px")
    print(f"h*phi:         {spacing_h * PHI:.3f}px")
    print(f"h*phi^2:       {spacing_h * PHI**2:.3f}px")
    print()

    print(f"Type scale (ratio {args.scale_ratio:g}, root {args.root_size:g}px)")
    print("----------------------------------------")
    for step in range(-args.scale_steps, args.scale_steps + 1):
        px = geometry.size * (args.scale_ratio**step)
        rem = px / args.root_size
        print(f"step {step:+d}:       {px:7.3f}px  {rem:.4f}rem")

    if args.reference_cpl:
        print()
        print("Measure reference")
        print("-----------------")
        for target in args.reference_cpl:
            width = width_for_cpl(target, geometry.size, metrics.mu)
            print(f"{target:g} cpl:       {width:.1f}px")

    return h_base, h_corrected


def maybe_advisory(h_base: float, h_corrected: float, size: float) -> None:
    assert size > 0
    ratio = max(h_base, h_corrected) / size
    if ratio > ADVISORY_RATIO:
        print(
            f"advisory: line-height ratio {ratio:.2f} is unusually high; "
            "confirm --width/--cpl reflects the actual read length, and "
            "consider h_base.",
            file=sys.stderr,
        )


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    metric = p.add_mutually_exclusive_group(required=True)
    metric.add_argument(
        "--font", type=Path, help="path to one font file (woff2/woff/ttf/otf)"
    )
    metric.add_argument(
        "--mu",
        type=positive_float,
        help="character constant, used when no font file is available",
    )

    geometry = p.add_mutually_exclusive_group(required=True)
    geometry.add_argument(
        "--width",
        "--measure",
        dest="width",
        type=positive_float,
        help="content measure in CSS px",
    )
    geometry.add_argument(
        "--cpl",
        type=positive_float,
        help="target characters per line; measure is derived from it",
    )

    samples = p.add_mutually_exclusive_group()
    samples.add_argument(
        "--sample-text",
        help="representative text for weighted advance calculation",
    )
    samples.add_argument(
        "--sample-file",
        type=Path,
        help="UTF-8 file of representative text for weighted advance calculation",
    )

    p.add_argument(
        "--size", required=True, type=positive_float, help="font size in CSS px"
    )
    p.add_argument(
        "--x-ratio",
        type=positive_float,
        help="manual x-height/cap-height ratio, overriding measured font data",
    )
    p.add_argument(
        "--x-height",
        type=positive_float,
        help="manual x-height in font units; requires --cap-height",
    )
    p.add_argument(
        "--cap-height",
        type=positive_float,
        help="manual cap height in font units; requires --x-height",
    )
    p.add_argument(
        "--width-factor",
        type=positive_float,
        default=DEFAULT_WIDTH_FACTOR,
        help="GRT width factor; default 34",
    )
    p.add_argument(
        "--spacing-source",
        choices=("base", "corrected"),
        default="base",
        help="line height used for spacing units; default base",
    )
    p.add_argument(
        "--scale-steps",
        type=non_negative_int,
        default=4,
        help="type scale steps each direction; default 4",
    )
    p.add_argument(
        "--scale-ratio",
        type=ratio_float,
        default=1.2,
        help="type scale ratio; default 1.2",
    )
    p.add_argument(
        "--root-size",
        type=positive_float,
        default=16.0,
        help="root font size for rem output; default 16",
    )
    p.add_argument(
        "--reference-cpl",
        type=positive_float,
        nargs="*",
        default=[45.0, 55.0, 65.0, 75.0],
        help="CPL values for measure reference; pass no values to omit",
    )
    return p


def validate_args(p: argparse.ArgumentParser, args: argparse.Namespace) -> None:
    height_count = int(args.x_height is not None) + int(
        args.cap_height is not None
    )
    if height_count == 1:
        p.error("--x-height and --cap-height must be used together")
    if args.x_ratio is not None and height_count > 0:
        p.error("--x-ratio cannot be combined with --x-height/--cap-height")
    if args.mu is not None and (
        args.sample_text is not None or args.sample_file is not None
    ):
        p.error("--sample-text and --sample-file require --font")


def main(argv: list[str] | None = None) -> int:
    p = parser()
    args = p.parse_args(argv)
    validate_args(p, args)

    try:
        if args.font is not None:
            weight_source, weights = load_weights(args)
            measured = measure_font(args.font, weights, weight_source)
            metrics = with_manual_x_metrics(
                measured, args.x_ratio, args.x_height, args.cap_height
            )
        else:
            assert args.mu is not None
            metrics = manual_metrics(
                args.mu, args.x_ratio, args.x_height, args.cap_height
            )
        geometry = geometry_from_args(args, metrics.mu)
        h_base, h_corrected = print_report(args, metrics, geometry)
        maybe_advisory(h_base, h_corrected, geometry.size)
    except ValueError as error:
        p.error(str(error))

    return 0


if __name__ == "__main__":
    sys.exit(main())
