"""Cursor CLI integration - implements AICodeGeneratorProtocol.

Cursor CLI is a repo-aware AI coding tool that can:
  - Read the full repo context
  - Generate and apply code changes directly
  - Create commits with meaningful messages
  - Work with project-level instructions (.cursorrules)

This is the CORE code generation engine for P2HS when AI_PROVIDER=cursor.

Supports two invocation modes:
  1. Script (preferred): p2hs-cursor-agent.sh --dir <repo_path> --message "<prompt>" --yes [--rules <path>]
     Set CURSOR_AGENT_SCRIPT env var to the path of the bash wrapper on the server.
  2. Direct: cursor agent --workspace <repo_path> --print --force --trust <prompt>

Plan mode uses the native --plan flag:
  cursor agent --workspace <repo_path> --mode=plan --print --trust <prompt>

Agent apply mode persists a per-project Cursor chat id (``cursor_agent_session_id`` in project settings)
and passes ``--resume`` plus a stable ``P2HS_CURSOR_AGENT_HOME`` (via ``p2hs-cursor-agent.sh``) so each
codegen run continues the same CLI session when the stream-json output exposes a session id.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import shutil
from pathlib import Path
from typing import Awaitable, Callable, Optional

import structlog

from src.config import get_settings
from src.integrations.ai_provider import CodegenResult

logger = structlog.get_logger()

# ``asyncio`` subprocess stdout uses StreamReader with a default 64 KiB per-read line limit.
# Cursor ``stream-json`` can emit single NDJSON lines far larger (nested tool_call payloads).
_CURSOR_SUBPROCESS_STREAM_LIMIT = 16 * 1024 * 1024
_AGENT_PROMPT_REL = ".p2hs/ai-context/agent-prompt.md"
_AGENT_PROMPT_POINTER = (
    "Read .p2hs/ai-context/agent-prompt.md in full. "
    "That file is the complete task prompt for this run. Follow it."
)


def write_agent_prompt_file(repo_path: str, prompt: str) -> str:
    """Write the full prompt to disk and return a short argv pointer.

    Passing the combined system+user prompt as a spawn argument hits Linux
    ARG_MAX (``spawn E2BIG``) on GitHub-hosted runners.
    """
    dest = Path(repo_path) / _AGENT_PROMPT_REL
    dest.parent.mkdir(parents=True, exist_ok=True)
    dest.write_text(prompt or "", encoding="utf-8")
    return _AGENT_PROMPT_POINTER

# Optional second arg ``stream`` is "stdout" | "stderr" for WebSocket clients (backward compatible).
LineCallback = Optional[Callable[..., Awaitable[None]]]


# Strip obvious server filesystem paths from lines sent to WebSocket / transcript consumers.
_WS_PATH_RE = re.compile(
    r"(/home2?/[^\s:]+|/home/[^\s:]+|/Users/[^\s:]+|/private/var/[^\s:]+|/var/[^\s:]+|\\\\Users\\\\[^\s:\\]+)",
    re.IGNORECASE,
)


def _redact_paths_for_ws(line: str) -> str:
    return _WS_PATH_RE.sub("[path]", line)


def _cursor_agent_host_crash_hint(combined_stdout_stderr: str) -> str:
    """If the agent segfaulted / dumped core, return a short operator hint for WS + logs."""
    c = (combined_stdout_stderr or "").lower()
    if "dumped core" not in c and "segmentation fault" not in c:
        return ""
    return (
        "The Cursor CLI agent crashed on the worker (process ended with a core dump). "
        "This is an environment or Cursor binary issue, not Git. "
        "Try: update/reinstall the `agent` binary for this OS; set CURSOR_AGENT_STREAM_PROGRESS=false "
        "to disable stream-json; set CURSOR_AGENT_COREUTILS_TIMEOUT=false to drop GNU `timeout` from the shell wrapper."
    )


def _apply_cursor_agent_home_env(env: dict, agent_home: str) -> None:
    """Point Cursor CLI state at a persistent per-project directory (direct ``agent`` invocation)."""
    root = agent_home.rstrip("/\\")
    env["HOME"] = root
    env["XDG_CONFIG_HOME"] = os.path.join(root, ".config")
    env["XDG_DATA_HOME"] = os.path.join(root, ".local", "share")
    env["XDG_CACHE_HOME"] = os.path.join(root, ".cache")
    env["XDG_STATE_HOME"] = os.path.join(root, ".local", "state")


# NDJSON keys that may carry the Cursor Agent chat/session id (schema varies by CLI version).
_CURSOR_SESSION_ID_KEYS = (
    "session_id",
    "chat_id",
    "conversation_id",
    "thread_id",
    "cursor_session_id",
)


def _extract_cursor_session_id_from_agent_output(stdout: str) -> Optional[str]:
    """Parse stream-json (or mixed) stdout for a session/chat id to use with ``--resume``."""
    last: Optional[str] = None
    for raw in stdout.splitlines():
        line = raw.strip()
        if not line:
            continue
        try:
            obj = json.loads(line)
        except json.JSONDecodeError:
            continue
        if not isinstance(obj, dict):
            continue
        candidates: list[dict] = [obj]
        inner = obj.get("data")
        if isinstance(inner, dict):
            candidates.append(inner)
        msg = obj.get("message")
        if isinstance(msg, dict):
            candidates.append(msg)
        for c in candidates:
            for k in _CURSOR_SESSION_ID_KEYS:
                v = c.get(k)
                if isinstance(v, str) and len(v.strip()) >= 8:
                    last = v.strip()
        # event payloads e.g. {"type":"system","subtype":"init","session_id":"..."}
        for k in _CURSOR_SESSION_ID_KEYS:
            v = obj.get(k)
            if isinstance(v, str) and len(v.strip()) >= 8:
                last = v.strip()
    return last


def _text_from_cursor_content(content: object) -> str:
    """Flatten Cursor message content (string, text parts, or nested dict) to plain text."""
    if isinstance(content, str):
        return content.strip()
    if isinstance(content, list):
        parts: list[str] = []
        for part in content:
            if isinstance(part, str) and part.strip():
                parts.append(part)
            elif isinstance(part, dict):
                if part.get("type") in (None, "text"):
                    text = part.get("text")
                    if isinstance(text, str) and text.strip():
                        parts.append(text)
        return "".join(parts).strip()
    if isinstance(content, dict):
        return _text_from_cursor_content(
            content.get("content") or content.get("text") or content.get("result") or ""
        )
    return ""


def _assistant_text_from_stream_json(stdout: str) -> str:
    """Extract the user-facing assistant paragraph from Cursor NDJSON.

    Cursor emits ``{"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}}``
    and a final ``{"type":"result","result":"..."}``. Tool/thinking events are ignored so
    git plumbing and ``[Shell]`` markers never become the chat reply.
    """
    assistant_chunks: list[str] = []
    result_text = ""
    for raw in stdout.splitlines():
        line = raw.strip()
        if not line:
            continue
        try:
            obj = json.loads(line)
        except json.JSONDecodeError:
            continue
        if not isinstance(obj, dict):
            continue
        t = (obj.get("type") or obj.get("kind") or "").lower()
        if t == "result":
            extracted = _text_from_cursor_content(
                obj.get("result") or obj.get("text") or obj.get("message")
            )
            if extracted:
                result_text = extracted
            continue
        if t not in ("assistant", "message", "text"):
            continue
        message = obj.get("message")
        extracted = ""
        if isinstance(message, dict):
            extracted = _text_from_cursor_content(message.get("content") or message.get("text"))
        if not extracted:
            extracted = _text_from_cursor_content(
                obj.get("text") or obj.get("content") or message
            )
        if extracted:
            assistant_chunks.append(extracted)
    combined = result_text or "\n".join(assistant_chunks)
    return combined.strip()[:8000]


async def _emit_line(
    line_callback: LineCallback,
    line: str,
    stream: str = "stdout",
    *,
    detail_format: Optional[str] = None,
) -> None:
    if not line_callback:
        return
    safe = _redact_paths_for_ws(line)
    try:
        await line_callback(safe, stream, detail_format=detail_format)
    except TypeError:
        try:
            await line_callback(safe, stream)
        except TypeError:
            await line_callback(safe)


class CursorCLIProvider:
    provider_name = "cursor"

    def __init__(self, cli_path: Optional[str] = None, api_key: Optional[str] = None, model: Optional[str] = None):
        settings = get_settings()
        self._cli = cli_path or settings.cursor_cli_path
        self._script = settings.cursor_agent_script
        self._timeout = settings.cursor_cli_timeout
        self._api_key = api_key or settings.cursor_api_key
        self._model = model or settings.cursor_model
        self._stream_progress = bool(getattr(settings, "cursor_agent_stream_progress", False))
        self._coreutils_timeout = bool(getattr(settings, "cursor_agent_coreutils_timeout", True))

    def _build_env(self, *, plan_mode: bool) -> dict:
        env_extra: dict[str, str] = {}
        if self._api_key:
            env_extra["CURSOR_API_KEY"] = self._api_key
        env_extra["CURSOR_TIMEOUT"] = str(max(int(self._timeout) - 10, 60))
        if self._stream_progress and not plan_mode:
            env_extra["CURSOR_OUTPUT_FORMAT"] = "stream-json"
            env_extra["CURSOR_STREAM_PARTIAL_OUTPUT"] = "1"
        if not self._coreutils_timeout:
            env_extra["P2HS_CURSOR_AGENT_NO_COREUTILS_TIMEOUT"] = "1"
        return {**os.environ, **env_extra}

    async def _run_cli_streaming(
        self,
        repo_path: str,
        prompt: str,
        system_prompt: Optional[str] = None,
        plan_mode: bool = False,
        line_callback: LineCallback = None,
        *,
        resume_session_id: Optional[str] = None,
        agent_home: Optional[str] = None,
        project_meta_path: Optional[str] = None,
    ) -> tuple[str, str, int]:
        """Execute Cursor CLI, streaming stdout/stderr line-by-line to line_callback.

        Returns (full_stdout, full_stderr, returncode).
        """
        rules_file_for_script: Optional[str] = None
        if system_prompt:
            if project_meta_path:
                os.makedirs(project_meta_path, exist_ok=True)
                canon = os.path.join(project_meta_path, ".cursorrules")
                with open(canon, "w", encoding="utf-8") as f:
                    f.write(system_prompt)
                use_script = bool(
                    self._script and os.path.isfile(self._script) and not plan_mode
                )
                if use_script:
                    rules_file_for_script = canon
                else:
                    shutil.copyfile(canon, os.path.join(repo_path, ".cursorrules"))
            else:
                with open(os.path.join(repo_path, ".cursorrules"), "w", encoding="utf-8") as f:
                    f.write(system_prompt)

        run_env = dict(self._build_env(plan_mode=plan_mode))
        stream_ws = self._stream_progress and not plan_mode
        detail_fmt = "stream-json" if stream_ws else None

        if agent_home:
            run_env["P2HS_CURSOR_AGENT_HOME"] = agent_home
            # Direct ``agent`` binary does not read the wrapper script; isolate like p2hs-cursor-agent.sh.
            if not (self._script and os.path.isfile(self._script) and not plan_mode):
                _apply_cursor_agent_home_env(run_env, agent_home)
        if resume_session_id:
            run_env["CURSOR_RESUME_SESSION_ID"] = resume_session_id.strip()

        if self._script and os.path.isfile(self._script) and not plan_mode:
            # Script wrapper doesn't support --plan; use direct invocation for plan mode.
            # Resume uses CURSOR_RESUME_SESSION_ID (see p2hs-cursor-agent.sh).
            cmd = [self._script, "--dir", repo_path, "--message", write_agent_prompt_file(repo_path, prompt)]
            if self._model:
                cmd += ["--model", self._model]
            if rules_file_for_script:
                cmd += ["--rules", rules_file_for_script]
            invoke_mode = "script"
            invoke_target = self._script
        else:
            # Direct agent invocation (prompt must be last)
            cmd = [self._cli, "--workspace", repo_path, "--print", "--force", "--trust"]
            if plan_mode:
                cmd += ["--mode", "plan"]
            if self._model:
                cmd += ["--model", self._model]
            if stream_ws:
                cmd += ["--output-format", "stream-json", "--stream-partial-output"]
            if resume_session_id:
                cmd += ["--resume", resume_session_id.strip()]
            cmd.append(write_agent_prompt_file(repo_path, prompt))
            invoke_mode = "direct"
            invoke_target = self._cli

        logger.info(
            "cursor_cli_invoke",
            mode=invoke_mode,
            invoke_target=os.path.basename(str(invoke_target)),
            repo_basename=os.path.basename(repo_path.rstrip("/\\")),
            plan_mode=plan_mode,
            stream_progress=stream_ws,
            timeout_s=self._timeout,
            prompt_chars=len(prompt or ""),
            has_api_key=bool(self._api_key),
            model_set=bool(self._model),
            resume_session=bool(resume_session_id),
            persistent_agent_home=bool(agent_home),
        )

        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=repo_path,
            env=run_env,
            limit=_CURSOR_SUBPROCESS_STREAM_LIMIT,
        )

        stdout_lines: list[str] = []
        stderr_lines: list[str] = []

        async def _read_stdout() -> None:
            assert proc.stdout is not None
            async for raw_line in proc.stdout:
                line = raw_line.decode("utf-8", errors="replace").rstrip()
                stdout_lines.append(line)
                if not line.strip():
                    continue
                try:
                    await _emit_line(
                        line_callback, line, "stdout", detail_format=detail_fmt
                    )
                except Exception:
                    pass

        async def _read_stderr() -> None:
            assert proc.stderr is not None
            async for raw_line in proc.stderr:
                line = raw_line.decode("utf-8", errors="replace").rstrip()
                stderr_lines.append(line)
                if not line.strip():
                    continue
                try:
                    await _emit_line(line_callback, line, "stderr", detail_format=None)
                except Exception:
                    pass

        try:
            await asyncio.wait_for(
                asyncio.gather(_read_stdout(), _read_stderr()),
                timeout=self._timeout,
            )
        except asyncio.TimeoutError:
            try:
                proc.kill()
            except ProcessLookupError:
                pass
            raise

        await proc.wait()
        out = "\n".join(stdout_lines)
        err = "\n".join(stderr_lines)
        rc = int(proc.returncode if proc.returncode is not None else -1)
        logger.info(
            "cursor_cli_finished",
            returncode=rc,
            plan_mode=plan_mode,
            stdout_lines=len(stdout_lines),
            stderr_lines=len(stderr_lines),
            stdout_chars=len(out),
            stderr_chars=len(err),
            success=rc == 0,
        )
        if rc != 0:
            crash_hint = _cursor_agent_host_crash_hint(f"{err}\n{out}")
            log_extra = {
                "returncode": rc,
                "plan_mode": plan_mode,
                "repo_path": repo_path,
                "stderr_tail": err[-1200:] if err else "",
                "stdout_tail": out[-800:] if out else "",
            }
            if crash_hint:
                log_extra["operator_hint"] = crash_hint
            logger.warning("cursor_cli_nonzero_exit", **log_extra)
        return out, err, rc

    async def _get_latest_commit(self, repo_path: str) -> tuple[str, str]:
        proc = await asyncio.create_subprocess_exec(
            "git", "log", "-1", "--format=%H%n%s",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=repo_path,
        )
        stdout, _ = await proc.communicate()
        lines = stdout.decode().strip().split("\n")
        sha = lines[0] if lines else ""
        message = lines[1] if len(lines) > 1 else ""
        return sha, message

    async def _get_changed_files(self, repo_path: str) -> list[str]:
        proc = await asyncio.create_subprocess_exec(
            "git", "diff", "--name-only", "HEAD~1",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=repo_path,
        )
        stdout, _ = await proc.communicate()
        return [f for f in stdout.decode().strip().split("\n") if f]

    async def _get_changed_files_detail(self, repo_path: str) -> list[dict]:
        proc = await asyncio.create_subprocess_exec(
            "git", "diff", "--numstat", "--diff-filter=AMCRD", "HEAD~1",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=repo_path,
        )
        stdout, _ = await proc.communicate()
        proc_status = await asyncio.create_subprocess_exec(
            "git", "diff", "--name-status", "--diff-filter=AMCRD", "HEAD~1",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=repo_path,
        )
        stdout_status, _ = await proc_status.communicate()
        status_lines = [l for l in stdout_status.decode().strip().split("\n") if l]
        status_map: dict[str, str] = {}
        for line in status_lines:
            parts = line.split("\t", 1)
            if len(parts) == 2:
                status_map[parts[1]] = parts[0][0]
        result = []
        for line in stdout.decode().strip().split("\n"):
            if not line:
                continue
            parts = line.split("\t")
            if len(parts) >= 3:
                add_s, del_s, path = parts[0], parts[1], parts[2]
                code = status_map.get(path, "M")
                change_type = "added" if code == "A" else "deleted" if code == "D" else "modified"
                result.append({
                    "path": path,
                    "change_type": change_type,
                    "lines_added": int(add_s) if add_s != "-" else 0,
                    "lines_removed": int(del_s) if del_s != "-" else 0,
                })
        return result

    async def generate_plan(
        self,
        system_prompt: str,
        user_prompt: str,
        repo_path: Optional[str] = None,
        line_callback: LineCallback = None,
        *,
        project_meta_path: Optional[str] = None,
    ) -> CodegenResult:
        """Generate an implementation plan using Cursor's --plan (read-only) mode.

        When repo_path is provided, Cursor reads the actual codebase and produces
        a context-aware plan. Without repo_path, returns a placeholder result.
        """
        if not repo_path:
            return CodegenResult(
                plan={"document": "Plan requires a repository path. Use repo-based mode."},
                summary="Plan requires repository access.",
                provider=self.provider_name,
            )

        try:
            stdout, stderr, rc = await self._run_cli_streaming(
                repo_path=repo_path,
                prompt=user_prompt,
                system_prompt=system_prompt,
                plan_mode=True,
                line_callback=line_callback,
                project_meta_path=project_meta_path,
            )

            # Cursor --plan outputs the plan to stdout
            plan_document = stdout.strip()
            if rc != 0 and not plan_document:
                plan_document = (
                    f"(Agent exited with code {rc}.)\n"
                    + (stderr.strip() or stdout.strip() or "No plan output.")
                )

            # Sanity-check: make sure no commits were made (plan mode should be read-only)
            rc_proc = await asyncio.create_subprocess_exec(
                "git", "status", "--porcelain",
                stdout=asyncio.subprocess.PIPE,
                cwd=repo_path,
            )
            status_out, _ = await rc_proc.communicate()
            if status_out.decode().strip():
                # Unexpected changes — restore clean state
                restore_proc = await asyncio.create_subprocess_exec(
                    "git", "checkout", ".",
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                    cwd=repo_path,
                )
                await restore_proc.communicate()
                logger.warning(
                    "cursor_plan_mode_unexpected_changes",
                    repo=repo_path,
                    note="restored working tree",
                )

            logger.info(
                "cursor_plan_mode_completed",
                repo=repo_path,
                plan_length=len(plan_document),
            )

            return CodegenResult(
                plan={"document": plan_document},
                summary=plan_document[:300] if plan_document else "Plan generated.",
                provider=self.provider_name,
                provider_invocation_failed=rc != 0,
            )
        except asyncio.TimeoutError:
            logger.error("cursor_plan_mode_timeout", repo=repo_path, timeout=self._timeout)
            return CodegenResult(
                plan={"document": ""},
                summary="Plan generation timed out. Please try a more focused request.",
                provider=self.provider_name,
                provider_invocation_failed=True,
            )
        except asyncio.LimitOverrunError as e:
            logger.error("cursor_plan_stream_line_too_long", error=str(e))
            return CodegenResult(
                plan={"document": ""},
                summary="Plan stream exceeded buffer size; retry with a smaller scope.",
                provider=self.provider_name,
                provider_invocation_failed=True,
            )
        except Exception as e:
            logger.error("cursor_plan_mode_error", error=str(e))
            return CodegenResult(
                plan={"document": ""},
                summary=f"Plan generation failed: {e}",
                provider=self.provider_name,
                provider_invocation_failed=True,
            )

    async def apply_changes(
        self,
        repo_path: str,
        plan: dict,
        system_prompt: str,
        line_callback: LineCallback = None,
        *,
        project_meta_path: Optional[str] = None,
    ) -> CodegenResult:
        user_prompt = plan.get("user_prompt", plan.get("original_user_prompt", ""))
        return await self.generate_and_apply(
            repo_path,
            system_prompt,
            user_prompt,
            line_callback=line_callback,
            project_meta_path=project_meta_path,
        )

    async def generate_and_apply(
        self,
        repo_path: str,
        system_prompt: str,
        user_prompt: str,
        line_callback: LineCallback = None,
        *,
        cursor_resume_session_id: Optional[str] = None,
        cursor_agent_home: Optional[str] = None,
        project_meta_path: Optional[str] = None,
    ) -> CodegenResult:
        """The primary flow: Cursor reads repo, applies changes, commits."""
        try:
            stdout, stderr, rc = await self._run_cli_streaming(
                repo_path=repo_path,
                prompt=user_prompt,
                system_prompt=system_prompt,
                plan_mode=False,
                line_callback=line_callback,
                resume_session_id=cursor_resume_session_id,
                agent_home=cursor_agent_home,
                project_meta_path=project_meta_path,
            )
            session_from_output = _extract_cursor_session_id_from_agent_output(stdout)
            sha, message = await self._get_latest_commit(repo_path)
            changed = await self._get_changed_files(repo_path)
            changed_detail = await self._get_changed_files_detail(repo_path)

            from_stream = _assistant_text_from_stream_json(stdout)
            agent_response = (
                from_stream.strip()
                or (message or "").strip()
                or (stdout.strip() if not self._stream_progress else "")
            )
            if rc != 0:
                crash = _cursor_agent_host_crash_hint(f"{stderr}\n{stdout}")
                tail = (stderr.strip() or stdout.strip())[-1500:]
                if crash:
                    agent_response = (
                        crash + ("\n\n---\n" + _redact_paths_for_ws(tail) if tail else "")
                    ).strip()
                elif tail:
                    agent_response = (
                        agent_response + "\n\n---\n" + _redact_paths_for_ws(tail)
                    ).strip()

            logger.info(
                "cursor_cli_completed",
                repo=repo_path,
                sha=sha[:8] if sha else "none",
                files_changed=len(changed),
                agent_returncode=rc,
                agent_response_preview=agent_response[:200] if agent_response else "",
                cursor_session_captured=bool(session_from_output),
            )

            return CodegenResult(
                commit_sha=sha,
                summary=agent_response,
                files_changed=changed,
                files_changed_detail=changed_detail if changed_detail else None,
                provider=self.provider_name,
                provider_invocation_failed=rc != 0,
                cursor_session_id=session_from_output,
            )
        except asyncio.TimeoutError:
            logger.error("cursor_cli_timeout", repo=repo_path, timeout=self._timeout)
            return CodegenResult(
                summary="Code generation took too long. Please try a more focused request.",
                provider=self.provider_name,
                provider_invocation_failed=True,
            )
        except asyncio.LimitOverrunError as e:
            # Should be rare after raising subprocess stream limit; keep explicit log if it regresses.
            logger.error(
                "cursor_cli_stream_line_too_long",
                error=str(e),
                hint="Increase _CURSOR_SUBPROCESS_STREAM_LIMIT if legitimate tool output exceeds it.",
            )
            return CodegenResult(
                summary="The coding agent stream exceeded the line-size buffer. Retry the request or shorten the task.",
                provider=self.provider_name,
                provider_invocation_failed=True,
            )
        except FileNotFoundError:
            logger.error("cursor_cli_not_found", cli=self._cli)
            return CodegenResult(
                summary="Code generation service is temporarily unavailable.",
                provider=self.provider_name,
                provider_invocation_failed=True,
            )
        except Exception as e:
            logger.error("cursor_cli_error", error=str(e))
            return CodegenResult(
                summary="Code generation encountered an unexpected error. Please try again.",
                provider=self.provider_name,
                provider_invocation_failed=True,
            )
