from __future__ import annotations

import contextvars
import importlib
import json
import logging
import os
import shlex
import shutil
import subprocess
from functools import lru_cache
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    Dict,
    Iterable,
    Mapping,
    MutableMapping,
    Optional,
    Sequence,
    Union,
    cast,
)

if TYPE_CHECKING:
    from .config import RepoConfig, TemplateRepoConfig

SUBCOMMAND_HINTS = ("exec", "resume")


def extract_flag_value(args: Iterable[str], flag: str) -> Optional[str]:
    if not args:
        return None
    for arg in args:
        if not isinstance(arg, str):
            continue
        if arg.startswith(f"{flag}="):
            return arg.split("=", 1)[1] or None
    args_list = [str(a) for a in args]
    for idx, arg in enumerate(args_list):
        if arg == flag and idx + 1 < len(args_list):
            return args_list[idx + 1]
    return None


def inject_flag(
    args: Iterable[str],
    flag: str,
    value: Optional[str],
    *,
    subcommands: Iterable[str] = SUBCOMMAND_HINTS,
) -> list[str]:
    if not value:
        return [str(a) for a in args]
    args_list = [str(a) for a in args]
    if extract_flag_value(args_list, flag):
        return args_list
    insert_at = None
    for cmd in subcommands:
        try:
            insert_at = args_list.index(cmd)
            break
        except ValueError:
            continue
    if insert_at is None:
        if args_list and not args_list[0].startswith("-"):
            return [args_list[0], flag, value] + args_list[1:]
        return [flag, value] + args_list
    return args_list[:insert_at] + [flag, value] + args_list[insert_at:]


def apply_codex_options(
    args: Iterable[str],
    *,
    model: Optional[str] = None,
    reasoning: Optional[str] = None,
    supports_reasoning: Optional[bool] = None,
) -> list[str]:
    with_model = inject_flag(args, "--model", model)
    if reasoning and supports_reasoning is False:
        return with_model
    return inject_flag(with_model, "--reasoning", reasoning)


def _read_help_text(binary: str) -> str:
    try:
        result = subprocess.run(
            [binary, "--help"],
            capture_output=True,
            text=True,
            check=False,
        )
    except FileNotFoundError:
        return ""
    return "\n".join(filter(None, [result.stdout, result.stderr]))


@lru_cache(maxsize=8)
def supports_flag(binary: str, flag: str) -> bool:
    return flag in _read_help_text(binary)


def supports_reasoning(binary: str) -> bool:
    return supports_flag(binary, "--reasoning")


class RepoNotFoundError(Exception):
    pass


_repo_root_ctx: contextvars.ContextVar[Optional[Path]] = contextvars.ContextVar(
    "codex_autorunner_repo_root", default=None
)


def set_repo_root_context(
    repo_root: Optional[Path],
) -> contextvars.Token[Optional[Path]]:
    """Set the current repo root for the active context."""
    return _repo_root_ctx.set(repo_root.resolve() if repo_root else None)


def reset_repo_root_context(token: contextvars.Token[Optional[Path]]) -> None:
    _repo_root_ctx.reset(token)


def get_repo_root_context() -> Optional[Path]:
    return _repo_root_ctx.get()


def find_repo_root(start: Optional[Path] = None) -> Path:
    ctx_root = get_repo_root_context()
    if ctx_root is not None and (ctx_root / ".git").exists():
        return ctx_root

    current = (start or Path.cwd()).resolve()
    for parent in [current] + list(current.parents):
        if (parent / ".git").exists():
            return parent
    raise RepoNotFoundError("Could not find .git directory in current or parent paths")


def canonicalize_path(path: Path) -> Path:
    return path.expanduser().resolve()


def is_within(*, root: Path, target: Path) -> bool:
    try:
        target.relative_to(root)
        return True
    except ValueError:
        return False


def atomic_write(path: Path, content: str, durable: bool = False) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = path.with_suffix(path.suffix + ".tmp")
    with tmp_path.open("w", encoding="utf-8") as f:
        f.write(content)
        if durable:
            f.flush()
            os.fsync(f.fileno())
    tmp_path.replace(path)
    if durable:
        dir_fd = os.open(path.parent, os.O_RDONLY)
        try:
            os.fsync(dir_fd)
        finally:
            os.close(dir_fd)


def read_json(path: Path) -> Optional[dict]:
    if not path.exists():
        return None
    with path.open("r", encoding="utf-8") as f:
        return cast(Optional[dict], json.load(f))


def _default_path_prefixes() -> list[str]:
    """
    launchd and other non-interactive runners often have a minimal PATH that
    excludes Homebrew/MacPorts locations.
    """
    home = Path.home()
    candidates = [
        "/opt/homebrew/bin",  # Apple Silicon Homebrew
        "/usr/local/bin",  # Intel Homebrew + common user installs
        "/opt/local/bin",  # MacPorts
        str(home / ".opencode" / "bin"),  # OpenCode default install
        str(home / ".local" / "bin"),  # Common user-local installs
    ]
    repo_candidates: list[str] = []
    repo_root = get_repo_root_context()
    if repo_root is None:
        try:
            repo_root = find_repo_root()
        except RepoNotFoundError:
            repo_root = None
    if repo_root is not None:
        bin_dir = "Scripts" if os.name == "nt" else "bin"
        repo_candidates = [
            str(repo_root / ".venv" / bin_dir),
            str(repo_root / ".codex-autorunner" / "bin"),
            str(repo_root / "bin"),
        ]
        car_shim = repo_root / "car"
        if car_shim.exists():
            repo_candidates.append(str(repo_root))
    return [
        p
        for p in (repo_candidates + candidates)
        if (os.path.isdir(p) or os.path.isfile(p))
    ]


def augmented_path(path: Optional[str] = None) -> str:
    prefixes = _default_path_prefixes()
    existing = [p for p in (path or "").split(os.pathsep) if p]
    merged: list[str] = []
    for p in prefixes + existing:
        if p and p not in merged:
            merged.append(p)
    return os.pathsep.join(merged)


def subprocess_env(
    extra_paths: Optional[Sequence[str]] = None,
    base_env: Optional[Mapping[str, str]] = None,
) -> Dict[str, str]:
    env = dict(base_env) if base_env is not None else dict(os.environ)
    path = env.get("PATH")
    merged = augmented_path(path)
    if extra_paths:
        extra = [p for p in extra_paths if p]
        if extra:
            merged = augmented_path(os.pathsep.join(extra + [merged]))
    env["PATH"] = merged
    return env


def resolve_executable(
    binary: str, *, env: Optional[Mapping[str, str]] = None
) -> Optional[str]:
    """
    Resolve an executable path in a way that's resilient to minimal PATHs.
    Returns an absolute path if found, else None.
    """
    if not binary:
        return None
    # If explicitly provided a path, respect it.
    if os.path.sep in binary or (os.path.altsep and os.path.altsep in binary):
        candidate = Path(binary).expanduser()
        if candidate.is_file() and os.access(str(candidate), os.X_OK):
            return str(candidate)
        return None

    resolved = shutil.which(binary)
    if resolved:
        return resolved
    path = env.get("PATH") if env is not None else os.environ.get("PATH")
    resolved = shutil.which(binary, path=augmented_path(path))
    return resolved


def default_editor(*, fallback: str = "vi") -> str:
    return os.environ.get("EDITOR") or fallback


def resolve_opencode_binary(raw_command: Optional[str] = None) -> Optional[str]:
    """
    Resolve the OpenCode binary for minimal PATH environments.
    """
    if not raw_command:
        return None
    try:
        parts = [part for part in shlex.split(raw_command) if part]
    except ValueError:
        return None
    if not parts:
        return None
    return resolve_executable(parts[0])


def infer_home_from_workspace(workspace_root: Union[Path, str]) -> Optional[Path]:
    """
    Infer the user's home directory from a workspace path.

    Handles:
    - macOS with /Users/username
    - Linux with /home/username
    - macOS with /System/Volumes/Data/Users/username (Docker/WSL/Parallels)

    Returns None if the path doesn't match expected patterns.
    """
    resolved = Path(workspace_root).resolve()
    parts = resolved.parts
    if (
        len(parts) >= 6
        and parts[0] == os.path.sep
        and parts[1] == "System"
        and parts[2] == "Volumes"
        and parts[3] == "Data"
        and parts[4] == "Users"
    ):
        return Path(parts[0]) / parts[1] / parts[2] / parts[3] / parts[4] / parts[5]
    if (
        len(parts) >= 3
        and parts[0] == os.path.sep
        and parts[1]
        in (
            "Users",
            "home",
        )
    ):
        return Path(parts[0]) / parts[1] / parts[2]
    return None


def build_opencode_supervisor(
    *,
    opencode_command: Optional[Sequence[str]] = None,
    opencode_binary: Optional[str] = None,
    workspace_root: Optional[Path] = None,
    logger: Optional["logging.Logger"] = None,
    request_timeout: Optional[float] = None,
    max_handles: Optional[int] = None,
    idle_ttl_seconds: Optional[float] = None,
    server_scope: str = "workspace",
    session_stall_timeout_seconds: Optional[float] = None,
    max_text_chars: Optional[int] = None,
    base_env: Optional[MutableMapping[str, str]] = None,
    subagent_models: Optional[Mapping[str, str]] = None,
) -> Optional[Any]:
    """
    Unified factory for building OpenCodeSupervisor instances.

    Centralizes:
    - Binary/serve-command resolution
    - Auth (username/password) sourcing from env
    - Request timeout / max handles / idle TTL behavior
    - Subagent model configuration
    """
    command = list(opencode_command or [])
    if not command and opencode_binary:
        command = [
            opencode_binary,
            "serve",
            "--hostname",
            "127.0.0.1",
            "--port",
            "0",
        ]

    resolved_source = None
    if opencode_command:
        resolved_source = opencode_command[0]
    elif opencode_binary:
        resolved_source = opencode_binary
    resolved_binary = resolve_opencode_binary(resolved_source)

    if not command:
        return None

    if resolved_binary:
        command[0] = resolved_binary

    if not _command_available(command, workspace_root=workspace_root, env=base_env):
        return None

    if base_env is None:
        base_env = os.environ
    username = base_env.get("OPENCODE_SERVER_USERNAME")
    password = base_env.get("OPENCODE_SERVER_PASSWORD")
    if password and not username:
        username = "opencode"

    supervisor_module = importlib.import_module(
        "codex_autorunner.agents.opencode.supervisor"
    )
    supervisor_cls = supervisor_module.OpenCodeSupervisor
    supervisor = supervisor_cls(
        command,
        logger=logger,
        request_timeout=request_timeout,
        max_handles=max_handles,
        idle_ttl_seconds=idle_ttl_seconds,
        server_scope=server_scope,
        session_stall_timeout_seconds=session_stall_timeout_seconds,
        max_text_chars=max_text_chars,
        username=username if password else None,
        password=password if password else None,
        base_env=base_env,
        subagent_models=subagent_models,
    )
    return cast(Any, supervisor)


def _command_available(
    command: Sequence[str],
    *,
    workspace_root: Optional[Path],
    env: Optional[MutableMapping[str, str]] = None,
) -> bool:
    if not command or workspace_root is None:
        return False
    entry = str(command[0]).strip()
    if not entry:
        return False
    if os.path.sep in entry or (os.path.altsep and os.path.altsep in entry):
        path = Path(entry)
        if not path.is_absolute():
            path = workspace_root / path
        return path.is_file() and os.access(path, os.X_OK)
    return resolve_executable(entry, env=env) is not None


def find_template_repo(
    config: "RepoConfig", repo_id: str
) -> Optional["TemplateRepoConfig"]:
    for repo in config.templates.repos:
        if repo.id == repo_id:
            return repo
    return None


def resolve_opencode_auth_path(
    workspace_root: Optional[Union[Path, str]] = None,
    *,
    env: Optional[MutableMapping[str, str]] = None,
) -> Optional[Path]:
    """
    Resolve the canonical OpenCode auth.json path.

    This is the single authority for OpenCode auth path resolution across
    supervisor, runtime, and docker wrapping layers.

    Resolution order:
    1. XDG_DATA_HOME/opencode/auth.json if XDG_DATA_HOME is set
    2. HOME/.local/share/opencode/auth.json if HOME is set
    3. Inferred from workspace_root if provided (macOS/Linux patterns)

    Args:
        workspace_root: Optional workspace path for HOME inference fallback
        env: Optional environment dict (defaults to os.environ)

    Returns:
        Resolved auth path or None if home cannot be determined
    """
    check_env = env if env is not None else os.environ

    data_home = check_env.get("XDG_DATA_HOME")
    if data_home:
        return Path(data_home) / "opencode" / "auth.json"

    home = check_env.get("HOME")
    if home:
        return Path(home) / ".local" / "share" / "opencode" / "auth.json"

    if workspace_root is not None:
        inferred = infer_home_from_workspace(workspace_root)
        if inferred is not None:
            return inferred / ".local" / "share" / "opencode" / "auth.json"

    return None
