#!/usr/bin/env python3
"""Update an existing Linux x86_64 home using a downloaded current updater.

The website release generator pins the bootstrap and initial root. HTTPS supplies
initial trust, exactly as for install.sh; the native engine verifies signed
metadata and the target archive, and retains existing trust high-water marks.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import shlex
import stat
import subprocess
import sys
import tarfile
import tempfile
import urllib.request

VERSION = "0.60.6-alpha.1"
ARCHIVE_SHA256 = "22523f5482910b591cc2b8a44da1c8355f94de9c7f3109db649117d69dfe40c7"
PROFILE = "application-v1"
ROOT_SHA256 = "495be108a7d0c0faa602cd68c553bb6b7ba91e9ded16d1974dbfe48ab2e3526e"
ORIGIN = "https://nookins.app"


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        raise ValueError("Download redirected; preserve the home and retry the published script")


def private_directory(path):
    info = path.lstat()
    if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
        raise ValueError(f"Expected an owned private directory: {path}")


def safe_path(path):
    if not path.is_absolute() or path == Path("/") or ".." in path.parts:
        raise ValueError("Choose an absolute normalized existing home")
    for ancestor in [*reversed(path.parents), path]:
        info = ancestor.lstat()
        if not stat.S_ISDIR(info.st_mode):
            raise ValueError(f"Path contains a link or non-directory: {ancestor}")
        if info.st_mode & 0o022 and not info.st_mode & stat.S_ISVTX:
            raise ValueError(f"Path is writable by other users: {ancestor}")
    private_directory(path)


def read_json(path):
    fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
    with os.fdopen(fd, "rb") as source:
        info = os.fstat(source.fileno())
        if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022:
            raise ValueError(f"Unsafe settings file: {path}")
        data = source.read(16385)
    if len(data) > 16384:
        raise ValueError(f"Settings exceed the size limit: {path}")
    return json.loads(data)


def download(url, destination, expected, limit):
    digest = hashlib.sha256()
    opener = urllib.request.build_opener(NoRedirect)
    request = urllib.request.Request(url, headers={"User-Agent": f"Nookins-Rescue/{VERSION}"})
    with opener.open(request, timeout=60) as response, destination.open("xb") as output:
        if response.status != 200:
            raise ValueError("Download failed")
        total = 0
        while chunk := response.read(1 << 20):
            total += len(chunk)
            if total > limit:
                raise ValueError("Download exceeds its size limit")
            digest.update(chunk)
            output.write(chunk)
        output.flush()
        os.fsync(output.fileno())
    if digest.hexdigest() != expected:
        raise ValueError(f"Checksum mismatch; refusing to execute {destination.name}")


def extract_updater(archive, work):
    allowed = {
        "generation-v1/bin/nookins",
        "generation-v1/release-manifest.json",
        "generation-v1/manifest.json",
        "generation-v1/assets/agent-workspace-v1/manifest.json",
        "generation-v1/assets/agent-workspace-v1/component.json",
        "generation-v1/LICENSE-APACHE", "generation-v1/LICENSE-MIT",
        "generation-v1/NOTICE.md",
    }
    found = set()
    with tarfile.open(archive, "r:gz") as source:
        count = 0
        for member in source:
            count += 1
            if count > 100000:
                raise ValueError("Archive has too many entries")
            if member.name not in allowed:
                continue
            if member.name in found or not member.isfile() or member.size > 512 << 20:
                raise ValueError("Invalid or duplicate bootstrap member")
            found.add(member.name)
            output = work / member.name
            output.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
            with source.extractfile(member) as data, output.open("xb") as target:
                remaining = member.size
                while remaining:
                    chunk = data.read(min(1 << 20, remaining))
                    if not chunk:
                        raise ValueError("Truncated bootstrap member")
                    target.write(chunk)
                    remaining -= len(chunk)
            output.chmod(0o700 if member.name.endswith("/bin/nookins") else 0o600)
    if found != allowed:
        raise ValueError("Incomplete application bootstrap")
    return work / "generation-v1/bin/nookins"


def retained_paths(home, explicit):
    """Select preferences, never a past installation's physical identity."""
    candidates = [explicit] if explicit else [
        home / "maintenance/update.json", Path(str(home) + ".update.json"),
        home / "maintenance/history/update-settings.json",
    ]
    saved = None
    selected = None
    for candidate in candidates:
        if os.path.lexists(candidate):
            selected, saved = candidate, read_json(candidate)
            break
    if explicit and saved is None:
        raise ValueError("The explicitly selected settings file is missing")
    if saved:
        if saved.get("schema") != 1:
            raise ValueError("Unsupported settings schema; preserve it and select valid --settings")
        old = Path(saved["home"])
        def rebase(value):
            path = Path(value)
            if not path.is_absolute() or ".." in path.parts:
                raise ValueError("Settings contain an invalid path")
            if path.is_relative_to(old / "maintenance"):
                return home / "maintenance" / path.relative_to(old / "maintenance")
            return path
        trust, operations = rebase(saved["trust_store"]), rebase(saved["operations"])
        if selected == home / "maintenance/history/update-settings.json":
            # Consolidation moved these standard sibling paths. The receipt's
            # filesystem coordinates are not needed to read current preferences.
            if trust == Path(str(old) + ".update-trust"):
                trust = home / "maintenance/trust"
            if operations == Path(str(old) + ".update-operations"):
                operations = home / "maintenance/operations"
        if not os.path.lexists(trust):
            raise ValueError(f"Configured publisher trust is missing: {trust}; restore it or explicitly configure trust")
        return trust, operations
    stores = [path for path in [home / "maintenance/trust", Path(str(home) + ".update-trust")]
              if os.path.lexists(path)]
    if len(stores) > 1:
        raise ValueError("Multiple publisher trust stores exist; choose --settings explicitly")
    return (stores[0] if stores else home / "maintenance/trust"), home / "maintenance/operations"


def recover_pending(binary, home, settings, operations, work):
    """Finish the old operation before requesting this release, without hiding failure."""
    command = [str(binary), "--home", str(home), "--output", "json", "update",
               "--settings", str(settings)]
    evidence = work / "recovery-output.jsonl"
    # Keep raw native diagnostics private; only the canonical terminal envelope
    # and the updater-cleared active index can authorize the next request.
    with evidence.open("xb") as output:
        result = subprocess.run(command, stdout=output, stderr=subprocess.STDOUT, check=False)
    if evidence.stat().st_size > 512 << 10:
        raise ValueError(f"Recovery output exceeds the inspection bound; retained at {evidence}")
    envelopes = []
    for line in evidence.read_bytes().splitlines():
        if len(line) > 64 << 10:
            continue
        try:
            value = json.loads(line)
        except (ValueError, UnicodeDecodeError):
            continue
        if isinstance(value, dict) and value.get("schema") == "ward.cli.v1" and value.get("command") == "update":
            envelopes.append(value)
    if len(envelopes) != 1 or os.path.lexists(operations / "active.json"):
        raise ValueError(f"Previous update has no confirmed terminal recovery; inspect {evidence}")
    value = envelopes[0]
    data = value.get("data")
    if not isinstance(data, dict) or data.get("schema") != 2 or data.get("service_state") not in ("running", "stopped"):
        raise ValueError(f"Previous update recovery result is invalid; inspect {evidence}")
    error = value.get("error")
    completed = result.returncode == 0 and value.get("ok") is True and data.get("outcome") == "completed" and data.get("rollback") is False
    restored = result.returncode == 1 and value.get("ok") is False and data.get("outcome") == "restored" and data.get("rollback") is True and isinstance(error, dict) and error.get("code") == "update_restored"
    if not completed and not restored:
        raise ValueError(f"Previous update recovery did not complete; inspect {evidence}")
    print("Previous update recovered" + (" by restoring the previous version" if restored else "") + "; requesting the current release.", flush=True)


def rescue(home, explicit=None):
    safe_path(home)
    trust, operations = retained_paths(home, explicit)
    if PROFILE != "application-v1":
        raise ValueError("Rescue requires an application-v1 public release")
    work = Path(tempfile.mkdtemp(prefix=home.name + ".rescue-", dir=home.parent))
    print(f"Rescue inputs and recovery commands will remain in {work}", flush=True)
    archive = work / f"nookins-{VERSION}-linux-x86_64.tar.gz"
    download(f"{ORIGIN}/downloads/{archive.name}", archive, ARCHIVE_SHA256, 512 << 20)
    binary = extract_updater(archive, work)
    maintenance = home / "maintenance"
    maintenance.mkdir(mode=0o700, exist_ok=True)
    private_directory(maintenance)
    repository = work / "repository.json"
    repository.write_text(json.dumps({"schema": 1, "transport": "https",
        "metadata": ORIGIN + "/updates/metadata/", "targets": ORIGIN + "/updates/targets/"}))
    settings = work / "update.json"
    if not os.path.lexists(trust):
        root = work / "root.json"
        download(ORIGIN + "/updates/root.json", root, ROOT_SHA256, 8 << 20)
        subprocess.run([str(binary), "--home", str(home), "update", "--configure",
            "--settings", str(work / "initialized.json"), "--repository", str(repository),
            "--trusted-root", str(root)], check=True)
    else:
        private_directory(trust)
    settings.write_text(json.dumps({"schema": 1, "home": str(home),
        "state": str(home / "maintenance/state"), "trust_store": str(trust),
        "repository": str(repository), "operations": str(operations), "channel": "alpha"}))
    command = [str(binary), "--home", str(home), "update", "--force",
               "--version", VERSION, "--settings", str(settings)]
    retry = shlex.join(command)
    (work / "retry.txt").write_text(retry + "\n")
    print(f"Running the current updater. Retry command: {retry}", flush=True)
    if os.path.lexists(operations / "active.json"):
        # Finish the existing transaction first; never clear an active record or
        # pretend a different target completed it.
        recover_pending(binary, home, settings, operations, work)
    subprocess.run(command, check=True)
    print(f"Update completed. Recovery inputs retained at {work}")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--home", required=True, type=Path)
    parser.add_argument("--settings", type=Path, help="Existing preferences to select retained trust and operation paths")
    args = parser.parse_args()
    if platform.system() != "Linux" or platform.machine() != "x86_64":
        parser.error("This release supports Linux x86_64 with glibc 2.39 or newer")
    os.umask(0o077)
    try:
        rescue(args.home, args.settings)
    except (OSError, ValueError, KeyError, subprocess.CalledProcessError, tarfile.TarError) as error:
        print(f"Rescue stopped: {error}\nThe existing home and recovery inputs were preserved.", file=sys.stderr)
        return 1
    return 0


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