#!/usr/bin/env python3

"""Carrying Panama's settings to another machine.

Different from panama-settings-backup, which snapshots this machine so it can be
put back exactly as it was, arrangement and all. This produces something meant
to travel: the preferences that describe taste rather than hardware.

Two decisions shape the whole thing.

The export is an ALLOW-LIST taken from the preference schema, not a deny-list of
things to strip. Anything the schema does not declare is dropped, so a key added
later that happens to hold a token cannot leak into a file somebody emails to
themselves. Being wrong in this direction loses a setting; being wrong the other
way publishes a secret.

The import validates every value against the schema again on arrival and skips
what does not fit, one key at a time, with a reason. A file from an older Panama
is a normal thing to have, and refusing it wholesale because one key changed
shape would make the feature useless exactly when it is most wanted.

    panama-settings-sync export PATH
    panama-settings-sync preview PATH
    panama-settings-sync import PATH
"""

from __future__ import annotations

import json
import os
import re
import socket
import sys
import time
from pathlib import Path

HOME = Path(os.environ.get("HOME", str(Path.home())))
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
SETTINGS = CONFIG_ROOT / "panama/settings.json"

SCHEMA = Path(__file__).resolve().parents[1] / "config" / "PreferenceSchema.qml"

FORMAT = "panama-settings-sync/1"

# Settings that describe this machine rather than how it should behave. Held
# separately from the allow-list because each needs a reason, and a reason is
# what stops the list growing by habit.
MACHINE_SPECIFIC = {
    # Monitor arrangement, keyed by output names that mean nothing elsewhere.
    "displays": "describes this machine's monitors",
    # Which settings page was last open. Session noise.
    "lastPage": "is where you happened to be looking",
    # Governs migration of the store itself; importing one would mislabel it.
    "schemaVersion": "belongs to the store, not to you",
}

# Settings that travel but may not land: an absolute path is only a setting on a
# machine where the file exists. Carried, then checked on arrival.
PATH_VALUED = {"wallpaperPath", "wallpaperSlideshowPaths"}


class BoundaryError(RuntimeError):
    """A user-visible validation or file failure."""


def schema() -> dict[str, dict]:
    """Every declared preference, by key.

    Parsed from the schema rather than kept as a second list here, so a setting
    added there is exportable without anyone remembering to update this.
    """
    try:
        source = SCHEMA.read_text(encoding="utf-8")
    except OSError as error:
        raise BoundaryError("The preference schema could not be read.") from error

    entries: dict[str, dict] = {}
    for chunk in source.split("key: ")[1:]:
        head = chunk[:1600]
        key = re.match(r'"([A-Za-z0-9_]+)"', chunk)
        kind = re.search(r'type:\s*"([a-z]+)"', head)
        if not (key and kind):
            continue
        entry = {"key": key.group(1), "type": kind.group(1)}
        for bound in ("min", "max"):
            found = re.search(rf"\b{bound}:\s*(-?[0-9.]+)", head)
            if found:
                entry[bound] = float(found.group(1))
        pattern = re.search(r'pattern:\s*"((?:[^"\\]|\\.)*)"', head)
        if pattern:
            entry["pattern"] = pattern.group(1).replace("\\\\", "\\")
        # Option values are quoted for a word and bare for a number -- vrrPolicy
        # is an enum of 0..3. Capturing only the quoted form left numeric enums
        # with no choices at all, which then read as unverifiable and were
        # refused: a valid setting dropped on the way in.
        options = [quoted if quoted else bare for quoted, bare
                   in re.findall(r'value:\s*(?:"([^"]*)"|(-?[0-9]+(?:\.[0-9]+)?))', head)]
        if options:
            entry["options"] = options
        entries[entry["key"]] = entry
    if not entries:
        raise BoundaryError("The preference schema yielded no settings.")
    return entries


def stored() -> dict:
    if not SETTINGS.is_file():
        return {}
    try:
        value = json.loads(SETTINGS.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as error:
        raise BoundaryError("This machine's settings could not be read.") from error
    return value if isinstance(value, dict) else {}


def fits(entry: dict, value) -> str:
    """"" if the value is usable for this setting, else why it is not."""
    kind = entry["type"]
    if kind == "bool":
        return "" if isinstance(value, bool) else "is not a yes or no"
    if kind == "enum":
        # Compared as text because an enum is words in some settings and numbers
        # in others, and JSON gives back 3 where the schema wrote 3.
        if isinstance(value, bool) or not isinstance(value, (str, int, float)):
            return "is not one of this setting's choices"
        value = str(value)
        choices = entry.get("options") or []
        # A schema entry whose choices could not be read is not grounds to
        # accept anything: an unrecognised value would be written straight into
        # the store and break whatever reads it.
        if not choices:
            return "cannot be checked against this setting's choices"
        return "" if value in choices else "is not one of this setting's choices"
    # "real" is what the schema calls a float. Spelling it "float" here meant
    # fifteen settings were accepted without their range being checked at all.
    if kind in ("int", "real", "float"):
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            return "is not a number"
        if "min" in entry and value < entry["min"]:
            return f"is below the minimum of {entry['min']:g}"
        if "max" in entry and value > entry["max"]:
            return f"is above the maximum of {entry['max']:g}"
        return ""
    if kind == "string":
        if not isinstance(value, str):
            return "is not text"
        if "options" in entry and entry["options"] and value not in entry["options"]:
            return "is not one of the choices this setting allows"
        if "pattern" in entry:
            try:
                if not re.match(entry["pattern"], value):
                    return "does not match the form this setting takes"
            except re.error:
                return ""
        return ""
    if kind == "json":
        return "" if isinstance(value, (list, dict)) else "is not a list or an object"
    return ""


def exportable() -> tuple[dict, list[dict]]:
    known = schema()
    current = stored()

    carried: dict = {}
    left: list[dict] = []
    for key, value in sorted(current.items()):
        if key in MACHINE_SPECIFIC:
            left.append({"key": key, "reason": MACHINE_SPECIFIC[key]})
            continue
        if key not in known:
            left.append({"key": key, "reason": "is not a setting this version declares"})
            continue
        problem = fits(known[key], value)
        if problem:
            left.append({"key": key, "reason": "holds a value that " + problem})
            continue
        carried[key] = value
    return carried, left


def export(path: str) -> dict:
    carried, left = exportable()
    bundle = {
        "format": FORMAT,
        "exportedAt": int(time.time()),
        "exportedFrom": socket.gethostname(),
        "settings": carried,
    }
    target = Path(path).expanduser()
    try:
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(json.dumps(bundle, indent=2, sort_keys=True) + "\n",
                          encoding="utf-8")
        # Readable only by its owner: it is not secret, but it is a description
        # of somebody's machine and there is no reason to hand it around.
        target.chmod(0o600)
    except OSError as error:
        raise BoundaryError("That file could not be written.") from error

    return {"path": str(target), "carried": len(carried), "left": left}


def read_bundle(path: str) -> dict:
    source = Path(path).expanduser()
    try:
        bundle = json.loads(source.read_text(encoding="utf-8"))
    except FileNotFoundError as error:
        raise BoundaryError("That file does not exist.") from error
    except (OSError, json.JSONDecodeError) as error:
        raise BoundaryError("That file is not a settings export.") from error
    if not isinstance(bundle, dict) or not str(bundle.get("format", "")).startswith("panama-settings-sync/"):
        raise BoundaryError("That file is not a settings export.")
    if not isinstance(bundle.get("settings"), dict):
        raise BoundaryError("That export contains no settings.")
    return bundle


def plan(path: str) -> dict:
    """What an import would do, without doing any of it."""
    bundle = read_bundle(path)
    known = schema()
    current = stored()

    apply: dict = {}
    changes: list[dict] = []
    skipped: list[dict] = []

    for key, value in sorted(bundle["settings"].items()):
        if key in MACHINE_SPECIFIC:
            skipped.append({"key": key, "reason": MACHINE_SPECIFIC[key]})
            continue
        if key not in known:
            skipped.append({"key": key, "reason": "is not a setting this version has"})
            continue
        problem = fits(known[key], value)
        if problem:
            skipped.append({"key": key, "reason": "the value " + problem})
            continue
        if key in PATH_VALUED:
            missing = [p for p in (value if isinstance(value, list) else [value])
                       if isinstance(p, str) and p and not Path(p).expanduser().exists()]
            if missing:
                skipped.append({"key": key,
                                "reason": "points at a file this machine does not have"})
                continue
        if current.get(key) == value:
            continue
        apply[key] = value
        changes.append({"key": key,
                        "from": current.get(key, None),
                        "to": value})

    return {
        "path": str(Path(path).expanduser()),
        "exportedFrom": str(bundle.get("exportedFrom", "")),
        "exportedAt": int(bundle.get("exportedAt", 0)),
        "changes": changes,
        "skipped": skipped,
        "apply": apply,
    }


def apply_import(path: str) -> dict:
    """Merge an export into this machine's settings.

    Written whole through a temporary file and a rename, so a crash midway
    leaves the old settings intact rather than half of each. Settings not named
    by the export are untouched: this is a merge, not a replacement, because an
    export from a machine that never changed a setting should not reset it here.
    """
    prepared = plan(path)
    if not prepared["apply"]:
        return {**prepared, "applied": 0}

    current = stored()
    current.update(prepared["apply"])

    try:
        SETTINGS.parent.mkdir(parents=True, exist_ok=True)
        temporary = SETTINGS.with_suffix(".sync-tmp")
        temporary.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n",
                             encoding="utf-8")
        os.replace(temporary, SETTINGS)
    except OSError as error:
        raise BoundaryError("This machine's settings could not be written.") from error

    return {**prepared, "applied": len(prepared["apply"])}


def main(arguments: list[str]) -> int:
    try:
        if len(arguments) == 2 and arguments[0] == "export":
            result = export(arguments[1])
        elif len(arguments) == 2 and arguments[0] == "preview":
            result = plan(arguments[1])
        elif len(arguments) == 2 and arguments[0] == "import":
            result = apply_import(arguments[1])
        else:
            raise BoundaryError(
                "Usage: panama-settings-sync export PATH | preview PATH | import PATH")
    except BoundaryError as error:
        print(json.dumps({"error": str(error)}, separators=(",", ":")))
        return 0

    result["error"] = ""
    print(json.dumps(result, separators=(",", ":")))
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
