#!/usr/bin/env python3
"""Install a user-level background runner for the Silo trusted client.

The service definition contains only the Silo server, local sync root, and
watch settings. It never copies the bearer token, device token, or private
key into an operating-system launch file.
"""

from __future__ import annotations

import datetime
import json
import math
import os
import pathlib
import plistlib
import shlex
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
import xml.etree.ElementTree as ET


SERVICE_ID = "com.havre.silo"
WINDOWS_TASK_NAME = "Havre Silo"
SERVICE_CONFIG_NAME = "silo-service.json"
LINUX_UNIT_NAME = "havre-silo.service"
DEFAULT_RETRY_SECONDS = 15.0
DEFAULT_MAX_FAILURES = 5
MAX_RETRY_SECONDS = 300.0


def _state_home() -> pathlib.Path:
    return pathlib.Path(os.environ.get("HAVRE_HOME") or (pathlib.Path.home() / ".havre"))


def _user_home() -> pathlib.Path:
    return pathlib.Path.home()


def _platform_name(value: str | None = None) -> str:
    if value:
        normalized = value.strip().lower()
        if normalized in {"windows", "win32", "win"}:
            return "windows"
        if normalized in {"darwin", "macos", "mac"}:
            return "macos"
        if normalized in {"linux", "linux2"}:
            return "linux"
        raise ValueError("Silo service platform must be windows, macos, or linux")
    if sys.platform.startswith("win"):
        return "windows"
    if sys.platform == "darwin":
        return "macos"
    if sys.platform.startswith("linux"):
        return "linux"
    raise RuntimeError(f"Silo background sync is not supported on {sys.platform}")


def _validate_server(server: str) -> str:
    value = str(server or "").strip().rstrip("/")
    parsed = urllib.parse.urlparse(value)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise ValueError("Silo server must be an http:// or https:// URL")
    if parsed.username or parsed.password:
        raise ValueError("Silo server URLs cannot contain embedded credentials")
    return value


def _normalize_root(root: str | os.PathLike[str]) -> pathlib.Path:
    value = pathlib.Path(root).expanduser().resolve()
    if not value.is_dir():
        raise ValueError("Silo service root must be an existing directory")
    return value


def _normalize_prefixes(values) -> list[str]:
    if values is None:
        return []
    if isinstance(values, str):
        values = [values]
    normalized: list[str] = []
    for value in values:
        prefix = str(value or "").strip().replace("\\", "/").strip("/")
        parts = prefix.split("/")
        if not prefix or any(part in {"", ".", ".."} for part in parts):
            raise ValueError("Silo retention prefixes must be non-empty relative folders")
        if prefix not in normalized:
            normalized.append(prefix)
    return normalized


def _positive_float(value, label: str, *, maximum: float | None = None) -> float:
    try:
        number = float(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"Silo {label} must be a finite positive number") from exc
    if not math.isfinite(number) or number <= 0 or (maximum is not None and number > maximum):
        if maximum is None:
            raise ValueError(f"Silo {label} must be a finite positive number")
        raise ValueError(f"Silo {label} must be between zero and {maximum:g} seconds")
    return number


def _positive_int(value, label: str) -> int:
    try:
        number = int(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"Silo {label} must be a positive integer") from exc
    if number <= 0:
        raise ValueError(f"Silo {label} must be a positive integer")
    return number


def _config_path(state_home: pathlib.Path | None = None) -> pathlib.Path:
    return (state_home or _state_home()) / SERVICE_CONFIG_NAME


def _linux_unit_path(user_home: pathlib.Path | None = None) -> pathlib.Path:
    return (user_home or _user_home()) / ".config" / "systemd" / "user" / LINUX_UNIT_NAME


def _macos_plist_path(user_home: pathlib.Path | None = None) -> pathlib.Path:
    return (user_home or _user_home()) / "Library" / "LaunchAgents" / f"{SERVICE_ID}.plist"


def _windows_task_xml_path(state_home: pathlib.Path | None = None) -> pathlib.Path:
    return (state_home or _state_home()) / "silo-service-task.xml"


def _write_private(path: pathlib.Path, data: bytes | str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    if isinstance(data, str):
        data = data.encode("utf-8")
    fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
    try:
        fchmod = getattr(os, "fchmod", None)
        if fchmod is not None:
            fchmod(fd, 0o600)
        with os.fdopen(fd, "wb") as handle:
            handle.write(data)
        os.replace(temporary, path)
    except Exception:
        try:
            os.close(fd)
        except OSError:
            pass
        try:
            os.unlink(temporary)
        except OSError:
            pass
        raise
    try:
        os.chmod(path, 0o600)
    except OSError:
        pass


def _run(command: list[str], runner=None):
    if runner is not None:
        return runner(command)
    return subprocess.run(command, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                          text=True, encoding="utf-8", errors="replace")


def watch_argv(
    *,
    client_path: pathlib.Path,
    server: str,
    root: pathlib.Path,
    scope: str = "CA",
    interval_seconds: float = 30.0,
    retry_seconds: float = DEFAULT_RETRY_SECONDS,
    max_failures: int = DEFAULT_MAX_FAILURES,
    public_library: bool = False,
    include_prefixes=None,
    include_all: bool = False,
) -> list[str]:
    """Build the exact argv used by the background process."""
    server = _validate_server(server)
    root = pathlib.Path(root).expanduser().resolve()
    interval = _positive_float(interval_seconds, "watch interval")
    retry = _positive_float(retry_seconds, "retry delay", maximum=MAX_RETRY_SECONDS)
    failures = _positive_int(max_failures, "retry limit")
    prefixes = _normalize_prefixes(include_prefixes)
    if include_all and prefixes:
        raise ValueError("Choose retained folders or the full remote library, not both")
    argv = [
        str(sys.executable), "-u", str(pathlib.Path(client_path).expanduser().resolve()),
        "--server", server, "silo-watch", str(root), "--scope", str(scope or "CA"),
        "--interval-seconds", f"{interval:g}", "--retry-seconds", f"{retry:g}",
        "--max-failures", str(failures),
    ]
    for prefix in prefixes:
        argv.extend(["--include-prefix", prefix])
    if include_all:
        argv.append("--include-all")
    if public_library:
        argv.append("--public-library")
    return argv


def _payload(
    *,
    platform: str,
    state_home: pathlib.Path,
    user_home: pathlib.Path,
    client_path: pathlib.Path,
    server: str,
    root: pathlib.Path,
    scope: str,
    interval_seconds: float,
    retry_seconds: float,
    max_failures: int,
    public_library: bool,
    include_prefixes,
    include_all: bool,
    definition_path: pathlib.Path,
) -> dict:
    prefixes = _normalize_prefixes(include_prefixes)
    return {
        "schema_version": 1,
        "service_id": SERVICE_ID,
        "platform": platform,
        "server": _validate_server(server),
        "root": str(root),
        "scope": str(scope or "CA"),
        "interval_seconds": _positive_float(interval_seconds, "watch interval"),
        "retry_seconds": _positive_float(retry_seconds, "retry delay", maximum=MAX_RETRY_SECONDS),
        "max_failures": _positive_int(max_failures, "retry limit"),
        "public_library": bool(public_library),
        "include_prefixes": prefixes,
        "include_all": bool(include_all),
        "client_path": str(client_path),
        "definition_path": str(definition_path),
        "state_home": str(state_home),
        "user_home": str(user_home),
        "installed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    }


def _linux_unit(argv: list[str], client_path: pathlib.Path) -> str:
    command = shlex.join(argv)
    working_directory = shlex.quote(str(client_path.parent))
    return (
        "[Unit]\n"
        "Description=Havre Silo encrypted folder sync\n"
        "After=network-online.target\n"
        "Wants=network-online.target\n\n"
        "[Service]\n"
        f"ExecStart={command}\n"
        f"WorkingDirectory={working_directory}\n"
        "Restart=on-failure\n"
        "RestartSec=30\n"
        "NoNewPrivileges=yes\n"
        "PrivateTmp=yes\n\n"
        "[Install]\n"
        "WantedBy=default.target\n"
    )


def _macos_plist(argv: list[str], state_home: pathlib.Path) -> bytes:
    payload = {
        "Label": SERVICE_ID,
        "ProgramArguments": argv,
        "RunAtLoad": True,
        "KeepAlive": True,
        "ProcessType": "Background",
        "ThrottleInterval": 30,
        "StandardOutPath": str(state_home / "silo-service.log"),
        "StandardErrorPath": str(state_home / "silo-service.log"),
    }
    return plistlib.dumps(payload, fmt=plistlib.FMT_XML, sort_keys=False)


def _windows_task_xml(argv: list[str], client_path: pathlib.Path) -> bytes:
    namespace = "http://schemas.microsoft.com/windows/2004/02/mit/task"
    root = ET.Element("Task", {"version": "1.4", "xmlns": namespace})
    registration = ET.SubElement(root, "RegistrationInfo")
    ET.SubElement(registration, "Description").text = (
        "Havre Silo encrypted folder sync. Credentials remain in the user's local Silo state."
    )
    triggers = ET.SubElement(root, "Triggers")
    logon = ET.SubElement(triggers, "LogonTrigger")
    ET.SubElement(logon, "Enabled").text = "true"
    principals = ET.SubElement(root, "Principals")
    principal = ET.SubElement(principals, "Principal", {"id": "Author"})
    ET.SubElement(principal, "LogonType").text = "InteractiveToken"
    ET.SubElement(principal, "RunLevel").text = "LeastPrivilege"
    settings = ET.SubElement(root, "Settings")
    ET.SubElement(settings, "MultipleInstancesPolicy").text = "IgnoreNew"
    ET.SubElement(settings, "DisallowStartIfOnBatteries").text = "false"
    ET.SubElement(settings, "StopIfGoingOnBatteries").text = "false"
    ET.SubElement(settings, "StartWhenAvailable").text = "true"
    ET.SubElement(settings, "ExecutionTimeLimit").text = "PT0S"
    ET.SubElement(settings, "Hidden").text = "true"
    restart = ET.SubElement(settings, "RestartOnFailure")
    ET.SubElement(restart, "Interval").text = "PT1M"
    ET.SubElement(restart, "Count").text = "3"
    actions = ET.SubElement(root, "Actions", {"Context": "Author"})
    execute = ET.SubElement(actions, "Exec")
    ET.SubElement(execute, "Command").text = argv[0]
    ET.SubElement(execute, "Arguments").text = subprocess.list2cmdline(argv[1:])
    ET.SubElement(execute, "WorkingDirectory").text = str(client_path.parent)
    return ET.tostring(root, encoding="utf-16", xml_declaration=True)


def _launchctl_domain() -> str:
    try:
        uid = os.getuid()
    except AttributeError:
        uid = 501
    return f"gui/{uid}"


def _install_native(platform: str, *, argv: list[str], client_path: pathlib.Path,
                    state_home: pathlib.Path, user_home: pathlib.Path, runner=None) -> pathlib.Path:
    if platform == "linux":
        path = _linux_unit_path(user_home)
        _write_private(path, _linux_unit(argv, client_path))
        systemctl = shutil.which("systemctl")
        if not systemctl:
            raise RuntimeError("systemctl is required for a Linux user service")
        _run([systemctl, "--user", "daemon-reload"], runner)
        result = _run([systemctl, "--user", "enable", "--now", LINUX_UNIT_NAME], runner)
        if getattr(result, "returncode", 0) != 0:
            detail = (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()
            raise RuntimeError(f"Linux Silo service could not start{': ' + detail if detail else ''}")
        return path
    if platform == "macos":
        path = _macos_plist_path(user_home)
        _write_private(path, _macos_plist(argv, state_home))
        launchctl = shutil.which("launchctl")
        if not launchctl:
            raise RuntimeError("launchctl is required for a macOS user service")
        domain = _launchctl_domain()
        _run([launchctl, "bootout", domain, str(path)], runner)
        result = _run([launchctl, "bootstrap", domain, str(path)], runner)
        if getattr(result, "returncode", 0) != 0:
            detail = (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()
            raise RuntimeError(f"macOS Silo service could not start{': ' + detail if detail else ''}")
        return path
    if platform == "windows":
        path = _windows_task_xml_path(state_home)
        _write_private(path, _windows_task_xml(argv, client_path))
        schtasks = shutil.which("schtasks.exe") or shutil.which("schtasks")
        if not schtasks:
            raise RuntimeError("schtasks is required for a Windows user service")
        result = _run([schtasks, "/Create", "/TN", WINDOWS_TASK_NAME, "/XML", str(path), "/F"], runner)
        if getattr(result, "returncode", 0) != 0:
            detail = (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()
            raise RuntimeError(f"Windows Silo task could not be created{': ' + detail if detail else ''}")
        _run([schtasks, "/Run", "/TN", WINDOWS_TASK_NAME], runner)
        return path
    raise RuntimeError(f"unsupported Silo service platform: {platform}")


def _uninstall_native(platform: str, *, state_home: pathlib.Path, user_home: pathlib.Path, runner=None) -> pathlib.Path:
    if platform == "linux":
        path = _linux_unit_path(user_home)
        systemctl = shutil.which("systemctl")
        if systemctl:
            _run([systemctl, "--user", "disable", "--now", LINUX_UNIT_NAME], runner)
            _run([systemctl, "--user", "daemon-reload"], runner)
        path.unlink(missing_ok=True)
        return path
    if platform == "macos":
        path = _macos_plist_path(user_home)
        launchctl = shutil.which("launchctl")
        if launchctl:
            _run([launchctl, "bootout", _launchctl_domain(), str(path)], runner)
        path.unlink(missing_ok=True)
        return path
    if platform == "windows":
        path = _windows_task_xml_path(state_home)
        schtasks = shutil.which("schtasks.exe") or shutil.which("schtasks")
        if schtasks:
            _run([schtasks, "/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], runner)
        path.unlink(missing_ok=True)
        return path
    raise RuntimeError(f"unsupported Silo service platform: {platform}")


def _status_native(platform: str, *, state_home: pathlib.Path, user_home: pathlib.Path, runner=None) -> dict:
    if platform == "linux":
        path = _linux_unit_path(user_home)
        systemctl = shutil.which("systemctl")
        if not systemctl:
            return {"installed": path.is_file(), "running": False, "detail": "systemctl unavailable"}
        result = _run([systemctl, "--user", "is-active", LINUX_UNIT_NAME], runner)
        state = (getattr(result, "stdout", "") or "").strip()
        return {"installed": path.is_file(), "running": getattr(result, "returncode", 1) == 0 and state == "active",
                "detail": state or "inactive"}
    if platform == "macos":
        path = _macos_plist_path(user_home)
        launchctl = shutil.which("launchctl")
        if not launchctl:
            return {"installed": path.is_file(), "running": False, "detail": "launchctl unavailable"}
        result = _run([launchctl, "print", f"{_launchctl_domain()}/{SERVICE_ID}"], runner)
        return {"installed": path.is_file(), "running": getattr(result, "returncode", 1) == 0,
                "detail": "loaded" if getattr(result, "returncode", 1) == 0 else "not loaded"}
    if platform == "windows":
        path = _windows_task_xml_path(state_home)
        schtasks = shutil.which("schtasks.exe") or shutil.which("schtasks")
        if not schtasks:
            return {"installed": path.is_file(), "running": False, "detail": "schtasks unavailable"}
        result = _run([schtasks, "/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/NH"], runner)
        output = (getattr(result, "stdout", "") or "").lower()
        return {"installed": path.is_file() and getattr(result, "returncode", 1) == 0,
                "running": "running" in output, "detail": "running" if "running" in output else "ready"}
    raise RuntimeError(f"unsupported Silo service platform: {platform}")


def install(
    *,
    client_path: pathlib.Path,
    server: str,
    root: str | os.PathLike[str],
    scope: str = "CA",
    interval_seconds: float = 30.0,
    retry_seconds: float = DEFAULT_RETRY_SECONDS,
    max_failures: int = DEFAULT_MAX_FAILURES,
    public_library: bool = False,
    include_prefixes=None,
    include_all: bool = False,
    platform: str | None = None,
    state_home: pathlib.Path | None = None,
    user_home: pathlib.Path | None = None,
    runner=None,
) -> dict:
    platform = _platform_name(platform)
    state_home = pathlib.Path(state_home or _state_home()).expanduser().resolve()
    user_home = pathlib.Path(user_home or _user_home()).expanduser().resolve()
    client_path = pathlib.Path(client_path).expanduser().resolve()
    root = _normalize_root(root)
    argv = watch_argv(
        client_path=client_path, server=server, root=root, scope=scope,
        interval_seconds=interval_seconds, retry_seconds=retry_seconds,
        max_failures=max_failures, public_library=public_library,
        include_prefixes=include_prefixes, include_all=include_all,
    )
    definition_path = _install_native(
        platform, argv=argv, client_path=client_path, state_home=state_home,
        user_home=user_home, runner=runner,
    )
    payload = _payload(
        platform=platform, state_home=state_home, user_home=user_home,
        client_path=client_path, server=server, root=root, scope=scope,
        interval_seconds=interval_seconds, retry_seconds=retry_seconds,
        max_failures=max_failures, public_library=public_library,
        include_prefixes=include_prefixes, include_all=include_all,
        definition_path=definition_path,
    )
    _write_private(_config_path(state_home), json.dumps(payload, indent=2) + "\n")
    return {"installed": True, "running": True, "platform": platform,
            "definition_path": str(definition_path), "config_path": str(_config_path(state_home)),
            "root": str(root), "server": _validate_server(server)}


def uninstall(*, platform: str | None = None, state_home: pathlib.Path | None = None,
              user_home: pathlib.Path | None = None, runner=None) -> dict:
    platform = _platform_name(platform)
    state_home = pathlib.Path(state_home or _state_home()).expanduser().resolve()
    user_home = pathlib.Path(user_home or _user_home()).expanduser().resolve()
    definition_path = _uninstall_native(platform, state_home=state_home, user_home=user_home, runner=runner)
    config_path = _config_path(state_home)
    removed = config_path.exists()
    config_path.unlink(missing_ok=True)
    return {"installed": False, "running": False, "platform": platform,
            "removed": removed, "definition_path": str(definition_path),
            "config_path": str(config_path)}


def status(*, platform: str | None = None, state_home: pathlib.Path | None = None,
           user_home: pathlib.Path | None = None, runner=None) -> dict:
    platform = _platform_name(platform)
    state_home = pathlib.Path(state_home or _state_home()).expanduser().resolve()
    user_home = pathlib.Path(user_home or _user_home()).expanduser().resolve()
    config_path = _config_path(state_home)
    native = _status_native(platform, state_home=state_home, user_home=user_home, runner=runner)
    result = {"platform": platform, "config_path": str(config_path),
              "definition_path": str(_linux_unit_path(user_home) if platform == "linux" else
                                      _macos_plist_path(user_home) if platform == "macos" else
                                      _windows_task_xml_path(state_home)), **native}
    if config_path.is_file():
        try:
            config = json.loads(config_path.read_text(encoding="utf-8"))
            result.update({"root": config.get("root"), "server": config.get("server"),
                           "interval_seconds": config.get("interval_seconds"),
                           "include_prefixes": config.get("include_prefixes", [])})
        except (OSError, ValueError):
            result["detail"] = "service configuration is unreadable"
    return result


def _selftest() -> int:
    tests: list[tuple[str, bool]] = []
    with tempfile.TemporaryDirectory(prefix="havre_silo_service_") as temporary:
        base = pathlib.Path(temporary)
        state_home = base / ".havre"
        user_home = base / "user"
        root = base / "My Silo"
        root.mkdir(parents=True)
        client = base / "havre_remote.py"
        client.write_text("# test client\n", encoding="utf-8")
        argv = watch_argv(
            client_path=client, server="https://havre.cloud", root=root,
            interval_seconds=45, retry_seconds=20, max_failures=4,
            include_prefixes=["Documents", "Photos/2026"],
        )
        joined = " ".join(argv)
        tests.append(("watch command keeps the selected root", str(root) in argv))
        tests.append(("watch command carries bounded retry settings", "--max-failures" in argv and "4" in argv))
        tests.append(("watch command carries selected prefixes", argv.count("--include-prefix") == 2))
        tests.append(("watch command contains no credential fields", "token" not in joined.lower() and "private" not in joined.lower()))

        linux = _linux_unit(argv, client)
        tests.append(("Linux unit restarts after failure", "Restart=on-failure" in linux and "RestartSec=30" in linux))
        tests.append(("Linux unit is user scoped", "WantedBy=default.target" in linux and "NoNewPrivileges=yes" in linux))

        mac = plistlib.loads(_macos_plist(argv, state_home))
        tests.append(("macOS agent starts at login", mac["RunAtLoad"] is True and mac["KeepAlive"] is True))
        tests.append(("macOS agent uses argument arrays", mac["ProgramArguments"] == argv))

        windows = _windows_task_xml(argv, client).decode("utf-16")
        tests.append(("Windows task runs with least privilege", "LeastPrivilege" in windows and "InteractiveToken" in windows))
        tests.append(("Windows task restarts on failure", "RestartOnFailure" in windows and "PT1M" in windows))
        tests.append(("launch definitions do not contain credentials", "device_token" not in (linux + windows) and "silo_private" not in (linux + windows)))

        try:
            watch_argv(client_path=client, server="https://user:secret@havre.cloud", root=root)
        except ValueError:
            invalid_server = True
        else:
            invalid_server = False
        tests.append(("embedded server credentials are rejected", invalid_server))
        try:
            watch_argv(client_path=client, server="https://havre.cloud", root=root,
                       include_prefixes=["../outside"], include_all=True)
        except ValueError:
            invalid_selection = True
        else:
            invalid_selection = False
        tests.append(("unsafe retention selection is rejected", invalid_selection))

        payload = _payload(
            platform="linux", state_home=state_home, user_home=user_home,
            client_path=client, server="https://havre.cloud", root=root,
            scope="CA", interval_seconds=30, retry_seconds=15, max_failures=5,
            public_library=False, include_prefixes=[], include_all=False,
            definition_path=_linux_unit_path(user_home),
        )
        encoded = json.dumps(payload)
        tests.append(("saved service configuration excludes tokens and keys", "token" not in encoded and "private" not in encoded))
        private_path = state_home / "private-test.json"
        _write_private(private_path, "service test")
        tests.append(("service state writes through a private atomic file", private_path.read_text(encoding="utf-8") == "service test"))

        class _Result:
            returncode = 0
            stdout = "active"
            stderr = ""

        recorded_commands: list[list[str]] = []

        def fake_runner(command):
            recorded_commands.append(list(command))
            return _Result()

        original_which = shutil.which
        shutil.which = lambda name: name
        try:
            lifecycle_passed = True
            for platform_name in ("linux", "macos", "windows"):
                platform_home = base / f"{platform_name}-state"
                platform_user_home = base / f"{platform_name}-user"
                installed = install(
                    client_path=client, server="https://havre.cloud", root=root,
                    platform=platform_name, state_home=platform_home,
                    user_home=platform_user_home, runner=fake_runner,
                )
                service_definition = pathlib.Path(installed["definition_path"])
                config = pathlib.Path(installed["config_path"])
                lifecycle_passed = lifecycle_passed and installed["installed"] \
                    and service_definition.is_file() and config.is_file()
                removed = uninstall(
                    platform=platform_name, state_home=platform_home,
                    user_home=platform_user_home, runner=fake_runner,
                )
                lifecycle_passed = lifecycle_passed and not removed["installed"] \
                    and not service_definition.exists() and not config.exists()
        finally:
            shutil.which = original_which
        tests.append(("install and uninstall exercise all three user-service paths", lifecycle_passed))
        tests.append(("service lifecycle uses native control commands", len(recorded_commands) >= 9))

    print("silo background service selftest")
    for label, passed in tests:
        print(f"  [{'PASS' if passed else 'FAIL'}] {label}")
    return 0 if all(passed for _, passed in tests) else 1


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