#!/usr/bin/env python3

"""Flip every compositor-backed setting, read it back, and put it back.

The existing write contract does this for five settings. There are sixty-five,
and the ones it does not cover are exactly where a silently-dead setting would
live: nothing fails, nothing logs, the row just does not do anything.

Each setting is driven through SystemSettings.commitPreference -- the same entry
point a settings row uses -- so this exercises the real routing, the real
compositor write, and the real read-back verification, not a shape.

Every original value is captured before anything changes and restored at the
end, through hyprctl directly, so a broken write path cannot also break the
restore.
"""

from __future__ import annotations

import ast
import json
import os
import re
import subprocess
import sys
import time

SCHEMA = "config/dot/quickshell/config/PreferenceSchema.qml"

# Read-back shapes this sweep knows how to compare. A gradient or a vec2 has no
# single scalar to diff, and guessing one would produce false failures; those
# are reported as skipped rather than quietly counted as verified.
#
# "css" is the CSS-shorthand shape Hyprland uses for gaps: "5 5 5 5". The
# preference behind it is a single int that Hyprland expands to four sides, so
# a uniform reading compares exactly. A non-uniform one is not something the
# preference can express at all, and is skipped rather than collapsed to a
# number that would be wrong -- these two settings were the only compositor
# settings the sweep had never verified.
COMPARABLE = {"bool", "int", "float", "str", "css"}

# Settings whose value this must not choose freely.
#
# kb_layout is the one that can lock someone out of their own keyboard if a
# sweep picks a layout they cannot type in, and the existing write contract
# already covers it deliberately.
SKIP_KEYS = {"keyboardLayout", "keyboardVariant", "keyboardOptions", "keyboardModel"}


def run(command: list[str], timeout: float = 20.0) -> subprocess.CompletedProcess:
    return subprocess.run(command, capture_output=True, text=True,
                          timeout=timeout, check=False)


def local_entries() -> list[dict]:
    """User-facing settings that Panama stores itself.

    These never reach the compositor, so the compositor cannot be asked whether
    they took. What can be asked is whether the value came back out of the
    preference store -- a setting that silently fails to persist is the same
    dead row from the user's side.
    """
    source = open(SCHEMA, encoding="utf-8").read()
    entries = []
    for chunk in source.split("key: ")[1:]:
        head = chunk[:1400]
        if re.search(r"internal:\s*true", head) or re.search(r"hypr:\s*\{", head):
            continue
        key = re.match(r'"([A-Za-z0-9_]+)"', chunk)
        kind = re.search(r'type:\s*"([a-z]+)"', head)
        if not (key and kind) or kind.group(1) == "json":
            continue
        entry = {"key": key.group(1), "type": kind.group(1)}
        for bound in ("min", "max", "step"):
            found = re.search(rf"\b{bound}:\s*(-?[0-9.]+)", head)
            if found:
                entry[bound] = float(found.group(1))
        options = re.search(r"options:\s*\[(.*?)\]", head, re.S)
        if options:
            entry["values"] = [
                ast.literal_eval(value) if value.lstrip().startswith(("'", '"'))
                else int(value)
                for value in re.findall(r"value:\s*(\"[^\"]*\"|-?\d+)", options.group(1))
            ]
        entries.append(entry)
    return entries


def schema_entries() -> list[dict]:
    """Every user-facing setting that maps onto a compositor option."""
    source = open(SCHEMA, encoding="utf-8").read()
    entries = []
    for chunk in source.split("key: ")[1:]:
        head = chunk[:1400]
        if re.search(r"internal:\s*true", head):
            continue
        hypr = re.search(r"hypr:\s*\{(.*?)\n\s*\}", head, re.S)
        if not hypr:
            continue
        key = re.match(r'"([A-Za-z0-9_]+)"', chunk)
        kind = re.search(r'type:\s*"([a-z]+)"', head)
        option = re.search(r'option:\s*"([^"]+)"', hypr.group(1))
        read_as = re.search(r'readAs:\s*"([a-z]+)"', hypr.group(1))
        if not (key and kind and option):
            continue
        entry = {
            "key": key.group(1),
            "type": kind.group(1),
            "option": option.group(1),
            "readAs": read_as.group(1) if read_as else "int",
            "invert": bool(re.search(r"invert:\s*true", hypr.group(1))),
        }
        for bound in ("min", "max", "step"):
            found = re.search(rf"\b{bound}:\s*(-?[0-9.]+)", head)
            if found:
                entry[bound] = float(found.group(1))
        options = re.search(r"options:\s*\[(.*?)\]", head, re.S)
        if options:
            entry["values"] = [
                ast.literal_eval(value) if value.lstrip().startswith(("'", '"'))
                else int(value)
                for value in re.findall(r"value:\s*(\"[^\"]*\"|-?\d+)", options.group(1))
            ]
        entries.append(entry)
    return entries


def read_option(entry: dict):
    """What the compositor currently holds, in the shape the schema declares."""
    result = run(["hyprctl", "-j", "getoption", entry["option"]])
    if result.returncode != 0:
        return None
    try:
        payload = json.loads(result.stdout)
    except json.JSONDecodeError:
        return None
    field = {"bool": "bool", "int": "int", "float": "float",
             "str": "str", "css": "css"}.get(entry["readAs"])
    if field is None or field not in payload:
        return None
    if entry["readAs"] == "css":
        return css_scalar(payload[field])
    return payload[field]


def css_scalar(text):
    """The single number a CSS shorthand stands for, or None if it is not one.

    Hyprland answers "5 5 5 5" for a gap of five. The preference is one int, so
    four equal sides read back exactly; four different ones mean something this
    setting cannot have produced, and returning any one of them would report a
    write as verified against a value it never wrote.
    """
    parts = str(text or "").split()
    if not parts:
        return None
    try:
        numbers = [int(part) for part in parts]
    except ValueError:
        return None
    return numbers[0] if len(set(numbers)) == 1 else None


def stored_from_option(entry: dict, raw):
    """The value a preference would hold for this compositor reading."""
    if entry["readAs"] == "bool":
        value = bool(raw)
        return (not value) if entry["invert"] else value
    if entry["readAs"] == "int" and entry["type"] == "bool":
        value = bool(raw)
        return (not value) if entry["invert"] else value
    return raw


def pick_target(entry: dict, current):
    """A valid value this setting does not currently hold."""
    if entry["type"] == "bool":
        return not bool(current)
    if entry["type"] == "enum":
        for candidate in entry.get("values", []):
            if candidate != current:
                return candidate
        return None
    if entry["type"] in ("int", "real"):
        low = entry.get("min", 0.0)
        high = entry.get("max", max(low + 1.0, float(current or 0) + 1.0))
        step = entry.get("step", 1.0)
        candidate = float(current or 0) + step
        if candidate > high:
            candidate = float(current or 0) - step
        if candidate < low:
            candidate = low if low != current else high
        if entry["type"] == "int":
            candidate = int(round(candidate))
        if candidate == current:
            return None
        return candidate
    return None


def ipc(config_home: str, harness: str, *arguments: str) -> subprocess.CompletedProcess:
    environment = dict(os.environ, XDG_CONFIG_HOME=config_home)
    return subprocess.run(["qs", "-p", harness, "ipc", "call", "settings-system-test", *arguments],
                          capture_output=True, text=True, timeout=30, check=False,
                          env=environment)


def main() -> int:
    # Driven by settings-write-sweep-contract, which starts the harness this
    # needs and restores the desktop afterwards. Run bare -- by a suite runner
    # walking the directory, say -- it says so instead of failing on argv.
    if len(sys.argv) < 3:
        print("settings_write_sweep is driven by tests/quickshell/"
              "settings-write-sweep-contract; run that instead.")
        return 0

    config_home = sys.argv[1]
    harness = sys.argv[2]

    entries = [entry for entry in schema_entries() if entry["key"] not in SKIP_KEYS]
    verified, skipped, failures, originals = [], [], [], {}

    for entry in entries:
        raw = read_option(entry)
        if raw is None or entry["readAs"] not in COMPARABLE:
            skipped.append((entry["key"], f"read-back shape {entry['readAs']}"))
            continue

        current = stored_from_option(entry, raw)
        originals[entry["key"]] = (entry, raw)
        target = pick_target(entry, current)
        if target is None:
            skipped.append((entry["key"], "no second valid value"))
            continue

        # Whatever happens below, this setting goes back to where it was
        # before the next one is touched. A sweep that dies holding sixty
        # settings at test values would be worse than no sweep.
        try:
            answer = ipc(config_home, harness, "commit", entry["key"], json.dumps(target))
            if answer.returncode != 0:
                failures.append((entry["key"], f"commit failed: {answer.stderr.strip()[:80]}"))
                continue
            if answer.stdout.strip().lower() not in ("true", ""):
                failures.append((entry["key"], f"commit refused the value ({answer.stdout.strip()[:40]})"))
                continue

            # The compositor is asked again for up to a second. commitPreference
            # verifies before storing, so a correct write is usually visible
            # immediately -- but "usually" makes a flaky test, and a flaky test
            # is worse than none. A write that never happens never matches, no
            # matter how long this waits.
            after = None
            matched = False
            deadline = time.monotonic() + 1.0
            while True:
                after = stored_from_option(entry, read_option(entry))
                if isinstance(target, float) or isinstance(after, float):
                    matched = after is not None and abs(float(after) - float(target)) < 0.02
                else:
                    matched = after == target
                if matched or time.monotonic() > deadline:
                    break
                time.sleep(0.05)
            if not matched:
                failures.append((entry["key"],
                                 f"set to {target!r} but the compositor reports {after!r} "
                                 f"({entry['option']})"))
                continue
            verified.append(entry["key"])
        finally:
            ipc(config_home, harness, "commit", entry["key"], json.dumps(current))

    # ── Settings Panama stores itself ────────────────────────────────────────
    # The harness answers its IPC socket slightly before it can serve queries,
    # and a read in that window comes back "Not ready to accept queries yet" --
    # which is a race in this test, not a setting that failed to round-trip.
    for _ in range(40):
        probe = ipc(config_home, harness, "stored", "use24Hour")
        if probe.returncode == 0 and "not ready" not in probe.stdout.lower():
            break
        time.sleep(0.25)

    local_verified, local_skipped, local_failures = [], [], []
    for entry in local_entries():
        raw = ipc(config_home, harness, "stored", entry["key"])
        if raw.returncode != 0:
            local_failures.append((entry["key"], "could not be read from the store"))
            continue
        try:
            current = json.loads(raw.stdout.strip() or "null")
        except json.JSONDecodeError:
            local_failures.append((entry["key"], f"stored value is not readable: {raw.stdout.strip()[:40]}"))
            continue

        target = pick_target(entry, current)
        if target is None:
            local_skipped.append((entry["key"], "no second valid value"))
            continue

        try:
            answer = ipc(config_home, harness, "commit", entry["key"], json.dumps(target))
            if answer.returncode != 0 or answer.stdout.strip().lower() not in ("true", ""):
                local_failures.append((entry["key"], "the store refused the value"))
                continue
            back = ipc(config_home, harness, "stored", entry["key"])
            try:
                after = json.loads(back.stdout.strip() or "null")
            except json.JSONDecodeError:
                after = None
            if isinstance(target, float) or isinstance(after, float):
                matched = after is not None and abs(float(after) - float(target)) < 0.02
            else:
                matched = after == target
            if not matched:
                local_failures.append((entry["key"], f"set to {target!r} but the store reports {after!r}"))
                continue
            local_verified.append(entry["key"])
        finally:
            ipc(config_home, harness, "commit", entry["key"], json.dumps(current))

    print(json.dumps({
        "verified": verified,
        "skipped": skipped,
        "failures": failures,
        "total": len(entries),
        "localVerified": local_verified,
        "localSkipped": local_skipped,
        "localFailures": local_failures,
    }))
    return 0


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