from __future__ import annotations

import difflib
import http.client
import json
import os
import re
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast

from .base import ContextRequestResult, Patcher, PatchResult
from .payload_types import ContextRequestPayload, PatcherMeta, UsagePayload

OPENAI_API_KEY_ENV_FALLBACKS = ("Z_API", "OPENAI_API_KEY", "LLMDEBUG_EVAL_OPENAI_API_KEY")
_THREAD_HTTP_CLIENTS = threading.local()
_RETRYABLE_HTTP_ERRORS = (
    http.client.CannotSendRequest,
    http.client.ResponseNotReady,
    http.client.RemoteDisconnected,
    BrokenPipeError,
    ConnectionAbortedError,
    ConnectionResetError,
    TimeoutError,
    OSError,
)


@dataclass
class _PersistentHTTPClient:
    scheme: str
    host: str
    port: int
    _conn: http.client.HTTPConnection | http.client.HTTPSConnection | None = None
    _timeout_sec: float | None = None

    def _new_connection(
        self, *, timeout_sec: float
    ) -> http.client.HTTPConnection | http.client.HTTPSConnection:
        if self.scheme == "https":
            return http.client.HTTPSConnection(self.host, self.port, timeout=timeout_sec)
        return http.client.HTTPConnection(self.host, self.port, timeout=timeout_sec)

    def _ensure_connection(
        self, *, timeout_sec: float
    ) -> http.client.HTTPConnection | http.client.HTTPSConnection:
        if self._conn is None or self._timeout_sec != timeout_sec:
            self.close()
            self._conn = self._new_connection(timeout_sec=timeout_sec)
            self._timeout_sec = timeout_sec
        return self._conn

    def close(self) -> None:
        if self._conn is not None:
            try:
                self._conn.close()
            except Exception:
                pass
        self._conn = None
        self._timeout_sec = None

    def post_json(
        self,
        *,
        target: str,
        body: bytes,
        headers: dict[str, str],
        timeout_sec: float,
    ) -> tuple[int, str]:
        for attempt_idx in range(2):
            conn = self._ensure_connection(timeout_sec=timeout_sec)
            try:
                conn.request("POST", target, body=body, headers=headers)
                response = conn.getresponse()
                raw_text = response.read().decode("utf-8", errors="replace")
                if bool(getattr(response, "will_close", False)):
                    self.close()
                return int(getattr(response, "status", 0) or 0), raw_text
            except _RETRYABLE_HTTP_ERRORS:
                self.close()
                if attempt_idx == 0:
                    continue
                raise
        raise RuntimeError("unreachable")

PATCH_EDITS_JSON_SCHEMA: dict[str, Any] = {
    "type": "object",
    "required": ["edits"],
    "additionalProperties": False,
    "properties": {
        "edits": {
            "type": "array",
            "minItems": 1,
            "items": {
                "type": "object",
                "required": ["path", "find", "replace"],
                "additionalProperties": False,
                "properties": {
                    "path": {"type": "string", "minLength": 1},
                    "find": {"type": "string"},
                    "replace": {"type": "string"},
                    "count": {"type": "integer", "minimum": 0},
                },
            },
        }
    },
}


def _normalize_base_url(base_url: str) -> str:
    base = base_url.rstrip("/")
    # Already has a versioned path (e.g. /v1, /v4) — don't append /v1.
    if base.endswith(("/v1", "/v2", "/v3", "/v4")):
        return base
    return base + "/v1"


def _base_url_from_chat_url(url: str) -> str:
    suffix = "/chat/completions"
    if url.endswith(suffix):
        return url[: -len(suffix)]
    return url


def _first_nonempty_env(*keys: str) -> str:
    for key in keys:
        value = os.getenv(str(key), "")
        if isinstance(value, str) and value.strip():
            return value
    return ""


def _default_api_key() -> str:
    return _first_nonempty_env(*OPENAI_API_KEY_ENV_FALLBACKS) or "local"


def _chat_http_client_key(url: str) -> tuple[str, str, int]:
    parsed = urllib.parse.urlsplit(url)
    scheme = parsed.scheme.lower()
    if scheme not in {"http", "https"}:
        raise ValueError(f"unsupported URL scheme for chat completions: {parsed.scheme!r}")
    host = parsed.hostname
    if not host:
        raise ValueError(f"missing host in chat completions URL: {url!r}")
    port = parsed.port or (443 if scheme == "https" else 80)
    return scheme, host, port


def _chat_http_target(url: str) -> str:
    parsed = urllib.parse.urlsplit(url)
    path = parsed.path or "/"
    if parsed.query:
        return f"{path}?{parsed.query}"
    return path


def _should_use_proxy_transport(url: str) -> bool:
    parsed = urllib.parse.urlsplit(url)
    scheme = parsed.scheme.lower()
    if scheme not in {"http", "https"}:
        return False
    proxy_url = urllib.request.getproxies().get(scheme)
    if not proxy_url:
        return False
    host = parsed.hostname or ""
    return not urllib.request.proxy_bypass(host)


def _get_thread_http_client(*, url: str) -> _PersistentHTTPClient:
    cache = getattr(_THREAD_HTTP_CLIENTS, "clients", None)
    if not isinstance(cache, dict):
        cache = {}
        _THREAD_HTTP_CLIENTS.clients = cache
    key = _chat_http_client_key(url)
    client = cache.get(key)
    if not isinstance(client, _PersistentHTTPClient):
        client = _PersistentHTTPClient(scheme=key[0], host=key[1], port=key[2])
        cache[key] = client
    return client


def _nonnegative_int(value: Any, *, default: int = 0) -> int:
    try:
        coerced = int(value)
    except (TypeError, ValueError):
        return int(default)
    return max(0, coerced)


def _usage_counts_from_payload(usage: Any) -> tuple[int, int, int]:
    if not isinstance(usage, dict):
        return 0, 0, 0
    prompt_tokens = _nonnegative_int(usage.get("prompt_tokens"), default=0)
    completion_tokens = _nonnegative_int(usage.get("completion_tokens"), default=0)
    if prompt_tokens == 0 and completion_tokens == 0:
        prompt_tokens = _nonnegative_int(usage.get("input_tokens"), default=0)
        completion_tokens = _nonnegative_int(usage.get("output_tokens"), default=0)
    total_tokens = _nonnegative_int(
        usage.get("total_tokens"),
        default=prompt_tokens + completion_tokens,
    )
    return prompt_tokens, completion_tokens, total_tokens


def _usage_payload_from_counts(
    *, prompt_tokens: int, completion_tokens: int, total_tokens: int
) -> dict[str, int]:
    return {
        "prompt_tokens": _nonnegative_int(prompt_tokens, default=0),
        "completion_tokens": _nonnegative_int(completion_tokens, default=0),
        "total_tokens": _nonnegative_int(
            total_tokens,
            default=_nonnegative_int(prompt_tokens, default=0)
            + _nonnegative_int(completion_tokens, default=0),
        ),
    }


def _llm_call_count_from_meta(meta: PatcherMeta | None, *, default: int = 0) -> int:
    if not isinstance(meta, dict):
        return _nonnegative_int(default, default=0)
    value = meta.get("llm_call_count")
    if isinstance(value, int):
        return _nonnegative_int(value, default=0)
    return _nonnegative_int(default, default=0)


def _accumulate_llm_totals(
    *,
    totals: tuple[int, int, int, int],
    meta: PatcherMeta | None,
    default_calls: int = 0,
) -> tuple[int, int, int, int]:
    calls, prompt_tokens, completion_tokens, total_tokens = totals
    meta_calls = _llm_call_count_from_meta(meta, default=default_calls)
    meta_prompt, meta_completion, meta_total = _usage_counts_from_payload(
        meta.get("usage") if isinstance(meta, dict) else None
    )
    return (
        calls + meta_calls,
        prompt_tokens + meta_prompt,
        completion_tokens + meta_completion,
        total_tokens + meta_total,
    )


def _meta_with_llm_totals(
    *,
    meta: PatcherMeta,
    totals: tuple[int, int, int, int],
) -> PatcherMeta:
    calls, prompt_tokens, completion_tokens, total_tokens = totals
    out = cast(PatcherMeta, dict(meta))
    out["llm_call_count"] = _nonnegative_int(calls, default=0)
    out["usage"] = cast(
        UsagePayload,
        _usage_payload_from_counts(
        prompt_tokens=prompt_tokens,
        completion_tokens=completion_tokens,
        total_tokens=total_tokens,
        ),
    )
    return out


def _extract_unified_diff(text: str) -> str | None:
    s = _strip_think(text)
    s = s.strip("\n")
    if not s.strip():
        return None

    # Prefer fenced blocks if present.
    fence = re.search(r"```(?:diff|patch)?\n(.*?)\n```", s, re.DOTALL | re.IGNORECASE)
    if fence:
        candidate = fence.group(1).strip("\n")
        extracted = _extract_unified_diff(candidate)
        if extracted:
            return extracted

    lines = s.splitlines()

    # If it already looks like a git diff, keep from the first header.
    git = _extract_git_diff_blocks(lines)
    if git is not None:
        return git

    # Otherwise try to normalize "unified diff" blocks into git-apply format.
    normalized = _normalize_unified_blocks(lines)
    return normalized


def _strip_think(text: str) -> str:
    # Some reasoning models emit <think>...</think>; strip it.
    return re.sub(r"<think>.*?</think>\n?", "", text, flags=re.DOTALL | re.IGNORECASE)


def _normalize_unified_blocks(lines: list[str]) -> str | None:
    out_lines: list[str] = []
    i = 0
    n = len(lines)
    while i < n:
        line = lines[i]
        if not line.startswith("--- "):
            i += 1
            continue
        if i + 1 >= n or not lines[i + 1].startswith("+++ "):
            i += 1
            continue

        old_raw = lines[i][4:].strip()
        new_raw = lines[i + 1][4:].strip()
        old_path = _clean_path_token(old_raw)
        new_path = _clean_path_token(new_raw)
        if old_path is None or new_path is None:
            i += 2
            continue

        # Avoid accidental renames in model output (common mistake).
        if old_path != "/dev/null" and new_path != old_path:
            new_path = old_path

        out_lines.append(f"diff --git a/{old_path} b/{new_path}")
        out_lines.append(f"--- a/{old_path}")
        out_lines.append(f"+++ b/{new_path}")

        # Copy hunks until next file header, but stop on non-diff junk.
        i += 2
        seen_hunk = False
        while i < n:
            nxt = lines[i]
            if nxt.startswith("--- ") and (i + 1) < n and lines[i + 1].startswith("+++ "):
                break
            if nxt.startswith("@@"):
                seen_hunk = True
                out_lines.append(nxt)
                i += 1
                continue
            if seen_hunk and (
                nxt.startswith(" ")
                or nxt.startswith("+")
                or nxt.startswith("-")
                or nxt.startswith("\\")
            ):
                out_lines.append(nxt)
                i += 1
                continue
            # End of diff content (model started explaining, etc).
            break
        # Ensure a blank line between file diffs for readability.
        if out_lines and out_lines[-1] != "":
            out_lines.append("")

    if not out_lines:
        return None

    text = "\n".join(out_lines).rstrip("\n") + "\n"
    if "diff --git " not in text:
        return None
    return text


def _clean_path_token(raw: str) -> str | None:
    if not raw:
        return None
    # Common weird outputs:
    # - "--- buggy.py ---" (extra trailing token)
    # - "--- a/foo.py\t2026-..." (timestamps)
    token = raw.split()[0]
    if token in {"a/", "b/"}:
        return None
    token = token.removeprefix("a/")
    token = token.removeprefix("b/")
    if token == "/dev/null":
        return token
    return token


def _extract_git_diff_blocks(lines: list[str]) -> str | None:
    starts = [i for i, line in enumerate(lines) if line.startswith("diff --git ")]
    if not starts:
        return None

    captured: list[str] = []
    i = starts[0]
    n = len(lines)

    # Capture only "diff-like" lines; stop when model starts narrating.
    while i < n:
        line = lines[i]

        if line.startswith("diff --git "):
            captured.append(line)
            i += 1
            continue

        allowed = (
            line.startswith("index ")
            or line.startswith("new file mode ")
            or line.startswith("deleted file mode ")
            or line.startswith("old mode ")
            or line.startswith("new mode ")
            or line.startswith("similarity index ")
            or line.startswith("rename from ")
            or line.startswith("rename to ")
            or line.startswith("copy from ")
            or line.startswith("copy to ")
            or line.startswith("--- a/")
            or line == "--- /dev/null"
            or line.startswith("+++ b/")
            or line == "+++ /dev/null"
            or line.startswith("@@")
            or line.startswith(" ")
            or line.startswith("+")
            or line.startswith("-")
            or line.startswith("\\")
            or line == ""
        )
        if not allowed:
            break

        captured.append(line)
        i += 1

    text = "\n".join(captured).strip("\n") + "\n"
    if "diff --git " not in text:
        return None
    if "--- a/" not in text and "--- /dev/null" not in text:
        return None
    if "+++ b/" not in text and "+++ /dev/null" not in text:
        return None
    return text


@dataclass(frozen=True)
class OpenAICompatPatcher(Patcher):
    """OpenAI API-compatible chat-completions patcher.

    Tested against local OpenAI-compatible servers (vLLM/LM Studio/etc).
    """

    base_url: str
    model: str
    api_key: str | None = None
    timeout_sec: float = 120.0
    max_propose_sec: float = 0.0
    temperature: float = 0.0
    max_tokens: int = 2048
    response_mode: str = "edits"  # "edits" | "diff"
    force_patch_attempt: bool = False
    seed: int | None = None
    name: str = "openai"

    def request_context(
        self,
        *,
        prompt: str,
        context_dir: Path,
        cwd: Path,
    ) -> ContextRequestResult | None:
        del context_dir
        del cwd
        base_url = _normalize_base_url(self.base_url)
        url = f"{base_url}/chat/completions"
        api_key = self.api_key
        if api_key is None:
            api_key = _default_api_key()

        start = time.perf_counter()
        raw, meta = _request_context_via_json(
            url=url,
            api_key=api_key,
            model=self.model,
            prompt=prompt,
            temperature=self.temperature,
            max_tokens=self.max_tokens,
            timeout_sec=self.timeout_sec,
            seed=self.seed,
        )
        duration_sec = time.perf_counter() - start

        result_meta: PatcherMeta = dict(meta or {})
        result_meta.setdefault("base_url", base_url)
        result_meta.setdefault("model", self.model)
        result_meta.setdefault("temperature", self.temperature)
        result_meta.setdefault("max_tokens", self.max_tokens)
        result_meta.setdefault("response_mode", self.response_mode)
        result_meta.setdefault("api_compat_seed_requested", self.seed)
        result_meta.setdefault(
            "api_compat_seed_acknowledged", False if self.seed is not None else None
        )
        result_meta.setdefault("api_compat_seed_response", None)
        result_meta["request_context_duration_sec"] = duration_sec

        request_json, raw_response = _normalize_context_request_payload(raw)
        if request_json is None and raw_response is not None and "error" not in result_meta:
            result_meta["error"] = "invalid_request_context_payload"
        return ContextRequestResult(
            request_json=request_json,
            raw_response=raw_response,
            meta=result_meta,
        )

    def propose_patch(self, *, prompt: str, context_dir: Path, cwd: Path) -> PatchResult:
        del context_dir
        base_url = _normalize_base_url(self.base_url)
        url = f"{base_url}/chat/completions"

        api_key = self.api_key
        if api_key is None:
            api_key = _default_api_key()

        deadline_monotonic: float | None = None
        if self.max_propose_sec > 0:
            deadline_monotonic = time.monotonic() + self.max_propose_sec

        if self.response_mode == "edits":
            result = _propose_patch_via_edits(
                url=url,
                api_key=api_key,
                model=self.model,
                prompt=prompt,
                cwd=cwd,
                temperature=self.temperature,
                max_tokens=self.max_tokens,
                timeout_sec=self.timeout_sec,
                seed=self.seed,
                deadline_monotonic=deadline_monotonic,
            )
        else:
            diff_result = _propose_patch_via_diff(
                url=url,
                api_key=api_key,
                model=self.model,
                prompt=prompt,
                temperature=self.temperature,
                max_tokens=self.max_tokens,
                timeout_sec=self.timeout_sec,
                seed=self.seed,
                deadline_monotonic=deadline_monotonic,
            )
            if diff_result.diff is None and str((diff_result.meta or {}).get("error", "")) == "no_diff_found":
                edits_fallback = _propose_patch_via_edits(
                    url=url,
                    api_key=api_key,
                    model=self.model,
                    prompt=prompt,
                    cwd=cwd,
                    temperature=self.temperature,
                    max_tokens=self.max_tokens,
                    timeout_sec=self.timeout_sec,
                    seed=self.seed,
                    deadline_monotonic=deadline_monotonic,
                    allow_diff_fallback=False,
                )
                totals = (0, 0, 0, 0)
                diff_meta = dict(diff_result.meta or {})
                edits_meta = dict(edits_fallback.meta or {})
                totals = _accumulate_llm_totals(totals=totals, meta=diff_meta, default_calls=0)
                totals = _accumulate_llm_totals(totals=totals, meta=edits_meta, default_calls=0)
                merged_meta = dict(edits_meta)
                merged_meta["fallback_from"] = "diff"
                merged_meta["fallback_reason"] = "no_diff_found"
                merged_meta["fallback_meta"] = diff_meta
                if merged_meta.get("error") is None:
                    merged_meta.pop("error", None)
                result = PatchResult(
                    diff=edits_fallback.diff,
                    meta=_meta_with_llm_totals(meta=merged_meta, totals=totals),
                )
            else:
                result = diff_result

        result_meta = dict(result.meta or {})
        result_meta.setdefault("base_url", base_url)
        result_meta.setdefault("model", self.model)
        result_meta.setdefault("temperature", self.temperature)
        result_meta.setdefault("max_tokens", self.max_tokens)
        result_meta.setdefault("timeout_sec", self.timeout_sec)
        result_meta.setdefault("max_propose_sec", self.max_propose_sec)
        result_meta.setdefault("response_mode", self.response_mode)
        result_meta.setdefault("api_compat_seed_requested", self.seed)
        result_meta.setdefault(
            "api_compat_seed_acknowledged", False if self.seed is not None else None
        )
        result_meta.setdefault("api_compat_seed_response", None)
        result = PatchResult(diff=result.diff, meta=result_meta)

        if result.diff is not None or not self.force_patch_attempt:
            return result

        forced = _build_forced_attempt_diff(prompt=prompt, cwd=cwd)
        if forced is None:
            return result

        meta = dict(result.meta or {})
        meta["forced_patch_attempt"] = True
        meta["forced_patch_target"] = forced[0]
        meta["forced_patch_reason"] = "no_model_diff"
        return PatchResult(diff=forced[1], meta=meta)


def _request_context_via_json(
    *,
    url: str,
    api_key: str,
    model: str,
    prompt: str,
    temperature: float,
    max_tokens: int,
    timeout_sec: float,
    seed: int | None,
) -> tuple[dict[str, Any] | None, PatcherMeta]:
    messages = [
        {
            "role": "system",
            "content": (
                "Return only JSON for staged context requests. "
                "Use this schema exactly: "
                '{"need_more_context":true,"requests":[{"type":"file","path":"buggy.py"},{"type":"search","path":"buggy.py","pattern":"needle"},{"type":"frame","index":1}],"sufficiency_rationale":"..."} '
                "Allowed request types: file, search, frame. "
                "Use need_more_context=false with empty requests when evidence is sufficient. "
                "No prose and no markdown."
            ),
        },
        {"role": "user", "content": prompt},
    ]

    totals = (0, 0, 0, 0)
    raw, meta = _chat_completion(
        url=url,
        api_key=api_key,
        model=model,
        messages=messages,
        temperature=temperature,
        max_tokens=max_tokens,
        timeout_sec=timeout_sec,
        seed=seed,
        extra_payload={"response_format": {"type": "json_object"}},
    )
    totals = _accumulate_llm_totals(totals=totals, meta=meta, default_calls=1)
    if raw is None:
        err = str(meta.get("error", ""))
        body = str(meta.get("body", ""))
        if err == "http_error" and "response_format" in body:
            raw, meta = _chat_completion(
                url=url,
                api_key=api_key,
                model=model,
                messages=messages,
                temperature=temperature,
                max_tokens=max_tokens,
                timeout_sec=timeout_sec,
                seed=seed,
            )
            totals = _accumulate_llm_totals(totals=totals, meta=meta, default_calls=1)
    meta = _meta_with_llm_totals(meta=cast(PatcherMeta, dict(meta)), totals=totals)
    return raw, meta


def _normalize_context_request_payload(
    response: dict[str, Any] | None,
) -> tuple[ContextRequestPayload | None, str | None]:
    if response is None:
        return None, None

    content = _get_first_choice_content(response)
    if content is None:
        return None, _safe_trunc(response)
    payload = _extract_json_object(content)
    if payload is None:
        return None, content

    normalized: ContextRequestPayload = {
        "need_more_context": bool(payload.get("need_more_context")),
        "sufficiency_rationale": str(payload.get("sufficiency_rationale") or ""),
    }
    requests = payload.get("requests")
    if isinstance(requests, list):
        normalized["requests"] = requests
    else:
        normalized["requests"] = []
    return normalized, content


def _propose_patch_via_diff(
    *,
    url: str,
    api_key: str,
    model: str,
    prompt: str,
    temperature: float,
    max_tokens: int,
    timeout_sec: float,
    seed: int | None,
    deadline_monotonic: float | None = None,
) -> PatchResult:
    base_messages = [
        {
            "role": "system",
            "content": (
                "You fix failing tests by returning a single unified diff. "
                "Return ONLY the diff; no explanations. Do not use Markdown fences."
            ),
        },
        {"role": "user", "content": prompt},
    ]

    # Try twice: second attempt strengthens formatting instructions without assistant prefill.
    attempts: list[list[dict[str, str]]] = [
        base_messages,
        [
            {
                "role": "system",
                "content": (
                    "You fix failing tests by returning a single unified diff. "
                    "Return ONLY the diff; no explanations. Do not use Markdown fences. "
                    "Start your response with 'diff --git a/'."
                ),
            },
            {"role": "user", "content": prompt},
        ],
    ]

    last_meta: PatcherMeta | None = None
    totals = (0, 0, 0, 0)
    for idx, messages in enumerate(attempts, start=1):
        raw, meta = _chat_completion(
            url=url,
            api_key=api_key,
            model=model,
            messages=messages,
            temperature=temperature,
            max_tokens=max_tokens,
            timeout_sec=timeout_sec,
            seed=seed,
            deadline_monotonic=deadline_monotonic,
        )
        totals = _accumulate_llm_totals(totals=totals, meta=meta, default_calls=1)
        last_meta = _meta_with_llm_totals(meta={"attempt": idx, **meta}, totals=totals)
        if raw is None:
            if _is_request_budget_exceeded(meta):
                break
            continue

        content = _get_first_choice_content(raw)
        if content is None:
            last_meta["error"] = "no_choice_content"
            last_meta["response"] = _safe_trunc(raw)
            continue

        diff = _extract_unified_diff(content)
        if diff is None:
            last_meta["error"] = "no_diff_found"
            last_meta["raw_response"] = content[:2000]
            continue

        fixed = _fix_hunk_counts(diff)
        return PatchResult(diff=fixed, meta=last_meta)

    if last_meta is None:
        last_meta = _meta_with_llm_totals(meta={"error": "unknown"}, totals=totals)
    return PatchResult(diff=None, meta=last_meta)


def _propose_patch_via_edits(
    *,
    url: str,
    api_key: str,
    model: str,
    prompt: str,
    cwd: Path,
    temperature: float,
    max_tokens: int,
    timeout_sec: float,
    seed: int | None,
    deadline_monotonic: float | None = None,
    allow_diff_fallback: bool = True,
) -> PatchResult:
    messages = [
        {
            "role": "system",
            "content": (
                "Return only JSON with this schema: "
                '{"edits":[{"path":"relative/path.py","find":"exact old text","replace":"new text","count":1}]}. '
                "No prose. No markdown. "
                "Use only exact existing paths from the prompt's available file list. "
                "Never use placeholder paths like relative/path.py."
            ),
        },
        {"role": "user", "content": prompt},
    ]

    totals = (0, 0, 0, 0)

    def _with_totals(meta: PatcherMeta) -> PatcherMeta:
        return _meta_with_llm_totals(meta=meta, totals=totals)

    payload: dict[str, Any] | None = None
    meta: PatcherMeta = {}
    stages = [
        (
            "json_schema",
            {"response_format": {"type": "json_schema", "schema": PATCH_EDITS_JSON_SCHEMA}},
        ),
        ("json_object", {"response_format": {"type": "json_object"}}),
        ("plain", None),
    ]
    stage_attempts: list[PatcherMeta] = []
    fallback_reason: str | None = None
    fallback_raw_response: str | None = None
    fallback_payload: dict[str, Any] | None = None

    def _fallback_to_diff(
        *,
        reason: str,
        extra_error_meta: PatcherMeta | None = None,
    ) -> PatchResult:
        nonlocal totals
        if not allow_diff_fallback:
            error_meta: PatcherMeta = {"error": reason, **meta}
            if extra_error_meta:
                error_meta.update(extra_error_meta)
            return PatchResult(diff=None, meta=_with_totals(error_meta))

        diff_fallback = _propose_patch_via_diff(
            url=url,
            api_key=api_key,
            model=model,
            prompt=prompt,
            temperature=temperature,
            max_tokens=max_tokens,
            timeout_sec=timeout_sec,
            seed=seed,
            deadline_monotonic=deadline_monotonic,
        )
        if diff_fallback.diff is not None:
            fallback_meta = dict(diff_fallback.meta or {})
            totals = _accumulate_llm_totals(totals=totals, meta=fallback_meta, default_calls=0)
            fallback_meta["fallback_from"] = "edits"
            fallback_meta["fallback_reason"] = reason
            fallback_meta["edits_response_mode"] = str(meta.get("structured_output_mode") or "")
            fallback_meta["edits_response_attempts"] = stage_attempts
            if extra_error_meta:
                fallback_meta["fallback_trigger"] = dict(extra_error_meta)
            return PatchResult(diff=diff_fallback.diff, meta=_with_totals(fallback_meta))

        fallback_meta = dict(diff_fallback.meta or {})
        totals = _accumulate_llm_totals(totals=totals, meta=fallback_meta, default_calls=0)
        error_meta: PatcherMeta = {
            "error": reason,
            "fallback_meta": fallback_meta,
            **meta,
        }
        if extra_error_meta:
            error_meta.update(extra_error_meta)
        return PatchResult(diff=None, meta=_with_totals(error_meta))

    for stage_name, extra_payload in stages:
        raw, stage_meta = _chat_completion(
            url=url,
            api_key=api_key,
            model=model,
            messages=messages,
            temperature=temperature,
            max_tokens=max_tokens,
            timeout_sec=timeout_sec,
            seed=seed,
            extra_payload=extra_payload,
            deadline_monotonic=deadline_monotonic,
        )
        totals = _accumulate_llm_totals(totals=totals, meta=stage_meta, default_calls=1)
        meta = dict(stage_meta)
        meta["structured_output_mode"] = stage_name
        attempt: PatcherMeta = {
            "mode": stage_name,
            "ok": raw is not None,
            "error": str(meta.get("error", "")),
            "status": meta.get("status"),
        }
        if raw is None:
            stage_attempts.append(attempt)
            # Don't spam retries when prompt already overflows available context.
            if _is_context_exceeded_http_error(meta) or _is_request_budget_exceeded(meta):
                attempt["terminal"] = True
                break
            continue

        raw_reasoning = _get_first_choice_reasoning_content(raw)
        if raw_reasoning is not None:
            meta["reasoning_content_present"] = bool(raw_reasoning.strip())
            meta["reasoning_content_chars"] = len(raw_reasoning)

        content = _get_first_choice_content(raw)
        if content is None:
            attempt["parse_error"] = "no_choice_content"
            stage_attempts.append(attempt)
            fallback_reason = "no_json_payload"
            fallback_raw_response = _safe_trunc(raw)
            continue

        payload_candidate = _extract_json_object(content)
        if payload_candidate is None:
            attempt["parse_error"] = "no_json_payload"
            stage_attempts.append(attempt)
            fallback_reason = "no_json_payload"
            fallback_raw_response = content[:2000]
            continue

        edits, edits_shape = _normalize_edits_payload(payload_candidate)
        if edits is None:
            attempt["parse_error"] = "missing_edits"
            stage_attempts.append(attempt)
            fallback_reason = "missing_edits"
            fallback_payload = payload_candidate
            continue

        payload = dict(payload_candidate)
        payload["edits"] = edits
        if edits_shape is not None:
            attempt["edits_shape"] = edits_shape
            meta["edits_payload_shape"] = edits_shape
        stage_attempts.append(attempt)
        break

    if stage_attempts:
        meta["structured_output_attempts"] = stage_attempts

    if payload is None:
        # If prompt is already over context budget, a diff fallback call will also fail.
        if _is_context_exceeded_http_error(meta):
            return PatchResult(diff=None, meta=_with_totals(dict(meta)))
        if fallback_reason is None:
            return PatchResult(diff=None, meta=_with_totals(dict(meta)))

        error_meta: PatcherMeta = {}
        if fallback_reason == "missing_edits":
            error_meta["payload"] = _safe_trunc(fallback_payload) if fallback_payload else "{}"
        else:
            error_meta["raw_response"] = fallback_raw_response or ""
        return _fallback_to_diff(reason=fallback_reason, extra_error_meta=error_meta)

    edits = payload.get("edits")
    if not isinstance(edits, list) or not edits:
        # Defensive guard: schema/validation above should prevent this.
        return PatchResult(diff=None, meta=_with_totals(dict(meta)))

    changed: list[tuple[str, str, str]] = []
    find_recovery_modes: list[str] = []
    for entry in edits:
        if not isinstance(entry, dict):
            return PatchResult(
                diff=None,
                meta=_with_totals({"error": "bad_edit_entry", "entry": _safe_trunc(entry), **meta}),
            )

        path = entry.get("path")
        find = entry.get("find", entry.get("old"))
        replace = entry.get("replace", entry.get("new"))
        count_raw = entry.get("count", 1)
        count = count_raw if isinstance(count_raw, int) else 1

        if not isinstance(path, str) or not path.strip():
            return PatchResult(
                diff=None,
                meta=_with_totals({"error": "bad_path", "entry": _safe_trunc(entry), **meta}),
            )
        if not isinstance(find, str) or not isinstance(replace, str):
            return PatchResult(
                diff=None,
                meta=_with_totals(
                    {"error": "bad_find_replace", "entry": _safe_trunc(entry), **meta}
                ),
            )
        if count < 0:
            count = 0

        file_path = (cwd / path).resolve()
        try:
            file_path.relative_to(cwd.resolve())
        except Exception:
            return PatchResult(
                diff=None,
                meta=_with_totals({"error": "path_outside_case", "path": path, **meta}),
            )
        if not file_path.exists():
            return PatchResult(
                diff=None,
                meta=_with_totals({"error": "file_not_found", "path": path, **meta}),
            )

        before = file_path.read_text(encoding="utf-8")
        after, match_mode = _apply_find_replace_with_recovery(
            before=before,
            find=find,
            replace=replace,
            count=count,
        )
        if after is None:
            return _fallback_to_diff(
                reason="find_not_found",
                extra_error_meta={"path": path, "find_preview": find[:200]},
            )
        normalized_mode = match_mode.removesuffix("_noop")
        if normalized_mode != "exact":
            find_recovery_modes.append(normalized_mode)
        if after == before:
            continue
        changed.append((path, before, after))

    if not changed:
        return _fallback_to_diff(reason="no_effective_changes")

    if find_recovery_modes:
        unique_modes = sorted(set(find_recovery_modes))
        meta["find_recovery_applied"] = True
        meta["find_recovery_mode"] = unique_modes[0] if len(unique_modes) == 1 else "mixed"
        meta["find_recovery_modes"] = unique_modes
    else:
        meta["find_recovery_applied"] = False

    diff = _build_diff_from_changes(changed)
    return PatchResult(diff=diff, meta=_with_totals(dict(meta)))


def _chat_completion(
    *,
    url: str,
    api_key: str,
    model: str,
    messages: list[dict[str, str]],
    temperature: float,
    max_tokens: int,
    timeout_sec: float,
    seed: int | None,
    extra_payload: dict[str, Any] | None = None,
    deadline_monotonic: float | None = None,
) -> tuple[dict[str, Any] | None, PatcherMeta]:
    payload: dict[str, Any] = {
        "model": model,
        "messages": messages,
        "temperature": temperature,
        "max_tokens": max_tokens,
    }
    if seed is not None:
        payload["seed"] = seed
    if extra_payload:
        payload.update(extra_payload)

    remaining_budget_sec = _remaining_request_budget_sec(deadline_monotonic)
    effective_timeout_sec = timeout_sec
    if remaining_budget_sec is not None:
        effective_timeout_sec = min(timeout_sec, remaining_budget_sec)

    base_meta: PatcherMeta = {
        "model": model,
        "base_url": _base_url_from_chat_url(url),
        "temperature": temperature,
        "max_tokens": max_tokens,
        "timeout_sec": timeout_sec,
        "effective_timeout_sec": effective_timeout_sec,
        "remaining_budget_sec": remaining_budget_sec,
        "llm_call_count": 1,
        "usage": _usage_payload_from_counts(prompt_tokens=0, completion_tokens=0, total_tokens=0),
        "api_compat_seed_requested": seed,
        "api_compat_seed_acknowledged": False if seed is not None else None,
        "api_compat_seed_response": None,
    }

    if effective_timeout_sec <= 0:
        return None, {
            **base_meta,
            "error": "request_budget_exceeded",
            "llm_call_count": 0,
        }

    headers = {
        "Content-Type": "application/json",
        "Accept": "application/json",
        "Authorization": f"Bearer {api_key}",
    }

    raw_text = None
    payload_bytes = json.dumps(payload).encode("utf-8")
    try:
        if _should_use_proxy_transport(url):
            req = urllib.request.Request(
                url=url,
                data=payload_bytes,
                headers=headers,
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=effective_timeout_sec) as resp:
                status = int(getattr(resp, "status", 0) or 0)
                raw_text = resp.read().decode("utf-8", errors="replace")
        else:
            client = _get_thread_http_client(url=url)
            status, raw_text = client.post_json(
                target=_chat_http_target(url),
                body=payload_bytes,
                headers=headers,
                timeout_sec=effective_timeout_sec,
            )
    except urllib.error.HTTPError as e:
        body = ""
        try:
            body = e.read().decode("utf-8", errors="replace")
        except Exception:
            body = ""
        return None, {**base_meta, "error": "http_error", "status": e.code, "body": body[:2000]}
    except ValueError as e:
        return None, {**base_meta, "error": "bad_request_url", "exc": f"{type(e).__name__}: {e}"}
    except Exception as e:
        return None, {**base_meta, "error": "request_failed", "exc": f"{type(e).__name__}: {e}"}

    if status >= 400:
        return None, {**base_meta, "error": "http_error", "status": status, "body": raw_text[:2000]}

    try:
        data = json.loads(raw_text) if raw_text is not None else {}
    except Exception:
        return None, {**base_meta, "error": "bad_json_response", "body": (raw_text or "")[:2000]}

    meta: PatcherMeta = dict(base_meta)
    usage = data.get("usage")
    if isinstance(usage, dict):
        prompt_tokens, completion_tokens, total_tokens = _usage_counts_from_payload(usage)
        meta["usage"] = _usage_payload_from_counts(
            prompt_tokens=prompt_tokens,
            completion_tokens=completion_tokens,
            total_tokens=total_tokens,
        )
    if seed is not None:
        response_seed = _extract_response_seed(data)
        meta["api_compat_seed_response"] = response_seed
        meta["api_compat_seed_acknowledged"] = response_seed == seed
    reasoning = _get_first_choice_reasoning_content(data)
    if reasoning is not None:
        meta["reasoning_content_present"] = bool(reasoning.strip())
        meta["reasoning_content_chars"] = len(reasoning)
    return data if isinstance(data, dict) else None, meta


def _get_first_choice_content(resp: dict[str, Any]) -> str | None:
    choices = resp.get("choices")
    if not isinstance(choices, list) or not choices:
        return None
    c0 = choices[0]
    if not isinstance(c0, dict):
        return None
    msg = c0.get("message")
    if isinstance(msg, dict):
        return _coerce_content_to_text(msg.get("content"))
    # Some servers return `text` (completions-like).
    text = c0.get("text")
    return text if isinstance(text, str) else None


def _get_first_choice_reasoning_content(resp: dict[str, Any]) -> str | None:
    choices = resp.get("choices")
    if not isinstance(choices, list) or not choices:
        return None
    c0 = choices[0]
    if not isinstance(c0, dict):
        return None
    msg = c0.get("message")
    if not isinstance(msg, dict):
        return None
    reasoning_content = msg.get("reasoning_content")
    return reasoning_content if isinstance(reasoning_content, str) else None


def _coerce_content_to_text(content: Any) -> str | None:
    if isinstance(content, str):
        return content
    if not isinstance(content, list):
        return None
    direct_chunks: list[str] = []
    fallback_chunks: list[str] = []
    for part in content:
        if isinstance(part, str):
            fallback_chunks.append(part)
            continue
        if not isinstance(part, dict):
            continue

        text = _extract_content_part_text(part)
        if not text:
            continue

        part_type_raw = part.get("type")
        part_type = str(part_type_raw).strip().lower() if isinstance(part_type_raw, str) else ""
        if part_type in {
            "reasoning",
            "reasoning_text",
            "thinking",
            "analysis",
            "thought",
            "thought_text",
            "summary",
            "summary_text",
        }:
            continue

        if not part_type or part_type in {"text", "output_text", "input_text", "message"}:
            direct_chunks.append(text)
        else:
            # Unknown non-reasoning type: keep as fallback for compatibility.
            fallback_chunks.append(text)

    if direct_chunks:
        return "".join(direct_chunks)
    if fallback_chunks:
        return "".join(fallback_chunks)
    return None


def _extract_content_part_text(part: dict[str, Any]) -> str | None:
    for key in ("text", "content", "value"):
        value = part.get(key)
        if isinstance(value, str):
            return value
        if isinstance(value, dict):
            nested = value.get("value")
            if isinstance(nested, str):
                return nested
            nested = value.get("text")
            if isinstance(nested, str):
                return nested
    return None


def _remaining_request_budget_sec(deadline_monotonic: float | None) -> float | None:
    if deadline_monotonic is None:
        return None
    return max(0.0, deadline_monotonic - time.monotonic())


def _is_request_budget_exceeded(meta: PatcherMeta) -> bool:
    return str(meta.get("error", "")) == "request_budget_exceeded"


def _is_context_exceeded_http_error(meta: PatcherMeta) -> bool:
    error = str(meta.get("error", ""))
    if error != "http_error":
        return False
    body = str(meta.get("body", "")).lower()
    return "exceeds the available context size" in body or "context size" in body


def _normalize_edits_payload(payload: dict[str, Any]) -> tuple[list[dict[str, Any]] | None, str | None]:
    edits = payload.get("edits")
    if isinstance(edits, list) and edits:
        return edits, "edits_list"
    if isinstance(edits, dict) and _looks_like_edit_entry(edits):
        return [edits], "edits_object"

    if _looks_like_edit_entry(payload):
        return [payload], "single_edit_root"

    edit = payload.get("edit")
    if isinstance(edit, dict) and _looks_like_edit_entry(edit):
        return [edit], "edit_object"

    changes = payload.get("changes")
    if isinstance(changes, list) and changes:
        return changes, "changes_list"

    return None, None


def _looks_like_edit_entry(entry: dict[str, Any]) -> bool:
    has_path = "path" in entry
    has_find = "find" in entry or "old" in entry
    has_replace = "replace" in entry or "new" in entry
    return has_path and has_find and has_replace


def _apply_find_replace_with_recovery(
    *,
    before: str,
    find: str,
    replace: str,
    count: int,
) -> tuple[str | None, str]:
    replace_count = count or -1

    if find in before:
        after = before.replace(find, replace, replace_count)
        if after != before:
            return after, "exact"
        return before, "exact_noop"

    before_nl = _normalize_newlines(before)
    find_nl = _normalize_newlines(find)
    replace_nl = _normalize_newlines(replace)

    if not find_nl:
        return None, "none"

    if before_nl.count(find_nl) == 1:
        after_nl = before_nl.replace(find_nl, replace_nl, replace_count)
        if after_nl != before_nl:
            return after_nl, "normalized_newlines"
        return before, "normalized_newlines_noop"

    pattern = _compile_whitespace_normalized_pattern(find_nl)
    matches = list(pattern.finditer(before_nl))
    if len(matches) == 1:
        match = matches[0]
        after_nl = before_nl[: match.start()] + replace_nl + before_nl[match.end() :]
        if after_nl != before_nl:
            return after_nl, "normalized_whitespace"
        return before, "normalized_whitespace_noop"

    # Fuzzy recovery is intentionally restricted to single targeted replacements.
    if count == 1:
        after_fuzzy = _apply_fuzzy_block_replacement(
            before_nl=before_nl,
            find_nl=find_nl,
            replace_nl=replace_nl,
        )
        if after_fuzzy is not None:
            if after_fuzzy != before_nl:
                return after_fuzzy, "fuzzy_block"
            return before, "fuzzy_block_noop"

    return None, "none"


def _normalize_newlines(text: str) -> str:
    return text.replace("\r\n", "\n").replace("\r", "\n")


def _compile_whitespace_normalized_pattern(text: str) -> re.Pattern[str]:
    pieces: list[str] = []
    i = 0
    while i < len(text):
        ch = text[i]
        if ch in {" ", "\t"}:
            while i < len(text) and text[i] in {" ", "\t"}:
                i += 1
            pieces.append(r"[ \t]+")
            continue
        if ch == "\n":
            # Ignore trailing horizontal whitespace before line breaks.
            pieces.append(r"[ \t]*\n")
            i += 1
            continue
        pieces.append(re.escape(ch))
        i += 1
    return re.compile("".join(pieces))


def _apply_fuzzy_block_replacement(
    *,
    before_nl: str,
    find_nl: str,
    replace_nl: str,
) -> str | None:
    find_block = find_nl.strip()
    if not find_block:
        return None
    # Keep fuzzy replacement conservative to avoid accidental rewrites.
    if len(find_block) < 16:
        return None

    line_chunks = before_nl.splitlines(keepends=True)
    if not line_chunks:
        return None
    if len(line_chunks) > 6000:
        return None

    target_lines = len(find_block.splitlines())
    if target_lines <= 0:
        return None

    candidate_window_lengths = {target_lines}
    if target_lines > 1:
        candidate_window_lengths.add(target_lines - 1)
        candidate_window_lengths.add(target_lines + 1)
    candidate_window_lengths = {
        size for size in candidate_window_lengths if 1 <= size <= len(line_chunks)
    }
    if not candidate_window_lengths:
        return None

    cumulative_lengths = [0]
    for chunk in line_chunks:
        cumulative_lengths.append(cumulative_lengths[-1] + len(chunk))

    scored_windows: list[tuple[float, int, int]] = []
    for window_len in sorted(candidate_window_lengths):
        max_start = len(line_chunks) - window_len
        for start in range(max_start + 1):
            raw_window = "".join(line_chunks[start : start + window_len]).strip()
            if not raw_window:
                continue
            ratio = difflib.SequenceMatcher(None, raw_window, find_block).ratio()
            scored_windows.append((ratio, start, window_len))

    if not scored_windows:
        return None

    scored_windows.sort(key=lambda item: item[0], reverse=True)
    best_ratio, best_start, best_len = scored_windows[0]
    second_ratio = scored_windows[1][0] if len(scored_windows) > 1 else 0.0

    min_ratio = 0.96 if target_lines == 1 else 0.92
    if best_ratio < min_ratio:
        return None
    if (best_ratio - second_ratio) < 0.03:
        return None

    start_offset = cumulative_lengths[best_start]
    end_offset = cumulative_lengths[best_start + best_len]
    return before_nl[:start_offset] + replace_nl + before_nl[end_offset:]


def _safe_trunc(x: object, limit: int = 2000) -> str:
    try:
        s = json.dumps(x, ensure_ascii=False, default=str)
    except Exception:
        s = repr(x)
    return s[:limit]


def _extract_response_seed(resp: dict[str, Any]) -> int | None:
    candidates: list[Any] = [
        resp.get("seed"),
        resp.get("response_seed"),
    ]
    metadata = resp.get("metadata")
    if isinstance(metadata, dict):
        candidates.append(metadata.get("seed"))
        candidates.append(metadata.get("response_seed"))
    for candidate in candidates:
        if isinstance(candidate, int):
            return candidate
        if isinstance(candidate, str):
            try:
                return int(candidate)
            except ValueError:
                continue
    return None


def _extract_json_object(text: str) -> dict[str, Any] | None:
    s = _strip_think(text).strip()
    if not s:
        return None

    fence = re.search(r"```(?:json)?\n(.*?)\n```", s, flags=re.DOTALL | re.IGNORECASE)
    if fence:
        s = fence.group(1).strip()

    try:
        data = json.loads(s)
        return data if isinstance(data, dict) else None
    except Exception:
        pass

    decoder = json.JSONDecoder()
    for i, ch in enumerate(s):
        if ch != "{":
            continue
        try:
            obj, _end = decoder.raw_decode(s[i:])
            return obj if isinstance(obj, dict) else None
        except Exception:
            continue
    return None


def _build_diff_from_changes(changes: list[tuple[str, str, str]]) -> str:
    parts: list[str] = []
    for path, before, after in changes:
        udiff = difflib.unified_diff(
            before.splitlines(),
            after.splitlines(),
            fromfile=f"a/{path}",
            tofile=f"b/{path}",
            lineterm="",
        )
        file_lines = list(udiff)
        if not file_lines:
            continue
        parts.append(f"diff --git a/{path} b/{path}")
        parts.extend(file_lines)
        if parts and parts[-1] != "":
            parts.append("")
    return "\n".join(parts).rstrip("\n") + "\n"


def _build_forced_attempt_diff(*, prompt: str, cwd: Path) -> tuple[str, str] | None:
    """Build a deterministic minimal patch when model output is empty/invalid."""
    candidates = _extract_file_index_paths(prompt)
    if not candidates:
        return None

    target_rel: str | None = None
    for rel in candidates:
        if not rel.endswith(".py"):
            continue
        lower = rel.lower()
        name = Path(rel).name.lower()
        if name.startswith("test_") or "/test" in lower or "\\test" in lower:
            continue
        target_rel = rel
        break
    if target_rel is None:
        for rel in candidates:
            if rel.endswith(".py"):
                target_rel = rel
                break
    if target_rel is None:
        return None

    target_path = (cwd / target_rel).resolve()
    try:
        target_path.relative_to(cwd.resolve())
    except Exception:
        return None
    if not target_path.exists():
        return None

    before = target_path.read_text(encoding="utf-8")
    marker = "# llmdebug-force-attempt"
    if marker in before:
        marker = "# llmdebug-force-attempt-2"
    sep = "" if before.endswith("\n") else "\n"
    after = before + f"{sep}{marker}\n"
    if after == before:
        return None

    diff = _build_diff_from_changes([(target_rel, before, after)])
    return target_rel, diff


def _extract_file_index_paths(prompt: str) -> list[str]:
    lines = prompt.splitlines()
    start = None
    for i, line in enumerate(lines):
        if line.strip().startswith("Available files"):
            start = i + 1
            break
    if start is None:
        return []

    out: list[str] = []
    for line in lines[start:]:
        s = line.strip()
        if not s:
            if out:
                break
            continue
        if s.endswith(":"):
            break
        if s.startswith("Pytest output") or s.startswith("Project files:"):
            break
        out.append(s)
    return out


_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$")


def _fix_hunk_counts(diff: str) -> str:
    """Make unified diff hunks syntactically consistent.

    LLMs often emit correct hunk bodies but incorrect `@@ -l,s +l,s @@` counts.
    Git treats those diffs as corrupt. We recompute the counts from the hunk body
    and rewrite the header counts, keeping the start line numbers as-is.
    """
    lines = diff.splitlines()
    out: list[str] = []
    i = 0
    n = len(lines)
    while i < n:
        line = lines[i]
        m = _HUNK_RE.match(line)
        if not m:
            out.append(line)
            i += 1
            continue

        old_start = max(1, int(m.group(1)))
        new_start = max(1, int(m.group(3)))
        suffix = m.group(5) or ""

        # Collect hunk body.
        body: list[str] = []
        i += 1
        while i < n:
            nxt = lines[i]
            if nxt.startswith("diff --git ") or nxt.startswith("@@ "):
                break
            if nxt.startswith("--- a/") or nxt.startswith("+++ b/"):
                break
            body.append(nxt)
            i += 1

        old_count = 0
        new_count = 0
        for b in body:
            if not b:
                continue
            c = b[0]
            if c == " ":
                old_count += 1
                new_count += 1
            elif c == "-":
                old_count += 1
            elif c == "+":
                new_count += 1
            else:
                # "\ No newline..." or other metadata does not affect counts.
                pass

        out.append(f"@@ -{old_start},{old_count} +{new_start},{new_count} @@{suffix}")
        out.extend(body)

    return "\n".join(out).rstrip("\n") + "\n"
