"""Save a ganban board to git without touching the working tree."""

import asyncio
import re
import subprocess
from dataclasses import dataclass
from functools import cmp_to_key
from pathlib import Path

from ganban.ids import compare_ids, max_id, next_id
from ganban.loader import BRANCH_NAME
from ganban.models import Board, Card, CardLink, Column, MarkdownDoc
from ganban.parser import serialize_markdown


@dataclass
class MergeRequired:
    """Returned by check_for_merge when the branch has diverged."""

    base: str  # common ancestor commit
    ours: str  # our commit (what board was loaded from)
    theirs: str  # their commit (current branch tip)


def check_for_merge(board: Board, branch: str = BRANCH_NAME) -> MergeRequired | None:
    """Check if saving would require a merge.

    Returns MergeRequired with the 3 commit hashes if branch has diverged,
    or None if a simple save is possible.

    The caller can then:
    1. Load all 3 boards: load_board(repo, commit=base/ours/theirs)
    2. Compare them to find conflicts
    3. Build a resolved board
    4. Save with: save_board(resolved, parents=[ours, theirs])
    """
    repo_path = Path(board.repo_path)
    current_tip = _get_branch_tip(repo_path, branch)

    if current_tip is None:
        return None  # Branch doesn't exist, simple save

    if not board.commit:
        return None  # No base commit, simple save

    if current_tip == board.commit:
        return None  # Branch hasn't moved, simple save

    # Branch has moved - find merge base
    merge_base = _get_merge_base(repo_path, board.commit, current_tip)

    if merge_base is None:
        # No common ancestor - shouldn't happen in normal usage
        return None

    return MergeRequired(
        base=merge_base,
        ours=board.commit,
        theirs=current_tip,
    )


def check_remote_for_merge(board: Board, remote: str = "origin", branch: str = BRANCH_NAME) -> MergeRequired | None:
    """Check if a remote has changes that need merging.

    Call this after fetching to see if the remote tracking branch has diverged
    from the board's current commit.

    Args:
        board: The current board state
        remote: Remote name (e.g., "origin")
        branch: Branch name on the remote

    Returns:
        MergeRequired if remote has diverged, None if up to date.
    """
    repo_path = Path(board.repo_path)

    if not board.commit:
        return None  # No base commit

    # Get remote tracking branch commit
    remote_ref = f"refs/remotes/{remote}/{branch}"
    remote_tip = _get_ref(repo_path, remote_ref)

    if remote_tip is None:
        return None  # Remote tracking branch doesn't exist

    if remote_tip == board.commit:
        return None  # Already up to date

    # Find merge base
    merge_base = _get_merge_base(repo_path, board.commit, remote_tip)

    if merge_base is None:
        return None  # No common ancestor

    if merge_base == remote_tip:
        return None  # Remote is behind us, nothing to merge

    return MergeRequired(
        base=merge_base,
        ours=board.commit,
        theirs=remote_tip,
    )


def _get_ref(repo_path: Path, ref: str) -> str | None:
    """Get the commit hash for any ref, or None if it doesn't exist."""
    result = subprocess.run(
        ["git", "rev-parse", "--verify", ref],
        cwd=repo_path,
        capture_output=True,
    )
    if result.returncode != 0:
        return None
    return result.stdout.decode("utf-8").strip()


async def save_board(
    board: Board,
    message: str = "Update board",
    branch: str = BRANCH_NAME,
    parents: list[str] | None = None,
) -> str:
    """Save a board to git and return the new commit hash.

    This creates blobs, trees, and a commit without modifying the working tree.

    Args:
        board: The board state to save
        message: Commit message
        branch: Branch name to update
        parents: Explicit parent commits. If None, uses board.commit.
                 For merge commits, pass [ours_commit, theirs_commit].

    Returns:
        The new commit hash
    """
    return await asyncio.to_thread(_save_board_sync, board, message, branch, parents)


def _save_board_sync(
    board: Board,
    message: str,
    branch: str,
    parents: list[str] | None,
) -> str:
    """Synchronous implementation of save_board."""
    repo_path = Path(board.repo_path)

    # Build tree from board state
    tree = _build_board_tree(repo_path, board)

    # Determine parents
    if parents is None:
        if board.commit:
            parents = [board.commit]
        else:
            # Check if branch exists
            current_tip = _get_branch_tip(repo_path, branch)
            parents = [current_tip] if current_tip else []

    # Create commit
    parent_args = []
    for parent in parents:
        if parent:
            parent_args.extend(["-p", parent])

    new_commit = _git(
        repo_path,
        ["commit-tree", tree, *parent_args, "-m", message],
    )

    # Update branch ref
    _git(repo_path, ["update-ref", f"refs/heads/{branch}", new_commit])

    return new_commit


def try_auto_merge(
    board: Board,
    merge_info: MergeRequired,
    message: str = "Merge changes",
    branch: str = BRANCH_NAME,
) -> str | None:
    """Attempt an automatic merge if there are no conflicts.

    Args:
        board: The board with our changes
        merge_info: The MergeRequired from check_for_merge
        message: Commit message for the merge
        branch: Branch to update

    Returns:
        The new merge commit hash if successful, None if there are conflicts.
    """
    repo_path = Path(board.repo_path)

    # Build our tree
    our_tree = _build_board_tree(repo_path, board)

    # Attempt 3-way merge
    merged_tree, has_conflicts = _merge_trees(repo_path, merge_info.base, our_tree, merge_info.theirs)

    if has_conflicts:
        return None

    # Clean merge - create merge commit with two parents using the MERGED tree
    parent_args = ["-p", merge_info.ours, "-p", merge_info.theirs]
    new_commit = _git(
        repo_path,
        ["commit-tree", merged_tree, *parent_args, "-m", message],
    )

    # Update branch ref
    _git(repo_path, ["update-ref", f"refs/heads/{branch}", new_commit])

    return new_commit


def _build_board_tree(repo_path: Path, board: Board) -> str:
    """Build the complete tree for a board and return its hash."""
    # Build card blobs and .all tree
    card_blobs = _hash_cards(repo_path, board.cards)
    all_tree = _mktree(
        repo_path,
        [("100644", "blob", sha, f"{card_id}.md") for card_id, sha in card_blobs.items()],
    )

    # Build column trees
    column_trees = []
    for column in board.columns:
        column_tree = _build_column_tree(repo_path, column)
        column_trees.append((column.path, column_tree))

    # Build root tree entries
    root_entries = [
        ("040000", "tree", all_tree, ".all"),
    ]

    for path, tree_sha in column_trees:
        root_entries.append(("040000", "tree", tree_sha, path))

    if board.content.title or board.content.body or board.content.sections:
        index_blob = _hash_object(repo_path, serialize_markdown(board.content))
        root_entries.append(("100644", "blob", index_blob, "index.md"))

    return _mktree(repo_path, root_entries)


def _build_column_tree(repo_path: Path, column: Column) -> str:
    """Build a tree for a column directory."""
    entries = []

    # Add index.md if column has content
    if column.content.title or column.content.body or column.content.sections:
        index_blob = _hash_object(repo_path, serialize_markdown(column.content))
        entries.append(("100644", "blob", index_blob, "index.md"))

    # Add symlinks for card links
    for link in column.links:
        target = f"../.all/{link.card_id}.md"
        symlink_blob = _hash_object(repo_path, target)
        filename = f"{link.position}.{link.slug}.md"
        entries.append(("120000", "blob", symlink_blob, filename))

    return _mktree(repo_path, entries)


def _get_branch_tip(repo_path: Path, branch: str) -> str | None:
    """Get the current commit hash of a branch, or None if it doesn't exist."""
    result = subprocess.run(
        ["git", "rev-parse", "--verify", f"refs/heads/{branch}"],
        cwd=repo_path,
        capture_output=True,
    )
    if result.returncode != 0:
        return None
    return result.stdout.decode("utf-8").strip()


def _get_merge_base(repo_path: Path, commit1: str, commit2: str) -> str | None:
    """Find the merge base of two commits, or None if no common ancestor."""
    result = subprocess.run(
        ["git", "merge-base", commit1, commit2],
        cwd=repo_path,
        capture_output=True,
    )
    if result.returncode != 0:
        return None
    return result.stdout.decode("utf-8").strip()


def _merge_trees(repo_path: Path, base_commit: str, our_tree: str, their_commit: str) -> tuple[str, bool]:
    """Attempt a 3-way merge.

    Args:
        repo_path: Path to the git repository
        base_commit: Common ancestor commit
        our_tree: Our tree hash (from _build_board_tree)
        their_commit: Their commit hash

    Returns:
        Tuple of (merged_tree_hash, has_conflicts)
    """
    # git merge-tree --write-tree needs commits, not raw trees.
    # Create a temporary commit for our tree so we can use it in the merge.
    our_temp_commit = _git(repo_path, ["commit-tree", our_tree, "-p", base_commit, "-m", "temp merge commit"])

    # git merge-tree outputs the merged tree hash
    # Exit code 0 = clean merge, non-zero = conflicts
    result = subprocess.run(
        ["git", "merge-tree", "--write-tree", f"--merge-base={base_commit}", our_temp_commit, their_commit],
        cwd=repo_path,
        capture_output=True,
    )

    # First line of output is the tree hash
    output = result.stdout.decode("utf-8").strip()
    tree_hash = output.split("\n")[0] if output else ""

    has_conflicts = result.returncode != 0

    return tree_hash, has_conflicts


def _hash_cards(repo_path: Path, cards: dict[str, Card]) -> dict[str, str]:
    """Hash all card markdown files and return {card_id: blob_sha}."""
    return {card_id: _hash_object(repo_path, serialize_markdown(card.content)) for card_id, card in cards.items()}


def _hash_object(repo_path: Path, content: str) -> str:
    """Write content to git object store and return the blob hash."""
    result = subprocess.run(
        ["git", "hash-object", "-w", "--stdin"],
        cwd=repo_path,
        input=content.encode("utf-8"),
        capture_output=True,
        check=True,
    )
    return result.stdout.decode("utf-8").strip()


def _mktree(repo_path: Path, entries: list[tuple[str, str, str, str]]) -> str:
    """Create a tree object from entries and return its hash.

    Each entry is (mode, type, sha, name).
    """
    lines = [f"{mode} {typ} {sha}\t{name}" for mode, typ, sha, name in entries]
    content = "\n".join(lines) + "\n" if lines else ""

    result = subprocess.run(
        ["git", "mktree"],
        cwd=repo_path,
        input=content.encode("utf-8"),
        capture_output=True,
        check=True,
    )
    return result.stdout.decode("utf-8").strip()


def _git(repo_path: Path, args: list[str]) -> str:
    """Run a git command and return stdout."""
    result = subprocess.run(
        ["git", *args],
        cwd=repo_path,
        capture_output=True,
        check=True,
    )
    return result.stdout.decode("utf-8").strip()


# --- Board manipulation helpers ---


def create_card(
    board: Board,
    title: str,
    body: str = "",
    column: Column | None = None,
    position: int | None = None,
) -> Card:
    """Create a new card and add it to the board.

    Args:
        board: The board to add the card to
        title: Card title
        body: Card body text
        column: Column to add the card to (default: first column)
        position: Position in column (default: end of column)

    Returns:
        The created Card
    """
    card_id = next_id(max_id(list(board.cards.keys())))
    card = Card(
        id=card_id,
        path=f".all/{card_id}.md",
        content=MarkdownDoc(title=title, body=body),
    )
    board.cards[card_id] = card

    # Add to column
    target_column = column or (board.columns[0] if board.columns else None)
    if target_column:
        slug = slugify(title)
        link = CardLink(
            card_id=card_id,
            position=position if position is not None else len(target_column.links) + 1,
            slug=slug,
        )
        if position is not None:
            target_column.links.insert(position, link)
        else:
            target_column.links.append(link)

    return card


def create_column(
    board: Board,
    name: str,
    order: str | None = None,
    hidden: bool = False,
) -> Column:
    """Create a new column and add it to the board.

    Args:
        board: The board to add the column to
        name: Column display name
        order: Sort order prefix (default: auto-increment from highest)
        hidden: Whether column is hidden (prefix with .)

    Returns:
        The created Column
    """
    if order is None:
        existing_orders = [c.order for c in board.columns]
        order = next_id(max_id(existing_orders)) if existing_orders else "1"

    column = Column(
        order=order,
        name=name,
        path=build_column_path(order, name, hidden),
        hidden=hidden,
        content=MarkdownDoc(),
    )
    board.columns.append(column)
    board.columns.sort(key=cmp_to_key(lambda a, b: compare_ids(a.order, b.order)))

    return column


def slugify(text: str) -> str:
    """Convert text to a URL-friendly slug."""
    slug = text.lower()
    slug = re.sub(r"[^a-z0-9]+", "-", slug)
    slug = slug.strip("-")
    return slug or "untitled"


def build_column_path(order: str, name: str, hidden: bool = False) -> str:
    """Build column directory path from components."""
    prefix = "." if hidden else ""
    return f"{prefix}{order}.{slugify(name)}"
