"""Smart workspace auto-commit hook with conventional commit messages.

Replaces the per-turn ``_auto_commit_workspace()`` with an intelligent
session-close commit that analyzes ``git diff --stat``, categorizes
changes by file path, and generates meaningful commit messages.

Uses a shared ``asyncio.Lock`` (provided by ``BackgroundHookExecutor``)
to serialize git operations across concurrent session hooks, preventing
``.git/index.lock`` contention.

Key public symbols:

- ``WorkspaceAutoCommitHook``  — Implements ``SessionLifecycleHook``.
- ``COMMIT_CATEGORIES``        — Path prefix → commit prefix mapping.
- ``EXTENSION_CATEGORIES``     — File extension → commit prefix mapping.
"""

from __future__ import annotations

import asyncio
import logging
import os
import time
import subprocess

from core.session_hooks import HookContext
from core.initialization_manager import initialization_manager

logger = logging.getLogger(__name__)

# Path prefix → conventional commit prefix
COMMIT_CATEGORIES: dict[str, str] = {
    ".context/": "framework",
    ".claude/skills/": "skills",
    ".claude/settings/": "config",
    ".claude/mcps/": "config",
    "Knowledge/": "content",
    "Projects/": "project",
}

# File extension → conventional commit prefix
EXTENSION_CATEGORIES: dict[str, str] = {
    ".pdf": "output",
    ".pptx": "output",
    ".docx": "output",
    ".png": "output",
    ".jpg": "output",
}

DEFAULT_CATEGORY = "chore"


class WorkspaceAutoCommitHook:
    """Smart git commit at session close with conventional commit messages.

    Analyzes changed files via ``git diff --stat``, categorizes them by
    path pattern, generates a meaningful commit message, and skips
    trivial changes.

    Accepts an optional shared ``asyncio.Lock`` to serialize git
    operations across concurrent hook executions (multiple sessions
    closing at the same time).

    All git subprocesses have a 10-second timeout to prevent hanging on
    ``.git/index.lock`` contention with live agent sessions.
    """

    name = "workspace_auto_commit"

    # Timeout for individual git subprocess calls (seconds).
    # Must be short — a hanging git command should fail fast,
    # not block the background hook for 30s.
    GIT_TIMEOUT = 10

    def __init__(self, git_lock: asyncio.Lock | None = None) -> None:
        self._git_lock = git_lock

    async def execute(self, context: HookContext) -> None:
        """Analyze changes and commit with a smart message."""
        ws_path = initialization_manager.get_cached_workspace_path()
        if self._git_lock:
            async with self._git_lock:
                await asyncio.to_thread(self._smart_commit, ws_path)
        else:
            await asyncio.to_thread(self._smart_commit, ws_path)

    @staticmethod
    def _cleanup_stale_git_lock(ws_path: str) -> None:
        """Remove stale .git/index.lock if no git process is using it.

        Prevents cascading failures when the backend was killed mid-commit.
        Uses ``pgrep`` to verify no live git process targets this repo.
        """
        lock_file = os.path.join(ws_path, ".git", "index.lock")
        if not os.path.exists(lock_file):
            return
        try:
            result = subprocess.run(
                ["pgrep", "-f", f"git.*{ws_path}"],
                capture_output=True, text=True, timeout=5,
            )
            if result.returncode != 0:  # No matching git process
                os.remove(lock_file)
                logger.warning("Removed stale .git/index.lock before auto-commit")
            else:
                logger.info("Git index.lock exists and git is running — skipping cleanup")
        except Exception as e:
            logger.warning("Failed to check/clean stale git lock: %s", e)

    def _smart_commit(self, ws_path: str) -> None:
        """Run git operations in a background thread.

        All subprocess calls use ``GIT_TIMEOUT`` to fail fast on lock
        contention rather than hanging.  A ``TimeoutExpired`` aborts the
        commit attempt — the changes will be picked up next time.
        """
        # 0. Clean stale lock from previous crash
        self._cleanup_stale_git_lock(ws_path)

        try:
            # 1. Check for changes
            status = subprocess.run(
                ["git", "status", "--porcelain"],
                cwd=ws_path, capture_output=True, text=True,
                timeout=self.GIT_TIMEOUT,
            )
            if not status.stdout.strip():
                return  # No changes

            # 2. Stage all changes
            add_result = subprocess.run(
                ["git", "add", "-A"],
                cwd=ws_path, capture_output=True,
                timeout=self.GIT_TIMEOUT,
            )
            if add_result.returncode != 0:
                logger.warning("git add failed: %s", add_result.stderr)
                return

            # 3. Analyze staged changes
            diff_stat = subprocess.run(
                ["git", "diff", "--cached", "--stat"],
                cwd=ws_path, capture_output=True, text=True,
                timeout=self.GIT_TIMEOUT,
            )
            changed_files = self._parse_diff_stat(diff_stat.stdout)

            # 4. Generate commit message
            if not changed_files:
                message = "chore: session changes"
            elif self._is_trivial(changed_files):
                message = f"chore: session sync ({len(changed_files)} files)"
            else:
                message = self._generate_commit_message(changed_files)

            # 5. Commit
            commit_result = subprocess.run(
                ["git", "commit", "-m", message],
                cwd=ws_path, capture_output=True,
                timeout=self.GIT_TIMEOUT,
            )
            logger.info("Auto-committed workspace: %s", message)

            # 5b. Emit GIT_COMMIT event for DDD cultivation v2
            if commit_result.returncode == 0:
                try:
                    from core.cultivation_dispatcher import (
                        EventType, emit_cultivation_event_threadsafe,
                    )
                    emit_cultivation_event_threadsafe(
                        EventType.GIT_COMMIT,
                        source="auto_commit_hook",
                        payload={"files": changed_files, "message": message},
                        priority=2,
                    )
                except Exception:
                    pass  # Non-blocking: cultivation emit failure never breaks commit

                # 5c. Emit git_commit event for job scheduler (code_intel reindex)
                try:
                    from jobs.scheduler import emit_event, load_state, save_state
                    sched_state = load_state()
                    emit_event(sched_state, "git_commit", data={
                        "files": changed_files,
                        "message": message,
                    })
                    save_state(sched_state)
                except Exception as e:
                    logger.debug("Failed to emit git_commit event: %s", e)

        except subprocess.TimeoutExpired:
            logger.warning(
                "Git operation timed out after %ds (likely index.lock contention) — "
                "skipping auto-commit, changes will be picked up next time",
                self.GIT_TIMEOUT,
            )
            return  # Don't push if commit failed

        # 6. Auto-push to remote (brain safety)
        # Runs AFTER the commit try-block so its exceptions don't get caught
        # by the commit timeout handler. _auto_push has its own exception handling.
        self._auto_push(ws_path)

    @staticmethod
    def _parse_diff_stat(diff_output: str) -> list[str]:
        """Extract file paths from ``git diff --stat`` output."""
        files = []
        for line in diff_output.strip().splitlines():
            if "|" in line:
                file_path = line.split("|")[0].strip()
                if file_path:
                    files.append(file_path)
        return files

    @staticmethod
    def _categorize_file(file_path: str) -> str:
        """Map a file path to a conventional commit category."""
        for prefix, category in COMMIT_CATEGORIES.items():
            if file_path.startswith(prefix):
                return category
        for ext, category in EXTENSION_CATEGORIES.items():
            if file_path.endswith(ext):
                return category
        return DEFAULT_CATEGORY

    def _is_trivial(self, files: list[str]) -> bool:
        """Check if all changes are trivial (only skill config syncs)."""
        return all(
            self._categorize_file(f) in ("skills", "chore")
            for f in files
        )

    def _generate_commit_message(self, files: list[str]) -> str:
        """Generate a conventional commit message from changed files."""
        categories: dict[str, int] = {}
        for f in files:
            cat = self._categorize_file(f)
            categories[cat] = categories.get(cat, 0) + 1

        if not categories:
            return "chore: session changes"

        dominant = max(categories, key=lambda k: (categories[k], k))
        total = sum(categories.values())

        if total == 1:
            return f"{dominant}: update {files[0]}"
        elif len(categories) == 1:
            return f"{dominant}: update {total} files"
        else:
            parts = [
                f"{cat} ({n})"
                for cat, n in sorted(categories.items(), key=lambda x: -x[1])
            ]
            return f"{dominant}: {', '.join(parts)}"

    def _auto_push(self, ws_path: str) -> None:
        """Push to remote if configured, reachable, and enough time has passed.

        Rate-limited: only pushes if last push was >4h ago OR >20 commits ahead.
        This avoids adding 15-45s network latency on every session close.
        Non-blocking: failure is logged, not raised.
        """
        try:
            # Check remote exists
            remote_check = subprocess.run(
                ["git", "remote", "get-url", "origin"],
                cwd=ws_path, capture_output=True, text=True, timeout=5,
            )
            if remote_check.returncode != 0:
                return  # No remote configured — silent skip

            # Rate limit: check commits ahead of remote.
            # Try origin/HEAD first; fall back to @{u} (upstream tracking ref)
            # because origin/HEAD only exists after clone, can be stale.
            commits_ahead = 0
            for ref in ("origin/HEAD", "@{u}"):
                ahead_check = subprocess.run(
                    ["git", "rev-list", "--count", f"{ref}..HEAD"],
                    cwd=ws_path, capture_output=True, text=True, timeout=5,
                )
                if ahead_check.returncode == 0:
                    try:
                        commits_ahead = int(ahead_check.stdout.strip())
                    except (ValueError, AttributeError):
                        continue
                    break

            if commits_ahead == 0:
                return  # Nothing to push

            if commits_ahead < 20:
                # Check time since last push (use reflog of remote tracking branch)
                last_push_check = subprocess.run(
                    ["git", "log", "-1", "--format=%ct", "@{u}"],
                    cwd=ws_path, capture_output=True, text=True, timeout=5,
                )
                try:
                    last_push_ts = int(last_push_check.stdout.strip())
                    hours_since_push = (time.time() - last_push_ts) / 3600
                except (ValueError, AttributeError):
                    hours_since_push = 999

                if hours_since_push < 4:
                    return  # Pushed recently enough, skip

            # Dry-run first (validates auth without side effects)
            dry_run = subprocess.run(
                ["git", "push", "--dry-run", "origin", "HEAD"],
                cwd=ws_path, capture_output=True, text=True, timeout=15,
            )
            if dry_run.returncode != 0:
                logger.warning("Auto-push dry-run failed: %s", dry_run.stderr[:200])
                return

            # Actual push
            push = subprocess.run(
                ["git", "push", "origin", "HEAD"],
                cwd=ws_path, capture_output=True, text=True, timeout=30,
            )
            if push.returncode == 0:
                logger.info("Auto-pushed %d commits to remote (brain safety)", commits_ahead)
            else:
                logger.warning("Auto-push failed: %s", push.stderr[:200])
        except subprocess.TimeoutExpired:
            logger.warning("Auto-push timed out (30s) — will retry next session")
        except Exception as exc:
            logger.warning("Auto-push error: %s", exc)
