# -*- coding: utf-8 -*-
"""Git integration endpoints for Coding Mode.

All endpoints are mounted under ``/workspace/git/`` and operate on the
workspace directory resolved from the request (same agent-context pattern
used by other workspace routes).

Uses the shared command runner; no extra Python deps.
"""
from __future__ import annotations

import asyncio
import logging
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field

from ..agent_context import get_agent_for_request, get_coding_dir
from ..utils import safe_join
from ...utils.command_runner import run_command_async

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/workspace/git", tags=["git"])

# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------

_DEFAULT_GITIGNORE = """\
# Auto-generated by QwenPaw Coding Mode

# Python artefacts
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
env/
.env

# JS / build artefacts
node_modules/
dist/
build/

# OS
.DS_Store

# QwenPaw runtime / internal directories (not user content)
sessions/
missions/
ralph_loops/
memory/
embedding_cache/
browser/
dialog/
backup/
active_skills/
customized_skills/
tool_result/
tool_results/
skills/

# QwenPaw runtime files
chats.json
jobs.json
skill.json
*.skill.json.lock
.skill.json.lock
copaw_file_metadata.json
memory_file_metadata.json
.bootstrap_completed
.reme_store_v1
*.weixin-migrate.bak
agent.json
"""

# ---------------------------------------------------------------------------
# Git command helper
# ---------------------------------------------------------------------------


async def _git(cwd: Path, *args: str) -> tuple[int, str, str]:
    """Run ``git <args>`` in *cwd*.

    Returns ``(returncode, stdout, stderr)``.
    """
    result = await run_command_async(
        ["git", *args],
        cwd=str(cwd),
        encoding="utf-8",
        errors="replace",
        check=False,
        timeout=None,
    )
    return result.returncode, result.stdout, result.stderr


def _raise_if_not_repo(rc: int, stderr: str) -> None:
    if rc != 0 and "not a git repository" in stderr.lower():
        raise HTTPException(status_code=422, detail="Not a git repository")


# ---------------------------------------------------------------------------
# Auto-init helper
# ---------------------------------------------------------------------------


async def _write_gitignore(cwd: Path) -> None:
    """Write a default .gitignore if one does not already exist."""
    gitignore_path = cwd / ".gitignore"
    if await asyncio.to_thread(gitignore_path.exists):
        return
    try:
        await asyncio.to_thread(
            gitignore_path.write_text,
            _DEFAULT_GITIGNORE,
            encoding="utf-8",
        )
    except Exception:
        pass


async def _exclude_nested_repos(cwd: Path) -> None:
    """Write nested git repos into .git/info/exclude so they are not staged."""

    def _scan() -> list[str]:
        try:
            return [
                p.parent.relative_to(cwd).as_posix() + "/"
                for p in cwd.rglob(".git")
                if p.is_dir() and p != cwd / ".git"
            ]
        except Exception:
            return []

    nested = await asyncio.to_thread(_scan)
    if not nested:
        return

    exclude_path = cwd / ".git" / "info" / "exclude"

    def _write_exclude() -> None:
        exclude_path.parent.mkdir(parents=True, exist_ok=True)
        existing = exclude_path.read_text() if exclude_path.exists() else ""
        with exclude_path.open("a") as fh:
            for rel in nested:
                if rel not in existing:
                    fh.write(rel + "\n")

    try:
        await asyncio.to_thread(_write_exclude)
    except Exception:
        pass


async def _auto_init_repo(cwd: Path) -> str:
    """Initialise a git repo in *cwd* if it is not already one.

    Returns the branch name after initialisation, or ``""`` on failure.
    """
    rc, _, err = await _git(cwd, "init")
    if rc != 0:
        logger.warning("git init failed in %s: %s", cwd, err)
        return ""

    await _write_gitignore(cwd)
    await _exclude_nested_repos(cwd)

    await _git(cwd, "add", "-A", "--ignore-errors")

    commit_rc, _, commit_err = await _git(
        cwd,
        "-c",
        "user.email=qwenpaw@localhost",
        "-c",
        "user.name=QwenPaw",
        "commit",
        "--allow-empty",
        "-m",
        "Initial commit",
    )
    if commit_rc != 0:
        logger.debug("Initial commit failed in %s: %s", cwd, commit_err)

    rc2, branch_out, _ = await _git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
    return branch_out.strip() if rc2 == 0 else "main"


async def _resolve_branch(cwd: Path) -> tuple[str, bool]:
    """Return ``(branch, needs_init)``.

    ``needs_init`` is True when the workspace has no own git repo or its
    git top-level is a parent directory (inherited repo).
    """
    rc, toplevel, _ = await _git(cwd, "rev-parse", "--show-toplevel")
    if rc != 0:
        return "", True
    try:
        is_own = Path(toplevel.strip()).resolve() == cwd.resolve()
    except Exception:
        is_own = False
    if not is_own:
        return "", True
    rc2, branch_out, _ = await _git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
    return branch_out.strip() if rc2 == 0 else "HEAD", False


# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------


class ChangedFile(BaseModel):
    path: str
    status: str  # "M", "A", "D", "?", "R", …
    staged: bool


class GitStatus(BaseModel):
    branch: str
    changes: list[ChangedFile]
    ahead: int = 0
    behind: int = 0


class BranchInfo(BaseModel):
    name: str
    current: bool
    remote: bool


class CommitInfo(BaseModel):
    hash: str
    author: str
    date: str
    message: str


class CheckoutRequest(BaseModel):
    branch: str
    create: bool = Field(
        default=False,
        description="Create the branch if it doesn't exist",
    )


class StageRequest(BaseModel):
    paths: list[str] = Field(
        default_factory=list,
        description="File paths to stage (empty = all)",
    )


class UnstageRequest(BaseModel):
    paths: list[str] = Field(default_factory=list)


class CommitRequest(BaseModel):
    message: str


class DiscardRequest(BaseModel):
    paths: list[str] = Field(
        default_factory=list,
        description="File paths to discard (empty = discard all)",
    )


class RevertRequest(BaseModel):
    commit_hash: str = Field(description="Commit hash to revert")


# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------


@router.get("/status", response_model=GitStatus, summary="Git status")
async def git_status(request: Request) -> GitStatus:
    """Return branch, ahead/behind counts and changed files.

    Auto-initialises a git repo (with an initial commit) the first time the
    workspace is opened if it does not already have one.
    """
    workspace = await get_agent_for_request(request)
    cwd = get_coding_dir(workspace)

    branch, needs_init = await _resolve_branch(cwd)
    if needs_init:
        branch = await _auto_init_repo(cwd)
        if not branch:
            return GitStatus(branch="", changes=[], ahead=0, behind=0)

    # Ahead / behind (may fail if no upstream)
    ahead = behind = 0
    rc2, rev_out, _ = await _git(
        cwd,
        "rev-list",
        "--left-right",
        "--count",
        "@{u}...HEAD",
    )
    if rc2 == 0:
        parts = rev_out.strip().split()
        if len(parts) == 2:
            behind, ahead = int(parts[0]), int(parts[1])

    # Changed files – normal mode collapses untracked directories into one
    # entry each (avoids thousands of rows on fresh repos).
    # -c core.quotepath=false prevents git from escaping non-ASCII filenames
    # (e.g. Chinese characters) into octal sequences.
    _, status_out, _ = await _git(
        cwd,
        "-c",
        "core.quotepath=false",
        "status",
        "--porcelain",
        "--untracked-files=normal",
    )
    changes: list[ChangedFile] = []
    for line in status_out.splitlines():
        if len(line) < 4:
            continue
        xy = line[:2]
        path = line[3:].strip()
        if " -> " in path:
            path = path.split(" -> ")[-1]
        # Strip trailing slash (git uses "dirname/" for untracked directories)
        path = path.rstrip("/")
        staged_char, unstaged_char = xy[0], xy[1]
        if staged_char not in (" ", "?"):
            changes.append(
                ChangedFile(path=path, status=staged_char, staged=True),
            )
        if unstaged_char == "?":
            changes.append(ChangedFile(path=path, status="?", staged=False))
        elif unstaged_char != " ":
            changes.append(
                ChangedFile(path=path, status=unstaged_char, staged=False),
            )

    return GitStatus(
        branch=branch,
        changes=changes,
        ahead=ahead,
        behind=behind,
    )


@router.get(
    "/branches",
    response_model=list[BranchInfo],
    summary="List branches",
)
async def list_branches(request: Request) -> list[BranchInfo]:
    workspace = await get_agent_for_request(request)
    rc, out, err = await _git(
        get_coding_dir(workspace),
        "branch",
        "-a",
        "--format=%(refname:short)|%(HEAD)",
    )
    _raise_if_not_repo(rc, err)
    branches: list[BranchInfo] = []
    for line in out.splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.split("|")
        name = parts[0].strip()
        current = len(parts) > 1 and parts[1].strip() == "*"
        remote = name.startswith("origin/") or name.startswith("remotes/")
        branches.append(BranchInfo(name=name, current=current, remote=remote))
    return branches


@router.post("/checkout", summary="Switch (or create) branch")
async def checkout_branch(body: CheckoutRequest, request: Request) -> dict:
    workspace = await get_agent_for_request(request)
    args = ["checkout"]
    if body.create:
        args += ["-b", body.branch]
    else:
        args.append(body.branch)
    rc, _, err = await _git(get_coding_dir(workspace), *args)
    if rc != 0:
        raise HTTPException(status_code=400, detail=err.strip())
    return {"branch": body.branch}


@router.get(
    "/diff",
    summary="Get diff for a file (or full staged/unstaged diff)",
)
async def get_diff(
    request: Request,
    path: str | None = None,
    staged: bool = False,
    untracked: bool = False,
) -> dict:
    """Return unified diff text."""
    workspace = await get_agent_for_request(request)
    cwd = get_coding_dir(workspace)

    if path and untracked:
        safe_join(cwd, path)
        rc, out, _ = await _git(
            cwd,
            "diff",
            "--no-index",
            "--",
            "/dev/null",
            path,
        )
        # --no-index returns exit code 1 when there is a diff
        if rc not in (0, 1):
            return {"diff": ""}
        return {"diff": out}

    args = ["diff"]
    if staged:
        args.append("--staged")
    if path:
        safe_join(cwd, path)
        args += ["--", path]
    rc, out, err = await _git(cwd, *args)
    if rc != 0:
        raise HTTPException(status_code=400, detail=err.strip())
    return {"diff": out}


@router.post("/stage", summary="Stage files (git add)")
async def stage_files(body: StageRequest, request: Request) -> dict:
    workspace = await get_agent_for_request(request)
    paths = body.paths if body.paths else ["."]
    rc, _, err = await _git(get_coding_dir(workspace), "add", "--", *paths)
    if rc != 0:
        raise HTTPException(status_code=400, detail=err.strip())
    return {"staged": paths}


@router.post("/unstage", summary="Unstage files (git restore --staged)")
async def unstage_files(body: UnstageRequest, request: Request) -> dict:
    workspace = await get_agent_for_request(request)
    paths = body.paths if body.paths else ["."]
    rc, _, err = await _git(
        get_coding_dir(workspace),
        "restore",
        "--staged",
        "--",
        *paths,
    )
    if rc != 0:
        raise HTTPException(status_code=400, detail=err.strip())
    return {"unstaged": paths}


@router.post("/commit", summary="Commit staged changes")
async def commit_changes(body: CommitRequest, request: Request) -> dict:
    if not body.message.strip():
        raise HTTPException(
            status_code=400,
            detail="Commit message cannot be empty",
        )
    workspace = await get_agent_for_request(request)
    rc, out, err = await _git(
        get_coding_dir(workspace),
        "commit",
        "-m",
        body.message.strip(),
    )
    if rc != 0:
        raise HTTPException(status_code=400, detail=err.strip() or out.strip())
    return {"committed": True, "output": out.strip()}


@router.post("/discard", summary="Discard working-directory changes")
async def discard_changes(body: DiscardRequest, request: Request) -> dict:
    """Discard unstaged changes.

    *  For tracked modified files: ``git restore <paths>``
    *  For untracked files: ``git clean -fd <paths>``

    Pass an empty ``paths`` list to discard **all** changes.
    """
    workspace = await get_agent_for_request(request)
    cwd = get_coding_dir(workspace)
    targets = body.paths if body.paths else ["."]
    # restore may fail for untracked files — that is expected
    rc_r, _, err_r = await _git(cwd, "restore", "--", *targets)
    rc_c, _, err_c = await _git(cwd, "clean", "-fd", "--", *targets)
    # Only raise if both failed
    if rc_r != 0 and rc_c != 0:
        detail = err_r.strip() or err_c.strip()
        if detail:
            raise HTTPException(status_code=400, detail=detail)
    return {"discarded": targets}


@router.get("/commit-diff", summary="Show diff introduced by a commit")
async def get_commit_diff(request: Request, commit_hash: str) -> dict:
    """Return ``git show`` output (stat + unified patch) for *commit_hash*."""
    workspace = await get_agent_for_request(request)
    cwd = get_coding_dir(workspace)
    rc, out, err = await _git(cwd, "show", "--stat", "--patch", commit_hash)
    if rc != 0:
        raise HTTPException(status_code=400, detail=err.strip())
    return {"diff": out, "hash": commit_hash}


@router.post(
    "/revert",
    summary="Revert a commit (creates a new revert commit)",
)
async def revert_commit(body: RevertRequest, request: Request) -> dict:
    """Run ``git revert --no-edit <hash>``.

    Creates a new commit that reverses the changes introduced by *hash*.
    """
    workspace = await get_agent_for_request(request)
    cwd = get_coding_dir(workspace)
    rc, out, err = await _git(
        cwd,
        "-c",
        "user.email=qwenpaw@localhost",
        "-c",
        "user.name=QwenPaw",
        "revert",
        "--no-edit",
        body.commit_hash,
    )
    if rc != 0:
        raise HTTPException(status_code=400, detail=err.strip() or out.strip())
    return {"reverted": body.commit_hash, "output": out.strip()}


@router.get(
    "/log",
    response_model=list[CommitInfo],
    summary="Recent commit log",
)
async def get_log(request: Request, limit: int = 20) -> list[CommitInfo]:
    workspace = await get_agent_for_request(request)
    fmt = "%H|%an|%ad|%s"
    rc, out, err = await _git(
        get_coding_dir(workspace),
        "log",
        f"-{limit}",
        f"--format={fmt}",
        "--date=short",
    )
    _raise_if_not_repo(rc, err)
    commits: list[CommitInfo] = []
    for line in out.splitlines():
        parts = line.split("|", 3)
        if len(parts) == 4:
            commits.append(
                CommitInfo(
                    hash=parts[0][:8],
                    author=parts[1],
                    date=parts[2],
                    message=parts[3],
                ),
            )
    return commits
