"""Tracked and local file-backed state for metabranch workflows."""

from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path

import yaml

from ..exceptions import InvalidMetabranchError, RepoLockError
from ..git import GitClient
from ..models import (
    CONFIG_FILE_NAME,
    MetabranchConfigFile,
    MetabranchDefinition,
    RepoLock,
    RepoLockStatus,
    WorkflowJournal,
    load_json_model,
    store_json_model,
    validate_model,
)

LOCAL_STATE_DIR_NAME = "metabranch"
SHORT_OPERATION_LOCK_STALE_AFTER_SECONDS = 300


class TrackedConfig:
    """Tracked metabranch config helpers."""

    def __init__(self, git_client: GitClient | None = None) -> None:
        self.git_client = git_client or GitClient()

    @property
    def path(self) -> Path:
        """Return the shared config path."""
        return self.git_client.working_dir / CONFIG_FILE_NAME

    def exists(self) -> bool:
        """Return whether the current worktree has metabranch config."""
        return MetabranchConfigFile.exists_for(self.git_client)

    def exists_in_branch(self, branch_name: str) -> bool:
        """Return whether a branch contains metabranch config."""
        return MetabranchConfigFile.exists_in_branch(self.git_client, branch_name)

    def load(self) -> MetabranchDefinition:
        """Load the current worktree definition."""
        if not self.exists():
            raise InvalidMetabranchError()
        return MetabranchConfigFile.load_for(self.git_client)

    def load_from_branch(self, branch_name: str) -> MetabranchDefinition | None:
        """Load a definition from another branch."""
        if branch_name == self.git_client.current_branch_name() and self.exists():
            return self.load()
        data = self.git_client.read_text_from_branch(branch_name, CONFIG_FILE_NAME)
        if data is None:
            return None
        return validate_model(
            yaml.safe_load(data) or {},
            MetabranchConfigFile,
            "Invalid metabranch config payload",
        ).metabranch

    def save(self, definition: MetabranchDefinition) -> None:
        """Persist the current definition to the worktree."""
        MetabranchConfigFile.save_for(self.git_client, definition)


class LocalState:
    """Local runtime files under the Git directory."""

    def __init__(self, git_client: GitClient | None = None) -> None:
        self.git_client = git_client or GitClient()

    @property
    def root(self) -> Path:
        """Return the local metabranch state directory."""
        return self.git_client.git_dir / LOCAL_STATE_DIR_NAME

    @property
    def registry_path(self) -> Path:
        """Return the metabranch registry path."""
        return self.root / "registry.json"

    @property
    def active_path(self) -> Path:
        """Return the active-metabranch path."""
        return self.root / "active.json"

    @property
    def workflows_dir(self) -> Path:
        """Return the workflow journal directory."""
        return self.root / "workflows"

    @property
    def locks_dir(self) -> Path:
        """Return the local lock directory."""
        return self.root / "locks"

    @property
    def repo_lock_path(self) -> Path:
        """Return the repo-wide mutation lock path."""
        return self.locks_dir / "repo.lock"

    @property
    def tmp_dir(self) -> Path:
        """Return the local temp directory for editor-driven workflows."""
        return self.root / "tmp"

    def ensure_layout(self) -> None:
        """Ensure the local state layout exists."""
        self.root.mkdir(parents=True, exist_ok=True)
        self.workflows_dir.mkdir(parents=True, exist_ok=True)
        self.locks_dir.mkdir(parents=True, exist_ok=True)
        self.tmp_dir.mkdir(parents=True, exist_ok=True)

    def load_registry(self) -> dict[str, dict[str, object]]:
        """Load the local registry."""
        self.ensure_layout()
        if not self.registry_path.exists():
            return {}
        with open(self.registry_path) as handle:
            data = json.load(handle)
        return data if isinstance(data, dict) else {}

    def save_registry(self, registry: dict[str, dict[str, object]]) -> None:
        """Persist the local registry."""
        self.ensure_layout()
        with open(self.registry_path, "w") as handle:
            json.dump(registry, handle, indent=2, sort_keys=True)

    def register(self, definition: MetabranchDefinition) -> None:
        """Update the local registry with the given metabranch."""
        registry = self.load_registry()
        registry[definition.name] = {
            "base_ref": definition.base_ref,
            "branches": list(definition.branches),
        }
        self.save_registry(registry)

    def unregister(self, branch_name: str) -> None:
        """Remove a metabranch from the local registry and active pointer."""
        registry = self.load_registry()
        if branch_name in registry:
            del registry[branch_name]
            self.save_registry(registry)
        if self.get_active() == branch_name:
            self.set_active(None, workflow_id=self.get_active_workflow_id())

    def list_names(self) -> list[str]:
        """Return locally known metabranch names."""
        return sorted(self.load_registry())

    def get_definition(self, branch_name: str) -> MetabranchDefinition | None:
        """Return a definition reconstructed from the local registry."""
        entry = self.load_registry().get(branch_name)
        if not isinstance(entry, dict):
            return None

        base_ref = entry.get("base_ref")
        branches = entry.get("branches", [])
        if not isinstance(base_ref, str) or not isinstance(branches, list):
            return None

        parsed_branches = tuple(
            branch_name for branch_name in branches if isinstance(branch_name, str)
        )
        return MetabranchDefinition(
            name=branch_name,
            base_ref=base_ref,
            branches=parsed_branches,
        )

    def set_active(
        self, branch_name: str | None, workflow_id: str | None = None
    ) -> None:
        """Persist the currently active metabranch, or clear it."""
        self.ensure_layout()
        with open(self.active_path, "w") as handle:
            json.dump(
                {"active": branch_name, "workflow_id": workflow_id},
                handle,
                indent=2,
                sort_keys=True,
            )

    def get_active(self) -> str | None:
        """Return the locally active metabranch."""
        self.ensure_layout()
        if not self.active_path.exists():
            return None
        with open(self.active_path) as handle:
            data = json.load(handle)
        if not isinstance(data, dict):
            return None
        active = data.get("active")
        return active if isinstance(active, str) else None

    def get_active_workflow_id(self) -> str | None:
        """Return the currently active workflow id."""
        self.ensure_layout()
        if not self.active_path.exists():
            return None
        with open(self.active_path) as handle:
            data = json.load(handle)
        if not isinstance(data, dict):
            return None
        workflow_id = data.get("workflow_id")
        return workflow_id if isinstance(workflow_id, str) else None

    def workflow_path(self, workflow_id: str) -> Path:
        """Return the file path for a workflow journal."""
        return self.workflows_dir / f"{workflow_id}.json"

    def save_workflow(self, journal: WorkflowJournal) -> None:
        """Persist a workflow journal."""
        self.ensure_layout()
        store_json_model(self.workflow_path(journal.workflow_id), journal)

    def load_workflow(self, workflow_id: str) -> WorkflowJournal | None:
        """Load a workflow journal by id."""
        self.ensure_layout()
        path = self.workflow_path(workflow_id)
        if not path.exists():
            return None
        try:
            return load_json_model(
                path,
                WorkflowJournal,
                "Invalid workflow journal payload",
            )
        except ValueError:
            return None

    def set_active_workflow(self, workflow_id: str | None) -> None:
        """Update the active workflow id while preserving the active branch."""
        self.set_active(self.get_active(), workflow_id=workflow_id)

    def get_active_workflow(self) -> WorkflowJournal | None:
        """Return the active workflow journal, if any."""
        workflow_id = self.get_active_workflow_id()
        if workflow_id is None:
            return None
        return self.load_workflow(workflow_id)

    def write_tmp_file(self, name: str, contents: str) -> Path:
        """Write a temp file under local metabranch state and return its path."""
        self.ensure_layout()
        path = self.tmp_dir / name
        path.write_text(contents)
        return path

    def load_repo_lock(self) -> RepoLock | None:
        """Return the repo-wide lock, if any."""
        self.ensure_layout()
        if not self.repo_lock_path.exists():
            return None
        try:
            return load_json_model(
                self.repo_lock_path,
                RepoLock,
                "Invalid repo lock payload",
            )
        except ValueError:
            return None

    def inspect_repo_lock(self) -> RepoLockStatus | None:
        """Return the repo lock plus stale-lock analysis."""
        repo_lock = self.load_repo_lock()
        if repo_lock is None:
            return None

        reason = self._stale_repo_lock_reason(repo_lock)
        return RepoLockStatus(
            lock=repo_lock,
            is_stale=reason is not None,
            reason=reason,
        )

    def acquire_repo_lock(
        self,
        *,
        owner: str,
        workflow_id: str | None = None,
        operation: str | None = None,
        metabranch_name: str | None = None,
    ) -> RepoLock:
        """Acquire the repo-wide mutation lock for the caller."""
        self.ensure_layout()
        existing_status = self.inspect_repo_lock()
        if existing_status is not None:
            if existing_status.lock.owner == owner:
                return existing_status.lock
            if existing_status.is_stale:
                reason = existing_status.reason or "stale repo lock"
                raise RepoLockError(
                    "A stale metabranch repo lock is present: "
                    f"{existing_status.lock.owner} ({reason}). "
                    "Run `metabranch abort` to clear it."
                )
            raise RepoLockError(
                "Another metabranch operation holds the repo lock: "
                f"{existing_status.lock.owner}"
            )

        repo_lock = RepoLock(
            owner=owner,
            workflow_id=workflow_id,
            operation=operation,
            metabranch_name=metabranch_name,
        )
        try:
            with open(self.repo_lock_path, "x") as handle:
                json.dump(
                    repo_lock.model_dump(mode="json"),
                    handle,
                    indent=2,
                    sort_keys=True,
                )
        except FileExistsError as err:
            existing_status = self.inspect_repo_lock()
            if existing_status is not None and existing_status.lock.owner != owner:
                if existing_status.is_stale:
                    reason = existing_status.reason or "stale repo lock"
                    raise RepoLockError(
                        "A stale metabranch repo lock is present: "
                        f"{existing_status.lock.owner} ({reason}). "
                        "Run `metabranch abort` to clear it."
                    ) from err
                raise RepoLockError(
                    "Another metabranch operation holds the repo lock: "
                    f"{existing_status.lock.owner}"
                ) from err
        return repo_lock

    def release_repo_lock(self, owner: str | None = None) -> None:
        """Release the repo-wide mutation lock."""
        self.ensure_layout()
        if not self.repo_lock_path.exists():
            return

        existing = self.load_repo_lock()
        if owner is not None and existing is not None and existing.owner != owner:
            raise RepoLockError(f"Repo lock is owned by {existing.owner}, not {owner}")
        self.repo_lock_path.unlink()

    def _stale_repo_lock_reason(self, repo_lock: RepoLock) -> str | None:
        """Return a stale-lock reason when the repo lock is no longer trustworthy."""
        if repo_lock.workflow_id is None:
            age_seconds = repo_lock.age_seconds(datetime.now(timezone.utc))
            if age_seconds > SHORT_OPERATION_LOCK_STALE_AFTER_SECONDS:
                return (
                    "short operation lock exceeded "
                    f"{SHORT_OPERATION_LOCK_STALE_AFTER_SECONDS}s"
                )
            return None

        journal = self.load_workflow(repo_lock.workflow_id)
        if journal is None:
            return "workflow journal is missing"
        if journal.status in {"completed", "aborted"}:
            return f"workflow is already {journal.status}"
        active_workflow_id = self.get_active_workflow_id()
        if active_workflow_id != repo_lock.workflow_id:
            return "active workflow pointer does not match lock owner"
        return None
