"""
Git-native storage for releaseops.

Reads and writes all releaseops state to the .releaseops/ directory.
All data is YAML files tracked by git.
"""

from __future__ import annotations

import hashlib
import logging
import os
import tempfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import yaml

from llmhq_releaseops.models.bundle import BundleManifest
from llmhq_releaseops.models.environment import EnvironmentConfig
from llmhq_releaseops.models.eval_result import EvalReport
from llmhq_releaseops.models.eval_suite import EvalSuite
from llmhq_releaseops.models.promotion import PromotionHistory

logger = logging.getLogger(__name__)


class ConcurrentModificationError(Exception):
    """Raised when a file was modified by another process between read and write."""


# Directory names within .releaseops/
BUNDLES_DIR = "bundles"
ENVIRONMENTS_DIR = "environments"
EVALS_DIR = "evals"
EVAL_REPORTS_DIR = "eval_reports"
REPLAYS_DIR = "replays"
PROMOTION_HISTORY_FILE = "promotion_history.yaml"


class GitStore:
    """
    Filesystem store for releaseops data.

    All state lives in .releaseops/ as YAML, tracked by git.
    """

    def __init__(self, repo_path: str = "."):
        self.repo_path = Path(repo_path).resolve()
        self.releaseops_dir = self.repo_path / ".releaseops"

    @property
    def bundles_dir(self) -> Path:
        return self.releaseops_dir / BUNDLES_DIR

    @property
    def environments_dir(self) -> Path:
        return self.releaseops_dir / ENVIRONMENTS_DIR

    @property
    def evals_dir(self) -> Path:
        return self.releaseops_dir / EVALS_DIR

    @property
    def eval_reports_dir(self) -> Path:
        return self.releaseops_dir / EVAL_REPORTS_DIR

    @property
    def policies_dir(self) -> Path:
        return self.repo_path / "policies"

    # ── Initialization ───────────────────────────────────────────────

    def is_initialized(self) -> bool:
        """Check if .releaseops/ directory exists."""
        return self.releaseops_dir.is_dir()

    def initialize(self) -> None:
        """Create the .releaseops/ directory structure."""
        dirs = [
            self.bundles_dir,
            self.environments_dir,
            self.evals_dir,
            self.eval_reports_dir,
            self.releaseops_dir / REPLAYS_DIR,
            self.policies_dir / "context",
            self.policies_dir / "tools",
            self.policies_dir / "safety",
        ]
        for d in dirs:
            d.mkdir(parents=True, exist_ok=True)

        # Create default environment files
        for env_name in ("dev", "staging", "prod"):
            env_path = self.environments_dir / f"{env_name}.yaml"
            if not env_path.exists():
                env = EnvironmentConfig(environment=env_name)
                self._write_yaml(env_path, env.to_dict())

        # Create empty promotion history
        history_path = self.releaseops_dir / PROMOTION_HISTORY_FILE
        if not history_path.exists():
            self._write_yaml(history_path, {"records": []})

        logger.info(f"Initialized .releaseops/ at {self.releaseops_dir}")

    # ── Bundles ──────────────────────────────────────────────────────

    def save_bundle(self, bundle: BundleManifest) -> Path:
        """Save a bundle manifest to .releaseops/bundles/{id}/{version}.yaml."""
        bundle_dir = self.bundles_dir / bundle.id
        bundle_dir.mkdir(parents=True, exist_ok=True)
        path = bundle_dir / f"{bundle.version}.yaml"
        self._write_yaml(path, bundle.to_dict())
        logger.info(f"Saved bundle {bundle.id} v{bundle.version} to {path}")
        return path

    def load_bundle(self, bundle_id: str, version: str) -> BundleManifest:
        """Load a bundle manifest from disk."""
        path = self.bundles_dir / bundle_id / f"{version}.yaml"
        if not path.exists():
            raise FileNotFoundError(
                f"Bundle '{bundle_id}' version '{version}' not found at {path}"
            )
        data = self._read_yaml(path)
        return BundleManifest.from_dict(data)

    def list_bundles(self) -> List[str]:
        """List all bundle IDs."""
        if not self.bundles_dir.exists():
            return []
        return sorted(
            d.name for d in self.bundles_dir.iterdir() if d.is_dir()
        )

    def list_bundle_versions(self, bundle_id: str) -> List[str]:
        """List all versions of a bundle."""
        bundle_dir = self.bundles_dir / bundle_id
        if not bundle_dir.exists():
            return []
        return sorted(
            p.stem for p in bundle_dir.glob("*.yaml")
        )

    def bundle_exists(self, bundle_id: str, version: str) -> bool:
        """Check if a specific bundle version exists."""
        path = self.bundles_dir / bundle_id / f"{version}.yaml"
        return path.exists()

    # ── Environments ─────────────────────────────────────────────────

    def save_environment(
        self, env: EnvironmentConfig, expected_hash: Optional[str] = None
    ) -> Path:
        """Save an environment config with optional optimistic concurrency.

        Args:
            env: Environment config to save.
            expected_hash: If provided, the file must still match this hash
                (from load_environment_with_hash). Raises ConcurrentModificationError
                if another process modified the file.
        """
        path = self.environments_dir / f"{env.environment}.yaml"
        if expected_hash is not None:
            current = self._file_hash(path)
            if current != expected_hash:
                raise ConcurrentModificationError(
                    f"Environment '{env.environment}' was modified by another process. "
                    "Re-read and retry."
                )
        self._write_yaml(path, env.to_dict())
        logger.info(f"Saved environment {env.environment}")
        return path

    def load_environment(self, env_name: str) -> EnvironmentConfig:
        """Load an environment config."""
        path = self.environments_dir / f"{env_name}.yaml"
        if not path.exists():
            raise FileNotFoundError(f"Environment '{env_name}' not found at {path}")
        data = self._read_yaml(path)
        return EnvironmentConfig.from_dict(data)

    def load_environment_with_hash(
        self, env_name: str
    ) -> Tuple[EnvironmentConfig, str]:
        """Load an environment config and its file hash for optimistic concurrency.

        Returns (config, file_hash). Pass file_hash to save_environment() to detect
        concurrent modifications.
        """
        path = self.environments_dir / f"{env_name}.yaml"
        if not path.exists():
            raise FileNotFoundError(f"Environment '{env_name}' not found at {path}")
        file_hash = self._file_hash(path)
        data = self._read_yaml(path)
        return EnvironmentConfig.from_dict(data), file_hash

    def list_environments(self) -> List[str]:
        """List all environment names."""
        if not self.environments_dir.exists():
            return []
        return sorted(
            p.stem for p in self.environments_dir.glob("*.yaml")
        )

    # ── Eval Suites ──────────────────────────────────────────────────

    def save_eval_suite(self, suite: EvalSuite) -> Path:
        """Save an eval suite definition."""
        path = self.evals_dir / f"{suite.id}.yaml"
        self._write_yaml(path, suite.to_dict())
        logger.info(f"Saved eval suite {suite.id}")
        return path

    def load_eval_suite(self, suite_id: str) -> EvalSuite:
        """Load an eval suite definition."""
        path = self.evals_dir / f"{suite_id}.yaml"
        if not path.exists():
            raise FileNotFoundError(f"Eval suite '{suite_id}' not found at {path}")
        data = self._read_yaml(path)
        return EvalSuite.from_dict(data)

    def list_eval_suites(self) -> List[str]:
        """List all eval suite IDs."""
        if not self.evals_dir.exists():
            return []
        return sorted(p.stem for p in self.evals_dir.glob("*.yaml"))

    # ── Eval Reports ─────────────────────────────────────────────────

    def save_eval_report(self, report: EvalReport) -> Path:
        """Save an eval report, keyed by bundle hash."""
        key = report.bundle_hash or f"{report.bundle_id}-{report.bundle_version}"
        path = self.eval_reports_dir / f"{key}.yaml"
        self._write_yaml(path, report.to_dict())
        logger.info(f"Saved eval report for {report.bundle_id} v{report.bundle_version}")
        return path

    def load_eval_report(self, key: str) -> EvalReport:
        """Load an eval report by key (bundle hash or id-version)."""
        path = self.eval_reports_dir / f"{key}.yaml"
        if not path.exists():
            raise FileNotFoundError(f"Eval report '{key}' not found at {path}")
        data = self._read_yaml(path)
        return EvalReport.from_dict(data)

    def list_eval_reports(self) -> List[str]:
        """List all eval report keys."""
        if not self.eval_reports_dir.exists():
            return []
        return sorted(p.stem for p in self.eval_reports_dir.glob("*.yaml"))

    # ── Promotion History ────────────────────────────────────────────

    def load_promotion_history(self) -> PromotionHistory:
        """Load the promotion history."""
        path = self.releaseops_dir / PROMOTION_HISTORY_FILE
        if not path.exists():
            return PromotionHistory()
        data = self._read_yaml(path)
        return PromotionHistory.from_dict(data)

    def load_promotion_history_with_hash(self) -> Tuple[PromotionHistory, str]:
        """Load promotion history with file hash for optimistic concurrency."""
        path = self.releaseops_dir / PROMOTION_HISTORY_FILE
        if not path.exists():
            return PromotionHistory(), ""
        file_hash = self._file_hash(path)
        data = self._read_yaml(path)
        return PromotionHistory.from_dict(data), file_hash

    def save_promotion_history(
        self, history: PromotionHistory, expected_hash: Optional[str] = None
    ) -> Path:
        """Save the promotion history with optional optimistic concurrency."""
        path = self.releaseops_dir / PROMOTION_HISTORY_FILE
        if expected_hash is not None:
            current = self._file_hash(path) if path.exists() else ""
            if current != expected_hash:
                raise ConcurrentModificationError(
                    "Promotion history was modified by another process. "
                    "Re-read and retry."
                )
        self._write_yaml(path, history.to_dict())
        return path

    # ── Policies ─────────────────────────────────────────────────────

    def read_policy(self, path: str) -> str:
        """Read a policy file content by relative path."""
        full_path = self.repo_path / path
        if not full_path.exists():
            raise FileNotFoundError(f"Policy file not found: {full_path}")
        return full_path.read_text(encoding="utf-8")

    def list_policies(self) -> Dict[str, List[str]]:
        """List all policy files grouped by type."""
        result: Dict[str, List[str]] = {}
        for policy_type in ("context", "tools", "safety"):
            policy_dir = self.policies_dir / policy_type
            if policy_dir.exists():
                result[policy_type] = sorted(
                    str(p.relative_to(self.repo_path))
                    for p in policy_dir.glob("*.yaml")
                )
            else:
                result[policy_type] = []
        return result

    # ── Internal helpers ─────────────────────────────────────────────

    def _write_yaml(self, path: Path, data: dict) -> None:
        """Write data as YAML atomically (temp file + rename).

        Prevents data corruption if the process crashes mid-write.
        """
        path.parent.mkdir(parents=True, exist_ok=True)
        fd, tmp_path = tempfile.mkstemp(
            dir=str(path.parent), suffix=".tmp", prefix=f".{path.name}."
        )
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                yaml.dump(data, f, default_flow_style=False, sort_keys=False)
                f.flush()
                os.fsync(f.fileno())
            os.replace(tmp_path, str(path))
        except BaseException:
            # Clean up temp file on any failure
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            raise

    def _read_yaml(self, path: Path) -> dict:
        """Read YAML from a file."""
        with open(path, "r", encoding="utf-8") as f:
            return yaml.safe_load(f) or {}

    def _file_hash(self, path: Path) -> str:
        """Compute SHA-256 hash of file contents for concurrency detection."""
        content = path.read_bytes()
        return hashlib.sha256(content).hexdigest()
