"""Shared infrastructure for the ``vcspull import`` subcommand tree.

Provides parent argparse parsers (for flag composition via ``parents=[]``)
and the ``_run_import()`` function that all per-service handlers delegate to.
"""

from __future__ import annotations

import argparse
import copy
import enum
import logging
import pathlib
import sys
import typing as t

from vcspull._internal.config_reader import (
    DuplicateAwareConfigReader,
    config_format_from_path,
)
from vcspull._internal.private_path import PrivatePath
from vcspull._internal.remotes import (
    AuthenticationError,
    ConfigurationError,
    ImportMode,
    ImportOptions,
    NotFoundError,
    RateLimitError,
    RemoteImportError,
    RemoteRepo,
    ServiceUnavailableError,
)
from vcspull.config import (
    canonicalize_workspace_path,
    find_home_config_files,
    get_pin_reason,
    is_pinned_for_op,
    merge_duplicate_workspace_roots,
    normalize_config_file_path,
    save_config,
    workspace_root_label,
)
from vcspull.exc import MultipleConfigWarning

from .._colors import Colors, get_color_mode
from .._output import OutputFormatter, get_output_mode

log = logging.getLogger(__name__)


class Importer(t.Protocol):
    """Structural type for any remote service importer."""

    service_name: str

    def fetch_repos(self, options: ImportOptions) -> t.Iterator[RemoteRepo]:
        """Yield repositories matching *options*."""
        ...


class ImportAction(enum.Enum):
    """Action resolved for each repo candidate during ``vcspull import``."""

    ADD = "add"
    SKIP_UNCHANGED = "skip_unchanged"
    SKIP_EXISTING = "skip_existing"
    UPDATE_URL = "update_url"
    SKIP_PINNED = "skip_pinned"
    PRUNE = "prune"


def _classify_import_action(
    *,
    incoming_url: str,
    existing_entry: t.Any,
    sync: bool,
) -> ImportAction:
    """Classify the import action for a single repository.

    Parameters
    ----------
    incoming_url : str
        URL from the remote service (already formatted with ``git+`` prefix).
    existing_entry : Any
        Current config entry for this repo name, or ``None`` if not in config.
    sync : bool
        Whether ``--sync`` was passed.

    Examples
    --------
    New repo is always added:

    >>> _classify_import_action(
    ...     incoming_url="git+ssh://x", existing_entry=None, sync=False
    ... )
    <ImportAction.ADD: 'add'>
    >>> _classify_import_action(
    ...     incoming_url="git+ssh://x", existing_entry=None, sync=True
    ... )
    <ImportAction.ADD: 'add'>

    Same URL is always classified as unchanged — even when pinned or sync is set.

    Note: ``_run_import`` may still stamp provenance metadata on unchanged entries
    when ``import_source`` is provided.

    >>> _classify_import_action(
    ...     incoming_url="git+ssh://x",
    ...     existing_entry={"repo": "git+ssh://x"},
    ...     sync=True,
    ... )
    <ImportAction.SKIP_UNCHANGED: 'skip_unchanged'>
    >>> _classify_import_action(
    ...     incoming_url="git+ssh://x",
    ...     existing_entry={"repo": "git+ssh://x", "options": {"pin": True}},
    ...     sync=True,
    ... )
    <ImportAction.SKIP_UNCHANGED: 'skip_unchanged'>

    ``url`` key takes precedence over ``repo`` key (matches extract_repos semantics):

    >>> _classify_import_action(
    ...     incoming_url="git+ssh://new",
    ...     existing_entry={"repo": "git+ssh://old", "url": "git+ssh://new"},
    ...     sync=False,
    ... )
    <ImportAction.SKIP_UNCHANGED: 'skip_unchanged'>

    Different URL without sync is skipped:

    >>> _classify_import_action(
    ...     incoming_url="git+ssh://x",
    ...     existing_entry={"repo": "git+https://x"},
    ...     sync=False,
    ... )
    <ImportAction.SKIP_EXISTING: 'skip_existing'>

    Different URL with sync updates the URL (unless pinned):

    >>> _classify_import_action(
    ...     incoming_url="git+ssh://x",
    ...     existing_entry={"repo": "git+https://x"},
    ...     sync=True,
    ... )
    <ImportAction.UPDATE_URL: 'update_url'>

    URL update is blocked by pin (only when URL differs):

    >>> _classify_import_action(
    ...     incoming_url="git+ssh://x",
    ...     existing_entry={"repo": "git+https://x", "options": {"pin": True}},
    ...     sync=True,
    ... )
    <ImportAction.SKIP_PINNED: 'skip_pinned'>

    Non-dict, non-str entries fall back to empty URL (treated as different):

    >>> _classify_import_action(
    ...     incoming_url="git+ssh://x", existing_entry=42, sync=False
    ... )
    <ImportAction.SKIP_EXISTING: 'skip_existing'>
    """
    if existing_entry is None:
        return ImportAction.ADD
    # url-first resolution (matches extract_repos() semantics)
    if isinstance(existing_entry, str):
        existing_url = existing_entry
    elif isinstance(existing_entry, dict):
        existing_url = existing_entry.get("url") or existing_entry.get("repo") or ""
    else:
        existing_url = ""
    # SKIP_UNCHANGED fires before pin check: nothing to update when URL matches
    if existing_url == incoming_url:
        return ImportAction.SKIP_UNCHANGED
    if sync and is_pinned_for_op(existing_entry, "import"):
        return ImportAction.SKIP_PINNED
    if sync:
        return ImportAction.UPDATE_URL
    return ImportAction.SKIP_EXISTING


def _classify_prune_action(
    *,
    existing_entry: dict[str, t.Any] | str | t.Any,
    imported_from_tag: str,
) -> ImportAction:
    """Classify whether an existing config entry should be pruned.

    Parameters
    ----------
    existing_entry : dict | str | Any
        Current config entry for this repo name.
    imported_from_tag : str
        The provenance tag to match against, e.g. ``"github:myorg"``.

    Examples
    --------
    String entries (no metadata) are never pruned:

    >>> _classify_prune_action(
    ...     existing_entry="git+ssh://x", imported_from_tag="github:org"
    ... )
    <ImportAction.SKIP_EXISTING: 'skip_existing'>

    Entry with matching tag is pruned:

    >>> _classify_prune_action(
    ...     existing_entry={
    ...         "repo": "git+ssh://x",
    ...         "metadata": {"imported_from": "github:org"},
    ...     },
    ...     imported_from_tag="github:org",
    ... )
    <ImportAction.PRUNE: 'prune'>

    Entry with different tag is not pruned:

    >>> _classify_prune_action(
    ...     existing_entry={
    ...         "repo": "git+ssh://x",
    ...         "metadata": {"imported_from": "github:other"},
    ...     },
    ...     imported_from_tag="github:org",
    ... )
    <ImportAction.SKIP_EXISTING: 'skip_existing'>

    Pinned entry with matching tag is not pruned:

    >>> _classify_prune_action(
    ...     existing_entry={
    ...         "repo": "git+ssh://x",
    ...         "options": {"pin": True},
    ...         "metadata": {"imported_from": "github:org"},
    ...     },
    ...     imported_from_tag="github:org",
    ... )
    <ImportAction.SKIP_PINNED: 'skip_pinned'>

    Entry without metadata is not pruned:

    >>> _classify_prune_action(
    ...     existing_entry={"repo": "git+ssh://x"},
    ...     imported_from_tag="github:org",
    ... )
    <ImportAction.SKIP_EXISTING: 'skip_existing'>
    """
    if not isinstance(existing_entry, dict):
        return ImportAction.SKIP_EXISTING
    metadata = existing_entry.get("metadata")
    if not isinstance(metadata, dict):
        return ImportAction.SKIP_EXISTING
    if metadata.get("imported_from") != imported_from_tag:
        return ImportAction.SKIP_EXISTING
    if is_pinned_for_op(existing_entry, "import"):
        return ImportAction.SKIP_PINNED
    return ImportAction.PRUNE


def _classify_untracked_prune_action(
    *,
    existing_entry: dict[str, t.Any] | str | t.Any,
) -> ImportAction:
    """Classify whether an untracked config entry should be pruned.

    "Untracked" means the entry has no import provenance from *any* source.
    This is the complement of :func:`_classify_prune_action`, which checks
    for a *specific* source tag.

    Parameters
    ----------
    existing_entry : dict | str | Any
        Current config entry for this repo name.

    Returns
    -------
    ImportAction
        ``PRUNE`` for entries without provenance (untracked),
        ``SKIP_PINNED`` for pinned entries,
        ``SKIP_EXISTING`` for entries that have provenance from any source.

    Examples
    --------
    String entries have no metadata — they are untracked:

    >>> _classify_untracked_prune_action(existing_entry="git+ssh://x")
    <ImportAction.PRUNE: 'prune'>

    Dict entry without metadata is untracked:

    >>> _classify_untracked_prune_action(
    ...     existing_entry={"repo": "git+ssh://x"},
    ... )
    <ImportAction.PRUNE: 'prune'>

    Dict entry with provenance from any source is tracked (kept):

    >>> _classify_untracked_prune_action(
    ...     existing_entry={
    ...         "repo": "git+ssh://x",
    ...         "metadata": {"imported_from": "github:org"},
    ...     },
    ... )
    <ImportAction.SKIP_EXISTING: 'skip_existing'>

    >>> _classify_untracked_prune_action(
    ...     existing_entry={
    ...         "repo": "git+ssh://x",
    ...         "metadata": {"imported_from": "gitlab:other"},
    ...     },
    ... )
    <ImportAction.SKIP_EXISTING: 'skip_existing'>

    Pinned entry without provenance is protected:

    >>> _classify_untracked_prune_action(
    ...     existing_entry={
    ...         "repo": "git+ssh://x",
    ...         "options": {"pin": True},
    ...     },
    ... )
    <ImportAction.SKIP_PINNED: 'skip_pinned'>

    Pinned entry with provenance is also protected:

    >>> _classify_untracked_prune_action(
    ...     existing_entry={
    ...         "repo": "git+ssh://x",
    ...         "options": {"pin": True},
    ...         "metadata": {"imported_from": "github:org"},
    ...     },
    ... )
    <ImportAction.SKIP_PINNED: 'skip_pinned'>
    """
    if not isinstance(existing_entry, dict):
        return ImportAction.PRUNE
    if is_pinned_for_op(existing_entry, "import"):
        return ImportAction.SKIP_PINNED
    metadata = existing_entry.get("metadata")
    if isinstance(metadata, dict) and metadata.get("imported_from"):
        return ImportAction.SKIP_EXISTING
    return ImportAction.PRUNE


# ---------------------------------------------------------------------------
# Parent parser factories
# ---------------------------------------------------------------------------


def _create_shared_parent() -> argparse.ArgumentParser:
    """Create parent parser with workspace, filtering, and output flags.

    Returns
    -------
    argparse.ArgumentParser
        Parent parser (``add_help=False``) carrying flags shared by all
        import service subcommands.
    """
    parent = argparse.ArgumentParser(add_help=False)
    parent.add_argument(
        "-w",
        "--workspace",
        dest="workspace",
        metavar="DIR",
        default=None,
        help="Workspace root directory (REQUIRED)",
    )

    # Filtering options
    filter_group = parent.add_argument_group("filtering")
    filter_group.add_argument(
        "-l",
        "--language",
        dest="language",
        metavar="LANG",
        help="Filter by programming language",
    )
    filter_group.add_argument(
        "--topics",
        dest="topics",
        metavar="TOPICS",
        help="Filter by topics (comma-separated)",
    )
    filter_group.add_argument(
        "--min-stars",
        dest="min_stars",
        type=int,
        default=0,
        metavar="N",
        help="Minimum stars (for search mode)",
    )
    filter_group.add_argument(
        "--archived",
        dest="include_archived",
        action="store_true",
        help="Include archived repositories",
    )
    filter_group.add_argument(
        "--forks",
        dest="include_forks",
        action="store_true",
        help="Include forked repositories",
    )
    filter_group.add_argument(
        "--limit",
        dest="limit",
        type=int,
        default=100,
        metavar="N",
        help="Maximum repositories to fetch (default: 100, 0 = no limit)",
    )

    # Output options
    output_group = parent.add_argument_group("output")
    output_group.add_argument(
        "-f",
        "--file",
        dest="config",
        metavar="FILE",
        help="Config file to write to (default: ~/.vcspull.yaml)",
    )
    output_group.add_argument(
        "--dry-run",
        "-n",
        action="store_true",
        help="Preview without writing to config file",
    )
    output_group.add_argument(
        "--yes",
        "-y",
        action="store_true",
        help="Skip confirmation prompt",
    )
    output_group.add_argument(
        "--json",
        action="store_true",
        dest="output_json",
        help="Output as JSON",
    )
    output_group.add_argument(
        "--ndjson",
        action="store_true",
        dest="output_ndjson",
        help="Output as NDJSON (one JSON per line)",
    )
    output_group.add_argument(
        "--https",
        action="store_true",
        dest="use_https",
        help="Use HTTPS clone URLs instead of SSH (default: SSH)",
    )
    output_group.add_argument(
        "--sync",
        dest="sync",
        action="store_true",
        help=(
            "Sync config with remote: update URLs for existing entries whose "
            "URL has changed, and remove entries no longer on the remote. "
            "Preserves all metadata (options, remotes, shell_command_after). "
            "Respects pinned entries (options.pin.import)."
        ),
    )
    output_group.add_argument(
        "--prune",
        dest="prune",
        action="store_true",
        help=(
            "Remove config entries tagged by a previous import that are "
            "no longer on the remote. Does not update URLs for existing "
            "entries (use --sync for that). Implied by --sync. "
            "Respects pinned entries."
        ),
    )
    output_group.add_argument(
        "--prune-untracked",
        dest="prune_untracked",
        action="store_true",
        help=(
            "With --sync or --prune, also remove config entries in the "
            "target workspace that lack import provenance (e.g. manually "
            "added repos). Entries imported from other sources and pinned "
            "entries are preserved. Requires --sync or --prune."
        ),
    )
    output_group.add_argument(
        "--color",
        choices=["auto", "always", "never"],
        default="auto",
        help="When to use colors (default: auto)",
    )
    return parent


def _create_token_parent() -> argparse.ArgumentParser:
    """Create parent parser with the ``--token`` flag.

    Returns
    -------
    argparse.ArgumentParser
        Parent parser carrying ``--token``.
    """
    parent = argparse.ArgumentParser(add_help=False)
    parent.add_argument(
        "--token",
        dest="token",
        metavar="TOKEN",
        help="API token (overrides env var; prefer env var for security)",
    )
    return parent


def _create_mode_parent() -> argparse.ArgumentParser:
    """Create parent parser with the ``-m/--mode`` flag.

    Returns
    -------
    argparse.ArgumentParser
        Parent parser carrying ``-m/--mode``.
    """
    parent = argparse.ArgumentParser(add_help=False)
    parent.add_argument(
        "-m",
        "--mode",
        dest="mode",
        choices=["user", "org", "search"],
        default="user",
        help="Import mode: user (default), org, or search",
    )
    return parent


def _create_target_parent() -> argparse.ArgumentParser:
    """Create parent parser with the required ``target`` positional.

    Returns
    -------
    argparse.ArgumentParser
        Parent parser carrying the ``target`` positional argument.
    """
    parent = argparse.ArgumentParser(add_help=False)
    parent.add_argument(
        "target",
        metavar="TARGET",
        help=(
            "User, org name, or search query. "
            "For GitLab, supports subgroups with slash notation (e.g., parent/child)."
        ),
    )
    return parent


# ---------------------------------------------------------------------------
# Config resolution
# ---------------------------------------------------------------------------


def _resolve_config_file(config_path_str: str | None) -> pathlib.Path:
    """Resolve the config file path.

    Parameters
    ----------
    config_path_str : str | None
        Config file path from user, or None for default

    Returns
    -------
    pathlib.Path
        Absolute config file path
    """
    if config_path_str:
        path = normalize_config_file_path(pathlib.Path(config_path_str))
        if config_format_from_path(path) is None:
            msg = f"Unsupported config file type: {path.suffix}"
            raise ValueError(msg)
        return path

    home_configs = find_home_config_files(filetype=["yaml", "json"])
    if home_configs:
        return home_configs[0]

    return pathlib.Path.home() / ".vcspull.yaml"


# ---------------------------------------------------------------------------
# Core import logic
# ---------------------------------------------------------------------------


def _run_import(
    importer: Importer,
    *,
    service_name: str,
    target: str,
    workspace: str,
    mode: str,
    language: str | None,
    topics: str | None,
    min_stars: int,
    include_archived: bool,
    include_forks: bool,
    limit: int,
    config_path_str: str | None,
    dry_run: bool,
    yes: bool,
    output_json: bool,
    output_ndjson: bool,
    color: str,
    use_https: bool = False,
    flatten_groups: bool = False,
    with_shared: bool = False,
    skip_groups: list[str] | None = None,
    sync: bool = False,
    prune: bool = False,
    prune_untracked: bool = False,
    import_source: str | None = None,
) -> int:
    """Run the import workflow for a single service.

    This is the core fetch / preview / confirm / write logic shared by every
    per-service handler.  The caller is responsible for constructing the
    *importer* instance; this function only orchestrates the import flow.

    Parameters
    ----------
    importer : Importer
        Already-constructed importer instance (any object satisfying
        the :class:`Importer` protocol)
    service_name : str
        Canonical service name (e.g. ``"github"``, ``"gitlab"``, ``"codecommit"``)
    target : str
        User, org, or search query
    workspace : str
        Workspace root directory
    mode : str
        Import mode (user, org, search)
    language : str | None
        Language filter
    topics : str | None
        Topics filter (comma-separated)
    min_stars : int
        Minimum stars filter
    include_archived : bool
        Include archived repositories
    include_forks : bool
        Include forked repositories
    limit : int
        Maximum repositories to fetch
    config_path_str : str | None
        Config file path
    dry_run : bool
        Preview without writing
    yes : bool
        Skip confirmation
    output_json : bool
        Output as JSON
    output_ndjson : bool
        Output as NDJSON
    color : str
        Color mode
    use_https : bool
        Use HTTPS clone URLs instead of SSH (default: False, i.e., SSH)
    flatten_groups : bool
        For GitLab org imports, flatten subgroup paths into base workspace
    with_shared : bool
        Include projects shared into a group from other namespaces (GitLab only)
    skip_groups : list[str] | None
        Exclude repos whose owner path contains any of these group name segments
    sync : bool
        Sync existing config entries whose URL has changed
        (default: False).  Entries with ``options.pin.import`` or
        ``options.allow_overwrite: false`` are exempt.
    prune : bool
        Remove config entries tagged by a previous import that are
        no longer on the remote (default: False).  Does not update
        URLs for existing entries; use ``sync`` for that.
    import_source : str | None
        Provenance tag for imported repos, e.g. ``"github:myorg"``.
        When provided, repos are tagged with ``metadata.imported_from``
        and stale tagged entries are pruned when ``sync`` is True.

    Returns
    -------
    int
        0 on success, 1 on error
    """
    output_mode = get_output_mode(output_json, output_ndjson)
    formatter = OutputFormatter(output_mode)
    colors = Colors(get_color_mode(color))

    if prune_untracked and not (sync or prune):
        log.error(
            "%s --prune-untracked requires --sync or --prune",
            colors.error("✗"),
        )
        return 1

    # Build import options
    import_mode = ImportMode(mode)
    topic_list = (
        [topic.strip() for topic in topics.split(",") if topic.strip()]
        if topics
        else []
    )

    skip_groups_list: list[str] = skip_groups or []

    # Validate skip_groups: each value must be a single path segment.
    # A slash inside a value like "bots/subteam" would never match any
    # owner segment (filter_repo splits repo.owner on "/" and compares
    # individual parts), so it silently has no effect.
    for group in skip_groups_list:
        if "/" in group:
            log.error(
                "--skip-group values must be single path segments; "
                "'/' is not allowed: %r",
                group,
            )
            return 1

    try:
        options = ImportOptions(
            mode=import_mode,
            target=target,
            include_forks=include_forks,
            include_archived=include_archived,
            language=language,
            topics=topic_list,
            min_stars=min_stars,
            limit=limit,
            with_shared=with_shared,
            skip_groups=skip_groups_list,
        )
    except ValueError as exc_:
        log.error("%s %s", colors.error("✗"), exc_)  # noqa: TRY400
        return 1

    # Warn if --language is used with services that don't return language info
    if options.language and service_name in ("gitlab", "codecommit"):
        log.warning(
            "%s %s does not return language metadata; "
            "--language filter may exclude all results",
            colors.warning("!"),
            importer.service_name,
        )
    if options.topics and service_name == "codecommit":
        log.warning(
            "%s %s does not support topic filtering; "
            "--topics filter may exclude all results",
            colors.warning("!"),
            importer.service_name,
        )
    if options.min_stars > 0 and service_name == "codecommit":
        log.warning(
            "%s %s does not track star counts; "
            "--min-stars filter may exclude all results",
            colors.warning("!"),
            importer.service_name,
        )
    if (
        options.with_shared
        and service_name == "gitlab"
        and options.mode != ImportMode.ORG
    ):
        log.warning(
            "%s --with-shared has no effect outside org mode; "
            "shared projects are only available via the GitLab group API",
            colors.warning("!"),
        )

    # Resolve workspace path
    workspace_path = pathlib.Path(workspace).expanduser().resolve()
    cwd = pathlib.Path.cwd()
    home = pathlib.Path.home()

    # Resolve config file
    try:
        config_file_path = _resolve_config_file(config_path_str)
    except (ValueError, MultipleConfigWarning) as exc_:
        log.error("%s %s", colors.error("✗"), exc_)  # noqa: TRY400
        return 1
    display_config_path = str(PrivatePath(config_file_path))

    # Fetch repositories
    if output_mode.value == "human":
        log.info(
            "%s Fetching repositories from %s...",
            colors.info("→"),
            colors.highlight(importer.service_name),
        )

    repos: list[RemoteRepo] = []
    try:
        for repo in importer.fetch_repos(options):
            repos.append(repo)

            # Emit for JSON/NDJSON output
            formatter.emit(repo.to_dict())

            # Log progress for human output
            if output_mode.value == "human" and len(repos) % 10 == 0:
                log.info(
                    "%s Fetched %s repositories...",
                    colors.muted("•"),
                    colors.info(str(len(repos))),
                )

    except AuthenticationError as exc:
        log.error(  # noqa: TRY400
            "%s Authentication error: %s", colors.error("✗"), exc
        )
        formatter.finalize()
        return 1
    except RateLimitError as exc:
        log.error(  # noqa: TRY400
            "%s Rate limit exceeded: %s", colors.error("✗"), exc
        )
        formatter.finalize()
        return 1
    except NotFoundError as exc:
        log.error("%s Not found: %s", colors.error("✗"), exc)  # noqa: TRY400
        formatter.finalize()
        return 1
    except ServiceUnavailableError as exc:
        log.error(  # noqa: TRY400
            "%s Service unavailable: %s", colors.error("✗"), exc
        )
        formatter.finalize()
        return 1
    except ConfigurationError as exc:
        log.error(  # noqa: TRY400
            "%s Configuration error: %s", colors.error("✗"), exc
        )
        formatter.finalize()
        return 1
    except RemoteImportError as exc:
        log.error("%s Error: %s", colors.error("✗"), exc)  # noqa: TRY400
        formatter.finalize()
        return 1

    if not repos:
        if output_mode.value == "human":
            log.info(
                "%s No repositories found matching criteria.",
                colors.warning("!"),
            )
        formatter.finalize()
        return 0

    if output_mode.value == "human":
        log.info(
            "\n%s Found %s repositories",
            colors.success("✓"),
            colors.info(str(len(repos))),
        )

    # Show preview in human mode
    if output_mode.value == "human":
        for repo in repos[:10]:  # Show first 10
            stars_str = f" ★{repo.stars}" if repo.stars > 0 else ""
            lang_str = f" [{repo.language}]" if repo.language else ""
            log.info(
                "  %s %s%s%s",
                colors.success("+"),
                colors.info(repo.name),
                colors.muted(lang_str),
                colors.muted(stars_str),
            )
        if len(repos) > 10:
            log.info(
                "  %s and %s more",
                colors.muted("..."),
                colors.info(str(len(repos) - 10)),
            )

    formatter.finalize()

    # Confirm with user
    if not dry_run and not yes and output_mode.value == "human":
        if not sys.stdin.isatty():
            log.info(
                "%s Non-interactive mode: use --yes to skip confirmation.",
                colors.error("✗"),
            )
            return 1
        try:
            confirm = input(
                f"\n{colors.info('Import')} {len(repos)} repositories to "
                f"{display_config_path}? [y/N]: ",
            ).lower()
        except EOFError:
            confirm = ""
        if confirm not in {"y", "yes"}:
            log.info("%s Aborted by user.", colors.error("✗"))
            return 0

    # Load existing config or create new
    raw_config: dict[str, t.Any]
    if config_file_path.exists():
        try:
            raw_config, duplicate_roots, _top_level_items = (
                DuplicateAwareConfigReader.load_with_duplicates(config_file_path)
            )
            raw_config = raw_config or {}
        except TypeError:
            if dry_run:
                log.warning(
                    "%s Config file is not a valid mapping: %s "
                    "(dry-run will preview against empty config)",
                    colors.warning("!"),
                    display_config_path,
                )
                raw_config = {}
                duplicate_roots = {}
            else:
                log.error(  # noqa: TRY400
                    "%s Config file is not a valid mapping: %s",
                    colors.error("✗"),
                    display_config_path,
                )
                return 1
        except Exception:
            if dry_run:
                log.warning(
                    "Error loading config file %s "
                    "(dry-run will preview against empty config)",
                    display_config_path,
                )
                raw_config = {}
                duplicate_roots = {}
            else:
                log.exception("Error loading config file")
                return 1

        if not isinstance(raw_config, dict):
            if dry_run:
                log.warning(
                    "%s Config file is not a valid mapping: %s "
                    "(dry-run will preview against empty config)",
                    colors.warning("!"),
                    display_config_path,
                )
                raw_config = {}
                duplicate_roots = {}
            else:
                log.error(
                    "%s Config file is not a valid mapping: %s",
                    colors.error("✗"),
                    display_config_path,
                )
                return 1

        # Merge duplicate workspace root sections so repos from all
        # occurrences are preserved (PyYAML only keeps the last key).
        if duplicate_roots:
            raw_config, _conflicts, _changes, _details = (
                merge_duplicate_workspace_roots(raw_config, duplicate_roots)
            )
    else:
        raw_config = {}

    # Add repositories to config
    checked_labels: set[str] = set()
    error_labels: set[str] = set()
    added_count = updated_url_count = 0
    skip_existing_count = skip_pinned_count = skip_unchanged_count = 0
    provenance_tagged_count = 0
    imported_workspace_repos: set[tuple[str, str]] = set()

    for repo in repos:
        # Determine workspace for this repo
        repo_workspace_path = workspace_path

        preserve_group_structure = (
            service_name == "gitlab"
            and options.mode == ImportMode.ORG
            and not flatten_groups
        )
        if preserve_group_structure and repo.owner.startswith(options.target):
            # Check if it is a subdirectory
            if repo.owner == options.target:
                subpath = ""
            elif repo.owner.startswith(options.target + "/"):
                subpath = repo.owner[len(options.target) + 1 :]
            else:
                subpath = ""

            if subpath:
                candidate = (workspace_path / subpath).resolve()
                if not candidate.is_relative_to(workspace_path.resolve()):
                    log.warning(
                        "%s Ignoring subgroup path that escapes workspace: %s",
                        colors.warning("⚠"),
                        repo.owner,
                    )
                    subpath = ""
                else:
                    repo_workspace_path = workspace_path / subpath

        repo_workspace_label = workspace_root_label(
            repo_workspace_path, cwd=cwd, home=home
        )

        # Normalize against existing config keys so that e.g. "~/code"
        # and "~/code/" (or absolute vs tilde forms) map to the same
        # section instead of creating a duplicate.
        if repo_workspace_label not in raw_config:
            canonical = canonicalize_workspace_path(repo_workspace_label, cwd=cwd)
            for existing_label in raw_config:
                try:
                    existing_canonical = canonicalize_workspace_path(
                        existing_label, cwd=cwd
                    )
                except (OSError, ValueError):
                    continue
                if existing_canonical == canonical:
                    repo_workspace_label = existing_label
                    break

        if repo_workspace_label not in checked_labels:
            if repo_workspace_label in raw_config and not isinstance(
                raw_config[repo_workspace_label], dict
            ):
                log.error(
                    "%s Workspace section '%s' is not a mapping in config",
                    colors.error("✗"),
                    repo_workspace_label,
                )
                error_labels.add(repo_workspace_label)
            checked_labels.add(repo_workspace_label)

        if repo_workspace_label in raw_config and not isinstance(
            raw_config[repo_workspace_label], dict
        ):
            continue

        if repo_workspace_label not in raw_config:
            raw_config[repo_workspace_label] = {}

        incoming_url = repo.to_vcspull_url(use_ssh=not use_https)
        existing_raw: t.Any = raw_config[repo_workspace_label].get(repo.name)
        action = _classify_import_action(
            incoming_url=incoming_url,
            existing_entry=existing_raw,
            sync=sync,
        )
        imported_workspace_repos.add((repo_workspace_label, repo.name))

        if action == ImportAction.ADD:
            if not dry_run:
                entry: dict[str, t.Any] = {"repo": incoming_url}
                if import_source:
                    entry["metadata"] = {"imported_from": import_source}
                raw_config[repo_workspace_label][repo.name] = entry
            else:
                log.info("[DRY-RUN] Would add: %s → %s", repo.name, incoming_url)
            added_count += 1
        elif action == ImportAction.UPDATE_URL:
            if not dry_run:
                updated = (
                    copy.deepcopy(existing_raw)
                    if isinstance(existing_raw, dict)
                    else {}
                )
                updated["repo"] = incoming_url
                updated.pop("url", None)
                if import_source:
                    # None is not safe: setdefault returns existing None
                    # instead of replacing it, causing TypeError.
                    if not isinstance(updated.get("metadata"), dict):
                        updated["metadata"] = {}
                    metadata = updated.setdefault("metadata", {})
                    metadata["imported_from"] = import_source
                raw_config[repo_workspace_label][repo.name] = updated
            else:
                log.info("[DRY-RUN] Would update URL: %s", repo.name)
            updated_url_count += 1
        elif action == ImportAction.SKIP_UNCHANGED:
            skip_unchanged_count += 1
            if import_source:
                live = raw_config[repo_workspace_label].get(repo.name)
                if isinstance(live, dict):
                    existing_meta = live.get("metadata")
                    needs_tag = (
                        not isinstance(existing_meta, dict)
                        or existing_meta.get("imported_from") != import_source
                    )
                    if needs_tag:
                        if not dry_run:
                            # None is not safe: setdefault returns existing
                            # None instead of replacing it, causing TypeError.
                            if not isinstance(existing_meta, dict):
                                live["metadata"] = {}
                            live.setdefault("metadata", {})["imported_from"] = (
                                import_source
                            )
                        provenance_tagged_count += 1
                elif isinstance(live, str):
                    if not dry_run:
                        raw_config[repo_workspace_label][repo.name] = {
                            "repo": live,
                            "metadata": {"imported_from": import_source},
                        }
                    provenance_tagged_count += 1
        elif action == ImportAction.SKIP_EXISTING:
            skip_existing_count += 1
        elif action == ImportAction.SKIP_PINNED:
            reason = get_pin_reason(existing_raw)
            log.info(
                "%s Skipping pinned repo %s%s",
                colors.warning("⊘"),
                repo.name,
                f" ({reason})" if reason else "",
            )
            skip_pinned_count += 1

    # Prune stale entries tagged by previous imports from this source
    pruned_count = 0
    if (sync or prune) and import_source:
        for ws_label, ws_entries in list(raw_config.items()):
            if not isinstance(ws_entries, dict):
                continue
            for repo_name in list(ws_entries):
                if (ws_label, repo_name) in imported_workspace_repos:
                    continue
                prune_action = _classify_prune_action(
                    existing_entry=ws_entries[repo_name],
                    imported_from_tag=import_source,
                )
                if prune_action == ImportAction.PRUNE:
                    if not dry_run:
                        del ws_entries[repo_name]
                    else:
                        log.info(
                            "[DRY-RUN] Would prune: %s (no longer on remote)",
                            repo_name,
                        )
                    pruned_count += 1
                elif prune_action == ImportAction.SKIP_PINNED:
                    reason = get_pin_reason(ws_entries[repo_name])
                    log.info(
                        "%s Skipping pruning pinned repo: %s%s",
                        colors.warning("⊘"),
                        repo_name,
                        f" ({reason})" if reason else "",
                    )
                    skip_pinned_count += 1

    # Prune untracked entries (no import provenance from any source)
    untracked_pruned: list[tuple[str, str]] = []
    if prune_untracked and import_source:
        target_workspaces = {ws for ws, _name in imported_workspace_repos}
        for ws_label in target_workspaces:
            ws_entries = raw_config.get(ws_label)
            if not isinstance(ws_entries, dict):
                continue
            for repo_name in list(ws_entries):
                if (ws_label, repo_name) in imported_workspace_repos:
                    continue
                ut_action = _classify_untracked_prune_action(
                    existing_entry=ws_entries[repo_name],
                )
                if ut_action == ImportAction.PRUNE:
                    untracked_pruned.append((ws_label, repo_name))
                elif ut_action == ImportAction.SKIP_PINNED:
                    reason = get_pin_reason(ws_entries[repo_name])
                    log.info(
                        "%s Skipping pruning pinned untracked repo: %s%s",
                        colors.warning("⊘"),
                        repo_name,
                        f" ({reason})" if reason else "",
                    )
                    skip_pinned_count += 1

        if untracked_pruned:
            if dry_run:
                for _ws_label, repo_name in untracked_pruned:
                    log.info(
                        "[DRY-RUN] --prune-untracked: Would remove %s (untracked)",
                        repo_name,
                    )
                pruned_count += len(untracked_pruned)
            elif not yes:
                # Confirmation prompt
                by_ws: dict[str, list[str]] = {}
                for ws_label, repo_name in untracked_pruned:
                    by_ws.setdefault(ws_label, []).append(repo_name)
                for ws_label, names in by_ws.items():
                    log.warning(
                        "%s --prune-untracked: %d untracked %s in %s:",
                        colors.warning("⚠"),
                        len(names),
                        "entry" if len(names) == 1 else "entries",
                        ws_label,
                    )
                    for name in names:
                        log.warning("     %s", name)
                log.warning(
                    "   These entries have no import provenance.",
                )
                if not sys.stdin.isatty():
                    log.info(
                        "%s Non-interactive mode: use --yes to skip confirmation.",
                        colors.error("✗"),
                    )
                else:
                    try:
                        confirm = input("   Remove? [y/N]: ").lower()
                    except EOFError:
                        confirm = ""
                    if confirm in {"y", "yes"}:
                        for ws_label, repo_name in untracked_pruned:
                            ws_e = raw_config.get(ws_label)
                            if isinstance(ws_e, dict) and repo_name in ws_e:
                                del ws_e[repo_name]
                        pruned_count += len(untracked_pruned)
                    else:
                        log.info(
                            "Kept %d untracked %s.",
                            len(untracked_pruned),
                            "entry" if len(untracked_pruned) == 1 else "entries",
                        )
            else:
                # --yes mode: proceed without confirmation
                for ws_label, repo_name in untracked_pruned:
                    ws_e = raw_config.get(ws_label)
                    if isinstance(ws_e, dict) and repo_name in ws_e:
                        del ws_e[repo_name]
                pruned_count += len(untracked_pruned)

    if error_labels:
        return 1

    if dry_run:
        if added_count > 0:
            log.info(
                "[DRY-RUN] Would add %s repositories",
                colors.info(str(added_count)),
            )
        if updated_url_count > 0:
            log.info(
                "[DRY-RUN] Would update %s repository URLs",
                colors.info(str(updated_url_count)),
            )
        if provenance_tagged_count > 0:
            log.info(
                "[DRY-RUN] Would tag %s repositories with import provenance",
                colors.info(str(provenance_tagged_count)),
            )
        if pruned_count > 0:
            log.info(
                "[DRY-RUN] Would prune %s stale entries",
                colors.info(str(pruned_count)),
            )
        if skip_unchanged_count > 0:
            log.info(
                "[DRY-RUN] %s repositories unchanged",
                colors.info(str(skip_unchanged_count)),
            )
        if skip_existing_count > 0:
            log.info(
                "[DRY-RUN] Skipped %s existing repositories",
                colors.info(str(skip_existing_count)),
            )
        if skip_pinned_count > 0:
            log.info(
                "[DRY-RUN] Skipped %s pinned repositories",
                colors.info(str(skip_pinned_count)),
            )
        if (
            added_count > 0
            or updated_url_count > 0
            or pruned_count > 0
            or provenance_tagged_count > 0
        ):
            log.info(
                "\n%s Dry run complete. Would write to %s",
                colors.warning("→"),
                colors.muted(display_config_path),
            )
        else:
            log.info(
                "%s Dry run complete. No changes to write.",
                colors.success("✓"),
            )
        return 0

    if (
        added_count == 0
        and updated_url_count == 0
        and pruned_count == 0
        and provenance_tagged_count == 0
    ):
        if skip_existing_count == 0 and skip_pinned_count == 0:
            log.info(
                "%s All repositories already exist in config. Nothing to add.",
                colors.success("✓"),
            )
        if skip_unchanged_count > 0:
            log.info(
                "%s %s repositories unchanged",
                colors.muted("•"),
                colors.info(str(skip_unchanged_count)),
            )
        if skip_existing_count > 0:
            log.info(
                "%s Skipped %s existing repositories"
                " (use --sync to update changed URLs)",
                colors.warning("!"),
                colors.info(str(skip_existing_count)),
            )
        if skip_pinned_count > 0:
            log.info(
                "%s Skipped %s pinned repositories",
                colors.warning("!"),
                colors.info(str(skip_pinned_count)),
            )
        return 0

    try:
        save_config(config_file_path, raw_config)
        if added_count > 0:
            log.info(
                "%s Added %s repositories to %s",
                colors.success("✓"),
                colors.info(str(added_count)),
                colors.muted(display_config_path),
            )
        if updated_url_count > 0:
            log.info(
                "%s Updated %s repository URLs in %s",
                colors.success("✓"),
                colors.info(str(updated_url_count)),
                colors.muted(display_config_path),
            )
        if provenance_tagged_count > 0:
            log.info(
                "%s Tagged %s repositories with import provenance in %s",
                colors.muted("•"),
                colors.info(str(provenance_tagged_count)),
                colors.muted(display_config_path),
            )
        if skip_unchanged_count > 0:
            log.info(
                "%s %s repositories unchanged",
                colors.muted("•"),
                colors.info(str(skip_unchanged_count)),
            )
        if skip_existing_count > 0:
            log.info(
                "%s Skipped %s existing repositories"
                " (use --sync to update changed URLs)",
                colors.warning("!"),
                colors.info(str(skip_existing_count)),
            )
        if pruned_count > 0:
            log.info(
                "%s Pruned %s repositories from %s",
                colors.success("✓"),
                colors.info(str(pruned_count)),
                colors.muted(display_config_path),
            )
        if skip_pinned_count > 0:
            log.info(
                "%s Skipped %s pinned repositories",
                colors.warning("!"),
                colors.info(str(skip_pinned_count)),
            )
    except OSError:
        log.exception("Error saving config to %s", display_config_path)
        return 1

    return 0
