import dataclasses
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, cast

import yaml

MANIFEST_VERSION = 2
MANIFEST_HEADER = "# GENERATED by CAR - DO NOT EDIT\n"
_SAFE_REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
_SANITIZE_REPO_ID_PATTERN = re.compile(r"[^A-Za-z0-9._-]+")


def is_safe_repo_id(value: str) -> bool:
    return bool(value and _SAFE_REPO_ID_PATTERN.match(value))


def sanitize_repo_id(value: str) -> str:
    base = (value or "").strip()
    base = _SANITIZE_REPO_ID_PATTERN.sub("-", base)
    base = base.strip("-")
    return base or "repo"


def ensure_unique_repo_id(base: str, existing_ids: set[str]) -> str:
    if base not in existing_ids:
        return base
    suffix = 2
    candidate = f"{base}-{suffix}"
    while candidate in existing_ids:
        suffix += 1
        candidate = f"{base}-{suffix}"
    return candidate


class ManifestError(Exception):
    pass


@dataclasses.dataclass
class ManifestRepo:
    id: str
    path: Path  # relative to hub root
    enabled: bool = True
    auto_run: bool = False
    kind: str = "base"  # base|worktree
    worktree_of: Optional[str] = None
    branch: Optional[str] = None
    display_name: Optional[str] = None
    worktree_setup_commands: Optional[List[str]] = None

    def to_dict(self, hub_root: Path) -> Dict[str, object]:
        rel = _relative_to_hub_root(hub_root, self.path)
        payload: Dict[str, object] = {
            "id": self.id,
            "path": rel.as_posix(),
            "enabled": bool(self.enabled),
            "auto_run": bool(self.auto_run),
            "kind": str(self.kind),
        }
        if self.display_name:
            payload["display_name"] = str(self.display_name)
        if self.worktree_of:
            payload["worktree_of"] = str(self.worktree_of)
        if self.branch:
            payload["branch"] = str(self.branch)
        if self.worktree_setup_commands:
            payload["worktree_setup_commands"] = [
                str(cmd) for cmd in self.worktree_setup_commands if str(cmd).strip()
            ]
        return payload


@dataclasses.dataclass
class Manifest:
    version: int
    repos: List[ManifestRepo]

    def get(self, repo_id: str) -> Optional[ManifestRepo]:
        for repo in self.repos:
            if repo.id == repo_id:
                return repo
        return None

    def get_by_path(self, hub_root: Path, repo_path: Path) -> Optional[ManifestRepo]:
        normalized_path = _relative_to_hub_root(hub_root, repo_path)
        for repo in self.repos:
            if repo.path == normalized_path:
                return repo
        return None

    def ensure_repo(
        self,
        hub_root: Path,
        repo_path: Path,
        repo_id: Optional[str] = None,
        display_name: Optional[str] = None,
        *,
        kind: str = "base",
        worktree_of: Optional[str] = None,
        branch: Optional[str] = None,
        worktree_setup_commands: Optional[List[str]] = None,
    ) -> ManifestRepo:
        base_name = display_name or repo_id or repo_path.name
        repo_id = sanitize_repo_id(repo_id or base_name)
        existing = self.get(repo_id)
        if existing:
            if display_name and not existing.display_name:
                existing.display_name = display_name
            return existing
        existing_ids = {repo.id for repo in self.repos}
        repo_id = ensure_unique_repo_id(repo_id, existing_ids)
        normalized_path = _relative_to_hub_root(hub_root, repo_path)
        repo = ManifestRepo(
            id=repo_id,
            path=normalized_path,
            enabled=True,
            auto_run=False,
            kind=str(kind),
            worktree_of=str(worktree_of) if worktree_of else None,
            branch=str(branch) if branch else None,
            display_name=display_name or base_name,
            worktree_setup_commands=(
                [str(cmd) for cmd in worktree_setup_commands if str(cmd).strip()]
                if worktree_setup_commands
                else None
            ),
        )
        self.repos.append(repo)
        return repo


def _relative_to_hub_root(hub_root: Path, target: Path) -> Path:
    if not target.is_absolute():
        target = (hub_root / target).resolve()
    else:
        target = target.resolve()
    try:
        return target.relative_to(hub_root)
    except ValueError:
        return Path(os.path.relpath(target, hub_root))


def load_manifest(manifest_path: Path, hub_root: Path) -> Manifest:
    if not manifest_path.exists():
        manifest_path.parent.mkdir(parents=True, exist_ok=True)
        manifest = Manifest(version=MANIFEST_VERSION, repos=[])
        save_manifest(manifest_path, manifest, hub_root)
        return manifest

    with manifest_path.open("r", encoding="utf-8") as f:
        raw_data = yaml.safe_load(f) or {}
    if not isinstance(raw_data, dict):
        raw_data = {}
    data = cast(Dict[str, Any], raw_data)

    version = data.get("version")
    if version != MANIFEST_VERSION:
        raise ManifestError(
            f"Unsupported manifest version {version}; expected {MANIFEST_VERSION}"
        )
    repos_data = data.get("repos", []) or []
    if not isinstance(repos_data, list):
        repos_data = []
    repos: List[ManifestRepo] = []
    for entry in repos_data:
        if not isinstance(entry, dict):
            continue
        repo_id = entry.get("id")
        path_val = entry.get("path")
        if not isinstance(repo_id, str) or not repo_id:
            continue
        if not isinstance(path_val, str) or not path_val:
            continue
        kind = entry.get("kind")
        if kind not in ("base", "worktree"):
            raise ManifestError(
                f"Invalid manifest repo kind for {repo_id}: {kind} (expected base|worktree)"
            )
        repos.append(
            ManifestRepo(
                id=repo_id,
                path=_relative_to_hub_root(hub_root, hub_root / path_val),
                enabled=bool(entry.get("enabled", True)),
                auto_run=bool(entry.get("auto_run", False)),
                kind=str(kind),
                worktree_of=(
                    str(entry.get("worktree_of")) if entry.get("worktree_of") else None
                ),
                branch=str(entry.get("branch")) if entry.get("branch") else None,
                display_name=(
                    str(entry.get("display_name"))
                    if entry.get("display_name")
                    else None
                ),
                worktree_setup_commands=(
                    [
                        str(cmd).strip()
                        for cmd in (entry.get("worktree_setup_commands") or [])
                        if isinstance(cmd, str) and str(cmd).strip()
                    ]
                    or None
                ),
            )
        )
    return Manifest(version=MANIFEST_VERSION, repos=repos)


def save_manifest(manifest_path: Path, manifest: Manifest, hub_root: Path) -> None:
    manifest_path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "version": MANIFEST_VERSION,
        "repos": [repo.to_dict(hub_root) for repo in manifest.repos],
    }
    with manifest_path.open("w", encoding="utf-8") as f:
        f.write(MANIFEST_HEADER)
        yaml.safe_dump(payload, f, sort_keys=False)
