#!/usr/bin/env python3
"""Add or remove Tenvor in a user's global OpenCode configuration.

The installer deliberately keeps the API key outside opencode.json and reads
the current model limits from Tenvor's public catalog before writing anything.
It uses only Python's standard library.
"""

from __future__ import annotations

import argparse
import getpass
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import signal
import stat
import subprocess
import sys
import tempfile
import warnings
from datetime import datetime, timezone
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


API_BASE = "https://orchestrator.tenvor.io"
CATALOG_URL = f"{API_BASE}/api/catalog/public"
MODEL_ID = "chat-35b-moe"
MODEL_REF = f"tenvor/{MODEL_ID}"
AGENT_ID = "tenvor-agent"
STATE_FILENAME = ".tenvor-opencode-state.json"
STATE_VERSION = 2
TESTED_OPENCODE_VERSION = "1.18.32"
# Official V1 installer, reviewed at its immutable upstream commit.
OPENCODE_INSTALLER_URL = (
    "https://raw.githubusercontent.com/anomalyco/opencode/"
    "ef2880f379129aa048be9e9353e30aa168d42c17/install"
)
OPENCODE_INSTALLER_SHA256 = "fc3c1b2123f49b6df545a7622e5127d21cd794b15134fc3b66e1ca49f7fb297e"
MAX_CATALOG_BYTES = 1024 * 1024
MAX_CONFIG_BYTES = 1024 * 1024
CACHE_PLUGIN_URL = "https://tenvor.io/setup/tenvor-session-kv-plugin.mjs"
CACHE_PLUGIN_SHA256 = "383594bd5da104d7ed5d43273e38d6fdc975668a060b467465321bea27c83329"
CACHE_PLUGIN_FILENAME = "tenvor-session-kv-plugin.mjs"
CACHE_STATE_FILENAME = ".tenvor-opencode-cache-state.json"
MAX_PLUGIN_BYTES = 256 * 1024


class SetupError(RuntimeError):
    """A safe, user-actionable setup failure."""


def _bounded_positive_int(value: Any, field: str) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
        raise SetupError(f"The public catalog returned an invalid {field}.")
    if value > 16_777_216:
        raise SetupError(f"The public catalog returned an implausible {field}.")
    return value


def validate_catalog(payload: Any) -> dict[str, Any]:
    if not isinstance(payload, dict) or not isinstance(payload.get("skus"), list):
        raise SetupError("The public catalog response is not in the expected format.")

    model = next(
        (
            sku
            for sku in payload["skus"]
            if isinstance(sku, dict)
            and sku.get("tag") == MODEL_ID
            and sku.get("availability") == "available"
        ),
        None,
    )
    if model is None:
        raise SetupError(f"{MODEL_ID} is not currently available in the public catalog.")

    context = _bounded_positive_int(model.get("max_context_tokens"), "context limit")
    output = _bounded_positive_int(model.get("max_output_tokens"), "output limit")
    if output > context:
        raise SetupError("The public catalog output limit exceeds its context limit.")

    display_name = model.get("display_name")
    if not isinstance(display_name, str) or not display_name.strip() or len(display_name) > 160:
        raise SetupError("The public catalog returned an invalid model display name.")

    return {
        "display_name": display_name.strip(),
        "context": context,
        "output": output,
    }


def fetch_catalog_model() -> dict[str, Any]:
    request = Request(
        CATALOG_URL,
        headers={"Accept": "application/json", "User-Agent": "Tenvor-OpenCode-Setup/1"},
    )
    try:
        with urlopen(request, timeout=15) as response:
            if response.status != 200:
                raise SetupError(f"The public catalog returned HTTP {response.status}.")
            raw = response.read(MAX_CATALOG_BYTES + 1)
    except (HTTPError, URLError, TimeoutError, OSError) as exc:
        raise SetupError(f"Could not read Tenvor's public catalog: {exc}") from exc

    if len(raw) > MAX_CATALOG_BYTES:
        raise SetupError("The public catalog response exceeded the safety limit.")
    try:
        return validate_catalog(json.loads(raw))
    except (json.JSONDecodeError, UnicodeDecodeError) as exc:
        raise SetupError("The public catalog did not return valid JSON.") from exc


def resolve_config_directory() -> Path:
    explicit = os.environ.get("OPENCODE_CONFIG_DIR", "").strip()
    if explicit:
        directory = Path(os.path.expanduser(explicit))
    else:
        xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
        directory = Path(os.path.expanduser(xdg)) if xdg else Path.home() / ".config"
        directory = directory / "opencode"
    if not directory.is_absolute():
        raise SetupError("The OpenCode configuration directory must be an absolute path.")
    return directory


def validate_api_key(value: str) -> str:
    key = value.strip()
    if not key.startswith("sk-dc-"):
        raise SetupError("That does not look like a Tenvor API key (expected sk-dc-…).")
    if len(key) > 4096 or "\n" in key or "\r" in key or "\0" in key:
        raise SetupError("The Tenvor API key has an invalid length or format.")
    return key


def read_config(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    if path.is_symlink():
        raise SetupError(f"Refusing to replace symlinked configuration: {path}")
    if not path.is_file():
        raise SetupError(f"OpenCode configuration is not a regular file: {path}")
    if path.stat().st_size > MAX_CONFIG_BYTES:
        raise SetupError("OpenCode configuration exceeds the 1 MiB safety limit.")
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, UnicodeDecodeError) as exc:
        raise SetupError(
            f"{path} is not strict JSON. The installer left it untouched; "
            "use the manual JSONC instructions instead."
        ) from exc
    if not isinstance(value, dict):
        raise SetupError("The OpenCode configuration root must be a JSON object.")
    return value


def _stage_write(path: Path, data: bytes, mode: int) -> Path:
    descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.tenvor-", dir=path.parent)
    temporary = Path(temporary_name)
    try:
        os.fchmod(descriptor, mode)
        with os.fdopen(descriptor, "wb") as output:
            output.write(data)
            output.flush()
            os.fsync(output.fileno())
        return temporary
    except BaseException:
        try:
            os.close(descriptor)
        except OSError:
            pass
        temporary.unlink(missing_ok=True)
        raise


def _atomic_write(path: Path, data: bytes, mode: int) -> None:
    temporary = _stage_write(path, data, mode)
    try:
        os.replace(temporary, path)
    except BaseException:
        temporary.unlink(missing_ok=True)
        raise


def _snapshot(path: Path) -> tuple[bytes, int] | None:
    if path.is_symlink():
        raise SetupError(f"Refusing to modify symlinked managed path: {path}")
    if not path.exists():
        return None
    if not path.is_file():
        raise SetupError(f"Managed path is not a regular file: {path}")
    return path.read_bytes(), stat.S_IMODE(path.stat().st_mode)


def _restore_snapshots(snapshots: dict[Path, tuple[bytes, int] | None]) -> None:
    writes = [(path, data, mode) for path, snapshot in snapshots.items()
              if snapshot is not None for data, mode in [snapshot]]
    deletes = [path for path, snapshot in snapshots.items() if snapshot is None]
    _transactional_update(writes, deletes)


def _transactional_update(
    writes: list[tuple[Path, bytes, int]], deletes: list[Path] | None = None
) -> None:
    deletes = deletes or []
    targets = [path for path, _, _ in writes] + deletes
    if len(targets) != len(set(targets)):
        raise SetupError("The managed update contains a duplicate path.")

    snapshots = {path: _snapshot(path) for path in targets}
    staged: list[tuple[Path, Path]] = []
    try:
        for path, data, mode in writes:
            staged.append((path, _stage_write(path, data, mode)))
    except BaseException:
        for _, temporary in staged:
            temporary.unlink(missing_ok=True)
        raise

    try:
        for path, temporary in staged:
            os.replace(temporary, path)
        for path in deletes:
            path.unlink(missing_ok=True)
    except BaseException as exc:
        rollback_errors: list[str] = []
        for path, snapshot in snapshots.items():
            try:
                if snapshot is None:
                    path.unlink(missing_ok=True)
                else:
                    data, mode = snapshot
                    _atomic_write(path, data, mode)
            except BaseException as rollback_exc:
                rollback_errors.append(f"{path}: {rollback_exc}")
        for _, temporary in staged:
            temporary.unlink(missing_ok=True)
        if rollback_errors:
            raise SetupError(
                "The managed update failed and automatic rollback was incomplete: "
                + "; ".join(rollback_errors)
            ) from exc
        raise SetupError("The managed update failed; all changed files were rolled back.") from exc


def _backup(path: Path) -> Path | None:
    if not path.exists():
        return None
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    candidate = path.with_name(f"{path.name}.backup-{stamp}")
    counter = 1
    while candidate.exists():
        candidate = path.with_name(f"{path.name}.backup-{stamp}-{counter}")
        counter += 1
    shutil.copy2(path, candidate)
    return candidate


def provider_config(model: dict[str, Any]) -> dict[str, Any]:
    return {
        "npm": "@ai-sdk/openai-compatible",
        "name": "Tenvor",
        "options": {
            "baseURL": f"{API_BASE}/v1",
            "apiKey": "{file:tenvor-api-key}",
        },
        "models": {
            MODEL_ID: {
                "name": f"Tenvor {model['display_name']}",
                "limit": {
                    "context": model["context"],
                    "output": model["output"],
                },
            }
        },
    }


def agent_config() -> dict[str, Any]:
    return {
        "description": "Tenvor alpha with explicit approval for every workspace tool",
        "mode": "primary",
        "model": MODEL_REF,
        "permission": {
            "*": "ask",
            "external_directory": "deny",
        },
    }


def _key_digest(api_key: str) -> str:
    return hashlib.sha256(api_key.encode("utf-8")).hexdigest()


def _managed_state(
    provider: dict[str, Any], agent: dict[str, Any] | None, api_key: str, config_existed: bool
) -> dict[str, Any]:
    return {
        "version": STATE_VERSION,
        "provider": provider,
        "agent": agent,
        "agent_owned": agent is not None,
        "key_sha256": _key_digest(api_key),
        "config_existed": config_existed,
    }


def read_managed_state(path: Path) -> dict[str, Any] | None:
    if path.is_symlink():
        raise SetupError(f"Refusing to read symlinked installer state: {path}")
    if not path.exists():
        return None
    if not path.is_file():
        raise SetupError(f"Installer state is not a regular file: {path}")
    if path.stat().st_size > MAX_CONFIG_BYTES:
        raise SetupError("Installer state exceeds the 1 MiB safety limit.")
    try:
        state = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, UnicodeDecodeError) as exc:
        raise SetupError("The Tenvor installer state is not valid JSON.") from exc
    legacy = isinstance(state, dict) and state.get("version") == 1
    expected_keys = {"version", "provider", "agent", "key_sha256", "config_existed"}
    if not legacy:
        expected_keys.add("agent_owned")
    if (
        not isinstance(state, dict)
        or set(state) != expected_keys
        or state.get("version") not in {1, STATE_VERSION}
        or not isinstance(state.get("provider"), dict)
        or (not isinstance(state.get("agent"), dict) if legacy or state.get("agent_owned") is True
            else state.get("agent") is not None)
        or (not legacy and not isinstance(state.get("agent_owned"), bool))
        or not isinstance(state.get("config_existed"), bool)
        or not isinstance(state.get("key_sha256"), str)
        or len(state["key_sha256"]) != 64
        or any(character not in "0123456789abcdef" for character in state["key_sha256"])
    ):
        raise SetupError("The Tenvor installer state has an unsupported format.")
    if legacy:
        state["agent_owned"] = True
    return state


def read_managed_key(path: Path) -> str:
    if path.is_symlink():
        raise SetupError(f"Refusing to read symlinked credential file: {path}")
    if not path.exists() or not path.is_file():
        raise SetupError(f"Managed credential is not a regular file: {path}")
    if path.stat().st_size > 4096:
        raise SetupError("Managed credential exceeds the safety limit.")
    try:
        return validate_api_key(path.read_text(encoding="utf-8"))
    except UnicodeDecodeError as exc:
        raise SetupError("Managed credential is not valid UTF-8.") from exc


def manual_tenvor_provider(config_directory: Path) -> bool:
    """Recognize an existing account without taking ownership of its credential."""
    if (config_directory / STATE_FILENAME).exists():
        return False
    providers = read_config(config_directory / "opencode.json").get("provider", {})
    if not isinstance(providers, dict):
        raise SetupError("The existing OpenCode provider setting must be a JSON object.")
    provider = providers.get("tenvor")
    if provider is None:
        return False
    if not isinstance(provider, dict) or not isinstance(provider.get("options"), dict) or (
        provider["options"].get("baseURL") != f"{API_BASE}/v1"
        or not isinstance(provider.get("models"), dict)
        or MODEL_ID not in provider["models"]
    ):
        raise SetupError(
            "An existing Tenvor provider has different endpoint or model settings. "
            "Review it manually; setup will not redirect its credential."
        )
    return True


def _cache_entry(plugin_path: Path) -> list[Any]:
    return [plugin_path.resolve().as_uri(), {"baseURL": f"{API_BASE}/v1"}]


def _is_tenvor_cache_entry(entry: Any) -> bool:
    module = entry[0] if isinstance(entry, list) and entry else entry
    return isinstance(module, str) and module.removeprefix("file://").endswith("/" + CACHE_PLUGIN_FILENAME)


def _read_cache_state(path: Path) -> dict[str, Any] | None:
    if path.is_symlink():
        raise SetupError("Refusing to read symlinked cache installer state.")
    if not path.exists():
        return None
    if not path.is_file() or path.stat().st_size > MAX_CONFIG_BYTES:
        raise SetupError("The cache installer state is not a regular bounded file.")
    try:
        state = json.loads(path.read_text(encoding="utf-8"))
    except (UnicodeError, ValueError) as exc:
        raise SetupError("The cache installer state is not valid JSON.") from exc
    if (not isinstance(state, dict) or state.get("version") != 1 or
        not isinstance(state.get("entry"), list) or
        not isinstance(state.get("plugin_owned"), bool) or
        not isinstance(state.get("agent_owned"), bool)):
        raise SetupError("The cache installer state has an unsupported format.")
    return state


def fetch_cache_plugin() -> bytes:
    request = Request(CACHE_PLUGIN_URL, headers={"User-Agent": "Tenvor-OpenCode-Setup/1"})
    try:
        with urlopen(request, timeout=15) as response:
            if response.status != 200:
                raise SetupError(f"The cache plugin returned HTTP {response.status}.")
            source = response.read(MAX_PLUGIN_BYTES + 1)
    except (HTTPError, URLError, TimeoutError, OSError) as exc:
        raise SetupError(f"Could not download Tenvor's cache plugin: {exc}") from exc
    if len(source) > MAX_PLUGIN_BYTES or hashlib.sha256(source).hexdigest() != CACHE_PLUGIN_SHA256:
        raise SetupError("The cache plugin failed SHA-256 verification; setup was not changed.")
    return source


def install_cache_plugin(config_directory: Path, source: bytes, *, backup_config: bool = True) -> dict[str, Any]:
    """Install the pinned V1 adapter and optionally add the restricted agent."""
    if hashlib.sha256(source).hexdigest() != CACHE_PLUGIN_SHA256:
        raise SetupError("The cache plugin failed SHA-256 verification; setup was not changed.")
    config_path = config_directory / "opencode.json"
    plugin_path = config_directory / CACHE_PLUGIN_FILENAME
    state_path = config_directory / CACHE_STATE_FILENAME
    config = read_config(config_path)
    plugins = config.setdefault("plugin", [])
    agents = config.setdefault("agent", {})
    if not isinstance(plugins, list) or not isinstance(agents, dict):
        raise SetupError("The existing OpenCode plugin or agent setting has the wrong type.")
    state = _read_cache_state(state_path)
    if state is None:
        existing = [entry for entry in plugins if _is_tenvor_cache_entry(entry)]
        if len(existing) > 1:
            raise SetupError("Multiple Tenvor cache plugins are configured; review them manually.")
        owned = not existing
        entry = _cache_entry(plugin_path) if owned else existing[0]
        if owned and (plugin_path.exists() or plugin_path.is_symlink()):
            raise SetupError(f"Refusing to overwrite pre-existing cache plugin: {plugin_path}")
        if not owned:
            if not isinstance(entry, list):
                raise SetupError("The existing Tenvor cache plugin needs an explicit endpoint option.")
            options = entry[1] if len(entry) > 1 else None
            if not isinstance(options, dict) or options.get("baseURL") != f"{API_BASE}/v1" or options.get("enabled") is False:
                raise SetupError("The existing Tenvor cache plugin options differ; review them manually.")
            existing_path = Path(entry[0].removeprefix("file://"))
            if not existing_path.is_file():
                raise SetupError("The existing Tenvor cache plugin file is missing.")
        agent_owned = AGENT_ID not in agents
        agent = agent_config() if agent_owned else None
        if agent_owned:
            agents[AGENT_ID] = agent
        if owned:
            plugins.append(entry)
        state = {"version": 1, "entry": entry, "plugin_owned": owned,
                 "agent_owned": agent_owned, "agent": agent,
                 "plugin_sha256": CACHE_PLUGIN_SHA256 if owned else None}
    else:
        entry = state.get("entry")
        if not isinstance(entry, list) or entry not in plugins:
            raise SetupError("The script-managed cache plugin entry was changed.")
        owned = state.get("plugin_owned") is True
        agent_owned = state.get("agent_owned") is True
        if owned and entry != _cache_entry(plugin_path):
            raise SetupError("The script-managed cache plugin endpoint was changed.")
        if agent_owned and AGENT_ID in agents and agents[AGENT_ID] != state.get("agent"):
            # A changed agent belongs to the user. Keep it and relinquish ownership.
            agent_owned = False
            state["agent_owned"] = False
            state["agent"] = None
        if AGENT_ID not in agents:
            # A rerun may adopt the now-vacant name without replacing a custom agent.
            agent_owned = True
            state["agent_owned"] = True
            agents[AGENT_ID] = agent_config()
            state["agent"] = agents[AGENT_ID]
        if owned and (plugin_path.is_symlink() or not plugin_path.is_file() or
                      hashlib.sha256(plugin_path.read_bytes()).hexdigest() != state.get("plugin_sha256")):
            raise SetupError("The script-managed cache plugin file was changed.")
        if not owned and state.get("plugin_sha256") is not None:
            raise SetupError("The cache installer state has an unsupported format.")
        state["plugin_sha256"] = CACHE_PLUGIN_SHA256 if owned else None
    encoded = (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode()
    encoded_state = (json.dumps(state, indent=2, ensure_ascii=False) + "\n").encode()
    writes = []
    if config_path.read_bytes() != encoded:
        writes.append((config_path, encoded, stat.S_IMODE(config_path.stat().st_mode)))
    if owned and (not plugin_path.exists() or plugin_path.read_bytes() != source):
        writes.append((plugin_path, source, 0o600))
    if not state_path.exists() or state_path.read_bytes() != encoded_state:
        writes.append((state_path, encoded_state, 0o600))
    try:
        backup = _backup(config_path) if backup_config and any(path == config_path for path, _, _ in writes) else None
        plugin_backup = None
        # An installer-owned plugin may change across releases. Retain its exact
        # previous bytes before replacing it, just as for opencode.json.
        if state_path.exists() and any(path == plugin_path for path, _, _ in writes):
            plugin_backup = _backup(plugin_path)
    except OSError as exc:
        raise SetupError(f"Could not back up the OpenCode configuration or plugin: {exc}") from exc
    if writes:
        _transactional_update(writes)
    return {"owned": owned, "agent_owned": agent_owned, "backup": backup,
            "plugin_backup": plugin_backup,
            "plugin_path": plugin_path if owned else None}


def remove_cache_plugin(config_directory: Path) -> bool:
    config_path = config_directory / "opencode.json"
    plugin_path = config_directory / CACHE_PLUGIN_FILENAME
    state_path = config_directory / CACHE_STATE_FILENAME
    if not state_path.exists() and not state_path.is_symlink():
        return False
    state = _read_cache_state(state_path)
    config = read_config(config_path)
    plugins = config.get("plugin")
    agents = config.get("agent")
    entry = state.get("entry")
    if not isinstance(plugins, list) or entry not in plugins or not isinstance(agents, dict):
        raise SetupError("The script-managed cache plugin configuration was changed.")
    owned = state.get("plugin_owned") is True
    if owned and (plugin_path.is_symlink() or not plugin_path.is_file() or
                  hashlib.sha256(plugin_path.read_bytes()).hexdigest() != state.get("plugin_sha256")):
        raise SetupError("The script-managed cache plugin file was changed.")
    if state.get("agent_owned") is True and agents.get(AGENT_ID) != state.get("agent"):
        raise SetupError("The script-managed Tenvor agent was changed.")
    deletes = [state_path]
    if owned:
        plugins.remove(entry)
        if not plugins:
            del config["plugin"]
        deletes.append(plugin_path)
    if state.get("agent_owned") is True:
        del agents[AGENT_ID]
        if not agents:
            del config["agent"]
    encoded = (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode()
    writes = []
    if config_path.read_bytes() != encoded:
        _backup(config_path)
        writes.append((config_path, encoded, stat.S_IMODE(config_path.stat().st_mode)))
    _transactional_update(writes, deletes)
    return True


def install(config_directory: Path, model: dict[str, Any], api_key: str | None) -> dict[str, Any]:
    if config_directory.is_symlink():
        raise SetupError(f"Refusing to use symlinked configuration directory: {config_directory}")
    config_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
    config_path = config_directory / "opencode.json"
    key_path = config_directory / "tenvor-api-key"
    state_path = config_directory / STATE_FILENAME
    jsonc_path = config_directory / "opencode.jsonc"
    config_existed_now = config_path.exists()
    config = read_config(config_path)
    state = read_managed_state(state_path)

    providers = config.get("provider")
    if providers is None:
        providers = {}
        config["provider"] = providers
    if not isinstance(providers, dict):
        raise SetupError("The existing OpenCode provider setting must be a JSON object.")

    agents = config.get("agent")
    if agents is None:
        agents = {}
        config["agent"] = agents
    if not isinstance(agents, dict):
        raise SetupError("The existing OpenCode agent setting must be a JSON object.")

    if state is None:
        collisions = []
        if "tenvor" in providers:
            collisions.append("provider.tenvor")
        if AGENT_ID in agents:
            collisions.append(f"agent.{AGENT_ID}")
        if key_path.exists() or key_path.is_symlink():
            collisions.append(str(key_path))
        if collisions:
            raise SetupError(
                "Refusing to overwrite pre-existing state: " + ", ".join(collisions)
            )
        if api_key is None:
            raise SetupError("No script-managed credential exists; enter a new API key.")
        selected_key = validate_api_key(api_key)
        config_existed = config_existed_now
    else:
        if providers.get("tenvor") != state["provider"]:
            raise SetupError(
                "The script-managed Tenvor provider was changed. Restore it or remove it manually."
            )
        agent_owned = state["agent_owned"] is True
        if agent_owned and AGENT_ID in agents and agents[AGENT_ID] != state["agent"]:
            # Preserve an edited agent, including edited approval rules. The provider,
            # credential, and plugin can still be upgraded independently.
            agent_owned = False
        if AGENT_ID not in agents:
            # A removed custom agent no longer owns this name; restore the
            # restricted default when setup is explicitly rerun.
            agent_owned = True
        existing_key = read_managed_key(key_path)
        if _key_digest(existing_key) != state["key_sha256"]:
            raise SetupError(
                "The script-managed credential was changed. Refusing to overwrite or remove it."
            )
        selected_key = validate_api_key(api_key) if api_key is not None else existing_key
        config_existed = state["config_existed"]
    if state is None:
        agent_owned = True

    managed_provider = provider_config(model)
    managed_agent = agent_config()
    providers["tenvor"] = managed_provider
    if agent_owned:
        agents[AGENT_ID] = managed_agent

    encoded = (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
    current = config_path.read_bytes() if config_path.exists() else None
    managed = _managed_state(managed_provider, managed_agent if agent_owned else None,
                             selected_key, config_existed)
    encoded_state = (json.dumps(managed, indent=2, ensure_ascii=False) + "\n").encode("utf-8")

    backup = None
    if current != encoded and config_path.exists():
        try:
            backup = _backup(config_path)
        except OSError as exc:
            raise SetupError(f"Could not back up the OpenCode configuration: {exc}") from exc

    writes: list[tuple[Path, bytes, int]] = []
    config_mode = stat.S_IMODE(config_path.stat().st_mode) if config_path.exists() else 0o600
    if current != encoded:
        writes.append((config_path, encoded, config_mode))
    key_bytes = selected_key.encode("utf-8")
    if (
        not key_path.exists()
        or key_path.read_bytes() != key_bytes
        or stat.S_IMODE(key_path.stat().st_mode) != 0o600
    ):
        writes.append((key_path, key_bytes, 0o600))
    if (
        not state_path.exists()
        or state_path.read_bytes() != encoded_state
        or stat.S_IMODE(state_path.stat().st_mode) != 0o600
    ):
        writes.append((state_path, encoded_state, 0o600))
    if writes:
        _transactional_update(writes)

    return {
        "config_path": config_path,
        "key_path": key_path,
        "backup": backup,
        "jsonc_conflict_possible": jsonc_path.exists(),
        "agent_customized": not agent_owned,
    }


def remove(config_directory: Path) -> dict[str, Any]:
    config_path = config_directory / "opencode.json"
    key_path = config_directory / "tenvor-api-key"
    state_path = config_directory / STATE_FILENAME
    state = read_managed_state(state_path)
    if state is None:
        raise SetupError(
            "No script-managed Tenvor installation was found; nothing was removed."
        )
    config = read_config(config_path)

    providers = config.get("provider")
    agents = config.get("agent")
    if not isinstance(providers, dict) or providers.get("tenvor") != state["provider"]:
        raise SetupError(
            "The script-managed Tenvor provider was changed. Refusing partial removal."
        )
    if (state["agent_owned"] and
        (not isinstance(agents, dict) or agents.get(AGENT_ID) != state["agent"])):
        raise SetupError(
            f"The script-managed {AGENT_ID} agent was changed. Refusing partial removal."
        )
    existing_key = read_managed_key(key_path)
    if _key_digest(existing_key) != state["key_sha256"]:
        raise SetupError(
            "The script-managed credential was changed. Refusing partial removal."
        )

    del providers["tenvor"]
    if not providers:
        del config["provider"]
    if state["agent_owned"]:
        del agents[AGENT_ID]
        if not agents:
            del config["agent"]

    try:
        backup = _backup(config_path)
    except OSError as exc:
        raise SetupError(f"Could not back up the OpenCode configuration: {exc}") from exc

    writes: list[tuple[Path, bytes, int]] = []
    deletes = [key_path, state_path]
    if config or state["config_existed"]:
        encoded = (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
        writes.append((config_path, encoded, stat.S_IMODE(config_path.stat().st_mode)))
    else:
        deletes.append(config_path)
    _transactional_update(writes, deletes)

    return {"config_path": config_path, "backup": backup, "key_removed": True}


def read_key_file(path: Path) -> str:
    if not path.is_file():
        raise SetupError(f"API key file is not a regular file: {path}")
    if path.stat().st_size > 4096:
        raise SetupError("API key file exceeds the safety limit.")
    return validate_api_key(path.read_text(encoding="utf-8"))


def prompt_api_key() -> str:
    # getpass normally falls back to echoed input without a usable terminal.
    # Stop before that fallback can expose a balance-bearing credential.
    try:
        with warnings.catch_warnings():
            warnings.simplefilter("error", getpass.GetPassWarning)
            return validate_api_key(getpass.getpass("Tenvor API key (input hidden): "))
    except (getpass.GetPassWarning, EOFError) as exc:
        raise SetupError(
            "Hidden terminal input is unavailable. Run setup in an interactive "
            "terminal, or use --key-file with an existing private credential file."
        ) from exc


def resolve_opencode() -> str | None:
    """Check PATH and a fixed set of documented user install locations."""
    executable = shutil.which("opencode")
    if executable:
        return os.path.abspath(executable)
    directories = [
        os.environ.get("OPENCODE_INSTALL_DIR", ""),
        os.environ.get("XDG_BIN_DIR", ""),
        str(Path.home() / ".opencode" / "bin"),
        str(Path.home() / "bin"),
        str(Path.home() / ".local" / "bin"),
    ]
    for directory in directories:
        if not directory:
            continue
        candidate = Path(directory).expanduser() / "opencode"
        if candidate.is_absolute() and candidate.is_file() and os.access(candidate, os.X_OK):
            return str(candidate)
    return None


def opencode_version(executable: str) -> str:
    try:
        result = subprocess.run(
            [executable, "--version"],
            check=True,
            capture_output=True,
            text=True,
            timeout=10,
        )
    except (OSError, UnicodeError, subprocess.SubprocessError):
        return "unknown"
    version = result.stdout.strip()
    return version if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?", version) else "unknown"


def install_opencode_client() -> None:
    """Run only the pinned official, user-local installer after consent."""
    if sys.platform not in {"linux", "darwin"}:
        raise SetupError("Optional installation supports Linux/macOS; use WSL or https://opencode.ai/docs/.")
    if os.geteuid() == 0:
        raise SetupError("Run optional OpenCode installation as your regular user, not with sudo.")
    required = ["bash", "curl", "tar" if sys.platform == "linux" else "unzip"]
    commands = {name: shutil.which(name) for name in required}
    if any(value is None for value in commands.values()):
        raise SetupError("Optional OpenCode installation requires " + ", ".join(required) + "; install missing prerequisites first.")
    target = Path.home() / ".opencode" / "bin" / "opencode"
    if any(path.is_symlink() for path in (target.parent.parent, target.parent, target)) or target.exists():
        raise SetupError("The default OpenCode installation path already exists or is symlinked; repair it manually.")
    # Do not pass provider credentials, shell hooks, or Actions file commands.
    environment = {
        key: value for key, value in os.environ.items()
        if key in {"HOME", "PATH", "SHELL", "LANG", "LC_ALL"}
    }
    environment.setdefault("SHELL", str(commands["bash"]))
    try:
        with tempfile.TemporaryDirectory(prefix="tenvor-opencode-install-") as directory:
            scratch = Path(directory)
            installer = scratch / "install.sh"
            subprocess.run(
                [str(commands["curl"]), "-q", "--proto", "=https", "--tlsv1.2",
                 "--fail", "--silent", "--show-error", "--max-time", "30",
                 "--max-filesize", "65536", OPENCODE_INSTALLER_URL, "--output", str(installer)],
                check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                env=environment, timeout=45,
            )
            source = installer.read_bytes()
            if len(source) > 65536 or hashlib.sha256(source).hexdigest() != OPENCODE_INSTALLER_SHA256:
                raise SetupError("The official OpenCode installer failed SHA-256 verification; it was not run.")
            environment["TMPDIR"] = str(scratch)
            environment["CURL_HOME"] = str(scratch)
            # The upstream script's curl must not load user credential headers.
            (scratch / ".curlrc").write_text("", encoding="utf-8")
            with subprocess.Popen(
                [str(commands["bash"]), "--noprofile", "--norc", str(installer),
                 "--version", TESTED_OPENCODE_VERSION, "--no-modify-path"],
                stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL, env=environment, start_new_session=True,
            ) as process:
                try:
                    result = process.wait(timeout=180)
                except (subprocess.TimeoutExpired, KeyboardInterrupt):
                    try:
                        os.killpg(process.pid, signal.SIGKILL)
                    except ProcessLookupError:
                        pass
                    process.wait()
                    raise
                if result != 0:
                    raise subprocess.CalledProcessError(result, "official OpenCode installer")
    except (OSError, subprocess.SubprocessError) as exc:
        raise SetupError(
            "OpenCode installation failed. No Tenvor key or configuration was changed. "
            "Check https://opencode.ai/docs/; a partial client installation may need repair."
        ) from exc


def upgrade_opencode_client(executable: str) -> None:
    """Upgrade only the official user-local binary with OpenCode's curl method."""
    target = Path.home() / ".opencode" / "bin" / "opencode"
    path = Path(executable)
    if (sys.platform not in {"linux", "darwin"} or os.geteuid() == 0 or
        path != target or any(item.is_symlink() for item in
                              (target.parent.parent, target.parent, target)) or
        not target.is_file() or target.stat().st_uid != os.geteuid()):
        raise SetupError(
            f"OpenCode at {executable} is older than {TESTED_OPENCODE_VERSION}. "
            "Update this nonstandard or package-managed installation with its own "
            "package manager, then rerun Tenvor setup; no Tenvor configuration was changed."
        )
    environment = {
        key: value for key, value in os.environ.items()
        if key in {"HOME", "PATH", "SHELL", "LANG", "LC_ALL"}
    }
    try:
        with tempfile.TemporaryDirectory(prefix="tenvor-opencode-upgrade-") as directory:
            scratch = Path(directory)
            environment["TMPDIR"] = str(scratch)
            environment["CURL_HOME"] = str(scratch)
            environment["XDG_CONFIG_HOME"] = str(scratch)
            environment["OPENCODE_CONFIG_DIR"] = str(scratch / "opencode")
            (scratch / "opencode").mkdir()
            (scratch / ".curlrc").write_text("", encoding="utf-8")
            with subprocess.Popen(
                [str(target), "upgrade", TESTED_OPENCODE_VERSION,
                 "--method", "curl"],
                stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL, env=environment, cwd=scratch,
                start_new_session=True,
            ) as process:
                try:
                    result = process.wait(timeout=180)
                except (subprocess.TimeoutExpired, KeyboardInterrupt):
                    try:
                        os.killpg(process.pid, signal.SIGKILL)
                    except ProcessLookupError:
                        pass
                    process.wait()
                    raise
                if result != 0:
                    raise subprocess.CalledProcessError(result, "OpenCode upgrade")
    except (OSError, subprocess.SubprocessError) as exc:
        raise SetupError(
            "The user-local OpenCode upgrade failed. Check the OpenCode installation "
            "and rerun setup; no Tenvor key or configuration was changed."
        ) from exc
    if (resolve_opencode() != str(target) or target.is_symlink() or
        opencode_version(str(target)) != TESTED_OPENCODE_VERSION):
        raise SetupError(
            "OpenCode upgrade did not leave the tested version at the same user-local "
            "path. Repair OpenCode and rerun setup; no Tenvor configuration was changed."
        )


def ensure_opencode() -> tuple[str, str]:
    executable = resolve_opencode()
    if executable is None:
        if not sys.stdin.isatty():
            raise SetupError(
                "OpenCode was not found. Run setup in an interactive terminal to choose "
                "installation, or install it using https://opencode.ai/docs/ and retry. "
                "No API key was read or Tenvor configuration changed."
            )
        print(f"OpenCode was not found. Optional install: tested version {TESTED_OPENCODE_VERSION}.")
        print("Installs for your user only; no sudo, system packages, or shell startup file changes.")
        try:
            approved = input("Download and run the pinned official OpenCode installer? [y/N] ")
        except EOFError:
            approved = ""
        if approved.strip().lower() not in {"y", "yes"}:
            raise SetupError("OpenCode installation declined; no API key was read or configuration changed.")
        install_opencode_client()
        executable = resolve_opencode()
        if executable is None:
            raise SetupError("OpenCode installation did not produce an executable; Tenvor was not configured.")
    version = opencode_version(executable)
    if version == "unknown":
        raise SetupError("OpenCode could not report its version; repair its installation before configuring Tenvor.")
    exact_version = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", version)
    if exact_version and tuple(map(int, exact_version.groups())) < tuple(map(int, TESTED_OPENCODE_VERSION.split("."))):
        upgrade_opencode_client(executable)
        version = opencode_version(executable)
    return executable, version


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Configure Tenvor for OpenCode.")
    parser.add_argument(
        "--key-file",
        type=Path,
        help="Read the Tenvor API key from this file instead of prompting.",
    )
    parser.add_argument(
        "--rotate-key",
        action="store_true",
        help="Prompt for and replace an existing Tenvor credential.",
    )
    parser.add_argument(
        "--remove",
        action="store_true",
        help="Remove only the Tenvor provider and its managed credential.",
    )
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    config_directory = resolve_config_directory()

    try:
        if args.remove:
            if args.key_file or args.rotate_key:
                raise SetupError("--remove cannot be combined with credential options.")
            has_provider_state = (config_directory / STATE_FILENAME).exists()
            has_cache_state = (config_directory / CACHE_STATE_FILENAME).exists()
            if not has_provider_state and not has_cache_state:
                raise SetupError("No script-managed Tenvor installation was found; nothing was removed.")
            paths = [config_directory / "opencode.json", config_directory / CACHE_PLUGIN_FILENAME,
                     config_directory / CACHE_STATE_FILENAME]
            if has_provider_state:
                paths.extend([config_directory / "tenvor-api-key", config_directory / STATE_FILENAME])
            snapshots = {path: _snapshot(path) for path in paths}
            try:
                if has_cache_state:
                    remove_cache_plugin(config_directory)
                result = remove(config_directory) if has_provider_state else None
            except BaseException as exc:
                try:
                    _restore_snapshots(snapshots)
                except BaseException as rollback_exc:
                    raise SetupError(f"Removal failed and rollback was incomplete: {rollback_exc}") from exc
                raise
            print("Tenvor setup was removed from the global OpenCode configuration.")
            print(f"Config: {config_directory / 'opencode.json'}")
            print(f"Credential removed: {'yes' if result and result['key_removed'] else 'not script-managed'}")
            if result and result["backup"]:
                print(f"Config backup: {result['backup']}")
            return 0

        executable, version = ensure_opencode()
        if not version.startswith("1."):
            raise SetupError("This cache plugin supports OpenCode 1.x; use the OpenCode 1 installer until the V2 plugin is released.")
        model = fetch_catalog_model()
        manual = manual_tenvor_provider(config_directory)
        if manual and (args.key_file or args.rotate_key):
            raise SetupError("This Tenvor provider uses an existing OpenCode credential; change it with OpenCode auth before rerunning setup.")
        plugin_source = fetch_cache_plugin()
        key_path = config_directory / "tenvor-api-key"
        result = None
        new_provider = not (config_directory / STATE_FILENAME).exists()
        api_key: str | None = None
        if not manual:
            if args.key_file:
                api_key = read_key_file(args.key_file.expanduser())
            elif args.rotate_key or not key_path.is_file() or key_path.is_symlink():
                api_key = prompt_api_key()
        paths = [config_directory / "opencode.json", config_directory / CACHE_PLUGIN_FILENAME,
                 config_directory / CACHE_STATE_FILENAME]
        if not manual:
            paths.extend([key_path, config_directory / STATE_FILENAME])
        snapshots = {path: _snapshot(path) for path in paths}
        try:
            if not manual:
                result = install(config_directory, model, api_key)
            cache = install_cache_plugin(config_directory, plugin_source,
                                         backup_config=not (result and new_provider))
        except BaseException as exc:
            if config_directory.exists():
                try:
                    _restore_snapshots(snapshots)
                except BaseException as rollback_exc:
                    raise SetupError(f"Setup failed and rollback was incomplete: {rollback_exc}") from exc
            raise
        print("Tenvor is ready in OpenCode.")
        print(f"Config: {config_directory / 'opencode.json'}")
        if result:
            print(f"Credential: {result['key_path']} (mode 600)")
        else:
            print("Credential: existing OpenCode account (preserved)")
        print(f"Model: {MODEL_REF}")
        print(f"Live limits: {model['context']:,} context / {model['output']:,} output tokens")
        if result and result["backup"]:
            print(f"Config backup: {result['backup']}")
        if cache["backup"]:
            print(f"Config backup: {cache['backup']}")
        if cache["plugin_backup"]:
            print(f"Cache plugin backup: {cache['plugin_backup']}")
        print("Session cache plugin: installed" if cache["owned"] else "Session cache plugin: existing installation preserved")
        print("Your existing default model and agents were preserved.")
        if (config_directory / 'opencode.jsonc').exists():
            print(
                "Note: opencode.jsonc also exists and loads after opencode.json; "
                "its conflicting settings may take precedence."
            )

        if version == TESTED_OPENCODE_VERSION:
            print(f"OpenCode {version} matches Tenvor's tested integration version.")
        else:
            print(
                f"OpenCode version: {version}. Tenvor's currently published test evidence "
                f"is for {TESTED_OPENCODE_VERSION}."
            )
        print(f"Run: {shlex.join([executable, '--agent', AGENT_ID])}")
        print("This exact command works in your current terminal; no shell restart is needed.")
        if (result and not result["agent_customized"]) or cache["agent_owned"]:
            print("Every workspace tool call will require approval; outside-workspace access is denied.")
        else:
            print("Your changed tenvor-agent was preserved and is no longer script-managed; review its model and tool permissions before using it.")
        return 0
    except SetupError as exc:
        print(f"Tenvor OpenCode setup stopped: {exc}", file=sys.stderr)
        return 1


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