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

import yaml

MANIFEST_VERSION = 3
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(frozen=True)
class ManifestValidationIssue:
    repo_id: str
    message: str


def preserve_manifest_destination(value: Any) -> Optional[Dict[str, Any]]:
    """Keep destination payload mappings round-trippable, including invalid values."""
    if not isinstance(value, dict):
        return None
    preserved: Dict[str, Any] = {}
    for raw_key, raw_value in value.items():
        if not isinstance(raw_key, str):
            continue
        key = raw_key.strip()
        if not key:
            continue
        if key == "kind" and isinstance(raw_value, str):
            preserved[key] = raw_value.strip()
            continue
        preserved[key] = raw_value
    return preserved


def normalize_manifest_destination(value: Any) -> Optional[Dict[str, Any]]:
    """Normalize destination payloads; return None for invalid shapes."""
    normalized = preserve_manifest_destination(value)
    if normalized is None:
        return None
    kind = normalized.get("kind")
    if not isinstance(kind, str) or not kind.strip():
        return None
    normalized["kind"] = kind.strip()
    return normalized


@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
    destination: Optional[Dict[str, Any]] = 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()
            ]
        destination = preserve_manifest_destination(self.destination)
        if destination is not None:
            payload["destination"] = destination
        return payload


@dataclasses.dataclass
class ManifestAgentWorkspace:
    id: str
    runtime: str
    path: Path  # relative to hub root
    enabled: bool = True
    display_name: Optional[str] = None
    destination: Optional[Dict[str, Any]] = 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,
            "runtime": self.runtime,
            "path": rel.as_posix(),
            "enabled": bool(self.enabled),
        }
        if self.display_name:
            payload["display_name"] = str(self.display_name)
        destination = preserve_manifest_destination(self.destination)
        if destination is not None:
            payload["destination"] = destination
        return payload


@dataclasses.dataclass
class Manifest:
    version: int
    repos: List[ManifestRepo]
    agent_workspaces: List[ManifestAgentWorkspace] = dataclasses.field(
        default_factory=list
    )
    issues: List[ManifestValidationIssue] = dataclasses.field(
        default_factory=list,
        repr=False,
        compare=False,
    )

    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 get_agent_workspace(
        self, workspace_id: str
    ) -> Optional[ManifestAgentWorkspace]:
        for workspace in self.agent_workspaces:
            if workspace.id == workspace_id:
                return workspace
        return None

    def get_agent_workspace_by_path(
        self, hub_root: Path, workspace_path: Path
    ) -> Optional[ManifestAgentWorkspace]:
        normalized_path = _relative_to_hub_root(hub_root, workspace_path)
        for workspace in self.agent_workspaces:
            if workspace.path == normalized_path:
                return workspace
        return None

    def issues_for_repo(self, repo_id: str) -> List[str]:
        return [
            issue.message
            for issue in self.issues
            if issue.repo_id in {repo_id, "manifest"}
        ]

    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,
        destination: Optional[Dict[str, Any]] = 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
            ),
            destination=normalize_manifest_destination(destination),
        )
        self.repos.append(repo)
        return repo

    def ensure_agent_workspace(
        self,
        hub_root: Path,
        *,
        workspace_id: str,
        runtime: str,
        display_name: Optional[str] = None,
        destination: Optional[Dict[str, Any]] = None,
    ) -> ManifestAgentWorkspace:
        from .core.state_roots import resolve_hub_agent_workspace_root

        base_name = display_name or workspace_id
        raw_workspace_id = (workspace_id or "").strip()
        raw_runtime = (runtime or "").strip()
        if not raw_workspace_id:
            raise ManifestError("agent workspace requires non-empty workspace_id")
        if not raw_runtime:
            raise ManifestError("agent workspace requires non-empty runtime")
        normalized_workspace_id = sanitize_repo_id(raw_workspace_id)
        normalized_runtime = sanitize_repo_id(raw_runtime)
        existing = self.get_agent_workspace(normalized_workspace_id)
        if existing:
            if display_name and not existing.display_name:
                existing.display_name = display_name
            return existing
        existing_ids = {workspace.id for workspace in self.agent_workspaces}
        normalized_workspace_id = ensure_unique_repo_id(
            normalized_workspace_id, existing_ids
        )
        workspace_root = resolve_hub_agent_workspace_root(
            hub_root,
            runtime=normalized_runtime,
            workspace_id=normalized_workspace_id,
        )
        workspace = ManifestAgentWorkspace(
            id=normalized_workspace_id,
            runtime=normalized_runtime,
            path=_relative_to_hub_root(hub_root, workspace_root),
            enabled=True,
            display_name=display_name or base_name,
            destination=normalize_manifest_destination(destination),
        )
        self.agent_workspaces.append(workspace)
        return workspace


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 _parse_manifest_repos_with_issues(
    *,
    repos_data: Any,
    hub_root: Path,
) -> tuple[List[ManifestRepo], List[ManifestValidationIssue]]:
    if repos_data is None:
        return [], []

    issues: List[ManifestValidationIssue] = []
    if not isinstance(repos_data, list):
        issues.append(
            ManifestValidationIssue(
                repo_id="manifest",
                message="manifest 'repos' must be a list",
            )
        )
        return [], issues

    # Import lazily to avoid circular-import issues at module import time.
    from .core.destinations import parse_destination_config

    repos: List[ManifestRepo] = []
    for idx, entry in enumerate(repos_data):
        if not isinstance(entry, dict):
            issues.append(
                ManifestValidationIssue(
                    repo_id=f"<index:{idx}>",
                    message="repo entry must be an object",
                )
            )
            continue

        repo_id = entry.get("id")
        path_val = entry.get("path")
        repo_label = (
            repo_id.strip()
            if isinstance(repo_id, str) and repo_id.strip()
            else f"<index:{idx}>"
        )
        if not isinstance(repo_id, str) or not repo_id.strip():
            issues.append(
                ManifestValidationIssue(
                    repo_id=repo_label,
                    message="repo entry requires non-empty string 'id'",
                )
            )
            continue
        if not isinstance(path_val, str) or not path_val.strip():
            issues.append(
                ManifestValidationIssue(
                    repo_id=repo_label,
                    message=f"repo '{repo_id}' requires non-empty string 'path'",
                )
            )
            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)"
            )

        destination = (
            preserve_manifest_destination(entry.get("destination"))
            if "destination" in entry
            else None
        )
        if "destination" in entry:
            parsed_destination = parse_destination_config(
                entry.get("destination"),
                context=f"repo '{repo_id}' destination",
            )
            if parsed_destination.valid:
                destination = normalize_manifest_destination(
                    parsed_destination.destination.to_dict()
                )
            else:
                for message in parsed_destination.errors:
                    issues.append(
                        ManifestValidationIssue(repo_id=repo_id, message=message)
                    )

        repos.append(
            ManifestRepo(
                id=repo_id.strip(),
                path=_relative_to_hub_root(hub_root, hub_root / path_val.strip()),
                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
                ),
                destination=destination,
            )
        )

    return repos, issues


def _parse_manifest_agent_workspaces_with_issues(
    *,
    agent_workspaces_data: Any,
    hub_root: Path,
) -> tuple[List[ManifestAgentWorkspace], List[ManifestValidationIssue]]:
    if agent_workspaces_data is None:
        return [], []

    issues: List[ManifestValidationIssue] = []
    if not isinstance(agent_workspaces_data, list):
        issues.append(
            ManifestValidationIssue(
                repo_id="manifest",
                message="manifest 'agent_workspaces' must be a list",
            )
        )
        return [], issues

    from .core.destinations import parse_destination_config
    from .core.state_roots import StateRootError, resolve_hub_agent_workspace_root

    agent_workspaces: List[ManifestAgentWorkspace] = []
    for idx, entry in enumerate(agent_workspaces_data):
        if not isinstance(entry, dict):
            issues.append(
                ManifestValidationIssue(
                    repo_id=f"<index:{idx}>",
                    message="agent workspace entry must be an object",
                )
            )
            continue

        workspace_id = entry.get("id")
        runtime = entry.get("runtime")
        path_val = entry.get("path")
        workspace_label = (
            workspace_id.strip()
            if isinstance(workspace_id, str) and workspace_id.strip()
            else f"<index:{idx}>"
        )
        if not isinstance(workspace_id, str) or not workspace_id.strip():
            issues.append(
                ManifestValidationIssue(
                    repo_id=workspace_label,
                    message="agent workspace entry requires non-empty string 'id'",
                )
            )
            continue
        if not isinstance(runtime, str) or not runtime.strip():
            issues.append(
                ManifestValidationIssue(
                    repo_id=workspace_label,
                    message=(
                        f"agent workspace '{workspace_id}' requires non-empty string "
                        "'runtime'"
                    ),
                )
            )
            continue
        if not isinstance(path_val, str) or not path_val.strip():
            issues.append(
                ManifestValidationIssue(
                    repo_id=workspace_label,
                    message=(
                        f"agent workspace '{workspace_id}' requires non-empty string "
                        "'path'"
                    ),
                )
            )
            continue

        destination = (
            preserve_manifest_destination(entry.get("destination"))
            if "destination" in entry
            else None
        )
        if "destination" in entry:
            parsed_destination = parse_destination_config(
                entry.get("destination"),
                context=f"agent workspace '{workspace_id}' destination",
            )
            if parsed_destination.valid:
                destination = normalize_manifest_destination(
                    parsed_destination.destination.to_dict()
                )
            else:
                for message in parsed_destination.errors:
                    issues.append(
                        ManifestValidationIssue(repo_id=workspace_id, message=message)
                    )

        workspace_path = _relative_to_hub_root(hub_root, hub_root / path_val.strip())
        canonical_workspace_path: Optional[Path] = None
        try:
            canonical_workspace_path = _relative_to_hub_root(
                hub_root,
                resolve_hub_agent_workspace_root(
                    hub_root,
                    runtime=runtime.strip(),
                    workspace_id=workspace_id.strip(),
                ),
            )
        except StateRootError as exc:
            issues.append(
                ManifestValidationIssue(
                    repo_id=workspace_id.strip(),
                    message=f"agent workspace '{workspace_id}' is invalid: {exc}",
                )
            )
        if (
            canonical_workspace_path is not None
            and workspace_path != canonical_workspace_path
        ):
            issues.append(
                ManifestValidationIssue(
                    repo_id=workspace_id.strip(),
                    message=(
                        f"agent workspace '{workspace_id}' path must match managed "
                        f"runtime root '{canonical_workspace_path.as_posix()}'"
                    ),
                )
            )

        agent_workspaces.append(
            ManifestAgentWorkspace(
                id=workspace_id.strip(),
                runtime=runtime.strip(),
                path=workspace_path,
                enabled=bool(entry.get("enabled", True)),
                display_name=(
                    str(entry.get("display_name"))
                    if entry.get("display_name")
                    else None
                ),
                destination=destination,
            )
        )

    return agent_workspaces, issues


def load_manifest_with_issues(
    manifest_path: Path, hub_root: Path
) -> tuple[Manifest, List[ManifestValidationIssue]]:
    if not manifest_path.exists():
        manifest_path.parent.mkdir(parents=True, exist_ok=True)
        manifest = Manifest(version=MANIFEST_VERSION, repos=[], agent_workspaces=[])
        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 not in {2, MANIFEST_VERSION}:
        raise ManifestError(
            f"Unsupported manifest version {version}; expected {MANIFEST_VERSION}"
        )
    repos, issues = _parse_manifest_repos_with_issues(
        repos_data=data.get("repos", []), hub_root=hub_root
    )
    agent_workspaces, workspace_issues = _parse_manifest_agent_workspaces_with_issues(
        agent_workspaces_data=data.get("agent_workspaces", []),
        hub_root=hub_root,
    )
    all_issues = [*issues, *workspace_issues]
    manifest = Manifest(
        version=MANIFEST_VERSION,
        repos=repos,
        agent_workspaces=agent_workspaces,
        issues=list(all_issues),
    )
    return manifest, list(all_issues)


def load_manifest(manifest_path: Path, hub_root: Path) -> Manifest:
    manifest, _ = load_manifest_with_issues(manifest_path, hub_root)
    return manifest


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],
        "agent_workspaces": [
            workspace.to_dict(hub_root) for workspace in manifest.agent_workspaces
        ],
    }
    with manifest_path.open("w", encoding="utf-8") as f:
        f.write(MANIFEST_HEADER)
        yaml.safe_dump(payload, f, sort_keys=False)
