"""Cross-project reference and collaboration management.

Provides functions to:
- Parse relation declarations from manifest.json
- Resolve references (file, directory, .agent/ project) and return content
- Manage collaborations (master/replica model)
- Validate relationship accessibility
- Manage manifest.json relations section (add/remove)
"""

import fnmatch
import json
from pathlib import Path
from typing import Any, NamedTuple


class ReferenceInfo(NamedTuple):
    """Parsed reference declaration from manifest.json."""

    name: str
    description: str
    source: str
    scope: list[str]
    use_when: str  # "always" | "session_start" | "on_demand"


class CollaborationInfo(NamedTuple):
    """Parsed collaboration declaration from manifest.json."""

    name: str
    description: str
    partner: str  # path to partner's .agent/ directory
    shared_scope: list[str]  # files in the shared scope
    role: str  # "master" | "replica"


class ReferenceStatus(NamedTuple):
    """Health check result for a reference."""

    name: str
    accessible: bool
    source_type: str  # "project" | "directory" | "file" | "unknown"
    resolved_path: str
    error: str | None = None


class CollaborationStatus(NamedTuple):
    """Health check result for a collaboration."""

    name: str
    role: str
    partner_accessible: bool
    resolved_path: str
    shared_files_in_sync: bool | None = None  # None if partner inaccessible
    drift_files: list[str] | None = None
    error: str | None = None


# ---------------------------------------------------------------------------
# Manifest I/O
# ---------------------------------------------------------------------------


def load_relations(agent_dir: Path) -> dict[str, Any]:
    """Load the relations section from manifest.json.

    Returns empty dict if manifest has no relations key.
    """
    manifest_path = agent_dir / "manifest.json"
    if not manifest_path.exists():
        return {}
    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        return manifest.get("relations", {})
    except (OSError, json.JSONDecodeError):
        return {}


def load_references(agent_dir: Path) -> list[ReferenceInfo]:
    """Load reference declarations from manifest.json."""
    relations = load_relations(agent_dir)
    raw_refs = relations.get("references", [])
    result: list[ReferenceInfo] = []
    for r in raw_refs:
        result.append(ReferenceInfo(
            name=r.get("name", ""),
            description=r.get("description", ""),
            source=r.get("source", ""),
            scope=r.get("scope", []),
            use_when=r.get("use_when", "on_demand"),
        ))
    return result


def get_reference_by_name(
    agent_dir: Path, name: str,
) -> ReferenceInfo | None:
    """Get a single reference by name, or None if not found."""
    for ref in load_references(agent_dir):
        if ref.name == name:
            return ref
    return None


def add_reference_to_manifest(
    agent_dir: Path,
    *,
    name: str,
    source: str,
    description: str = "",
    scope: list[str] | None = None,
    use_when: str = "on_demand",
) -> None:
    """Add a reference declaration to manifest.json.

    Raises ValueError if a reference with the same name already exists.
    """
    manifest_path = agent_dir / "manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))

    relations = manifest.setdefault("relations", {})
    references = relations.setdefault("references", [])

    for existing in references:
        if existing.get("name") == name:
            raise ValueError(f"Reference '{name}' already exists")

    entry: dict[str, Any] = {
        "name": name,
        "source": source,
    }
    if description:
        entry["description"] = description
    if scope:
        entry["scope"] = scope
    if use_when != "on_demand":
        entry["use_when"] = use_when

    references.append(entry)

    manifest_path.write_text(
        json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


def remove_reference_from_manifest(agent_dir: Path, name: str) -> bool:
    """Remove a reference by name. Returns True if found and removed."""
    manifest_path = agent_dir / "manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))

    relations = manifest.get("relations", {})
    references = relations.get("references", [])

    original_len = len(references)
    references = [r for r in references if r.get("name") != name]

    if len(references) == original_len:
        return False

    relations["references"] = references
    if not references and "references" in relations:
        del relations["references"]
    if not relations:
        manifest.pop("relations", None)

    manifest_path.write_text(
        json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )
    return True


# ---------------------------------------------------------------------------
# Collaboration manifest I/O
# ---------------------------------------------------------------------------


def load_collaborations(agent_dir: Path) -> list[CollaborationInfo]:
    """Load collaboration declarations from manifest.json."""
    relations = load_relations(agent_dir)
    raw = relations.get("collaborations", [])
    result: list[CollaborationInfo] = []
    for c in raw:
        result.append(CollaborationInfo(
            name=c.get("name", ""),
            description=c.get("description", ""),
            partner=c.get("partner", ""),
            shared_scope=c.get("shared_scope", []),
            role=c.get("role", "replica"),
        ))
    return result


def get_collaboration_by_name(
    agent_dir: Path, name: str,
) -> CollaborationInfo | None:
    """Get a single collaboration by name, or None if not found."""
    for c in load_collaborations(agent_dir):
        if c.name == name:
            return c
    return None


def add_collaboration_to_manifest(
    agent_dir: Path,
    *,
    name: str,
    partner: str,
    role: str = "replica",
    description: str = "",
    shared_scope: list[str] | None = None,
) -> None:
    """Add a collaboration declaration to manifest.json.

    Raises ValueError if a collaboration with the same name already exists
    or if role is invalid.
    """
    if role not in ("master", "replica"):
        raise ValueError(f"Invalid role '{role}'; must be 'master' or 'replica'")

    manifest_path = agent_dir / "manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))

    relations = manifest.setdefault("relations", {})
    collabs = relations.setdefault("collaborations", [])

    for existing in collabs:
        if existing.get("name") == name:
            raise ValueError(f"Collaboration '{name}' already exists")

    entry: dict[str, Any] = {
        "name": name,
        "partner": partner,
        "role": role,
    }
    if description:
        entry["description"] = description
    if shared_scope:
        entry["shared_scope"] = shared_scope

    collabs.append(entry)

    manifest_path.write_text(
        json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


def remove_collaboration_from_manifest(agent_dir: Path, name: str) -> bool:
    """Remove a collaboration by name. Returns True if found and removed."""
    manifest_path = agent_dir / "manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))

    relations = manifest.get("relations", {})
    collabs = relations.get("collaborations", [])

    original_len = len(collabs)
    collabs = [c for c in collabs if c.get("name") != name]

    if len(collabs) == original_len:
        return False

    relations["collaborations"] = collabs
    if not collabs:
        del relations["collaborations"]
    if not relations:
        manifest.pop("relations", None)

    manifest_path.write_text(
        json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )
    return True


# ---------------------------------------------------------------------------
# Source resolution
# ---------------------------------------------------------------------------


def _resolve_source_path(source: str, project_root: Path) -> Path:
    """Resolve a source string to an absolute path.

    For remote sources (git:...), fetches/caches and returns the cache path.
    For local sources, resolves relative paths against project_root.
    """
    from cokodo_agent.remote import (
        fetch_remote_source,
        get_cached_path,
        is_remote_source,
        parse_remote_source,
    )

    if is_remote_source(source):
        parsed = parse_remote_source(source)
        cached = get_cached_path(parsed)
        if cached is not None:
            return cached
        try:
            return fetch_remote_source(parsed)
        except (ConnectionError, FileNotFoundError):
            if cached is not None:
                return cached
            return Path(source)  # will be detected as "unknown"

    p = Path(source)
    if p.is_absolute():
        return p
    return (project_root / p).resolve()


def detect_source_type(resolved_path: Path) -> str:
    """Detect the type of a resolved source path.

    Returns: "project" | "directory" | "file" | "remote" | "unknown"
    """
    if not resolved_path.exists():
        return "unknown"
    if resolved_path.is_file():
        return "file"
    if resolved_path.is_dir():
        if (resolved_path / "manifest.json").exists():
            return "project"
        return "directory"
    return "unknown"


def _match_scope(files: list[str], scope: list[str]) -> list[str]:
    """Filter file list by scope glob patterns."""
    if not scope:
        return files
    matched: list[str] = []
    for f in files:
        for pattern in scope:
            if fnmatch.fnmatch(f, pattern):
                matched.append(f)
                break
    return matched


def _list_md_files(directory: Path) -> list[str]:
    """List all .md files in a directory, returning paths relative to it."""
    result: list[str] = []
    for p in sorted(directory.rglob("*.md")):
        if p.is_file():
            result.append(p.relative_to(directory).as_posix())
    return result


def _list_all_files(directory: Path) -> list[str]:
    """List all files in a directory, returning paths relative to it."""
    result: list[str] = []
    for p in sorted(directory.rglob("*")):
        if p.is_file():
            result.append(p.relative_to(directory).as_posix())
    return result


def resolve_reference_content(
    ref: ReferenceInfo,
    project_root: Path,
    file: str | None = None,
) -> dict[str, str]:
    """Resolve a reference and return its content.

    Args:
        ref: The reference declaration.
        project_root: Project root directory (parent of .agent/).
        file: If provided, return only this specific file.

    Returns:
        Dict mapping relative file paths to their text content.

    Raises:
        FileNotFoundError: If the source path does not exist.
    """
    resolved = _resolve_source_path(ref.source, project_root)
    source_type = detect_source_type(resolved)

    if source_type == "unknown":
        raise FileNotFoundError(f"Reference source not found: {ref.source}")

    if source_type == "file":
        if file is not None and file != resolved.name:
            raise FileNotFoundError(
                f"File '{file}' not found in single-file reference '{ref.name}'"
            )
        try:
            content = resolved.read_text(encoding="utf-8")
        except OSError as e:
            raise FileNotFoundError(f"Cannot read {ref.source}: {e}") from e
        return {resolved.name: content}

    if file is not None:
        target = resolved / file
        if not target.exists() or not target.is_file():
            raise FileNotFoundError(
                f"File '{file}' not found in reference '{ref.name}'"
            )
        try:
            content = target.read_text(encoding="utf-8")
        except OSError as e:
            raise FileNotFoundError(f"Cannot read {file}: {e}") from e
        return {file: content}

    if source_type == "project":
        all_files = _list_all_files(resolved)
    else:
        all_files = _list_md_files(resolved)

    scope = ref.scope
    if not scope and source_type == "directory":
        scope = ["**/*.md"]

    matched = _match_scope(all_files, scope) if scope else all_files

    result: dict[str, str] = {}
    for rel_path in matched:
        full = resolved / rel_path
        try:
            result[rel_path] = full.read_text(encoding="utf-8")
        except OSError:
            pass
    return result


# ---------------------------------------------------------------------------
# Collaboration resolution
# ---------------------------------------------------------------------------


def _resolve_partner_path(partner: str, project_root: Path) -> Path:
    """Resolve a partner path to an absolute path.

    The partner field points to the partner project's .agent/ directory.
    Relative paths are resolved against project_root.
    """
    p = Path(partner)
    if p.is_absolute():
        return p
    return (project_root / p).resolve()


def resolve_collaboration_content(
    collab: CollaborationInfo,
    project_root: Path,
    file: str | None = None,
) -> dict[str, str]:
    """Resolve shared content from the partner project.

    Returns dict mapping relative file paths to their text content.
    Only files matching shared_scope are returned.
    """
    partner_path = _resolve_partner_path(collab.partner, project_root)

    if not partner_path.exists() or not partner_path.is_dir():
        raise FileNotFoundError(
            f"Partner path not found: {collab.partner}"
        )

    if file is not None:
        target = partner_path / file
        if not target.exists() or not target.is_file():
            raise FileNotFoundError(
                f"File '{file}' not found in partner '{collab.name}'"
            )
        try:
            return {file: target.read_text(encoding="utf-8")}
        except OSError as e:
            raise FileNotFoundError(f"Cannot read {file}: {e}") from e

    all_files = _list_all_files(partner_path)
    scope = collab.shared_scope if collab.shared_scope else ["**/*.md"]
    matched = _match_scope(all_files, scope)

    result: dict[str, str] = {}
    for rel_path in matched:
        full = partner_path / rel_path
        try:
            result[rel_path] = full.read_text(encoding="utf-8")
        except OSError:
            pass
    return result


def diff_collaboration(
    collab: CollaborationInfo,
    project_root: Path,
    agent_dir: Path,
) -> dict[str, str]:
    """Compare shared-scope files between local and partner.

    Returns a dict of relative paths -> diff status:
    - "partner_only": file exists only in partner
    - "local_only": file exists only locally
    - "modified": file content differs
    - "in_sync": file content identical

    Only files matching shared_scope are compared.
    """
    partner_path = _resolve_partner_path(collab.partner, project_root)

    if not partner_path.exists():
        raise FileNotFoundError(
            f"Partner path not found: {collab.partner}"
        )

    scope = collab.shared_scope if collab.shared_scope else ["**/*.md"]

    partner_files = set(_match_scope(_list_all_files(partner_path), scope))
    local_files = set(_match_scope(_list_all_files(agent_dir), scope))

    all_paths = partner_files | local_files
    result: dict[str, str] = {}

    for rel_path in sorted(all_paths):
        in_partner = rel_path in partner_files
        in_local = rel_path in local_files

        if in_partner and not in_local:
            result[rel_path] = "partner_only"
        elif in_local and not in_partner:
            result[rel_path] = "local_only"
        else:
            p_content = _safe_read(partner_path / rel_path)
            l_content = _safe_read(agent_dir / rel_path)
            result[rel_path] = "in_sync" if p_content == l_content else "modified"

    return result


def pull_collaboration(
    collab: CollaborationInfo,
    project_root: Path,
    agent_dir: Path,
) -> list[str]:
    """Pull shared-scope files from master into the local replica.

    Only valid when local role is 'replica'.
    Returns list of files that were updated or created.
    """
    if collab.role != "replica":
        raise ValueError(
            f"Cannot pull: local role is '{collab.role}', not 'replica'"
        )

    partner_path = _resolve_partner_path(collab.partner, project_root)
    if not partner_path.exists():
        raise FileNotFoundError(
            f"Partner path not found: {collab.partner}"
        )

    scope = collab.shared_scope if collab.shared_scope else ["**/*.md"]
    partner_files = _match_scope(_list_all_files(partner_path), scope)

    updated: list[str] = []
    for rel_path in partner_files:
        src = partner_path / rel_path
        dst = agent_dir / rel_path

        src_content = _safe_read(src)
        if src_content is None:
            continue

        dst_content = _safe_read(dst)
        if dst_content == src_content:
            continue

        dst.parent.mkdir(parents=True, exist_ok=True)
        dst.write_text(src_content, encoding="utf-8")
        updated.append(rel_path)

    return updated


def _safe_read(path: Path) -> str | None:
    """Read a file's text content, returning None on any error."""
    try:
        return path.read_text(encoding="utf-8")
    except OSError:
        return None


# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------


def check_references(
    agent_dir: Path,
    project_root: Path,
) -> list[ReferenceStatus]:
    """Validate all declared references and return their status."""
    from cokodo_agent.remote import (
        get_cached_path,
        is_cache_fresh,
        is_remote_source,
        parse_remote_source,
    )

    refs = load_references(agent_dir)
    results: list[ReferenceStatus] = []

    for ref in refs:
        if is_remote_source(ref.source):
            try:
                parsed = parse_remote_source(ref.source)
            except ValueError as e:
                results.append(ReferenceStatus(
                    name=ref.name,
                    accessible=False,
                    source_type="remote",
                    resolved_path=ref.source,
                    error=f"Invalid remote source: {e}",
                ))
                continue

            cached = get_cached_path(parsed)
            if cached is not None:
                stype = detect_source_type(cached)
                fresh = is_cache_fresh(parsed)
                note = "" if fresh else " (stale cache)"
                results.append(ReferenceStatus(
                    name=ref.name,
                    accessible=True,
                    source_type=f"remote/{stype}{note}",
                    resolved_path=str(cached),
                ))
            else:
                results.append(ReferenceStatus(
                    name=ref.name,
                    accessible=False,
                    source_type="remote",
                    resolved_path=ref.source,
                    error="Not cached. Run 'co ref fetch' to download.",
                ))
            continue

        resolved = _resolve_source_path(ref.source, project_root)
        source_type = detect_source_type(resolved)

        if source_type == "unknown":
            results.append(ReferenceStatus(
                name=ref.name,
                accessible=False,
                source_type="unknown",
                resolved_path=str(resolved),
                error=f"Path not found: {resolved}",
            ))
        else:
            results.append(ReferenceStatus(
                name=ref.name,
                accessible=True,
                source_type=source_type,
                resolved_path=str(resolved),
            ))

    return results


def check_collaborations(
    agent_dir: Path,
    project_root: Path,
) -> list[CollaborationStatus]:
    """Validate all declared collaborations and return their status.

    Also verifies that the partner has a matching collaboration name
    pointing back (required for unambiguous pairing).
    """
    collabs = load_collaborations(agent_dir)
    results: list[CollaborationStatus] = []

    for collab in collabs:
        resolved = _resolve_partner_path(collab.partner, project_root)

        if not resolved.exists() or not resolved.is_dir():
            results.append(CollaborationStatus(
                name=collab.name,
                role=collab.role,
                partner_accessible=False,
                resolved_path=str(resolved),
                error=f"Partner path not found: {resolved}",
            ))
            continue

        # Check that partner has a matching collaboration name
        partner_collabs = load_collaborations(resolved)
        partner_has_match = any(
            pc.name == collab.name for pc in partner_collabs
        )

        try:
            diffs = diff_collaboration(collab, project_root, agent_dir)
            drift = [
                f for f, status in diffs.items()
                if status in ("partner_only", "modified")
            ]

            error_msg = None
            if not partner_has_match:
                error_msg = (
                    f"Partner has no matching collaboration named '{collab.name}'"
                )

            results.append(CollaborationStatus(
                name=collab.name,
                role=collab.role,
                partner_accessible=True,
                resolved_path=str(resolved),
                shared_files_in_sync=len(drift) == 0,
                drift_files=drift if drift else None,
                error=error_msg,
            ))
        except OSError as e:
            results.append(CollaborationStatus(
                name=collab.name,
                role=collab.role,
                partner_accessible=True,
                resolved_path=str(resolved),
                error=str(e),
            ))

    return results


# ---------------------------------------------------------------------------
# Circular reference detection
# ---------------------------------------------------------------------------


def detect_circular_references(
    agent_dir: Path,
    project_root: Path,
    _visited: set[str] | None = None,
) -> list[str]:
    """Detect circular references starting from agent_dir.

    Follows project-type references and collaborations to see if any path
    leads back to a previously visited .agent/ directory.

    Returns a list of cycle descriptions (empty if no cycles).
    """
    resolved_root = agent_dir.resolve()
    key = str(resolved_root)

    if _visited is None:
        _visited = set()

    if key in _visited:
        return [f"Circular: ... -> {resolved_root}"]

    _visited.add(key)
    cycles: list[str] = []

    refs = load_references(agent_dir)
    for ref in refs:
        resolved = _resolve_source_path(ref.source, project_root)
        if detect_source_type(resolved) == "project":
            partner_root = resolved.parent
            sub_cycles = detect_circular_references(
                resolved, partner_root, _visited.copy()
            )
            for c in sub_cycles:
                cycles.append(f"{resolved_root} -> ref:{ref.name} -> {c}")

    collabs = load_collaborations(agent_dir)
    for collab in collabs:
        resolved = _resolve_partner_path(collab.partner, project_root)
        if resolved.exists() and resolved.is_dir():
            partner_root = resolved.parent
            sub_cycles = detect_circular_references(
                resolved, partner_root, _visited.copy()
            )
            for c in sub_cycles:
                cycles.append(
                    f"{resolved_root} -> collab:{collab.name} -> {c}"
                )

    return cycles
