from __future__ import annotations

import hashlib
import os
import platform
import shutil
import subprocess
from functools import lru_cache
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Iterable, List, Literal, Optional, Sequence, Tuple

from receipts.models import DIPProfile, DIPReceipt, compute_hash


_REPO_ROOT_CACHE: Optional[Path] = None


class IntegrityError(RuntimeError):
    """Raised when a DIP receipt fails deterministic verification."""


def _repo_root() -> Optional[Path]:
    global _REPO_ROOT_CACHE
    if _REPO_ROOT_CACHE is not None:
        return _REPO_ROOT_CACHE
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            check=True,
            capture_output=True,
            text=True,
            timeout=2,
        )
        candidate = Path(result.stdout.strip()).resolve()
        _REPO_ROOT_CACHE = candidate
        return candidate
    except Exception:
        _REPO_ROOT_CACHE = None
        return None


def _normalise_paths(paths: Iterable[Path], *, preserve_symlinks: bool = False) -> List[Path]:
    seen: set[Path] = set()
    ordered: List[Path] = []
    for raw in paths:
        if raw is None:
            continue
        candidate = Path(raw).expanduser()
        symlink_absolute = candidate.absolute()
        try:
            resolved = candidate.resolve()
        except FileNotFoundError:
            resolved = symlink_absolute
        if preserve_symlinks and candidate.is_symlink():
            key = symlink_absolute
            normalized = symlink_absolute
        else:
            key = resolved
            normalized = resolved
        if key in seen:
            continue
        ordered.append(normalized)
        seen.add(key)
    return sorted(ordered)


def _hash_file(path: Path) -> str:
    hasher = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(131072), b""):
            if not chunk:
                break
            hasher.update(chunk)
    return hasher.hexdigest()


def _manifest_entries(files: Sequence[Path]) -> List[str]:
    if not files:
        return []
    repo_root = _repo_root()
    try:
        common_base = Path(os.path.commonpath([str(f) for f in files]))
    except ValueError:
        common_base = files[0].parent
    if not common_base.is_dir():
        common_base = common_base.parent
    entries: List[str] = []
    for file_path in files:
        try:
            if repo_root and repo_root in file_path.parents:
                rel_path = file_path.relative_to(repo_root)
            else:
                rel_path = file_path.relative_to(common_base)
        except ValueError:
            rel_path = Path(file_path.name)
        entries.append(f"{rel_path.as_posix()} {_hash_file(file_path)}")
    return entries


def _gather_files(paths: Iterable[Path], *, skip_dir_symlinks: bool = False) -> Tuple[List[Path], List[str]]:
    files: List[Path] = []
    missing: List[str] = []
    repo_root = _repo_root()
    for path in _normalise_paths(paths, preserve_symlinks=skip_dir_symlinks):
        if not path.exists():
            missing_entry = path.as_posix()
            if repo_root:
                try:
                    resolved = path.resolve(strict=False)
                    missing_entry = str(resolved.relative_to(repo_root))
                except Exception:
                    missing_entry = path.as_posix()
            missing.append(missing_entry)
            continue
        if path.is_dir():
            if skip_dir_symlinks and path.is_symlink():
                continue
            for dirpath, dirnames, filenames in os.walk(path, followlinks=not skip_dir_symlinks):
                base = Path(dirpath)
                if skip_dir_symlinks:
                    dirnames[:] = [
                        name
                        for name in sorted(dirnames)
                        if not (base / name).is_symlink()
                    ]
                else:
                    dirnames[:] = sorted(dirnames)
                for name in sorted(filenames):
                    files.append(base / name)
        elif path.is_file():
            files.append(path)
    return sorted(files), sorted(missing)


def _hash_paths(paths: Iterable[Path], *, label: str, skip_dir_symlinks: bool = False) -> str:
    files, missing = _gather_files(paths, skip_dir_symlinks=skip_dir_symlinks)
    entries = _manifest_entries(files)
    entries.extend(f"__missing__/{name}" for name in missing)
    if not entries:
        sentinel = f"{label}:EMPTY".encode("utf-8")
        return f"sha256:{hashlib.sha256(sentinel).hexdigest()}"
    manifest = "\n".join(sorted(entries)).encode("utf-8")
    return f"sha256:{hashlib.sha256(manifest).hexdigest()}"


def compute_manifest_hash(
    paths: Sequence[Path],
    *,
    label: str,
    skip_dir_symlinks: bool = False,
) -> str:
    """Public helper for computing manifest hashes used in DIP receipts."""

    return _hash_paths(paths, label=label, skip_dir_symlinks=skip_dir_symlinks)


def _detect_cuda_driver() -> Optional[str]:
    nv_binary = shutil.which("nvidia-smi")
    env_hints = (
        os.getenv("NVIDIA_DRIVER_VERSION")
        or os.getenv("CUDA_DRIVER_VERSION")
        or os.getenv("CUDA_VERSION")
    )
    if not nv_binary:
        return env_hints
    try:
        result = subprocess.run(
            [nv_binary, "--query-gpu=driver_version", "--format=csv,noheader"],
            check=True,
            capture_output=True,
            text=True,
            timeout=2,
        )
        line = result.stdout.strip().splitlines()[0]
        return line or env_hints
    except Exception:
        return env_hints


def _discover_dependencies_lock() -> Optional[Path]:
    """
    Locate a dependency lockfile to fingerprint the runtime environment.

    Preference order:
        1. Explicit env overrides (LOOM_DEPS_LOCK, POETRY_LOCK_PATH, PIPFILE_LOCK_PATH)
        2. Repository-root lock files (poetry.lock, requirements.lock, requirements.txt, Pipfile.lock, environment.yml)
    """
    env_hints = [
        os.getenv("LOOM_DEPS_LOCK"),
        os.getenv("POETRY_LOCK_PATH"),
        os.getenv("PIPFILE_LOCK_PATH"),
        os.getenv("DEPENDENCIES_LOCK_PATH"),
    ]
    for hint in env_hints:
        if not hint:
            continue
        candidate = Path(hint).expanduser()
        if candidate.exists():
            return candidate

    repo_root = _repo_root()
    candidate_names = [
        "poetry.lock",
        "requirements.lock",
        "requirements.txt",
        "Pipfile.lock",
        "environment.yml",
        "environment.yaml",
    ]
    search_roots: List[Path] = []
    if repo_root:
        search_roots.append(repo_root)
    search_roots.append(Path.cwd())

    checked: set[Path] = set()
    for root in search_roots:
        for name in candidate_names:
            candidate = (root / name).resolve()
            if candidate in checked:
                continue
            checked.add(candidate)
            if candidate.exists():
                return candidate
    return None


def _collect_environment_fingerprint(profile: DIPProfile) -> Dict[str, str]:
    libc_name, libc_version = platform.libc_ver()
    fingerprint: Dict[str, str] = {
        "python": platform.python_version(),
        "python_version": platform.python_version(),
        "python_implementation": platform.python_implementation(),
        "platform": platform.platform(),
        "machine": platform.machine(),
        "python_build": " ".join(platform.python_build()),
        "profile": profile.name,
    }
    if libc_name:
        fingerprint["libc"] = f"{libc_name}-{libc_version}" if libc_version else libc_name
    fingerprint.update(_numpy_fingerprint())
    cuda_driver = _detect_cuda_driver()
    if cuda_driver:
        fingerprint["cuda_driver"] = cuda_driver
    lock_path = _discover_dependencies_lock()
    if lock_path and lock_path.exists():
        repo_root = _repo_root()
        rel_path = None
        if repo_root:
            try:
                rel_path = lock_path.relative_to(repo_root)
            except ValueError:
                rel_path = None
        fingerprint["dependencies_lock_path"] = (rel_path or lock_path).as_posix()
        fingerprint["dependencies_lock_hash"] = f"sha256:{_hash_file(lock_path)}"
    else:
        sentinel = hashlib.sha256(b"dependencies:ABSENT").hexdigest()
        fingerprint["dependencies_lock_path"] = "absent"
        fingerprint["dependencies_lock_hash"] = f"sha256:{sentinel}"
    return fingerprint


@lru_cache(maxsize=1)
def _numpy_fingerprint() -> Dict[str, str]:
    payload: Dict[str, str] = {}
    try:
        import numpy as np  # type: ignore

        payload["numpy_version"] = getattr(np, "__version__", "unknown")
        try:
            from numpy import __config__  # type: ignore

            blas_info = __config__.get_info("blas_opt_info") or {}
            if not blas_info:
                # Try secondary key used on some builds
                blas_info = __config__.get_info("blas_ilp64_opt_info") or {}
            vendor_tokens: List[str] = []
            libraries = blas_info.get("libraries") or blas_info.get("library_dirs")
            if isinstance(libraries, (list, tuple)):
                vendor_tokens.extend(str(lib) for lib in libraries if lib)
            extra_info = blas_info.get("language") or blas_info.get("extra_link_args")
            if extra_info:
                if isinstance(extra_info, (list, tuple)):
                    vendor_tokens.extend(str(item) for item in extra_info if item)
                else:
                    vendor_tokens.append(str(extra_info))
            vendor_summary = ",".join(sorted(dict.fromkeys(vendor_tokens)))
            payload["numpy_blas"] = vendor_summary or "unknown"
            payload["numpy_build_hash"] = hashlib.sha256(
                repr(sorted(blas_info.items())).encode("utf-8")
            ).hexdigest()
        except Exception:
            payload.setdefault("numpy_blas", "unavailable")
            payload.setdefault("numpy_build_hash", "unavailable")
    except Exception:
        payload.setdefault("numpy_version", "not-installed")
        payload.setdefault("numpy_blas", "unavailable")
        payload.setdefault("numpy_build_hash", "unavailable")
    return payload.copy()


def _apply_context(receipt: DIPReceipt) -> None:
    # Allow orchestrators to supply thread and parent linkage without tight coupling.
    thread_id = os.getenv("LOOM_THREAD_ID")
    if thread_id:
        receipt.thread_id = thread_id.strip() or None
    parent_env = os.getenv("LOOM_PARENT_RECEIPT_IDS")
    if parent_env:
        parent_ids = [token.strip() for token in parent_env.split(",") if token.strip()]
        if parent_ids:
            receipt.parent_ids = parent_ids
            object.__setattr__(receipt, "allow_orphan", False)
        else:
            object.__setattr__(receipt, "allow_orphan", True)
    else:
        object.__setattr__(receipt, "allow_orphan", True)


def build_receipt_id(domain: str, asset_id: str, timestamp: datetime) -> str:
    token = hashlib.sha256(
        f"{domain}:{asset_id}:{timestamp.isoformat(timespec='microseconds')}".encode("utf-8")
    ).hexdigest()[:8]
    return f"DIP_{domain}_{timestamp.strftime('%Y%m%d_%H%M%S')}_{token}"


def discover_gatebook_path(candidates: Optional[Sequence[Path | str]] = None) -> Path:
    search_space: List[str] = []
    if candidates:
        search_space.extend(str(item) for item in candidates if item)
    env_path = os.getenv("GATEBOOK_PATH")
    if env_path:
        search_space.append(env_path)
    search_space.extend(["config/gatebook.yaml", "ops/gatebook/gatebook.yaml"])
    for token in search_space:
        candidate = Path(token).expanduser()
        if candidate.exists():
            return candidate
    # Fallback to first configured path even if missing to ensure deterministic sentinel hash.
    return Path(search_space[-1])


def generate_dip_receipt(
    code_paths: Sequence[Path],
    data_paths: Sequence[Path],
    model_artifacts: Sequence[Path],
    gatebook_path: Path,
    domain: str,
    asset_id: str,
    determinism_profile: Literal["STRICT", "QUASI"] = "STRICT",
    skip_dir_symlinks: bool = False,
    feature_contract_paths: Optional[Sequence[Path]] = None,
) -> DIPReceipt:
    profile = DIPProfile[determinism_profile.upper()]
    timestamp = datetime.now(timezone.utc)
    data_manifest_hash = _hash_paths(data_paths, label="data", skip_dir_symlinks=skip_dir_symlinks)
    model_hash = _hash_paths(model_artifacts, label="params", skip_dir_symlinks=skip_dir_symlinks)
    feature_contract_paths = feature_contract_paths or []
    feature_contract_hash = _hash_paths(
        feature_contract_paths,
        label="feature-contract",
        skip_dir_symlinks=skip_dir_symlinks,
    )
    receipt = DIPReceipt(
        receipt_id=build_receipt_id(domain, asset_id, timestamp),
        domain=domain,
        asset_id=asset_id,
        profile=profile,
        code_hash=_hash_paths(code_paths, label="code", skip_dir_symlinks=skip_dir_symlinks),
        data_manifest_hash=data_manifest_hash,
        model_hash=model_hash,
        gatebook_hash=_hash_paths([gatebook_path], label="gatebook", skip_dir_symlinks=skip_dir_symlinks),
        feature_contract_hash=feature_contract_hash,
        environment_fingerprint=_collect_environment_fingerprint(profile),
        ts=timestamp,
        allow_orphan=True,
    )
    _apply_context(receipt)
    object.__setattr__(receipt, "receipt_hash", compute_hash(receipt))
    return receipt


def _normalise_sequence(paths: Optional[Sequence[Path | str]]) -> List[Path]:
    if not paths:
        return []
    normalised: List[Path] = []
    for item in paths:
        if isinstance(item, Path):
            normalised.append(item)
        else:
            normalised.append(Path(str(item)))
    return normalised


def verify_dip(
    dip: DIPReceipt,
    *,
    code_paths: Optional[Sequence[Path | str]] = None,
    data_paths: Optional[Sequence[Path | str]] = None,
    model_artifacts: Optional[Sequence[Path | str]] = None,
    gatebook_path: Optional[Path | str] = None,
    feature_contract_paths: Optional[Sequence[Path | str]] = None,
    determinism_profile: Optional[Literal["STRICT", "QUASI"]] = None,
    skip_dir_symlinks: bool = False,
) -> None:
    """
    Verify that a DIPReceipt matches the current execution surfaces.

    Raises:
        IntegrityError: when the DIP receipt fails verification.
    """
    receipt_id = dip.receipt_id or "unknown"

    if not dip.seed or not str(dip.seed).strip():
        raise IntegrityError(f"DIP {receipt_id} missing deterministic seed")

    required_hashes = {
        "code hash": dip.code_hash,
        "data manifest hash": dip.data_manifest_hash,
        "model hash": dip.model_hash,
        "gatebook hash": dip.gatebook_hash,
        "feature contract hash": dip.feature_contract_hash,
        "policy bundle hash": dip.policy_bundle_hash,
    }
    for label, value in required_hashes.items():
        if not value or not str(value).strip():
            raise IntegrityError(f"DIP {receipt_id} missing {label}")

    if not dip.receipt_hash or dip.receipt_hash.strip() == "":
        raise IntegrityError(f"DIP {receipt_id} missing receipt hash – finalize_receipt() required")

    expected_profile = dip.profile
    if determinism_profile is not None:
        try:
            expected_profile = DIPProfile[determinism_profile.upper()]
        except KeyError as err:
            raise IntegrityError(
                f"DIP {receipt_id} has unsupported profile override '{determinism_profile}'"
            ) from err

    if dip.profile != expected_profile:
        raise IntegrityError(
            f"DIP {receipt_id} profile mismatch: receipt={dip.profile.name} expected={expected_profile.name}"
        )

    code_seq = _normalise_sequence(code_paths)
    data_seq = _normalise_sequence(data_paths)
    artifact_seq = _normalise_sequence(model_artifacts)
    feature_seq = _normalise_sequence(feature_contract_paths)
    gatebook_target = Path(gatebook_path) if gatebook_path is not None else None

    if not code_seq:
        raise IntegrityError(f"DIP {receipt_id} verification requires code_paths")
    if gatebook_target is None:
        raise IntegrityError(f"DIP {receipt_id} verification requires gatebook_path")

    observed_code = _hash_paths(code_seq, label="code", skip_dir_symlinks=skip_dir_symlinks)
    if dip.code_hash != observed_code:
        raise IntegrityError(
            f"DIP {receipt_id} code hash mismatch: receipt={dip.code_hash} observed={observed_code}"
        )

    observed_data = _hash_paths(data_seq, label="data", skip_dir_symlinks=skip_dir_symlinks)
    if dip.data_manifest_hash != observed_data:
        raise IntegrityError(
            f"DIP {receipt_id} data manifest hash mismatch: receipt={dip.data_manifest_hash} observed={observed_data}"
        )

    observed_params = _hash_paths(artifact_seq, label="params", skip_dir_symlinks=skip_dir_symlinks)
    if dip.model_hash != observed_params:
        raise IntegrityError(
            f"DIP {receipt_id} model hash mismatch: receipt={dip.model_hash} observed={observed_params}"
        )

    observed_gatebook = _hash_paths([gatebook_target], label="gatebook", skip_dir_symlinks=skip_dir_symlinks)
    if dip.gatebook_hash != observed_gatebook:
        raise IntegrityError(
            f"DIP {receipt_id} gatebook hash mismatch: receipt={dip.gatebook_hash} observed={observed_gatebook}"
        )

    observed_feature_contract = _hash_paths(
        feature_seq,
        label="feature-contract",
        skip_dir_symlinks=skip_dir_symlinks,
    )
    if dip.feature_contract_hash != observed_feature_contract:
        raise IntegrityError(
            f"DIP {receipt_id} feature contract hash mismatch: receipt={dip.feature_contract_hash} "
            f"observed={observed_feature_contract}"
        )

    computed_hash = compute_hash(dip)
    if dip.receipt_hash != computed_hash:
        raise IntegrityError(
            f"DIP {receipt_id} receipt hash mismatch: receipt={dip.receipt_hash} computed={computed_hash}"
        )

    expected_env = _collect_environment_fingerprint(expected_profile)
    for key, expected_value in expected_env.items():
        actual_value = dip.environment_fingerprint.get(key)
        if actual_value != expected_value:
            raise IntegrityError(
                f"DIP {receipt_id} environment fingerprint mismatch for '{key}': "
                f"receipt={actual_value!r} expected={expected_value!r}"
            )


def verify_dip_receipt(
    receipt: DIPReceipt,
    *,
    code_paths: Sequence[Path],
    data_paths: Sequence[Path],
    model_artifacts: Sequence[Path],
    gatebook_path: Path,
    feature_contract_paths: Optional[Sequence[Path]] = None,
    determinism_profile: Literal["STRICT", "QUASI"] = "STRICT",
    skip_dir_symlinks: bool = False,
) -> bool:
    expected_profile = DIPProfile[determinism_profile.upper()]
    if receipt.profile != expected_profile:
        return False
    if receipt.code_hash != _hash_paths(code_paths, label="code", skip_dir_symlinks=skip_dir_symlinks):
        return False
    if receipt.data_manifest_hash != _hash_paths(data_paths, label="data", skip_dir_symlinks=skip_dir_symlinks):
        return False
    if receipt.model_hash != _hash_paths(model_artifacts, label="params", skip_dir_symlinks=skip_dir_symlinks):
        return False
    if receipt.gatebook_hash != _hash_paths([gatebook_path], label="gatebook", skip_dir_symlinks=skip_dir_symlinks):
        return False
    feature_contract_paths = feature_contract_paths or []
    if receipt.feature_contract_hash != _hash_paths(
        feature_contract_paths,
        label="feature-contract",
        skip_dir_symlinks=skip_dir_symlinks,
    ):
        return False
    if not receipt.receipt_hash or receipt.receipt_hash != compute_hash(receipt):
        return False
    if expected_profile == DIPProfile.STRICT:
        expected_env = _collect_environment_fingerprint(expected_profile)
        for key, expected_value in expected_env.items():
            if receipt.environment_fingerprint.get(key) != expected_value:
                return False
    else:
        required_keys = {"python", "platform", "profile"}
        if not required_keys.issubset(set(receipt.environment_fingerprint.keys())):
            return False
    return True


def runtime_fingerprint(profile: DIPProfile = DIPProfile.STRICT) -> Dict[str, str]:
    """
    Public helper for collecting the current runtime fingerprint used in DIP receipts.
    """
    return _collect_environment_fingerprint(profile).copy()


__all__ = [
    "discover_gatebook_path",
    "generate_dip_receipt",
    "verify_dip_receipt",
    "verify_dip",
    "build_receipt_id",
    "IntegrityError",
    "runtime_fingerprint",
    "compute_manifest_hash",
]
