# -*- coding: utf-8 -*-
"""
TEITAN SELF-REPAIR V1 — SANDBOX BUILDER (FASE 2 — AR-74 HARDENED)
Builds isolated sandbox environments for repair candidate evaluation.
Strictly enforces canonical path containment, symlink/junction blocking,
file allowlists, path traversal protection, frozen baseline protection,
read-only verification dependency containment, and origin SHA256 snapshotting.
"""
from __future__ import annotations

import fnmatch
import hashlib
import json
import os
import shutil
from typing import Any, Dict, List, Optional, Tuple

from app.remediation.self_repair.sandbox_contract import SelfRepairSandboxContract
from app.remediation.self_repair.schemas import (
    RepairProposal,
    SandboxManifest,
    SourceHashRecord,
    _new_id,
)

FROZEN_BASELINES = [
    "artifacts/brain_host/capability_mission_planner.py",
    "artifacts/brain_host/generic_developer_executor.py",
]


PROTECTED_RUNTIME_COMPONENTS = [
    "artifacts/brain_host/brain_host_tls.py",
    "artifacts/brain_host/brain_adapter.py",
    "artifacts/brain_host/capability_mission_planner.py",
    "new_mission_dispatcher.py",
    "artifacts/brain_host/generic_developer_executor.py",
    "app/remediation/self_repair/live_shadow_hook.py",
]

FILE_ATTRIBUTE_REPARSE_POINT = 0x0400


def _compute_sha256(path: str) -> Optional[str]:
    try:
        if _is_reparse_point_or_symlink(path):
            return None
        if not os.path.isfile(path):
            return None
        with open(path, "rb") as f:
            return hashlib.sha256(f.read()).hexdigest().upper()
    except Exception:
        return None


def _is_reparse_point_or_symlink(path: str) -> bool:
    """Returns True if path is a symlink or a Windows reparse point (junction/mountpoint)."""
    try:
        if os.path.islink(path):
            return True
        if os.path.lexists(path):
            st = os.lstat(path)
            attrs = getattr(st, "st_file_attributes", 0)
            if attrs & FILE_ATTRIBUTE_REPARSE_POINT:
                return True
    except OSError:
        return True
    return False


def _is_canonically_within(child_path: str, parent_dir: str) -> bool:
    """
    Verifies that child_path resolves canonically inside parent_dir using
    os.path.realpath and os.path.commonpath (never string prefix startswith).
    """
    try:
        parent_real = os.path.normcase(os.path.realpath(os.path.abspath(parent_dir)))
        child_real = os.path.normcase(os.path.realpath(os.path.abspath(child_path)))
        common = os.path.commonpath([parent_real, child_real])
        return common == parent_real
    except ValueError:
        return False


def _assert_no_reparse_chain(root_dir: str, rel_norm: str) -> None:
    """
    Walks every path component from root_dir to rel_norm and raises
    SandboxSecurityViolation if any segment is a symlink or junction.
    """
    root_abs = os.path.abspath(root_dir)
    if _is_reparse_point_or_symlink(root_abs):
        raise SandboxSecurityViolation(
            f"Sandbox root or project root '{root_abs}' is a symlink or reparse point",
            "SANDBOX_SYMLINK_OR_JUNCTION_BLOCKED",
        )
    parts = [p for p in rel_norm.replace("\\", "/").split("/") if p and p != "."]
    curr = root_abs
    for part in parts:
        curr = os.path.join(curr, part)
        if os.path.lexists(curr) and _is_reparse_point_or_symlink(curr):
            raise SandboxSecurityViolation(
                f"Symlink or junction detected along path segment '{curr}'",
                "SANDBOX_SYMLINK_OR_JUNCTION_BLOCKED",
            )


class SandboxSecurityViolation(Exception):
    """Raised when sandbox isolation, traversal, or baseline constraints are violated."""
    def __init__(self, message: str, violation_type: str):
        super().__init__(message)
        self.violation_type = violation_type


class SandboxBuilder:
    """
    Constructs an isolated sandbox area with source and candidate copies.
    Never operates directly on production files.
    """

    PRODUCTION_WRITE_AUTHORITY: bool = False
    CORE_WRITE_AUTHORITY: bool = False

    def __init__(self, project_root: str = "."):
        self.project_root = os.path.realpath(os.path.abspath(project_root))

    def _resolve_in_project_root(self, rel_path: str, role_label: str = "file") -> Tuple[str, str]:
        """
        Canonically resolves rel_path within self.project_root:
        1. Rejects empty paths and '..' traversal segments.
        2. Rejects external absolute paths and prefix-collision paths via os.path.commonpath.
        3. Rejects symlinks and Windows junctions/reparse points along every segment.
        4. Verifies canonical realpath resides strictly within canonical project_root.
        Returns (clean_rel_posix, canonical_full_path).
        """
        raw_str = str(rel_path or "").strip()
        if not raw_str:
            raise SandboxSecurityViolation(
                f"Empty {role_label} path is forbidden",
                "SANDBOX_ESCAPE_BLOCKED",
            )

        raw_posix = raw_str.replace("\\", "/")
        if ".." in raw_posix.split("/"):
            raise SandboxSecurityViolation(
                f"Path traversal detected in {role_label} '{rel_path}'",
                "SANDBOX_ESCAPE_BLOCKED",
            )

        root_real = os.path.realpath(os.path.abspath(self.project_root))

        if os.path.isabs(raw_str) or (len(raw_str) >= 2 and raw_str[1] == ":"):
            abs_candidate = os.path.abspath(raw_str)
            try:
                common_lex = os.path.commonpath([
                    os.path.normcase(root_real),
                    os.path.normcase(abs_candidate),
                ])
            except ValueError:
                common_lex = ""
            if common_lex != os.path.normcase(root_real):
                raise SandboxSecurityViolation(
                    f"External absolute {role_label} path '{rel_path}' escapes project root '{root_real}'",
                    "SANDBOX_ESCAPE_BLOCKED",
                )
            rel_norm = os.path.relpath(abs_candidate, root_real).replace("\\", "/")
        else:
            rel_norm = os.path.normpath(raw_str).replace("\\", "/")

        if (
            not rel_norm
            or rel_norm == "."
            or rel_norm == ".."
            or rel_norm.startswith("../")
            or ".." in rel_norm.split("/")
            or os.path.isabs(rel_norm)
            or ":" in rel_norm
        ):
            raise SandboxSecurityViolation(
                f"Invalid or escaping relative {role_label} path '{rel_path}'",
                "SANDBOX_ESCAPE_BLOCKED",
            )

        # Check every segment for symlinks or Windows junctions before resolving realpath
        _assert_no_reparse_chain(root_real, rel_norm)

        candidate_full = os.path.join(root_real, rel_norm)
        candidate_real = os.path.realpath(candidate_full)

        if not _is_canonically_within(candidate_real, root_real):
            raise SandboxSecurityViolation(
                f"Canonical path '{candidate_real}' for {role_label} '{rel_path}' escapes project root '{root_real}'",
                "SANDBOX_ESCAPE_BLOCKED",
            )

        if os.path.normcase(candidate_real) == os.path.normcase(root_real):
            raise SandboxSecurityViolation(
                f"Path '{rel_path}' resolves to project root directory itself",
                "SANDBOX_ESCAPE_BLOCKED",
            )

        return rel_norm, candidate_real

    def validate_file_path(self, rel_path: str) -> str:
        """
        Validates path security:
        1. No path traversal (../)
        2. Must resolve canonically inside project_root without symlinks/junctions
        3. Must not match forbidden files or frozen baselines
        """
        raw_norm, _ = self._resolve_in_project_root(rel_path, role_label="modifiable_file")

        # Check frozen baselines specifically
        for frozen in FROZEN_BASELINES:
            if raw_norm == frozen or raw_norm.endswith("/" + frozen):
                raise SandboxSecurityViolation(
                    f"Access to frozen baseline '{frozen}' is strictly forbidden",
                    "FROZEN_BASELINE_BLOCKED",
                )

        # Check files_forbidden
        for forbidden_pattern in SelfRepairSandboxContract.FORBIDDEN_FILES:
            if fnmatch.fnmatch(raw_norm, forbidden_pattern) or raw_norm == forbidden_pattern:
                if any(raw_norm == fb or raw_norm.endswith("/" + fb) for fb in FROZEN_BASELINES):
                    raise SandboxSecurityViolation(
                        f"Access to frozen baseline '{raw_norm}' is strictly forbidden",
                        "FROZEN_BASELINE_BLOCKED",
                    )
                raise SandboxSecurityViolation(
                    f"File '{raw_norm}' is forbidden by sandbox contract rule",
                    "FORBIDDEN_FILE_BLOCKED",
                )

        return raw_norm

    def _safe_copy_into_sandbox(
        self,
        prod_full: str,
        clean_rel: str,
        source_dir: str,
        candidate_dir: str,
    ) -> Tuple[str, str, str]:
        """
        Safely copies a verified project_root file into source_dir and candidate_dir,
        enforcing canonical boundary and no-reparse checks on destinations.
        """
        _assert_no_reparse_chain(source_dir, clean_rel)
        _assert_no_reparse_chain(candidate_dir, clean_rel)

        dest_src = os.path.realpath(os.path.join(source_dir, clean_rel))
        dest_cand = os.path.realpath(os.path.join(candidate_dir, clean_rel))

        if not _is_canonically_within(dest_src, source_dir):
            raise SandboxSecurityViolation(
                f"Destination source path '{dest_src}' escapes sandbox source_dir '{source_dir}'",
                "SANDBOX_ESCAPE_BLOCKED",
            )
        if not _is_canonically_within(dest_cand, candidate_dir):
            raise SandboxSecurityViolation(
                f"Destination candidate path '{dest_cand}' escapes sandbox candidate_dir '{candidate_dir}'",
                "SANDBOX_ESCAPE_BLOCKED",
            )

        os.makedirs(os.path.dirname(dest_src), exist_ok=True)
        os.makedirs(os.path.dirname(dest_cand), exist_ok=True)

        shutil.copy2(prod_full, dest_src, follow_symlinks=False)
        shutil.copy2(prod_full, dest_cand, follow_symlinks=False)

        src_hash = _compute_sha256(prod_full)
        cand_init_hash = _compute_sha256(dest_cand)
        if not src_hash or src_hash != cand_init_hash:
            raise RuntimeError(
                f"Integrity mismatch during sandbox copy: {src_hash} != {cand_init_hash} for {clean_rel}"
            )
        return dest_src, dest_cand, src_hash

    def build_sandbox(
        self,
        proposal: RepairProposal,
        base_reports_dir: str = "reports/self_repair",
        candidate_id: Optional[str] = None,
    ) -> SandboxManifest:
        """
        Builds sandbox structure:
        <base_reports_dir>/<incident_id>/sandbox/
            source/
            candidate/
        Copies allowed files, calculates and verifies initial hashes, and stages
        verified in-project verification dependencies as read-only fixtures.
        """
        self.project_root = os.path.realpath(os.path.abspath(self.project_root))
        cid = candidate_id or _new_id("cand")
        incident_id = proposal.incident_id

        # Validate incident_id contains no traversal
        if ".." in str(incident_id) or "/" in str(incident_id) or "\\" in str(incident_id):
            raise SandboxSecurityViolation(
                f"Invalid incident_id '{incident_id}'",
                "SANDBOX_ESCAPE_BLOCKED",
            )

        sandbox_root = os.path.realpath(os.path.abspath(os.path.join(base_reports_dir, incident_id, "sandbox")))
        source_dir = os.path.join(sandbox_root, "source")
        candidate_dir = os.path.join(sandbox_root, "candidate")

        os.makedirs(source_dir, exist_ok=True)
        os.makedirs(candidate_dir, exist_ok=True)

        if _is_reparse_point_or_symlink(sandbox_root) or _is_reparse_point_or_symlink(source_dir) or _is_reparse_point_or_symlink(candidate_dir):
            raise SandboxSecurityViolation(
                "Sandbox directory contains a symlink or junction",
                "SANDBOX_SYMLINK_OR_JUNCTION_BLOCKED",
            )

        files_copied: List[Dict[str, Any]] = []
        source_hashes: List[SourceHashRecord] = []
        staged_rel_paths: set[str] = set()

        for rel_path in proposal.files_allowed:
            clean_rel = self.validate_file_path(rel_path)
            _, prod_full = self._resolve_in_project_root(clean_rel, role_label="modifiable_file")

            if not os.path.isfile(prod_full):
                continue

            dest_src, dest_cand, src_hash = self._safe_copy_into_sandbox(
                prod_full=prod_full,
                clean_rel=clean_rel,
                source_dir=source_dir,
                candidate_dir=candidate_dir,
            )

            rec = SourceHashRecord(
                relative_path=clean_rel,
                production_path=prod_full,
                source_sha256=src_hash,
                candidate_initial_sha256=src_hash,
            )
            source_hashes.append(rec)
            files_copied.append({
                "relative_path": clean_rel,
                "production_path": prod_full,
                "sandbox_source": dest_src,
                "sandbox_candidate": dest_cand,
                "sha256": src_hash,
                "role": "MODIFIABLE_FILE",
            })
            staged_rel_paths.add(clean_rel)

        # PARTE D: Copy verification dependencies as READ_ONLY fixtures into sandbox.
        # Strictly block external verification dependencies (no fallback to os.path.abspath(dep)).
        raw_v_deps = list(
            getattr(proposal, "verification_dependencies", None)
            or proposal.sandbox_contract.get("verification_dependencies", [])
            or []
        )
        for extra_test in list(proposal.focused_tests or []) + list(proposal.regression_tests or []):
            if extra_test and extra_test not in raw_v_deps:
                # Only treat file paths (not raw command strings with spaces) as file dependencies
                if " " not in str(extra_test).strip():
                    raw_v_deps.append(extra_test)

        verified_v_deps: List[str] = []
        for dep in raw_v_deps:
            clean_dep, prod_full = self._resolve_in_project_root(dep, role_label="verification_dependency")
            if clean_dep in staged_rel_paths:
                continue
            if not os.path.isfile(prod_full):
                raise SandboxSecurityViolation(
                    f"Verification dependency '{dep}' does not exist inside project root '{self.project_root}'",
                    "EXTERNAL_OR_MISSING_VERIFICATION_DEPENDENCY_BLOCKED",
                )

            dest_src, dest_cand, dep_hash = self._safe_copy_into_sandbox(
                prod_full=prod_full,
                clean_rel=clean_dep,
                source_dir=source_dir,
                candidate_dir=candidate_dir,
            )

            files_copied.append({
                "relative_path": clean_dep,
                "production_path": prod_full,
                "sandbox_source": dest_src,
                "sandbox_candidate": dest_cand,
                "sha256": dep_hash,
                "role": "READ_ONLY_VERIFICATION_DEPENDENCY",
            })
            staged_rel_paths.add(clean_dep)
            verified_v_deps.append(clean_dep)

        # Stage internal package support files as READ_ONLY_VERIFICATION_DEPENDENCY when
        # repairing internal Optimus modules so candidate_dir is self-contained without
        # inheriting parent PYTHONPATH.
        internal_prefixes = ("app/", "artifacts/", "runtime_v1/", "tools/")
        touches_internal = any(p.startswith(internal_prefixes) for p in staged_rel_paths)
        if touches_internal:
            for pkg_top in ("app", "artifacts", "runtime_v1", "tools"):
                pkg_dir = os.path.join(self.project_root, pkg_top)
                if not os.path.isdir(pkg_dir) or _is_reparse_point_or_symlink(pkg_dir):
                    continue
                for walk_root, dirnames, filenames in os.walk(pkg_dir, followlinks=False):
                    # Filter out any reparse/symlink directories or __pycache__
                    dirnames[:] = [
                        d for d in dirnames
                        if d != "__pycache__" and not _is_reparse_point_or_symlink(os.path.join(walk_root, d))
                    ]
                    for fname in filenames:
                        if not fname.endswith(".py"):
                            continue
                        full_py = os.path.join(walk_root, fname)
                        if _is_reparse_point_or_symlink(full_py):
                            continue
                        rel_py = os.path.relpath(full_py, self.project_root).replace("\\", "/")
                        if rel_py in staged_rel_paths:
                            continue
                        try:
                            clean_py, canon_py = self._resolve_in_project_root(rel_py, role_label="package_support")
                        except SandboxSecurityViolation:
                            continue
                        dest_src, dest_cand, py_hash = self._safe_copy_into_sandbox(
                            prod_full=canon_py,
                            clean_rel=clean_py,
                            source_dir=source_dir,
                            candidate_dir=candidate_dir,
                        )
                        files_copied.append({
                            "relative_path": clean_py,
                            "production_path": canon_py,
                            "sandbox_source": dest_src,
                            "sandbox_candidate": dest_cand,
                            "sha256": py_hash,
                            "role": "READ_ONLY_VERIFICATION_DEPENDENCY",
                        })
                        staged_rel_paths.add(clean_py)

        # PARTE G: Dynamic Protected Baseline Snapshot pre-repair (strictly within project_root)
        protected_baseline_before: Dict[str, str] = {}
        for pc in PROTECTED_RUNTIME_COMPONENTS:
            try:
                clean_pc, full_p = self._resolve_in_project_root(pc, role_label="protected_baseline")
            except SandboxSecurityViolation:
                continue
            if os.path.isfile(full_p):
                h = _compute_sha256(full_p)
                if h:
                    protected_baseline_before[clean_pc] = h

        manifest = SandboxManifest(
            incident_id=incident_id,
            repair_id=proposal.repair_id,
            candidate_id=cid,
            sandbox_root=sandbox_root,
            source_dir=source_dir,
            candidate_dir=candidate_dir,
            files_copied=files_copied,
            source_hashes=source_hashes,
        )
        setattr(manifest, "protected_baseline_before", protected_baseline_before)
        setattr(manifest, "verification_dependencies", list(verified_v_deps))

        # Persist sandbox_manifest.json and source_hashes.json
        self.persist_manifest(manifest, os.path.dirname(sandbox_root))
        return manifest

    def persist_manifest(self, manifest: SandboxManifest, incident_dir: str) -> Tuple[str, str]:
        """Persists sandbox_manifest.json and source_hashes.json."""
        os.makedirs(incident_dir, exist_ok=True)
        m_path = os.path.join(incident_dir, "sandbox_manifest.json")
        m_dict = manifest.to_dict()
        m_dict["protected_baseline_before"] = getattr(manifest, "protected_baseline_before", {})
        m_dict["verification_dependencies"] = getattr(manifest, "verification_dependencies", [])
        with open(m_path, "w", encoding="utf-8") as f:
            json.dump(m_dict, f, indent=2, ensure_ascii=False)

        sh_path = os.path.join(incident_dir, "source_hashes.json")
        payload = {
            "incident_id": manifest.incident_id,
            "repair_id": manifest.repair_id,
            "candidate_id": manifest.candidate_id,
            "source_hashes": [sh.to_dict() for sh in manifest.source_hashes],
        }
        with open(sh_path, "w", encoding="utf-8") as f:
            json.dump(payload, f, indent=2, ensure_ascii=False)

        return m_path, sh_path
