"""
SafeExecutor — PR3.

Execute allowlisted shell scripts via asyncio.create_subprocess_exec (not shell=True).
Security: path confinement to REPO_ROOT (realpath), strict env allowlist,
          stdin=DEVNULL, output cap, timeout clamp, non-root warning.
"""
from __future__ import annotations

import asyncio
import logging
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

# ── Config ────────────────────────────────────────────────────────────────────

def _get_repo_root() -> Path:
    """SOFIIA_REPO_ROOT env or auto-detect: app/ -> sofiia-console/ -> services/ -> repo."""
    env = os.getenv("SOFIIA_REPO_ROOT", "").strip()
    if env:
        return Path(env).resolve()
    return Path(__file__).resolve().parent.parent.parent.parent


_REPO_ROOT = _get_repo_root()

# Allowlisted scripts (relative to REPO_ROOT)
_SCRIPT_ALLOWLIST: frozenset = frozenset({
    "ops/preflight_sofiia_console.sh",
    "ops/redis_idempotency_smoke.sh",
    "ops/generate_release_evidence.sh",
})

# Env keys allowed to be passed from action_json
_ENV_KEY_ALLOWLIST: frozenset = frozenset({
    "STRICT",
    "SOFIIA_URL",
    "BFF_A",
    "BFF_B",
    "NODE_ID",
    "AGENT_ID",
})

_TIMEOUT_MAX_S: int = 300
_OUTPUT_CAP_BYTES: int = 8 * 1024  # 8 KB


# ── Public API ────────────────────────────────────────────────────────────────

class ScriptNotAllowedError(ValueError):
    pass


def _validate_script_path(script: str) -> Path:
    """
    Validate that script is in the allowlist and resides under REPO_ROOT.
    Returns resolved absolute Path.
    Raises ScriptNotAllowedError on any violation.
    """
    if not script or not script.strip():
        raise ScriptNotAllowedError("Empty script path")

    # Reject absolute paths and traversal immediately (before resolve)
    s = script.strip()
    if s.startswith("/"):
        raise ScriptNotAllowedError(f"Absolute paths not allowed: {s!r}")
    if ".." in Path(s).parts:
        raise ScriptNotAllowedError(f"Path traversal not allowed: {s!r}")

    # Exact allowlist check (on normalized relative form)
    normalized = s.replace("\\", "/")
    if normalized not in _SCRIPT_ALLOWLIST:
        raise ScriptNotAllowedError(f"Script not in allowlist: {normalized!r}")

    resolved = (_REPO_ROOT / normalized).resolve()
    # Confinement: must be under REPO_ROOT
    try:
        resolved.relative_to(_REPO_ROOT)
    except ValueError:
        raise ScriptNotAllowedError(f"Script escaped REPO_ROOT: {resolved}")

    return resolved


def _filter_env(raw_env: Optional[Dict[str, Any]]) -> tuple[Dict[str, str], List[str]]:
    """Return (filtered_env, list_of_dropped_keys)."""
    if not raw_env:
        return {}, []
    filtered: Dict[str, str] = {}
    dropped: List[str] = []
    for k, v in raw_env.items():
        if k in _ENV_KEY_ALLOWLIST:
            filtered[k] = str(v)
        else:
            dropped.append(k)
    return filtered, dropped


def _cap_bytes(data: bytes, cap: int = _OUTPUT_CAP_BYTES) -> str:
    if not data:
        return ""
    text = data.decode("utf-8", errors="replace")
    if len(data) > cap:
        tail = text[-(cap // 2):]
        return f"[...truncated...]\n{tail}"
    return text


async def run_script(
    script: str,
    env: Optional[Dict[str, Any]] = None,
    timeout_s: int = 120,
) -> Dict[str, Any]:
    """
    Execute allowlisted script. Returns:
      {ok, exit_code, stdout_tail, stderr_tail, duration_ms, timeout, warning?}
    """
    try:
        resolved = _validate_script_path(script)
    except ScriptNotAllowedError as e:
        return {"ok": False, "exit_code": None, "error": str(e),
                "stdout_tail": "", "stderr_tail": "", "duration_ms": 0, "timeout": False}

    # Build env: inherit minimal set, add allowed overrides
    base_env: Dict[str, str] = {
        k: v for k, v in os.environ.items()
        if k in {"PATH", "HOME", "LANG", "LC_ALL", "USER", "LOGNAME", "SOFIIA_DATA_DIR", "SOFIIA_REDIS_URL"}
    }
    filtered_env, dropped_keys = _filter_env(env)
    if dropped_keys:
        logger.warning("safe_executor: dropped non-allowlisted env keys: %s", dropped_keys)
    base_env.update(filtered_env)

    # Clamp timeout
    effective_timeout = max(1, min(int(timeout_s), _TIMEOUT_MAX_S))

    # Non-root check
    warning: Optional[str] = None
    try:
        if os.getuid() == 0:
            warning = "running_as_root"
            logger.warning("safe_executor: running as root, step status will be warn")
    except AttributeError:
        pass  # Windows / no getuid

    started = time.monotonic()
    timed_out = False
    exit_code: Optional[int] = None
    stdout_bytes = b""
    stderr_bytes = b""

    try:
        proc = await asyncio.create_subprocess_exec(
            "/bin/bash",
            str(resolved),
            cwd=str(_REPO_ROOT),
            env=base_env,
            stdin=asyncio.subprocess.DEVNULL,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        try:
            stdout_bytes, stderr_bytes = await asyncio.wait_for(
                proc.communicate(), timeout=float(effective_timeout)
            )
            exit_code = proc.returncode
        except asyncio.TimeoutError:
            timed_out = True
            try:
                proc.kill()
            except Exception:
                pass
            try:
                await asyncio.wait_for(proc.wait(), timeout=5.0)
            except Exception:
                pass
            exit_code = None
    except Exception as e:
        logger.error("safe_executor: failed to start %s: %s", script, e)
        duration_ms = int((time.monotonic() - started) * 1000)
        return {
            "ok": False,
            "exit_code": None,
            "error": str(e)[:200],
            "stdout_tail": "",
            "stderr_tail": "",
            "duration_ms": duration_ms,
            "timeout": False,
        }

    duration_ms = int((time.monotonic() - started) * 1000)
    ok = (not timed_out) and exit_code == 0
    result: Dict[str, Any] = {
        "ok": ok,
        "exit_code": exit_code,
        "stdout_tail": _cap_bytes(stdout_bytes),
        "stderr_tail": _cap_bytes(stderr_bytes),
        "duration_ms": duration_ms,
        "timeout": timed_out,
        "dropped_env_keys": dropped_keys or None,
    }
    if warning:
        result["warning"] = warning
    return result
