"""Business logic layer — returns data, never prints.

All Rich formatting happens in cli.py. This module works purely with
data structures so it's testable without console output.
"""

from __future__ import annotations

import os
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path

from gwtm.config import GwtConfig, get_all_managed_repos, load_config
from gwtm.utils import (
    DirtyWorktreeError,
    GitCommandError,
    GwtError,
    WorktreeExistsError,
    WorktreeNotFoundError,
    get_branch_status,
    get_repo_context,
    is_branch_merged,
    list_worktrees,
    run_git,
    validate_worktree_name,
)


# ── Result types ────────────────────────────────────────────────────────────


@dataclass(frozen=True)
class CarriedFile:
    relative_path: str
    is_dir: bool


@dataclass(frozen=True)
class NewWorktreeResult:
    name: str
    path: Path
    branch: str
    base_branch: str
    carried_files: list[CarriedFile] = field(default_factory=list)
    carried_changes: bool = False
    unignored_carried: list[str] = field(default_factory=list)


@dataclass(frozen=True)
class WorktreeStatus:
    name: str
    path: Path
    branch: str | None
    ahead: int
    behind: int
    dirty: int
    is_main: bool
    merged: bool


@dataclass(frozen=True)
class CdResult:
    path: Path | None
    name: str
    found: bool
    suggestions: list[str]


@dataclass(frozen=True)
class RemoveResult:
    name: str
    path: Path
    branch_deleted: bool


@dataclass(frozen=True)
class CloneResult:
    repo_name: str
    clone_path: Path
    worktrees_dir: Path
    default_branch: str


@dataclass(frozen=True)
class SyncResult:
    name: str
    strategy: str  # "rebase" or "merge"
    base_branch: str
    success: bool
    message: str


@dataclass(frozen=True)
class CleanCandidate:
    name: str
    path: Path
    branch: str | None


@dataclass(frozen=True)
class InitResult:
    config_path: Path
    default_branch: str
    repo_name: str


# ── Carry helpers ──────────────────────────────────────────────────────────


def carry_files(source: Path, dest: Path, patterns: list[str]) -> list[CarriedFile]:
    """Symlink files/dirs matching glob patterns from source into dest.

    Creates symlinks pointing back to the source so all worktrees share
    the same untracked files.  Silently skips patterns that match nothing.
    """
    carried: list[CarriedFile] = []

    for pattern in patterns:
        for match in source.glob(pattern):
            rel = match.relative_to(source)
            dst = dest / rel
            try:
                dst.parent.mkdir(parents=True, exist_ok=True)
                os.symlink(match.resolve(), dst, target_is_directory=match.is_dir())
                carried.append(CarriedFile(str(rel), is_dir=match.is_dir()))
            except OSError as exc:
                print(f"Warning: failed to carry {rel}: {exc}", file=sys.stderr)

    return carried


def carry_changes_to(source: Path, dest: Path) -> bool:
    """Transfer uncommitted changes from source worktree to dest via git stash.

    Uses ``git stash create`` (does not modify source working tree) followed
    by ``git stash apply`` in the destination.  Returns True if changes were
    applied.
    """
    try:
        stash_hash = run_git("stash", "create", cwd=source)
    except GitCommandError:
        return False

    if not stash_hash:
        return False  # No changes

    try:
        run_git("stash", "apply", stash_hash, cwd=dest)
        return True
    except GitCommandError as exc:
        print(f"Warning: failed to apply changes: {exc}", file=sys.stderr)
        return False


def _check_unignored(carried: list[CarriedFile], cwd: Path) -> list[str]:
    """Return carried paths that are NOT gitignored in the worktree.

    Carried files are symlinks, so we check without trailing slash — git
    treats symlinks as files even when they point to directories.
    """
    if not carried:
        return []

    paths = [cf.relative_path for cf in carried]
    result = subprocess.run(
        ["git", "check-ignore", *paths],
        cwd=cwd,
        capture_output=True,
        text=True,
    )
    ignored = set(result.stdout.strip().splitlines())
    return [p for p in paths if p not in ignored]


# ── Core operations ─────────────────────────────────────────────────────────


def create_worktree(
    name: str,
    base_branch: str | None = None,
    existing_branch: str | None = None,
    carry: list[str] | None = None,
    do_carry_changes: bool | None = None,
    no_carry: bool = False,
    cwd: Path | None = None,
) -> NewWorktreeResult:
    """Create a new worktree at ~/.worktrees/{repo}/{name}.

    If existing_branch is given, checks out that branch instead of creating
    a new one.
    """
    name = validate_worktree_name(name)
    ctx = get_repo_context(cwd)
    config = load_config(ctx.main_worktree)

    wt_path = config.repo_worktrees_dir(ctx.repo_name) / name

    if wt_path.exists():
        raise WorktreeExistsError(f"Worktree '{name}' already exists at {wt_path}")

    # Fetch latest if configured and remote exists
    if config.auto_fetch:
        try:
            run_git("fetch", "--quiet", cwd=ctx.main_worktree)
        except GitCommandError:
            pass  # No remote configured — skip

    if existing_branch:
        branch = existing_branch
        base = existing_branch  # report the branch itself as "base" in the result

        # Verify the branch exists (local or remote tracking)
        try:
            run_git("rev-parse", "--verify", branch, cwd=ctx.main_worktree)
        except GitCommandError:
            raise GwtError(f"Branch '{branch}' does not exist")

        # Create worktree directory parent
        wt_path.parent.mkdir(parents=True, exist_ok=True)

        # Attach worktree to existing branch
        run_git(
            "worktree", "add",
            str(wt_path),
            branch,
            cwd=ctx.main_worktree,
        )
    else:
        base = base_branch or config.default_base_branch
        branch = f"{config.branch_prefix}{name}"

        # Check if branch already exists
        try:
            run_git("rev-parse", "--verify", branch, cwd=ctx.main_worktree)
            raise WorktreeExistsError(f"Branch '{branch}' already exists")
        except GitCommandError:
            pass  # Branch doesn't exist — good

        # Ensure base branch exists
        try:
            run_git("rev-parse", "--verify", base, cwd=ctx.main_worktree)
        except GitCommandError:
            raise GwtError(f"Base branch '{base}' does not exist")

        # Create worktree directory parent
        wt_path.parent.mkdir(parents=True, exist_ok=True)

        # Create worktree with new branch
        run_git(
            "worktree", "add",
            "-b", branch,
            str(wt_path),
            base,
            cwd=ctx.main_worktree,
        )

    # ── Carry files and changes ────────────────────────────────────────
    carried: list[CarriedFile] = []
    changes_applied = False
    source = cwd or Path.cwd()

    if not no_carry:
        patterns = carry if carry is not None else config.carry_patterns
        should_carry_changes = do_carry_changes if do_carry_changes is not None else config.carry_changes

        if patterns:
            carried = carry_files(source, wt_path, patterns)

        if should_carry_changes:
            changes_applied = carry_changes_to(source, wt_path)

    # Check if carried files are gitignored
    unignored = _check_unignored(carried, wt_path)

    return NewWorktreeResult(
        name=name,
        path=wt_path,
        branch=branch,
        base_branch=base,
        carried_files=carried,
        carried_changes=changes_applied,
        unignored_carried=unignored,
    )


def get_worktree_statuses(
    repo_name: str | None = None,
    all_repos: bool = False,
    cwd: Path | None = None,
) -> dict[str, list[WorktreeStatus]]:
    """Get worktree statuses, keyed by repo name.

    If all_repos is True, scans all managed repos under ~/.worktrees/.
    Otherwise, uses the current repo.
    """
    result: dict[str, list[WorktreeStatus]] = {}

    if all_repos:
        repos = get_all_managed_repos()
        for rname in repos:
            statuses = _get_repo_worktree_statuses(rname, cwd=None)
            if statuses:
                result[rname] = statuses
    else:
        ctx = get_repo_context(cwd)
        config = load_config(ctx.main_worktree)
        statuses = _build_statuses(ctx.main_worktree, ctx.repo_name, config)
        result[ctx.repo_name] = statuses

    return result


def _get_repo_worktree_statuses(repo_name: str, cwd: Path | None) -> list[WorktreeStatus]:
    """Get statuses for a repo found via ~/.worktrees/ scan."""
    config = load_config()
    wt_dir = config.repo_worktrees_dir(repo_name)

    if not wt_dir.exists():
        return []

    # Find a worktree to use as cwd for git commands
    for child in wt_dir.iterdir():
        if child.is_dir() and (child / ".git").exists():
            try:
                ctx = get_repo_context(child)
                return _build_statuses(ctx.main_worktree, repo_name, config)
            except Exception:
                continue

    return []


def _build_statuses(
    main_worktree: Path,
    repo_name: str,
    config: GwtConfig,
) -> list[WorktreeStatus]:
    """Build WorktreeStatus list for a repo."""
    wts = list_worktrees(main_worktree)
    statuses: list[WorktreeStatus] = []

    for wt in wts:
        is_main = wt.path == main_worktree
        name = "main" if is_main else wt.path.name

        status_info = get_branch_status(wt.path, wt.branch or "HEAD")
        merged = False
        if wt.branch and not is_main:
            merged = is_branch_merged(
                main_worktree, wt.branch, config.default_base_branch
            )

        statuses.append(WorktreeStatus(
            name=name,
            path=wt.path,
            branch=wt.branch,
            ahead=status_info["ahead"],
            behind=status_info["behind"],
            dirty=status_info["dirty"],
            is_main=is_main,
            merged=merged,
        ))

    return statuses


def resolve_worktree_path(
    name: str,
    cwd: Path | None = None,
) -> CdResult:
    """Resolve a worktree name to its path with fuzzy matching.

    Resolution order: exact → prefix → substring → difflib close matches.
    """
    ctx = get_repo_context(cwd)
    config = load_config(ctx.main_worktree)

    # Special case: "main" returns the main worktree
    if name in ("main", "master"):
        return CdResult(
            path=ctx.main_worktree,
            name="main",
            found=True,
            suggestions=[],
        )

    wt_dir = config.repo_worktrees_dir(ctx.repo_name)
    available = _get_worktree_names(wt_dir)

    if not available:
        return CdResult(path=None, name=name, found=False, suggestions=[])

    # Exact match
    if name in available:
        return CdResult(
            path=wt_dir / name,
            name=name,
            found=True,
            suggestions=[],
        )

    # Prefix match
    prefix_matches = [n for n in available if n.startswith(name)]
    if len(prefix_matches) == 1:
        match = prefix_matches[0]
        return CdResult(path=wt_dir / match, name=match, found=True, suggestions=[])

    # Substring match
    substr_matches = [n for n in available if name in n]
    if len(substr_matches) == 1:
        match = substr_matches[0]
        return CdResult(path=wt_dir / match, name=match, found=True, suggestions=[])

    # Fuzzy match for suggestions
    import difflib
    suggestions = difflib.get_close_matches(name, available, n=3, cutoff=0.4)

    # If we had multiple prefix/substring matches, add those too
    all_suggestions = list(dict.fromkeys(prefix_matches + substr_matches + suggestions))

    return CdResult(
        path=None,
        name=name,
        found=False,
        suggestions=all_suggestions[:5],
    )


def remove_worktree(
    name: str,
    force: bool = False,
    keep_branch: bool = False,
    cwd: Path | None = None,
) -> RemoveResult:
    """Remove a worktree and optionally its branch."""
    ctx = get_repo_context(cwd)
    config = load_config(ctx.main_worktree)
    wt_path = config.repo_worktrees_dir(ctx.repo_name) / name

    if not wt_path.exists():
        raise WorktreeNotFoundError(f"Worktree '{name}' not found at {wt_path}")

    # Check for dirty state
    if not force:
        try:
            status = run_git("status", "--porcelain", cwd=wt_path)
            if status:
                raise DirtyWorktreeError(
                    f"Worktree '{name}' has uncommitted changes. Use --force to remove anyway."
                )
        except GitCommandError:
            pass

    # Detect the branch before removal
    branch: str | None = None
    try:
        branch = run_git("rev-parse", "--abbrev-ref", "HEAD", cwd=wt_path)
    except GitCommandError:
        pass

    # Remove the worktree
    args = ["worktree", "remove"]
    if force:
        args.append("--force")
    args.append(str(wt_path))

    run_git(*args, cwd=ctx.main_worktree)

    # Delete the branch unless asked to keep it
    branch_deleted = False
    if branch and not keep_branch and branch != "HEAD":
        try:
            run_git("branch", "-D", branch, cwd=ctx.main_worktree)
            branch_deleted = True
        except GitCommandError:
            pass  # Branch may already be gone

    # Clean up empty parent directories
    repo_wt_dir = config.repo_worktrees_dir(ctx.repo_name)
    if repo_wt_dir.exists() and not any(repo_wt_dir.iterdir()):
        repo_wt_dir.rmdir()

    return RemoveResult(name=name, path=wt_path, branch_deleted=branch_deleted)


def get_worktree_names(cwd: Path | None = None) -> list[str]:
    """Get worktree names for the current repo (for shell completion)."""
    ctx = get_repo_context(cwd)
    config = load_config(ctx.main_worktree)
    wt_dir = config.repo_worktrees_dir(ctx.repo_name)
    return _get_worktree_names(wt_dir)


def sync_worktree(
    name: str | None = None,
    all_worktrees: bool = False,
    cwd: Path | None = None,
) -> list[SyncResult]:
    """Sync worktree(s) by rebasing or merging onto the base branch."""
    ctx = get_repo_context(cwd)
    config = load_config(ctx.main_worktree)

    # Fetch latest
    if config.auto_fetch:
        try:
            run_git("fetch", "--quiet", cwd=ctx.main_worktree)
        except GitCommandError:
            pass

    results: list[SyncResult] = []
    wt_dir = config.repo_worktrees_dir(ctx.repo_name)

    if all_worktrees:
        names = _get_worktree_names(wt_dir)
    elif name:
        names = [name]
    else:
        # Sync current worktree if inside one
        cwd_path = cwd or Path.cwd()
        if wt_dir in cwd_path.parents or cwd_path == wt_dir:
            # We're inside a worktree dir
            names = [cwd_path.name]
        else:
            raise GwtError("Not inside a worktree. Specify a name or use --all.")

    for wt_name in names:
        wt_path = wt_dir / wt_name
        if not wt_path.exists():
            results.append(SyncResult(
                name=wt_name, strategy=config.sync_strategy,
                base_branch=config.default_base_branch,
                success=False, message=f"Worktree directory not found: {wt_path}",
            ))
            continue

        try:
            if config.sync_strategy == "rebase":
                run_git("rebase", config.default_base_branch, cwd=wt_path)
                msg = f"Rebased onto {config.default_base_branch}"
            else:
                run_git("merge", config.default_base_branch, cwd=wt_path)
                msg = f"Merged {config.default_base_branch}"

            results.append(SyncResult(
                name=wt_name, strategy=config.sync_strategy,
                base_branch=config.default_base_branch,
                success=True, message=msg,
            ))
        except GitCommandError as e:
            # Abort on conflict
            try:
                if config.sync_strategy == "rebase":
                    run_git("rebase", "--abort", cwd=wt_path)
                else:
                    run_git("merge", "--abort", cwd=wt_path)
            except GitCommandError:
                pass

            results.append(SyncResult(
                name=wt_name, strategy=config.sync_strategy,
                base_branch=config.default_base_branch,
                success=False, message=f"Conflict — aborted. Resolve manually.",
            ))

    return results


def get_repo_status(
    cwd: Path | None = None,
) -> tuple[str, Path, list[WorktreeStatus]]:
    """Get detailed status for the current repo.

    Returns (repo_name, main_path, worktree_statuses).
    """
    ctx = get_repo_context(cwd)
    config = load_config(ctx.main_worktree)
    statuses = _build_statuses(ctx.main_worktree, ctx.repo_name, config)
    return ctx.repo_name, ctx.main_worktree, statuses


def clean_worktrees(
    dry_run: bool = False,
    cwd: Path | None = None,
) -> list[CleanCandidate]:
    """Find and optionally remove worktrees with merged branches.

    Returns the list of candidates (removed if not dry_run).
    """
    ctx = get_repo_context(cwd)
    config = load_config(ctx.main_worktree)
    wt_dir = config.repo_worktrees_dir(ctx.repo_name)

    if not wt_dir.exists():
        return []

    candidates: list[CleanCandidate] = []
    wts = list_worktrees(ctx.main_worktree)

    for wt in wts:
        if wt.path == ctx.main_worktree:
            continue  # Skip main worktree

        # Only consider worktrees in our managed directory
        if wt_dir not in wt.path.parents and wt.path.parent != wt_dir:
            continue

        if wt.branch and is_branch_merged(ctx.main_worktree, wt.branch, config.default_base_branch):
            candidates.append(CleanCandidate(
                name=wt.path.name,
                path=wt.path,
                branch=wt.branch,
            ))

    if not dry_run:
        for candidate in candidates:
            try:
                remove_worktree(candidate.name, force=True, cwd=cwd)
            except GwtError:
                pass  # Best effort

    return candidates


def clone_repo(
    url: str,
    name: str | None = None,
    dest: Path | None = None,
) -> CloneResult:
    """Clone a repository and set up gwtm structure.

    1. Extract repo name from URL
    2. Clone to dest or CWD
    3. Create ~/.worktrees/{repo_name}/
    4. Write .gwtconfig.yaml with detected default branch
    """
    import re
    import subprocess
    import yaml

    config = load_config()

    # Extract repo name from URL
    if name is None:
        # Strip trailing .git and take last path component
        cleaned = url.rstrip("/")
        if cleaned.endswith(".git"):
            cleaned = cleaned[:-4]
        name = cleaned.rsplit("/", 1)[-1]
        if not name:
            raise GwtError(f"Cannot determine repo name from URL: {url}")

    # Determine clone destination
    clone_dest = dest or (config.clone_base or Path.cwd())
    clone_path = Path(clone_dest) / name

    if clone_path.exists():
        raise GwtError(f"Destination already exists: {clone_path}")

    # Clone the repository
    result = subprocess.run(
        ["git", "clone", url, str(clone_path)],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise GitCommandError(
            ["clone", url, str(clone_path)],
            result.returncode,
            result.stderr,
        )

    # Detect default branch
    try:
        default_branch = run_git(
            "symbolic-ref", "--short", "HEAD", cwd=clone_path,
        )
    except GitCommandError:
        default_branch = "main"

    # Create worktrees directory
    wt_dir = config.repo_worktrees_dir(name)
    wt_dir.mkdir(parents=True, exist_ok=True)

    # Write per-repo config
    repo_config = clone_path / ".gwtconfig.yaml"
    repo_config.write_text(yaml.dump(
        {"default_base_branch": default_branch},
        default_flow_style=False,
    ))

    return CloneResult(
        repo_name=name,
        clone_path=clone_path,
        worktrees_dir=wt_dir,
        default_branch=default_branch,
    )


def init_repo(force: bool = False) -> InitResult:
    """Initialize gwtm config for the current repository.

    Detects the default branch and writes a .gwtconfig.yaml template.
    Raises GwtError if config already exists (unless force=True).
    """
    from gwtm.config import write_repo_config

    ctx = get_repo_context()
    repo_root = ctx.main_worktree

    config_path = repo_root / ".gwtconfig.yaml"
    if config_path.exists() and not force:
        raise GwtError(
            f"Config already exists: {config_path}\n"
            "  Use --force to overwrite."
        )

    # Detect default branch — try multiple strategies
    default_branch = _detect_default_branch(repo_root)

    write_repo_config(repo_root, default_branch)

    return InitResult(
        config_path=config_path,
        default_branch=default_branch,
        repo_name=ctx.repo_name,
    )


def _detect_default_branch(repo_root: Path) -> str:
    """Detect the default branch using multiple strategies."""
    # 1. Try origin HEAD (works for cloned repos)
    try:
        ref = run_git("symbolic-ref", "refs/remotes/origin/HEAD", cwd=repo_root)
        return ref.split("/")[-1]
    except GitCommandError:
        pass

    # 2. Try current branch (good fallback for fresh repos)
    try:
        branch = run_git("symbolic-ref", "--short", "HEAD", cwd=repo_root)
        if branch in ("main", "master"):
            return branch
    except GitCommandError:
        pass

    # 3. Check if "main" or "master" branch exists
    try:
        branches = run_git("branch", "--list", "main", "master", cwd=repo_root)
        for line in branches.split("\n"):
            name = line.strip().lstrip("* ")
            if name in ("main", "master"):
                return name
    except GitCommandError:
        pass

    return "main"


def _get_worktree_names(wt_dir: Path) -> list[str]:
    """List worktree directory names under a repo's worktree dir."""
    if not wt_dir.exists():
        return []
    return sorted(d.name for d in wt_dir.iterdir() if d.is_dir())
