"""Sync artifacts from erk package to project's .claude/ directory."""

import json
import shutil
from dataclasses import dataclass
from pathlib import Path

from erk.artifacts.discovery import _compute_directory_hash, _compute_file_hash, _compute_hook_hash
from erk.artifacts.models import ArtifactFileState, ArtifactState
from erk.artifacts.paths import ErkPackageInfo, get_bundled_claude_dir, get_bundled_github_dir
from erk.artifacts.state import (
    load_artifact_state,
    load_installed_capabilities,
    save_artifact_state,
)
from erk.core.claude_settings import (
    ERK_EXIT_PLAN_HOOK_COMMAND,
    ERK_USER_PROMPT_HOOK_COMMAND,
    add_erk_hooks,
    get_repo_claude_settings_path,
    has_exit_plan_hook,
    has_user_prompt_hook,
)
from erk.core.init_utils import REQUIRED_GITIGNORE_ENTRIES, add_gitignore_entry
from erk_shared.context.types import AgentBackend


@dataclass(frozen=True)
class OrphanedPath:
    """Filesystem path for an orphaned state.toml key."""

    path: Path
    is_directory: bool


@dataclass(frozen=True)
class SyncResult:
    """Result of artifact sync operation."""

    success: bool
    artifacts_installed: int
    orphans: tuple[OrphanedPath, ...]
    message: str


@dataclass(frozen=True)
class ArtifactSyncConfig:
    """Configuration for artifact sync - enables testing without mocks."""

    package: ErkPackageInfo
    installed_capabilities: frozenset[str]
    sync_capabilities: bool  # False in tests to avoid capability install overwriting test fixtures
    backend: AgentBackend


def create_artifact_sync_config(
    project_dir: Path,
    *,
    backend: AgentBackend,
) -> ArtifactSyncConfig:
    """Create config with real values for production use."""
    return ArtifactSyncConfig(
        package=ErkPackageInfo.from_project_dir(project_dir),
        installed_capabilities=load_installed_capabilities(project_dir),
        sync_capabilities=True,
        backend=backend,
    )


def _copy_directory_contents(source_dir: Path, target_dir: Path) -> int:
    """Copy directory contents recursively, returning count of files copied."""
    if not source_dir.exists():
        return 0

    count = 0
    for source_path in source_dir.rglob("*"):
        if source_path.is_file():
            relative = source_path.relative_to(source_dir)
            target_path = target_dir / relative
            target_path.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(source_path, target_path)
            count += 1
    return count


@dataclass(frozen=True)
class SyncedArtifact:
    """Represents an artifact that was synced, with its computed hash."""

    key: str  # e.g. "skills/dignified-python", "commands/erk/system/impl-execute.md"
    hash: str
    file_count: int


def _key_to_orphaned_path(key: str, project_dir: Path) -> OrphanedPath | None:
    """Map a state.toml key to the filesystem path it represents.

    Returns None for keys that should never be auto-removed (hooks).
    """
    # hooks/ keys should never be auto-removed
    if key.startswith("hooks/"):
        return None

    claude_dir = project_dir / ".claude"

    # skills/X -> .claude/skills/X/ (directory)
    if key.startswith("skills/"):
        name = key.removeprefix("skills/")
        return OrphanedPath(path=claude_dir / "skills" / name, is_directory=True)

    # agents/X.md -> .claude/agents/X.md (file)
    # agents/X -> .claude/agents/X/ (directory)
    if key.startswith("agents/"):
        suffix = key.removeprefix("agents/")
        if suffix.endswith(".md"):
            return OrphanedPath(path=claude_dir / "agents" / suffix, is_directory=False)
        return OrphanedPath(path=claude_dir / "agents" / suffix, is_directory=True)

    # commands/erk/X -> .claude/commands/erk/X (file)
    if key.startswith("commands/erk/"):
        relative = key.removeprefix("commands/erk/")
        path = claude_dir / "commands" / "erk" / relative
        return OrphanedPath(path=path, is_directory=False)

    # workflows/X -> .github/workflows/X (file)
    if key.startswith("workflows/"):
        filename = key.removeprefix("workflows/")
        path = project_dir / ".github" / "workflows" / filename
        return OrphanedPath(path=path, is_directory=False)

    # actions/X -> .github/actions/X/ (directory)
    if key.startswith("actions/"):
        name = key.removeprefix("actions/")
        return OrphanedPath(path=project_dir / ".github" / "actions" / name, is_directory=True)

    # reviews/X -> .erk/reviews/X (file)
    if key.startswith("reviews/"):
        filename = key.removeprefix("reviews/")
        return OrphanedPath(path=project_dir / ".erk" / "reviews" / filename, is_directory=False)

    return None


def find_orphaned_artifact_paths(
    project_dir: Path,
    *,
    old_keys: frozenset[str],
    new_keys: frozenset[str],
) -> list[OrphanedPath]:
    """Find artifacts that were in old state but not in new state.

    Uses state.toml as the ownership ledger: keys present in old state but
    absent from new synced state are orphaned erk artifacts safe to remove.

    Returns list of orphaned paths that exist on disk (read-only, no deletion).
    """
    orphaned_keys = old_keys - new_keys
    result: list[OrphanedPath] = []

    for key in sorted(orphaned_keys):
        orphan = _key_to_orphaned_path(key, project_dir)
        if orphan is None:
            continue

        if not orphan.path.exists():
            continue

        result.append(orphan)

    return result


def delete_orphaned_artifacts(orphans: list[OrphanedPath]) -> int:
    """Delete orphaned artifacts from disk.

    Returns count of artifacts actually removed.
    """
    removed = 0

    for orphan in orphans:
        if not orphan.path.exists():
            continue

        if orphan.is_directory:
            shutil.rmtree(orphan.path)
        else:
            orphan.path.unlink()
            # Clean up empty parent directory
            parent = orphan.path.parent
            if parent.exists() and not any(parent.iterdir()):
                parent.rmdir()

        removed += 1

    return removed


def _sync_directory_artifacts(
    source_dir: Path, target_dir: Path, names: frozenset[str], key_prefix: str
) -> tuple[int, list[SyncedArtifact]]:
    """Sync directory-based artifacts (skills) to project.

    Args:
        source_dir: Parent directory containing source artifacts (e.g., bundled/skills/)
        target_dir: Parent directory for target artifacts (e.g., project/.claude/skills/)
        names: Set of artifact names to sync (e.g., BUNDLED_SKILLS)
        key_prefix: Prefix for artifact keys (e.g., "skills")

    Returns tuple of (file_count, synced_artifacts).
    """
    if not source_dir.exists():
        return 0, []

    copied = 0
    synced: list[SyncedArtifact] = []
    for name in sorted(names):
        source = source_dir / name
        if source.exists():
            target = target_dir / name
            count = _copy_directory_contents(source, target)
            copied += count
            synced.append(
                SyncedArtifact(
                    key=f"{key_prefix}/{name}",
                    hash=_compute_directory_hash(target),
                    file_count=count,
                )
            )
    return copied, synced


def _sync_agent_artifacts(
    source_dir: Path, target_dir: Path, names: frozenset[str]
) -> tuple[int, list[SyncedArtifact]]:
    """Sync agent artifacts to project (supports both directory-based and single-file).

    Key format depends on structure:
      - Directory: agents/{name} (like skills)
      - Single-file: agents/{name}.md (like commands)

    Returns tuple of (file_count, synced_artifacts).
    """
    if not source_dir.exists():
        return 0, []

    copied = 0
    synced: list[SyncedArtifact] = []
    for name in sorted(names):
        source_dir_path = source_dir / name
        source_file_path = source_dir / f"{name}.md"

        # Directory-based takes precedence, then single-file
        if source_dir_path.exists() and source_dir_path.is_dir():
            target = target_dir / name
            count = _copy_directory_contents(source_dir_path, target)
            copied += count
            synced.append(
                SyncedArtifact(
                    key=f"agents/{name}",
                    hash=_compute_directory_hash(target),
                    file_count=count,
                )
            )
        elif source_file_path.exists() and source_file_path.is_file():
            target_dir.mkdir(parents=True, exist_ok=True)
            target_file = target_dir / f"{name}.md"
            shutil.copy2(source_file_path, target_file)
            copied += 1
            synced.append(
                SyncedArtifact(
                    key=f"agents/{name}.md",
                    hash=_compute_file_hash(target_file),
                    file_count=1,
                )
            )
    return copied, synced


def _sync_commands(
    source_commands_dir: Path,
    target_commands_dir: Path,
    *,
    installed_capabilities: frozenset[str],
) -> tuple[int, list[SyncedArtifact]]:
    """Sync bundled commands to project. Only syncs erk namespace.

    Filters commands based on capability management: capability-managed commands
    are only synced if their capability is installed. Non-managed commands sync
    unconditionally.

    Returns tuple of (file_count, synced_artifacts).
    Each command is tracked individually.
    """
    # Inline import: artifact_health.py imports get_bundled_*_dir from this module
    from erk.artifacts.artifact_health import _get_bundled_by_type

    if not source_commands_dir.exists():
        return 0, []

    source = source_commands_dir / "erk"
    if not source.exists():
        return 0, []

    # Get commands that should be synced (includes required + installed capability commands)
    commands_to_sync = _get_bundled_by_type(
        "command", installed_capabilities=installed_capabilities
    )

    # Get ALL managed commands (to know which ones are capability-gated)
    all_managed_commands = _get_bundled_by_type("command", installed_capabilities=None)

    target = target_commands_dir / "erk"
    target.mkdir(parents=True, exist_ok=True)

    # Copy commands selectively based on capability filtering
    count = 0
    for source_path in source.rglob("*.md"):
        relative = source_path.relative_to(source)
        # Extract command name (e.g., "learn.md" -> "learn", "system/impl.md" -> "impl")
        command_name = source_path.stem

        # Determine if this command should be synced
        is_managed = command_name in all_managed_commands
        should_sync = command_name in commands_to_sync

        if is_managed and not should_sync:
            # This is a capability-managed command whose capability is not installed - skip it
            continue

        # Either non-managed (always sync) or managed with installed capability (sync)
        target_path = target / relative
        target_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source_path, target_path)
        count += 1

    # Track each command file individually (including nested directories)
    synced: list[SyncedArtifact] = []
    if target.exists():
        for cmd_file in target.rglob("*.md"):
            # Compute relative path (e.g., "system/impl-execute.md" or "plan-save.md")
            relative_path = cmd_file.relative_to(target)
            synced.append(
                SyncedArtifact(
                    key=f"commands/erk/{relative_path}",
                    hash=_compute_file_hash(cmd_file),
                    file_count=1,
                )
            )

    return count, synced


def _sync_workflows(
    bundled_github_dir: Path,
    target_workflows_dir: Path,
    *,
    installed_capabilities: frozenset[str],
) -> tuple[int, list[SyncedArtifact]]:
    """Sync erk-managed workflows to project's .github/workflows/ directory.

    Only syncs files listed in managed artifacts registry.
    Returns tuple of (file_count, synced_artifacts).
    """
    # Inline import: artifact_health.py imports get_bundled_*_dir from this module
    from erk.artifacts.artifact_health import _get_bundled_by_type

    source_workflows_dir = bundled_github_dir / "workflows"
    if not source_workflows_dir.exists():
        return 0, []

    count = 0
    synced: list[SyncedArtifact] = []
    for name in sorted(
        _get_bundled_by_type("workflow", installed_capabilities=installed_capabilities)
    ):
        workflow_name = f"{name}.yml"
        source_path = source_workflows_dir / workflow_name
        if source_path.exists():
            target_workflows_dir.mkdir(parents=True, exist_ok=True)
            target_path = target_workflows_dir / workflow_name
            shutil.copy2(source_path, target_path)
            count += 1
            synced.append(
                SyncedArtifact(
                    key=f"workflows/{workflow_name}",
                    hash=_compute_file_hash(target_path),
                    file_count=1,
                )
            )
    return count, synced


def _sync_actions(
    bundled_github_dir: Path,
    target_actions_dir: Path,
    *,
    installed_capabilities: frozenset[str],
) -> tuple[int, list[SyncedArtifact]]:
    """Sync erk-managed actions to project's .github/actions/ directory.

    Only syncs directories listed in managed artifacts registry.
    Returns tuple of (file_count, synced_artifacts).
    """
    # Inline import: artifact_health.py imports get_bundled_*_dir from this module
    from erk.artifacts.artifact_health import _get_bundled_by_type

    source_actions_dir = bundled_github_dir / "actions"
    if not source_actions_dir.exists():
        return 0, []

    count = 0
    synced: list[SyncedArtifact] = []
    for action_name in sorted(
        _get_bundled_by_type("action", installed_capabilities=installed_capabilities)
    ):
        source_path = source_actions_dir / action_name
        if source_path.exists() and source_path.is_dir():
            target_path = target_actions_dir / action_name
            file_count = _copy_directory_contents(source_path, target_path)
            count += file_count
            synced.append(
                SyncedArtifact(
                    key=f"actions/{action_name}",
                    hash=_compute_directory_hash(target_path),
                    file_count=file_count,
                )
            )
    return count, synced


def _sync_reviews(
    bundled_erk_dir: Path,
    target_reviews_dir: Path,
    *,
    installed_capabilities: frozenset[str],
) -> tuple[int, list[SyncedArtifact]]:
    """Sync erk-managed reviews to project's .erk/reviews/ directory.

    Reviews are bundled in .erk/reviews/ and installed to .erk/reviews/.
    Only syncs files listed in managed artifacts registry.
    Returns tuple of (file_count, synced_artifacts).
    """
    # Inline import: artifact_health.py imports get_bundled_*_dir from this module
    from erk.artifacts.artifact_health import _get_bundled_by_type

    source_reviews_dir = bundled_erk_dir / "reviews"
    if not source_reviews_dir.exists():
        return 0, []

    count = 0
    synced: list[SyncedArtifact] = []
    for review_name in sorted(
        _get_bundled_by_type("review", installed_capabilities=installed_capabilities)
    ):
        review_filename = f"{review_name}.md"
        source_path = source_reviews_dir / review_filename
        if source_path.exists() and source_path.is_file():
            target_reviews_dir.mkdir(parents=True, exist_ok=True)
            target_path = target_reviews_dir / review_filename
            shutil.copy2(source_path, target_path)
            count += 1
            synced.append(
                SyncedArtifact(
                    key=f"reviews/{review_filename}",
                    hash=_compute_file_hash(target_path),
                    file_count=1,
                )
            )
    return count, synced


def _hash_directory_artifacts(
    parent_dir: Path, names: frozenset[str], key_prefix: str
) -> list[SyncedArtifact]:
    """Compute hashes for directory-based artifacts without copying."""
    if not parent_dir.exists():
        return []

    artifacts: list[SyncedArtifact] = []
    for name in sorted(names):
        artifact_dir = parent_dir / name
        if artifact_dir.exists():
            artifacts.append(
                SyncedArtifact(
                    key=f"{key_prefix}/{name}",
                    hash=_compute_directory_hash(artifact_dir),
                    file_count=sum(1 for f in artifact_dir.rglob("*") if f.is_file()),
                )
            )
    return artifacts


def _hash_agent_artifacts(agents_dir: Path, names: frozenset[str]) -> list[SyncedArtifact]:
    """Compute hashes for agents (supports both directory-based and single-file).

    Key format depends on structure:
      - Directory: agents/{name} (like skills)
      - Single-file: agents/{name}.md (like commands)
    """
    if not agents_dir.exists():
        return []

    artifacts: list[SyncedArtifact] = []
    for name in sorted(names):
        dir_path = agents_dir / name
        file_path = agents_dir / f"{name}.md"

        # Directory-based takes precedence, then single-file
        if dir_path.exists() and dir_path.is_dir():
            artifacts.append(
                SyncedArtifact(
                    key=f"agents/{name}",
                    hash=_compute_directory_hash(dir_path),
                    file_count=sum(1 for f in dir_path.rglob("*") if f.is_file()),
                )
            )
        elif file_path.exists() and file_path.is_file():
            artifacts.append(
                SyncedArtifact(
                    key=f"agents/{name}.md",
                    hash=_compute_file_hash(file_path),
                    file_count=1,
                )
            )
    return artifacts


def _compute_source_artifact_state(
    project_dir: Path,
    *,
    package: ErkPackageInfo,
) -> list[SyncedArtifact]:
    """Compute artifact state from source (for erk repo dogfooding).

    Instead of copying files, just compute hashes from the source artifacts.
    """
    from erk.artifacts.artifact_health import _get_bundled_by_type

    artifacts: list[SyncedArtifact] = []

    # Hash directory-based skills
    skills_dir = package.bundled_claude_dir / "skills"
    skill_names = _get_bundled_by_type("skill", installed_capabilities=None)
    artifacts.extend(_hash_directory_artifacts(skills_dir, skill_names, "skills"))

    # Hash agents (supports both directory-based and single-file)
    agents_dir = package.bundled_claude_dir / "agents"
    agent_names = _get_bundled_by_type("agent", installed_capabilities=None)
    artifacts.extend(_hash_agent_artifacts(agents_dir, agent_names))

    # Hash commands from source (including nested directories)
    # In erk repo, installed_capabilities=None means hash all commands
    commands_dir = package.bundled_claude_dir / "commands" / "erk"
    if commands_dir.exists():
        for cmd_file in sorted(commands_dir.rglob("*.md")):
            # Compute relative path (e.g., "system/impl-execute.md" or "plan-save.md")
            relative_path = cmd_file.relative_to(commands_dir)
            artifacts.append(
                SyncedArtifact(
                    key=f"commands/erk/{relative_path}",
                    hash=_compute_file_hash(cmd_file),
                    file_count=1,
                )
            )

    # Hash workflows from source
    workflows_dir = package.bundled_github_dir / "workflows"
    if workflows_dir.exists():
        for name in sorted(_get_bundled_by_type("workflow", installed_capabilities=None)):
            workflow_name = f"{name}.yml"
            workflow_file = workflows_dir / workflow_name
            if workflow_file.exists():
                artifacts.append(
                    SyncedArtifact(
                        key=f"workflows/{workflow_name}",
                        hash=_compute_file_hash(workflow_file),
                        file_count=1,
                    )
                )

    # Hash actions from source
    actions_dir = package.bundled_github_dir / "actions"
    action_names = _get_bundled_by_type("action", installed_capabilities=None)
    artifacts.extend(_hash_directory_artifacts(actions_dir, action_names, "actions"))

    # Hash reviews from source (bundled in .erk/reviews/)
    reviews_dir = package.bundled_erk_dir / "reviews"
    if reviews_dir.exists():
        for review_name in sorted(_get_bundled_by_type("review", installed_capabilities=None)):
            review_filename = f"{review_name}.md"
            review_file = reviews_dir / review_filename
            if review_file.exists():
                artifacts.append(
                    SyncedArtifact(
                        key=f"reviews/{review_filename}",
                        hash=_compute_file_hash(review_file),
                        file_count=1,
                    )
                )

    # Hash hooks (check if installed in settings.json)
    settings_path = project_dir / ".claude" / "settings.json"
    if settings_path.exists():
        content = settings_path.read_text(encoding="utf-8")
        settings = json.loads(content)
        hook_checks = [
            ("hooks/user-prompt-hook", has_user_prompt_hook, ERK_USER_PROMPT_HOOK_COMMAND),
            ("hooks/exit-plan-mode-hook", has_exit_plan_hook, ERK_EXIT_PLAN_HOOK_COMMAND),
        ]
        for key, check_fn, command in hook_checks:
            if check_fn(settings):
                artifacts.append(
                    SyncedArtifact(key=key, hash=_compute_hook_hash(command), file_count=1)
                )

    return artifacts


def sync_dignified_review(project_dir: Path) -> SyncResult:
    """Sync dignified-review feature artifacts to project.

    Installs opt-in artifacts for the dignified-review workflow:
    - dignified-python skill (.claude/skills/dignified-python/)
    - dignified-python-review.yml workflow (.github/workflows/)
    - dignified-python-review.md prompt (.github/prompts/)

    Args:
        project_dir: Project root directory

    Returns:
        SyncResult indicating success/failure and count of files installed.
    """
    bundled_claude_dir = get_bundled_claude_dir()
    bundled_github_dir = get_bundled_github_dir()

    target_claude_dir = project_dir / ".claude"
    target_github_dir = project_dir / ".github"

    total_copied = 0

    # 1. Sync dignified-python skill
    skill_src = bundled_claude_dir / "skills" / "dignified-python"
    if skill_src.exists():
        skill_dst = target_claude_dir / "skills" / "dignified-python"
        count = _copy_directory_contents(skill_src, skill_dst)
        total_copied += count

    # 2. Sync dignified-python-review.yml workflow
    workflow_src = bundled_github_dir / "workflows" / "dignified-python-review.yml"
    if workflow_src.exists():
        workflow_dst = target_github_dir / "workflows" / "dignified-python-review.yml"
        workflow_dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(workflow_src, workflow_dst)
        total_copied += 1

    # 3. Sync dignified-python-review.md prompt
    prompt_src = bundled_github_dir / "prompts" / "dignified-python-review.md"
    if prompt_src.exists():
        prompt_dst = target_github_dir / "prompts" / "dignified-python-review.md"
        prompt_dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(prompt_src, prompt_dst)
        total_copied += 1

    return SyncResult(
        success=True,
        artifacts_installed=total_copied,
        orphans=(),
        message=f"Installed dignified-review ({total_copied} files)",
    )


def _sync_hooks(project_dir: Path) -> list[SyncedArtifact]:
    """Update EXISTING hooks in settings.json to current erk hook commands.

    Only updates hooks that are already installed (identified by ERK_HOOK_ID marker).
    Fresh hook installation is handled by HooksCapability during erk init.

    Only updates hooks if they already exist - initial installation is the
    responsibility of HooksCapability via the required capabilities system.

    Returns list of SyncedArtifact entries for state tracking.
    """
    # Inline import: breaks circular dependency with capabilities module
    from erk.capabilities.hooks import HooksCapability

    settings_path = get_repo_claude_settings_path(project_dir)
    if not settings_path.exists():
        return []

    # Only update if hooks already exist (by marker, not by current command version)
    hooks_cap = HooksCapability()
    if not hooks_cap.has_any_erk_hooks(project_dir):
        return []

    settings = json.loads(settings_path.read_text(encoding="utf-8"))

    updated_settings = add_erk_hooks(settings)

    # Only write if hooks actually changed
    if updated_settings != settings:
        settings_path.write_text(json.dumps(updated_settings, indent=2) + "\n", encoding="utf-8")

    # Return artifact entries for both hooks (always tracked regardless of changes)
    synced: list[SyncedArtifact] = []
    if has_user_prompt_hook(updated_settings):
        synced.append(
            SyncedArtifact(
                key="hooks/user-prompt-hook",
                hash=_compute_hook_hash(ERK_USER_PROMPT_HOOK_COMMAND),
                file_count=1,
            )
        )
    if has_exit_plan_hook(updated_settings):
        synced.append(
            SyncedArtifact(
                key="hooks/exit-plan-mode-hook",
                hash=_compute_hook_hash(ERK_EXIT_PLAN_HOOK_COMMAND),
                file_count=1,
            )
        )
    return synced


def find_missing_gitignore_entries(project_dir: Path) -> list[str]:
    """Find required gitignore entries that are missing.

    Args:
        project_dir: Project root directory

    Returns:
        List of missing gitignore entry strings.
    """
    gitignore_path = project_dir / ".gitignore"
    if not gitignore_path.exists():
        return []

    content = gitignore_path.read_text(encoding="utf-8")
    return [entry for entry in REQUIRED_GITIGNORE_ENTRIES if entry not in content]


def add_missing_gitignore_entries(project_dir: Path, entries: list[str]) -> None:
    """Add missing entries to the project's .gitignore file.

    Args:
        project_dir: Project root directory
        entries: List of entries to add
    """
    gitignore_path = project_dir / ".gitignore"
    if not gitignore_path.exists():
        return

    content = gitignore_path.read_text(encoding="utf-8")
    for entry in entries:
        content = add_gitignore_entry(content, entry)
    gitignore_path.write_text(content, encoding="utf-8")


def sync_artifacts(
    project_dir: Path,
    force: bool,
    *,
    config: ArtifactSyncConfig,
) -> SyncResult:
    """Sync artifacts from erk package to project's .claude/ and .github/ directories.

    When running in the erk repo itself, skips file copying but still computes
    and saves state for dogfooding.

    Args:
        project_dir: Target project directory
        force: Force sync even if up to date
        config: Configuration for artifact sync (use create_artifact_sync_config for production)
    """
    # Inline import: artifact_health.py imports get_bundled_*_dir from this module
    from erk.artifacts.artifact_health import _get_bundled_by_type

    # In erk repo: skip copying but still save state for dogfooding
    if config.package.in_erk_repo:
        all_synced = _compute_source_artifact_state(project_dir, package=config.package)
        files: dict[str, ArtifactFileState] = {}
        for artifact in all_synced:
            files[artifact.key] = ArtifactFileState(
                version=config.package.current_version,
                hash=artifact.hash,
            )
        save_artifact_state(
            project_dir,
            ArtifactState(version=config.package.current_version, files=files),
        )
        return SyncResult(
            success=True,
            artifacts_installed=0,
            orphans=(),
            message="Development mode: state.toml updated (artifacts read from source)",
        )

    if not config.package.bundled_claude_dir.exists():
        return SyncResult(
            success=False,
            artifacts_installed=0,
            orphans=(),
            message=f"Bundled .claude/ not found at {config.package.bundled_claude_dir}",
        )

    # Load old state before sync to detect orphaned artifacts
    old_state = load_artifact_state(project_dir)
    old_keys = frozenset(old_state.files.keys()) if old_state is not None else frozenset()

    target_claude_dir = project_dir / ".claude"
    target_claude_dir.mkdir(parents=True, exist_ok=True)

    total_copied = 0
    all_synced: list[SyncedArtifact] = []

    # Sync directory-based skills
    count, synced = _sync_directory_artifacts(
        config.package.bundled_claude_dir / "skills",
        target_claude_dir / "skills",
        _get_bundled_by_type("skill", installed_capabilities=config.installed_capabilities),
        "skills",
    )
    total_copied += count
    all_synced.extend(synced)

    # Sync agents (supports both directory-based and single-file)
    agent_names = _get_bundled_by_type(
        "agent", installed_capabilities=config.installed_capabilities
    )
    count, synced = _sync_agent_artifacts(
        config.package.bundled_claude_dir / "agents", target_claude_dir / "agents", agent_names
    )
    total_copied += count
    all_synced.extend(synced)

    count, synced = _sync_commands(
        config.package.bundled_claude_dir / "commands",
        target_claude_dir / "commands",
        installed_capabilities=config.installed_capabilities,
    )
    total_copied += count
    all_synced.extend(synced)

    # Sync workflows and actions from .github/
    if config.package.bundled_github_dir.exists():
        target_workflows_dir = project_dir / ".github" / "workflows"
        count, synced = _sync_workflows(
            config.package.bundled_github_dir,
            target_workflows_dir,
            installed_capabilities=config.installed_capabilities,
        )
        total_copied += count
        all_synced.extend(synced)

        target_actions_dir = project_dir / ".github" / "actions"
        count, synced = _sync_actions(
            config.package.bundled_github_dir,
            target_actions_dir,
            installed_capabilities=config.installed_capabilities,
        )
        total_copied += count
        all_synced.extend(synced)

    # Sync reviews (from bundled .erk/reviews/ to project .erk/reviews/)
    target_reviews_dir = project_dir / ".erk" / "reviews"
    count, synced = _sync_reviews(
        config.package.bundled_erk_dir,
        target_reviews_dir,
        installed_capabilities=config.installed_capabilities,
    )
    total_copied += count
    all_synced.extend(synced)

    # Sync hooks in settings.json - update to current erk hook commands
    synced_hooks = _sync_hooks(project_dir)
    all_synced.extend(synced_hooks)

    # Sync installed capabilities - for each project-scoped capability that is
    # already installed, call install() to ensure it's up-to-date. Since install()
    # is idempotent, this safely updates existing capabilities.
    # Skip when sync_capabilities=False (test mode) - capabilities use
    # get_bundled_*_dir() directly and would overwrite test fixtures.
    if config.sync_capabilities:
        from erk.core.capabilities.registry import list_capabilities

        backend = config.backend
        for cap in list_capabilities():
            if cap.scope == "project" and cap.is_installed(project_dir, backend=backend):
                cap.install(project_dir, backend=backend)

    # Build per-artifact state from synced artifacts
    files: dict[str, ArtifactFileState] = {}
    for artifact in all_synced:
        files[artifact.key] = ArtifactFileState(
            version=config.package.current_version,
            hash=artifact.hash,
        )

    # Find orphaned artifacts: keys in old state but not in new synced state
    new_keys = frozenset(files.keys())
    orphans = find_orphaned_artifact_paths(project_dir, old_keys=old_keys, new_keys=new_keys)

    # Save state with current version and per-artifact state
    save_artifact_state(
        project_dir, ArtifactState(version=config.package.current_version, files=files)
    )

    return SyncResult(
        success=True,
        artifacts_installed=total_copied,
        orphans=tuple(orphans),
        message=f"Synced {total_copied} artifact files",
    )
