"""ddcutil backend: subprocess invocation + output parsing. No GTK imports.

Parsing functions are pure (string in, dataclass out) so they are unit-tested
against captured ddcutil output. Subprocess wrappers are thin and route every
call through an argv array (never a shell string) with an explicit timeout.
"""

from __future__ import annotations

import re
import shutil
import subprocess

from .models import (
    Access,
    Display,
    Feature,
    FeatureCaps,
    FeatureMeta,
    FeatureValue,
    ValueType,
)

DDCUTIL = "ddcutil"
DEFAULT_TIMEOUT = 15.0


class DdcError(RuntimeError):
    """ddcutil returned a non-zero exit status or could not be run."""


# --------------------------------------------------------------------------- #
# Subprocess
# --------------------------------------------------------------------------- #
def have_ddcutil() -> bool:
    return shutil.which(DDCUTIL) is not None


def run_ddcutil(args: list[str], timeout: float = DEFAULT_TIMEOUT) -> tuple[int, str, str]:
    """Run ddcutil with an argv array. Returns (returncode, stdout, stderr)."""
    try:
        proc = subprocess.run(  # noqa: S603 - argv array, no shell, fixed binary
            [DDCUTIL, *args],
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )
    except FileNotFoundError as exc:
        raise DdcError(f"{DDCUTIL} not found on PATH") from exc
    except subprocess.TimeoutExpired as exc:
        raise DdcError(f"ddcutil timed out after {timeout}s: {' '.join(args)}") from exc
    return proc.returncode, proc.stdout, proc.stderr


def _display_args(display_no: int) -> list[str]:
    return ["--display", str(display_no)]


# --------------------------------------------------------------------------- #
# detect
# --------------------------------------------------------------------------- #
_RE_DISPLAY = re.compile(r"^Display\s+(\d+)\s*$")
_RE_BUS = re.compile(r"I2C bus:\s*/dev/i2c-(\d+)")
_RE_DRM = re.compile(r"DRM connector:\s*(\S+)")
_RE_MONITOR = re.compile(r"Monitor:\s*([^:]*):([^:]*):(.*)$")


def parse_detect(text: str) -> list[Display]:
    """Parse `ddcutil detect --terse` output into Display records."""
    displays: list[Display] = []
    number: int | None = None
    bus: int | None = None
    drm = ""
    mfg = model = serial = ""

    def flush() -> None:
        nonlocal number, bus, drm, mfg, model, serial
        if number is not None:
            displays.append(
                Display(
                    number=number,
                    bus=bus,
                    drm_connector=drm,
                    mfg=mfg.strip(),
                    model=model.strip(),
                    serial=serial.strip(),
                )
            )
        number, bus, drm, mfg, model, serial = None, None, "", "", "", ""

    for line in text.splitlines():
        m = _RE_DISPLAY.match(line.strip())
        if m:
            flush()
            number = int(m.group(1))
            continue
        if number is None:
            continue
        if mb := _RE_BUS.search(line):
            bus = int(mb.group(1))
        elif md := _RE_DRM.search(line):
            drm = md.group(1)
        elif mm := _RE_MONITOR.search(line):
            mfg, model, serial = mm.group(1), mm.group(2), mm.group(3)
    flush()
    return displays


def list_displays(timeout: float = DEFAULT_TIMEOUT) -> list[Display]:
    rc, out, err = run_ddcutil(["detect", "--terse"], timeout=timeout)
    # rc may be non-zero when some displays are invalid; still parse stdout.
    displays = parse_detect(out)
    if not displays and rc != 0:
        raise DdcError(err.strip() or out.strip() or "no displays detected")
    return displays


# --------------------------------------------------------------------------- #
# capabilities
# --------------------------------------------------------------------------- #
_RE_FEATURE = re.compile(r"^\s+Feature:\s*([0-9A-Fa-f]+)\s*\((.*)\)\s*$")
_RE_VALUE = re.compile(r"^\s+([0-9A-Fa-f]+):\s*(.*\S)\s*$")
_RE_MCCS = re.compile(r"MCCS version:\s*([\d.]+)", re.IGNORECASE)


def parse_capabilities(text: str) -> tuple[dict[int, FeatureCaps], str]:
    """Parse `ddcutil capabilities` -> ({code: FeatureCaps}, mccs_version)."""
    caps: dict[int, FeatureCaps] = {}
    mccs = ""
    current: FeatureCaps | None = None
    in_values = False

    for line in text.splitlines():
        if mv := _RE_MCCS.search(line):
            mccs = mv.group(1)
            continue
        if mf := _RE_FEATURE.match(line):
            current = FeatureCaps(code=int(mf.group(1), 16), name=mf.group(2).strip())
            caps[current.code] = current
            in_values = False
            continue
        if "Values:" in line:
            in_values = True
            continue
        if in_values and current is not None and (vm := _RE_VALUE.match(line)):
            current.values[int(vm.group(1), 16)] = vm.group(2).strip()
    return caps, mccs


def read_capabilities(display_no: int, timeout: float = 30.0) -> tuple[dict[int, FeatureCaps], str]:
    rc, out, err = run_ddcutil([*_display_args(display_no), "capabilities"], timeout=timeout)
    if rc != 0 and not out.strip():
        raise DdcError(err.strip() or "failed to read capabilities")
    return parse_capabilities(out)


# --------------------------------------------------------------------------- #
# vcpinfo (static MCCS metadata)
# --------------------------------------------------------------------------- #
_RE_VCPINFO_HEAD = re.compile(r"^VCP code\s+([0-9A-Fa-f]+):\s*(.*)$")
_RE_ATTR = re.compile(r"Attributes(?:\s*\(v([\d.]+)\))?:\s*(.*)$")
_RE_SNC_VALUE = re.compile(r"^\s+0x([0-9A-Fa-f]+):\s*(.*\S)\s*$")


def _attr_access(attrs: str) -> Access:
    if "Read Write" in attrs:
        return Access.READ_WRITE
    if "Read Only" in attrs:
        return Access.READ_ONLY
    if "Write Only" in attrs:
        return Access.WRITE_ONLY
    return Access.UNKNOWN


def _attr_type(attrs: str) -> ValueType:
    if "Continuous" in attrs and "Non-Continuous" not in attrs:
        return ValueType.CONTINUOUS
    if "Non-Continuous" in attrs:
        if "complex" in attrs:
            return ValueType.COMPLEX_NC
        if "simple" in attrs:
            return ValueType.SIMPLE_NC
        return ValueType.NON_CONT
    if "Table" in attrs:
        return ValueType.TABLE
    return ValueType.UNKNOWN


def parse_vcpinfo(text: str, code: int, mccs_version: str = "") -> FeatureMeta:
    """Parse `ddcutil vcpinfo <code>`. Picks the attribute line matching the
    monitor's MCCS version when several are present."""
    name = ""
    access = Access.UNKNOWN
    vtype = ValueType.UNKNOWN
    values: dict[int, str] = {}
    chosen_ver = ""
    in_values = False

    for line in text.splitlines():
        if mh := _RE_VCPINFO_HEAD.match(line):
            name = mh.group(2).strip()
            continue
        if ma := _RE_ATTR.search(line):
            ver, attrs = ma.group(1) or "", ma.group(2)
            # Prefer exact MCCS match; else first non-versioned/any line.
            take = (
                access == Access.UNKNOWN
                or (mccs_version and ver == mccs_version)
                or (not ver and not chosen_ver)
            )
            if mccs_version and chosen_ver == mccs_version and ver != mccs_version:
                take = False
            if take:
                access, vtype, chosen_ver = _attr_access(attrs), _attr_type(attrs), ver
            continue
        if "NC values" in line:
            in_values = True
            continue
        if in_values and (mv := _RE_SNC_VALUE.match(line)):
            values[int(mv.group(1), 16)] = mv.group(2).strip()
    return FeatureMeta(code=code, name=name, access=access, vtype=vtype, values=values)


def parse_vcpinfo_all(text: str, mccs_version: str = "") -> dict[int, FeatureMeta]:
    """Parse `ddcutil vcpinfo ALL` -> {code: FeatureMeta} by splitting on the
    per-feature `VCP code XX:` headers and reusing the single-block parser."""
    metas: dict[int, FeatureMeta] = {}
    code: int | None = None
    buf: list[str] = []

    def flush() -> None:
        if code is not None:
            metas[code] = parse_vcpinfo("\n".join(buf), code, mccs_version)

    for line in text.splitlines():
        head = _RE_VCPINFO_HEAD.match(line)
        if head:
            flush()
            code = int(head.group(1), 16)
            buf = [line]
        elif code is not None:
            buf.append(line)
    flush()
    return metas


def read_vcpinfo_all(mccs_version: str = "", timeout: float = 10.0) -> dict[int, FeatureMeta]:
    """Static MCCS metadata for all known feature codes (one fast call)."""
    _, out, _ = run_ddcutil(["vcpinfo", "ALL"], timeout=timeout)
    return parse_vcpinfo_all(out, mccs_version)


# --------------------------------------------------------------------------- #
# getvcp
# --------------------------------------------------------------------------- #
# `--verbose` prints a "Raw value:" line for every feature the monitor answers,
# carrying max_val and cur_val for ALL types (continuous, NC, complex,
# manufacturer) — the one uniform source of a feature's range.
_RE_RAW = re.compile(
    r"Raw value:\s*opcode=0x([0-9A-Fa-f]+),.*?max_val=(\d+).*?cur_val=(\d+)",
    re.IGNORECASE,
)

# Manufacturer-specific codes live outside ddcutil's known table, so `getvcp ALL`
# never reaches them; probe the conventional vendor range explicitly.
MANUFACTURER_CODES: tuple[int, ...] = tuple(range(0xE0, 0x100))


def parse_getvcp_verbose(text: str) -> dict[int, FeatureValue]:
    """Parse `ddcutil getvcp ... --verbose` -> {code: FeatureValue} from the
    `Raw value:` lines (current + maximum for every responding feature)."""
    result: dict[int, FeatureValue] = {}
    for line in text.splitlines():
        m = _RE_RAW.search(line)
        if m is None:
            continue
        code = int(m.group(1), 16)
        result[code] = FeatureValue(
            code=code,
            vtype=ValueType.UNKNOWN,
            current=int(m.group(3)),
            maximum=int(m.group(2)),
            raw=line.strip(),
        )
    return result


def get_value(display_no: int, code: int, timeout: float = 10.0) -> FeatureValue:
    rc, out, err = run_ddcutil(
        [*_display_args(display_no), "getvcp", f"{code:02X}", "--verbose"], timeout=timeout
    )
    parsed = parse_getvcp_verbose(out)
    value = parsed.get(code)
    if value is None:
        return FeatureValue(code=code, vtype=ValueType.UNKNOWN, supported=False, raw=err.strip())
    return value


def read_getvcp_all(display_no: int, timeout: float = 60.0) -> dict[int, FeatureValue]:
    """Read every known feature (`getvcp ALL`) plus the manufacturer range, each
    in a single multi-code i2c sweep. Returns {code: FeatureValue}."""
    _, out, _ = run_ddcutil(
        [*_display_args(display_no), "getvcp", "ALL", "--verbose"], timeout=timeout
    )
    values = parse_getvcp_verbose(out)

    mfg_codes = [f"{c:02X}" for c in MANUFACTURER_CODES]
    _, mfg_out, _ = run_ddcutil(
        [*_display_args(display_no), "getvcp", *mfg_codes, "--verbose"], timeout=timeout
    )
    values.update(parse_getvcp_verbose(mfg_out))
    return values


# --------------------------------------------------------------------------- #
# setvcp
# --------------------------------------------------------------------------- #
def set_value(
    display_no: int,
    code: int,
    value: int,
    *,
    continuous: bool,
    verify: bool = True,
    permit_unknown: bool = False,
    timeout: float = 10.0,
) -> None:
    arg = str(value) if continuous else f"0x{value:02x}"
    args = [*_display_args(display_no), "setvcp", f"{code:02X}", arg]
    if not verify:
        args.append("--noverify")
    if permit_unknown:
        args.append("--permit-unknown-feature")
    rc, _, err = run_ddcutil(args, timeout=timeout)
    if rc != 0:
        raise DdcError(err.strip() or f"setvcp {code:02X} {arg} failed (rc={rc})")


# --------------------------------------------------------------------------- #
# Composite: build the full feature list for a display
# --------------------------------------------------------------------------- #
def assemble_features(
    caps: dict[int, FeatureCaps],
    metas: dict[int, FeatureMeta],
    values: dict[int, FeatureValue],
    mccs: str,
) -> list[Feature]:
    """Merge capability list, static metadata and read values into UI features.

    Coverage = every feature the monitor actually responds to (`getvcp ALL`)
    plus any code advertised in `capabilities` (e.g. write-only restore codes
    that ALL cannot read). Pure: no I/O, unit-tested.
    """
    codes = sorted(set(values) | set(caps))
    features: list[Feature] = []
    for code in codes:
        cap = caps.get(code)
        meta = metas.get(code)
        value = values.get(code)
        name = (cap.name if cap else "") or (meta.name if meta else "") or f"Feature {code:02X}"
        vtype = meta.vtype if meta else ValueType.UNKNOWN
        access = meta.access if meta else Access.UNKNOWN
        # Monitor answered getvcp but ddcutil has no access flag (manufacturer
        # codes): assume it is adjustable rather than hiding it.
        if access is Access.UNKNOWN and value is not None:
            access = Access.READ_WRITE
        nc_values = (cap.values if cap and cap.values else (meta.values if meta else {})) or {}
        features.append(
            Feature(
                code=code,
                name=name,
                access=access,
                vtype=vtype,
                values=nc_values,
                value=value,
            )
        )
    return features


# Brightness (0x10) and contrast (0x12): the controls shown first, read in one
# tiny i2c call so they appear before the slow full sweep in build_features.
BASIC_CODES: tuple[int, ...] = (0x10, 0x12)


def build_basic_features(display_no: int, timeout: float = 10.0) -> list[Feature]:
    """Fast first pass: brightness and contrast only.

    Skips `capabilities` and the full `getvcp ALL` + manufacturer sweeps (the slow
    part), reading just two codes so the common controls render immediately.
    `build_features` then loads everything else.
    """
    metas = read_vcpinfo_all()
    codes = [f"{code:02X}" for code in BASIC_CODES]
    _, out, _ = run_ddcutil(
        [*_display_args(display_no), "getvcp", *codes, "--verbose"], timeout=timeout
    )
    values = parse_getvcp_verbose(out)
    return assemble_features({}, metas, values, "")


def build_features(
    display_no: int,
    *,
    read_values: bool = True,
    timeout: float = 30.0,
) -> tuple[list[Feature], str]:
    """Read capabilities + all metadata + all values and assemble UI features.

    Three ddcutil calls total (capabilities, vcpinfo ALL, getvcp ALL); the
    getvcp sweep is the slow one (one i2c pass). Run off the UI thread.
    """
    caps, mccs = read_capabilities(display_no)
    metas = read_vcpinfo_all(mccs)
    values = read_getvcp_all(display_no, timeout=timeout) if read_values else {}
    return assemble_features(caps, metas, values, mccs), mccs
