"""Shared utility: compute the runtime values of `_IO*(...)` ioctl
macros from a Linux UAPI header.

Used by:
  * ``tests/conformance/test_ioctl_registry_currency.py`` — the CI
    currency gate (Part 13 §5.2.3) that asserts every number in
    `hrb-code/src/data/driver_interfaces/*.json` matches what the
    live kernel header expands to.
  * ``scripts/regenerate_ioctl_registry.py`` — the one-shot
    regenerator that produces the JSON files from the live header
    when the maintainer is updating curated entries.

Mechanism: write a tiny C program that includes the target header
+ ``<stdio.h>``, prints each macro's expanded value as ``NAME=0xHEX``,
then compile-and-run it. The kernel headers' `_IO*` macros are
sizeof-dependent for `_IOR`/`_IOW`/`_IOWR`, so reading the macro
textually isn't enough — we MUST let the C compiler evaluate
them. Same approach the kernel's own UAPI test programs use.

Variable-size ioctls (e.g. ``HIDIOCGRAWNAME(len)``) take a runtime
size parameter — they're excluded from the registry's
fixed-number set in v1. A future schema extension can carry a
size-zero base value + a "variable_size" flag.

Honest emptiness: if ``cc`` is missing, the public functions
return ``None`` from ``compute_ioctl_numbers`` so the calling
test/generator can ``pytest.skip`` rather than fail-spurious.
"""

from __future__ import annotations

import re
import shutil
import subprocess
import tempfile
from pathlib import Path


def has_compiler() -> bool:
    """True iff a C compiler is on PATH. Both ``cc`` and ``gcc``
    are acceptable — ``cc`` is the POSIX-standard symlink that
    every Linux distro provides."""
    return shutil.which("cc") is not None or shutil.which("gcc") is not None


def compute_ioctl_numbers(
    header: str,
    macros: list[str] | list[tuple[str, str]],
) -> dict[str, int] | None:
    """Compile a probe C program that prints each macro's expanded
    value; return ``{name: integer_value}``. Returns ``None`` if
    no C compiler is available (caller should skip rather than
    fail). Raises ``RuntimeError`` if compilation/execution fails
    for a substantive reason (header missing, macro misspelled,
    distro headers stale relative to the registry).

    ``header`` is the include directive without the surrounding
    quotes — e.g. ``"linux/usbdevice_fs.h"``.

    ``macros`` is EITHER:
      * ``list[str]`` — bare macro names; each evaluated as
        ``(NAME)``. Use for fixed-number macros like
        ``USBDEVFS_CONTROL``.
      * ``list[tuple[str, str]]`` — `(display_name, call_expr)`
        pairs. Use for parameterized macros — e.g.
        ``[("HIDIOCGRAWNAME", "HIDIOCGRAWNAME(0)")]`` evaluates
        the macro with `len=0` and returns the result keyed by
        the bare display name. Lets the registry canonicalise
        variable-size macros at size=0.
    """
    if not has_compiler():
        return None

    # Normalise the input to (display, expr) pairs.
    norm: list[tuple[str, str]] = []
    for m in macros:
        if isinstance(m, str):
            norm.append((m, m))
        else:
            norm.append((m[0], m[1]))

    # Build the probe C source. The Linux UAPI headers per
    # convention do NOT transitively include <linux/ioctl.h> — so
    # without it explicit, the `_IO*` macros aren't defined and
    # `USBDEVFS_CONTROL` expands to `_IOWR(...)` as a literal
    # function call rather than the integer constant. Including it
    # explicitly is what every userspace ioctl client does.
    lines = [
        "#include <linux/ioctl.h>",
        f"#include <{header}>",
        "#include <stdio.h>",
        "int main(void) {",
    ]
    for display, expr in norm:
        # `(unsigned long)` cast safely widens whatever the macro's
        # underlying type is; `%08lx` prints the canonical 32-bit
        # ioctl number as hex.
        lines.append(f'  printf("{display}=0x%08lx\\n", (unsigned long)({expr}));')
    lines.append("  return 0;")
    lines.append("}")
    source = "\n".join(lines) + "\n"

    with tempfile.TemporaryDirectory() as tmp:
        src_path = Path(tmp) / "probe.c"
        bin_path = Path(tmp) / "probe"
        src_path.write_text(source)
        cc = shutil.which("cc") or shutil.which("gcc")
        assert cc is not None  # has_compiler() returned true above
        compile_result = subprocess.run(  # noqa: S603
            [cc, "-Wall", "-Werror", "-o", str(bin_path), str(src_path)],
            check=False,
            capture_output=True,
            text=True,
        )
        if compile_result.returncode != 0:
            raise RuntimeError(
                f"compile failed for header <{header}>:\n"
                f"--- source ---\n{source}\n"
                f"--- stderr ---\n{compile_result.stderr}"
            )
        run_result = subprocess.run(  # noqa: S603
            [str(bin_path)],
            check=False,
            capture_output=True,
            text=True,
        )
        if run_result.returncode != 0:
            raise RuntimeError(f"probe run failed: stderr={run_result.stderr}")

    # Parse `NAME=0xHEX` lines.
    result: dict[str, int] = {}
    for line in run_result.stdout.splitlines():
        m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)=0x([0-9a-fA-F]+)$", line)
        if m:
            result[m.group(1)] = int(m.group(2), 16)
    return result
