#!/usr/bin/env bash

# Carrying settings to another machine.
#
# The rules:
#
#   1. Export is an allow-list read from the preference schema, not a deny-list
#      of things to strip. A key added later that happens to hold a token must
#      not be able to leak into a file somebody emails to themselves. Being
#      wrong this way loses a setting; being wrong the other way publishes a
#      secret.
#   2. What describes the machine stays on the machine. The display arrangement
#      is keyed by output names that mean nothing elsewhere.
#   3. Every value is validated again on arrival, per key, 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.
#   4. Validation covers every type the schema actually uses. It did not: "real"
#      was spelled "float" and enums were assumed to be words, so 36 settings --
#      including every numeric enum -- were accepted unchecked, and numeric
#      enums were then refused outright once that was noticed.
#   5. Import is a merge. Settings the file does not mention are left alone.
#   6. The preview says what would change, in a shape a diff list can render.
#      "12 settings would change" is a number, not an answer; the page now
#      shows the rows, so `changes` carries {key, from, to} with both sides
#      already turned into text. Doing that stringification in the helper
#      rather than in QML is what makes the cap enforceable: a value long
#      enough to be something other than a setting is truncated once, here,
#      instead of being handed whole to a Text element and to anybody reading
#      over a shoulder.
#
# Runs entirely against a temporary config home. The real settings store is read
# for the export and never written.

set -uo pipefail

repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-settings-sync"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"

fail() {
    printf 'settings sync contract: %s\n' "$1" >&2
    exit 1
}

[[ -x "$helper" ]] || fail 'panama-settings-sync is not executable'
[[ -r "$schema" ]] || fail 'the preference schema is missing'

work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT

bundle="$work/export.json"
field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; }

# ── 1 & 2. Export carries taste, not hardware ───────────────────────────────

"$helper" export "$bundle" >"$work/export-result.json" || fail 'export failed'
reason="$(field "['error']" <"$work/export-result.json")"
[[ -z "$reason" ]] || fail "export reported: $reason"

[[ "$(stat -c '%a' "$bundle")" == "600" ]] \
    || fail 'the export is readable by other accounts'

python3 - "$bundle" "$schema" "${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" <<'PY' || fail 'the export carried the wrong things, in one direction or the other'
import json, re, sys
bundle = json.load(open(sys.argv[1]))
schema = open(sys.argv[2]).read()

settings = bundle["settings"]
if not settings:
    raise SystemExit('the export carried nothing at all')

declared = set(re.findall(r'key:\s*"([A-Za-z0-9_]+)"', schema))
for key in settings:
    if key not in declared:
        raise SystemExit(f'{key} is not declared in the schema but was exported')

for forbidden in ("displays", "lastPage", "schemaVersion"):
    if forbidden in settings:
        raise SystemExit(f'{forbidden} describes this machine and must not travel')

# Nothing credential-shaped, whatever the schema says about it.
raw = json.dumps(settings)
for marker in ("BEGIN ", "PRIVATE KEY", "Bearer "):
    if marker in raw:
        raise SystemExit(f'the export contains {marker!r}')
for key, value in settings.items():
    if isinstance(value, str) and len(value) > 300:
        raise SystemExit(f'{key} is long enough to be something other than a setting')

# The export must carry what it should, not merely refrain from carrying what it
# should not. Checking only the latter passes trivially when a whole class of
# setting is silently dropped -- which is exactly what happened: numeric enums
# were unreadable, so they never reached the bundle and every "did it arrive"
# check was satisfied by their absence.
present = set(settings)
current = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else {}
for key, value in current.items():
    if key in ("displays", "lastPage", "schemaVersion"):
        continue
    if key in declared and key not in present:
        raise SystemExit(f'{key} is set on this machine and declared, but was not carried')
PY

# ── 3, 4. Arrival is validated per key, and the types are all covered ───────

export XDG_CONFIG_HOME="$work/config"

python3 - "$bundle" "$work/tampered.json" <<'PY'
import json, sys
bundle = json.load(open(sys.argv[1]))
bundle["settings"].update({
    "gapsIn": 9999,                       # above the schema maximum
    "colorScheme": "chartreuse",          # not one of a word enum's choices
    "vrrPolicy": 47,                      # not one of a NUMERIC enum's choices
    "blurEnabled": "yes please",          # wrong type entirely
    "displays": {"DP-9": "elsewhere"},    # machine-specific, injected
    "someFutureToken": "sk-abcdef123456", # a key this version does not know
})
json.dump(bundle, open(sys.argv[2], "w"))
PY

"$helper" preview "$work/tampered.json" >"$work/preview.json" || fail 'preview failed'
python3 - "$work/preview.json" <<'PY' || fail 'a bad value was not refused with its reason'
import json, sys
plan = json.load(open(sys.argv[1]))
skipped = {entry["key"]: entry["reason"] for entry in plan["skipped"]}
for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "someFutureToken"):
    if key not in skipped:
        raise SystemExit(f'{key} was accepted and should not have been')
    if not skipped[key].strip():
        raise SystemExit(f'{key} was skipped without saying why')
changed = {entry["key"] for entry in plan["changes"]}
for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "someFutureToken"):
    if key in changed:
        raise SystemExit(f'{key} was refused and queued for application anyway')
PY

# ── 6. The preview renders as a diff, and cannot render a secret whole ──────

python3 - "$bundle" "$work/oversized.json" <<'PY'
import json, sys
bundle = json.load(open(sys.argv[1]))
# A real, free-text, non-path string setting, so this exercises a value that
# genuinely travels rather than one the validator would refuse for its own
# reasons. 4000 characters is not a location; it is somebody's paste buffer.
bundle["settings"]["weatherLocation"] = "Bearer sk-fixture-secret-" + ("x" * 4000)
json.dump(bundle, open(sys.argv[2], "w"))
PY

"$helper" preview "$work/oversized.json" >"$work/oversized-preview.json" \
    || fail 'preview failed on a bundle carrying an oversized value'
python3 - "$work/preview.json" "$work/oversized-preview.json" <<'PY' || fail 'the preview does not describe changes in a shape a diff list can render safely'
import json
import sys

CAP = 200

for path in sys.argv[1:]:
    plan = json.load(open(path))

    if "changes" not in plan:
        raise SystemExit('the preview does not say what would change')
    if "changeCount" not in plan:
        raise SystemExit('the preview lists changes without saying how many there are, '
                         'so a capped list reads as the whole truth')

    count = plan["changeCount"]
    if not isinstance(count, int) or count < 0:
        raise SystemExit('changeCount is not a count')
    if count != len(plan["apply"]):
        raise SystemExit(f'changeCount says {count} but {len(plan["apply"])} would be applied')
    if len(plan["changes"]) > count:
        raise SystemExit('the rendered list is longer than the number of changes')

    for entry in plan["changes"]:
        if set(entry) != {"key", "from", "to"}:
            raise SystemExit(f'a change carries {sorted(entry)}, expected key/from/to')
        for side in ("key", "from", "to"):
            if not isinstance(entry[side], str):
                raise SystemExit(f'{entry["key"]}.{side} is {type(entry[side]).__name__}, '
                                 'not text a row can render')
            if len(entry[side]) > CAP:
                raise SystemExit(f'{entry["key"]}.{side} is {len(entry[side])} characters; '
                                 'an uncapped value reaches the screen whole')

oversized = json.load(open(sys.argv[2]))
rendered = json.dumps(oversized["changes"])
if "x" * (CAP + 1) in rendered:
    raise SystemExit('an oversized value was reproduced in full in the change list')
PY

# A clean bundle must arrive intact. Refusing valid settings is the failure this
# contract exists to catch as much as accepting invalid ones -- fixing the enum
# check the first time turned every numeric enum into a rejection.
"$helper" import "$bundle" >"$work/import.json" || fail 'import failed'
python3 - "$bundle" "$work/config/panama/settings.json" <<'PY' || fail 'the round trip lost or changed a setting'
import json, sys
sent = json.load(open(sys.argv[1]))["settings"]
landed = json.load(open(sys.argv[2]))
missing = [k for k in sent if k not in landed]
if missing:
    raise SystemExit(f'{len(missing)} settings did not arrive, starting with {missing[:3]}')
wrong = [k for k, v in sent.items() if landed[k] != v]
if wrong:
    raise SystemExit(f'{len(wrong)} arrived with a different value, starting with {wrong[:3]}')
PY

# ── 5. Import merges rather than replaces ───────────────────────────────────

python3 - "$work/config/panama/settings.json" <<'PY'
import json, sys
store = json.load(open(sys.argv[1]))
store["aSettingTheBundleNeverMentions"] = "kept"
json.dump(store, open(sys.argv[1], "w"))
PY
"$helper" import "$bundle" >/dev/null || fail 'second import failed'
python3 - "$work/config/panama/settings.json" <<'PY' || fail 'import replaced the store instead of merging into it'
import json, sys
store = json.load(open(sys.argv[1]))
if store.get("aSettingTheBundleNeverMentions") != "kept":
    raise SystemExit('a setting the bundle did not mention was removed')
PY

applied="$("$helper" import "$bundle" | field "['applied']")"
[[ "$applied" == "0" ]] \
    || fail "importing the same bundle twice applied $applied changes the second time"

# ── Refusals ────────────────────────────────────────────────────────────────

printf 'not json at all\n' >"$work/junk.json"
reason="$("$helper" import "$work/junk.json" | field "['error']")"
[[ "$reason" == *"not a settings export"* ]] \
    || fail "a file that is not an export was not refused with a reason (got: $reason)"

reason="$("$helper" import "$work/absent.json" | field "['error']")"
[[ "$reason" == *"does not exist"* ]] \
    || fail "a missing file was not refused with a reason (got: $reason)"

printf 'settings sync contract: ok\n'
