#!/usr/bin/env python3
"""Install the public agent configuration kit without GitHub."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


DEFAULT_BASE_URL = "https://agents.illek.ie"
FALLBACK_BASE_URL = "https://agent-config-kit.pages.dev"


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def tree_hash(path: Path) -> str | None:
    if not path.is_dir():
        return None
    digest = hashlib.sha256()
    for item in sorted(path.rglob("*")):
        if not item.is_file():
            continue
        digest.update(item.relative_to(path).as_posix().encode())
        digest.update(item.read_bytes())
    return digest.hexdigest()


def fetch_bytes(relative: str, base_url: str, source_dir: Path | None) -> bytes:
    if source_dir is not None:
        return (source_dir / relative).read_bytes()
    url = f"{base_url.rstrip('/')}/{relative}"
    request = urllib.request.Request(url, headers={"User-Agent": "agent-config-kit-installer/1"})
    with urllib.request.urlopen(request, timeout=30) as response:
        return response.read()


def write_if_changed(path: Path, data: bytes, backup_dir: Path) -> bool:
    if path.is_file() and path.read_bytes() == data:
        return False
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.exists() or path.is_symlink():
        backup = backup_dir / path.relative_to(Path.home())
        backup.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(path, backup)
    temporary = path.with_name(f".{path.name}.agent-config-kit.tmp")
    try:
        temporary.write_bytes(data)
        os.replace(temporary, path)
    finally:
        temporary.unlink(missing_ok=True)
    return True


def replace_directory(source: Path, destination: Path, backup_dir: Path) -> bool:
    if tree_hash(source) == tree_hash(destination):
        return False
    destination.parent.mkdir(parents=True, exist_ok=True)
    if destination.exists() or destination.is_symlink():
        backup = backup_dir / destination.relative_to(Path.home())
        backup.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(destination, backup)
    temporary = destination.with_name(f".{destination.name}.agent-config-kit.tmp")
    if temporary.exists():
        shutil.rmtree(temporary)
    shutil.copytree(source, temporary)
    os.replace(temporary, destination)
    return True


def skill_names(root: Path) -> list[str]:
    result = []
    if not root.exists():
        return result
    for skill_file in root.glob("*/SKILL.md"):
        text = skill_file.read_text(encoding="utf-8")[:8192]
        match = re.search(r"(?m)^name:\s*[\"']?([^\n\"']+)", text)
        result.append(match.group(1).strip() if match else skill_file.parent.name)
    return sorted(set(result))


def openclaw_extra_dirs(config_path: Path) -> list[str]:
    if not config_path.is_file():
        return []
    config = json.loads(config_path.read_text(encoding="utf-8"))
    value = config.get("skills", {}).get("load", {}).get("extraDirs", [])
    return value if isinstance(value, list) else []


def audit(home: Path) -> dict[str, Any]:
    shared = home / ".agents" / "skills"
    cursor = home / ".cursor" / "skills"
    openclaw_config = home / ".openclaw" / "openclaw.json"
    return {
        "shared_skill_count": len(skill_names(shared)),
        "shared_skills": skill_names(shared),
        "codex_global_agents": (home / ".codex" / "AGENTS.md").is_file(),
        "cursor_builtin_skills_preserved": (home / ".cursor" / "skills-cursor").is_dir(),
        "cursor_shared_target": str(cursor.resolve()) if cursor.exists() else None,
        "cursor_uses_shared_skills": cursor.is_symlink() and cursor.resolve() == shared,
        "openclaw_uses_shared_skills": str(shared) in openclaw_extra_dirs(openclaw_config),
        "openclaw_agents": (home / ".openclaw" / "workspace" / "AGENTS.md").is_file(),
    }


def configure_openclaw(
    home: Path,
    shared_skills: Path,
    managed_skills: list[str],
    merged_agents: bytes,
    backup_dir: Path,
) -> tuple[bool, list[str]]:
    config_path = home / ".openclaw" / "openclaw.json"
    if not config_path.is_file():
        return False, []
    extra_dirs = openclaw_extra_dirs(config_path)
    shared_text = str(shared_skills)
    if shared_text not in extra_dirs:
        extra_dirs.append(shared_text)
        subprocess.run(
            [
                "openclaw",
                "config",
                "set",
                "skills.load.extraDirs",
                json.dumps(extra_dirs),
                "--strict-json",
            ],
            check=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
        )
    removed_shadows = []
    workspace_skills = home / ".openclaw" / "workspace" / "skills"
    for name in managed_skills:
        shadow = workspace_skills / name
        if not (shadow.exists() or shadow.is_symlink()):
            continue
        backup = backup_dir / shadow.relative_to(home)
        backup.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(shadow, backup)
        removed_shadows.append(name)
    write_if_changed(home / ".openclaw" / "workspace" / "AGENTS.md", merged_agents, backup_dir)
    return True, removed_shadows


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--base-url")
    parser.add_argument("--from-dir", type=Path)
    parser.add_argument("--audit", action="store_true")
    parser.add_argument("--skip-openclaw", action="store_true")
    args = parser.parse_args()

    home = Path.home()
    if args.audit:
        print(json.dumps(audit(home), indent=2, sort_keys=True))
        return

    source_dir = args.from_dir.resolve() if args.from_dir else None
    base_url = args.base_url or DEFAULT_BASE_URL
    try:
        manifest = json.loads(fetch_bytes("manifest.json", base_url, source_dir))
    except urllib.error.URLError:
        if source_dir is not None or args.base_url is not None:
            raise
        base_url = FALLBACK_BASE_URL
        manifest = json.loads(fetch_bytes("manifest.json", base_url, source_dir))
    version = str(manifest["version"])
    managed_skills = list(manifest["skills"])
    removed_skills = list(manifest.get("removed_skills", []))
    files = list(manifest["files"])

    kit_root = home / ".agent-config-kit"
    kit_root.mkdir(mode=0o700, parents=True, exist_ok=True)
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
    backup_dir = kit_root / "backups" / timestamp

    with tempfile.TemporaryDirectory(prefix="agent-config-kit-", dir=kit_root) as temporary_name:
        staging = Path(temporary_name)
        for item in files:
            relative = str(item["path"])
            try:
                data = fetch_bytes(relative, base_url, source_dir)
            except urllib.error.HTTPError as error:
                raise SystemExit(f"Failed to download {relative}: HTTP {error.code}") from None
            destination = staging / relative
            destination.parent.mkdir(parents=True, exist_ok=True)
            destination.write_bytes(data)
            destination.chmod(int(item["mode"]))
            if sha256(destination) != item["sha256"]:
                raise SystemExit(f"Hash mismatch for {relative}")

        shared_skills = home / ".agents" / "skills"
        shared_skills.mkdir(parents=True, exist_ok=True)
        changed_skills = []
        for name in managed_skills:
            if replace_directory(staging / "skills" / name, shared_skills / name, backup_dir):
                changed_skills.append(name)

        removed_shared = []
        for name in removed_skills:
            retired = shared_skills / name
            if not (retired.exists() or retired.is_symlink()):
                continue
            backup = backup_dir / retired.relative_to(home)
            backup.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(retired, backup)
            removed_shared.append(name)

        legacy_codex_skills = home / ".codex" / "skills"
        migrated_legacy = []
        for name in managed_skills:
            legacy = legacy_codex_skills / name
            if not (legacy.exists() or legacy.is_symlink()):
                continue
            backup = backup_dir / legacy.relative_to(home)
            backup.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(legacy, backup)
            migrated_legacy.append(name)

        removed_legacy = []
        for name in removed_skills:
            legacy = legacy_codex_skills / name
            if not (legacy.exists() or legacy.is_symlink()):
                continue
            backup = backup_dir / legacy.relative_to(home)
            backup.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(legacy, backup)
            removed_legacy.append(name)

        sources_changed = replace_directory(staging / "sources", kit_root / "sources", backup_dir)
        manifest_changed = write_if_changed(
            kit_root / "manifest.json",
            (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode(),
            backup_dir,
        )

        agents_data = (staging / "AGENTS.md").read_bytes()
        codex_agents_changed = write_if_changed(home / ".codex" / "AGENTS.md", agents_data, backup_dir)

        cursor_rule = (
            b"---\ndescription: Shared agent operating mode\nalwaysApply: true\n---\n\n" + agents_data
        )
        cursor_rule_changed = write_if_changed(
            home / ".cursor" / "rules" / "agent-config-kit.mdc",
            cursor_rule,
            backup_dir,
        )
        cursor_skills = home / ".cursor" / "skills"
        if cursor_skills.is_symlink():
            if cursor_skills.resolve() != shared_skills:
                cursor_skills.unlink()
        elif cursor_skills.exists():
            raise SystemExit(f"Refusing to replace non-symlink Cursor skill directory: {cursor_skills}")
        if not cursor_skills.exists():
            cursor_skills.parent.mkdir(parents=True, exist_ok=True)
            cursor_skills.symlink_to(shared_skills, target_is_directory=True)

        openclaw_configured = False
        removed_openclaw_shadows: list[str] = []
        if not args.skip_openclaw:
            merged_agents = agents_data + b"\n" + (staging / "profiles" / "openclaw.md").read_bytes()
            openclaw_configured, removed_openclaw_shadows = configure_openclaw(
                home,
                shared_skills,
                managed_skills,
                merged_agents,
                backup_dir,
            )

        manifest_digest = hashlib.sha256(json.dumps(manifest, sort_keys=True).encode()).hexdigest()
        state_path = kit_root / "state.json"
        previous_state: dict[str, Any] = {}
        if state_path.is_file():
            try:
                previous_state = json.loads(state_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                previous_state = {}
        installed_at = (
            previous_state.get("installed_at")
            if previous_state.get("manifest_sha256") == manifest_digest
            else datetime.now(timezone.utc).isoformat(timespec="seconds")
        )
        state = {
            "version": version,
            "installed_at": installed_at,
            "managed_skills": managed_skills,
            "removed_skills": removed_skills,
            "manifest_sha256": manifest_digest,
        }
        write_if_changed(
            state_path,
            (json.dumps(state, indent=2, sort_keys=True) + "\n").encode(),
            backup_dir,
        )

    result = {
        "version": version,
        "changed_skills": changed_skills,
        "removed_shared_skills": removed_shared,
        "removed_legacy_codex_skills": removed_legacy,
        "migrated_legacy_codex_skills": migrated_legacy,
        "sources_changed": sources_changed,
        "manifest_changed": manifest_changed,
        "codex_agents_changed": codex_agents_changed,
        "cursor_rule_changed": cursor_rule_changed,
        "cursor_builtin_skills_preserved": (home / ".cursor" / "skills-cursor").is_dir(),
        "openclaw_configured": openclaw_configured,
        "removed_openclaw_skill_shadows": removed_openclaw_shadows,
        "backup_dir": str(backup_dir) if backup_dir.exists() else None,
        "audit": audit(home),
    }
    print(json.dumps(result, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()
