# -*- coding: utf-8 -*-
"""Lay down the workspace files that live outside every repo, from their versioned copies.

Usage:  python3 scripts/install_workspace.py [--dry-run] [--workspace DIR] [--settings FILE]

WHAT THIS EXISTS FOR. Claude Code runs from /mnt/5_hdd/felhom.eu/git, which is not a git repository.
The workspace-root CLAUDE.md and the InstructionsLoaded hook shape every session and existed on ONE
machine — the one host that cannot be rebuilt from anything else. This restores them.

WHAT IT DOES NOT TOUCH. The auto-memory store. That is backed up, not versioned — see
workspace/README.md for why, and /opt/backup/scripts/backup-config.sh for where.

THE ONE WAY THIS DIFFERS FROM install_skills.py, AND IT IS THE IMPORTANT ONE. That script's targets
are disposable: a skill directory can be deleted and re-linked with nothing lost. These targets are
LIVE CONFIGURATION. ~/.claude/settings.json holds permissions, plugins and effort level that this
repo knows nothing about, and the workspace CLAUDE.md may have been edited on the machine since the
last sync. So:

  - every write is preceded by a TIMESTAMPED BACKUP of the file being replaced;
  - settings.json is MERGED, never rewritten from a template — this script owns exactly one key,
    hooks.InstructionsLoaded, and every other key is carried through untouched;
  - a diverging CLAUDE.md is reported, not silently resolved in either direction.

IDEMPOTENT. A second run writes nothing and says so. That is asserted by the caller in the session
report, because "it ran twice without error" and "it changed nothing the second time" are different
claims and only the second one means idempotent.
"""
import argparse
import datetime
import json
import os
import shutil
import sys

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Canonical source of the workspace instructions. Deliberately NOT workspace/ — see workspace/README.md.
CLAUDE_SRC = os.path.join(REPO, "documentation", "runbooks", "workspace-CLAUDE.md")
HOOK_SRC = os.path.join(REPO, "workspace", "hooks", "instructions-loaded.json")

changed = []
unchanged = []
problems = []


def stamp():
    return datetime.datetime.now().strftime("%Y%m%d-%H%M%S")


def backup(path):
    dst = "%s.bak-%s" % (path, stamp())
    shutil.copy2(path, dst)
    return dst


def read(path):
    with open(path, "r", encoding="utf-8") as fh:
        return fh.read()


def install_claude_md(workspace, dry_run, mode):
    """Install the workspace-root CLAUDE.md, as a symlink by default.

    THE LINK IS THE POINT (R-230(b)). Two files kept identical by hand and one check is a
    divergence class that has to be policed forever; one file reachable by two paths cannot diverge
    at all. Claude Code reads through symlinks — the four skills have been symlinks into this tree
    for months — but the link is still PROVEN from a fresh session rather than assumed, because a
    workspace file that silently stops loading is worse than two files kept in step by hand.

    `--copy` keeps the old two-file shape for a clone where linking is not wanted; check 5 accepts
    either.
    """
    target = os.path.join(workspace, "CLAUDE.md")
    if not os.path.exists(CLAUDE_SRC):
        problems.append("versioned source missing: %s" % CLAUDE_SRC)
        return
    src_text = read(CLAUDE_SRC)
    rel = os.path.relpath(CLAUDE_SRC, workspace)

    if mode == "link":
        if os.path.islink(target):
            if os.path.realpath(target) == os.path.realpath(CLAUDE_SRC):
                if not os.path.isfile(os.path.realpath(target)):
                    problems.append(
                        "%s is a symlink to %s but the target does not resolve — the workspace "
                        "instructions load NOTHING." % (target, os.readlink(target))
                    )
                else:
                    unchanged.append("CLAUDE.md  (already a symlink -> %s)" % rel)
                return
            if dry_run:
                changed.append("CLAUDE.md  WOULD re-point the symlink to %s" % rel)
                return
            os.unlink(target)
            os.symlink(rel, target)
            changed.append("CLAUDE.md  symlink re-pointed to %s" % rel)
            return

        if os.path.exists(target):
            # MIGRATION: a real file is here. Back it up before it stops being a file, and say so
            # if it differed — that difference is the last chance to notice an unsynced edit.
            differed = read(target) != src_text
            if dry_run:
                changed.append(
                    "CLAUDE.md  WOULD back up the regular file and replace it with a symlink%s"
                    % ("  (IT DIFFERS from the versioned copy)" if differed else "")
                )
                return
            b = backup(target)
            os.unlink(target)
            os.symlink(rel, target)
            changed.append(
                "CLAUDE.md  MIGRATED file -> symlink (%s backed up to %s)%s"
                % (
                    "content differed" if differed else "content was identical",
                    os.path.basename(b),
                    "\n           THE LIVE FILE DIFFERED — check the backup before discarding it."
                    if differed
                    else "",
                )
            )
            return

        if dry_run:
            changed.append("CLAUDE.md  WOULD create symlink -> %s" % rel)
            return
        os.symlink(rel, target)
        changed.append("CLAUDE.md  symlink created -> %s" % rel)
        return

    # --- copy mode: the original two-file shape ---
    if os.path.islink(target):
        if dry_run:
            changed.append("CLAUDE.md  WOULD replace the symlink with a real copy")
            return
        os.unlink(target)
        with open(target, "w", encoding="utf-8") as fh:
            fh.write(src_text)
        changed.append("CLAUDE.md  symlink replaced with a real copy")
        return

    if os.path.exists(target):
        if read(target) == src_text:
            unchanged.append("CLAUDE.md  (already byte-identical to the versioned copy)")
            return
        if dry_run:
            changed.append("CLAUDE.md  WOULD back up and overwrite (live file differs from source)")
            return
        b = backup(target)
        with open(target, "w", encoding="utf-8") as fh:
            fh.write(src_text)
        changed.append(
            "CLAUDE.md  DIVERGED -> backed up to %s, installed from %s.\n"
            "           If the LIVE file was the newer one, restore it and re-sync the versioned "
            "copy instead; instructions_gate check 5 enforces they match."
            % (os.path.basename(b), CLAUDE_SRC)
        )
        return

    if dry_run:
        changed.append("CLAUDE.md  WOULD create (absent)")
        return
    with open(target, "w", encoding="utf-8") as fh:
        fh.write(src_text)
    changed.append("CLAUDE.md  created from %s" % CLAUDE_SRC)


def install_hook(settings_path, dry_run):
    if not os.path.exists(HOOK_SRC):
        problems.append("versioned source missing: %s" % HOOK_SRC)
        return
    fragment = json.loads(read(HOOK_SRC))
    want = fragment.get("hooks", {}).get("InstructionsLoaded")
    if not want:
        problems.append("%s declares no hooks.InstructionsLoaded" % HOOK_SRC)
        return

    if os.path.exists(settings_path):
        try:
            settings = json.loads(read(settings_path))
        except ValueError as e:
            # A broken settings.json silently disables EVERY setting in it. Never overwrite one we
            # could not parse — that would destroy whatever the user was mid-way through fixing.
            problems.append(
                "%s is not valid JSON (%s). Refusing to write: a malformed settings file disables "
                "every setting in it, and overwriting would destroy the original." % (settings_path, e)
            )
            return
    else:
        settings = {}

    have = settings.get("hooks", {}).get("InstructionsLoaded")
    if have == want:
        unchanged.append("settings.json  (hooks.InstructionsLoaded already current)")
        return

    if dry_run:
        changed.append("settings.json  WOULD merge hooks.InstructionsLoaded (%s)"
                       % ("replacing an existing one" if have else "adding"))
        return

    before_keys = sorted(settings.keys())
    if os.path.exists(settings_path):
        b = backup(settings_path)
    else:
        b = None
        os.makedirs(os.path.dirname(settings_path), exist_ok=True)

    settings.setdefault("hooks", {})["InstructionsLoaded"] = want
    with open(settings_path, "w", encoding="utf-8") as fh:
        json.dump(settings, fh, indent=2, ensure_ascii=False)
        fh.write("\n")

    after_keys = sorted(json.loads(read(settings_path)).keys())
    lost = set(before_keys) - set(after_keys)
    if lost:
        problems.append("MERGE LOST KEYS from settings.json: %s" % ", ".join(sorted(lost)))
    changed.append(
        "settings.json  %s hooks.InstructionsLoaded%s; %d top-level key(s) carried through unchanged"
        % ("replaced" if have else "added",
           (" (backup %s)" % os.path.basename(b)) if b else "",
           len(before_keys))
    )


def main(argv):
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry-run", action="store_true",
                    help="report what would change; write nothing")
    ap.add_argument("--workspace", default=os.path.dirname(REPO),
                    help="workspace root (default: the parent of this repo)")
    ap.add_argument("--settings",
                    default=os.path.join(os.path.expanduser("~"), ".claude", "settings.json"))
    ap.add_argument("--copy", action="store_true",
                    help="install the workspace CLAUDE.md as a real copy instead of a symlink "
                         "(the pre-2026-08-06 shape; check 5 accepts either)")
    args = ap.parse_args(argv[1:])

    print("install_workspace%s" % ("  [--dry-run]" if args.dry_run else ""))
    print("  workspace : %s" % args.workspace)
    print("  settings  : %s" % args.settings)
    print("  mode      : %s" % ("copy" if args.copy else "symlink"))
    print("")

    if not os.path.isdir(args.workspace):
        print("FAIL: workspace root does not exist: %s" % args.workspace)
        return 2

    install_claude_md(args.workspace, args.dry_run, "copy" if args.copy else "link")
    install_hook(args.settings, args.dry_run)

    for c in changed:
        print("CHANGED    %s" % c)
    for u in unchanged:
        print("unchanged  %s" % u)
    for p in problems:
        print("PROBLEM    %s" % p)

    print("")
    if problems:
        print("install_workspace: %d PROBLEM(S)" % len(problems))
        return 1
    if not changed:
        print("install_workspace: nothing to do — already installed (idempotent)")
    else:
        print("install_workspace: %d change(s)" % len(changed))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
