from __future__ import annotations

import hashlib
import json
from pathlib import Path

from app.core.agent.processes.v2.workflows.doc_update_from_feature.doc_rules_pipeline.models import DocRulesBundle
from app.core.agent.processes.v2.workflows.doc_update_from_feature.doc_rules_pipeline.prompt_builder import DocChangePromptBuilder
from app.core.agent.processes.v2.workflows.doc_update_from_feature.doc_rules_pipeline.selector import DocRulesSelector
from app.core.agent.processes.v2.workflows.doc_update_from_feature.workflow_runtime.models import PlannedChange
from app.core.agent.utils.llm import AgentLlmService
from app.infra.observability.module_trace import ModuleTrace
from app.schemas.changeset import ChangeItem


class DocRulesChangesetGenerator:
    def __init__(
        self,
        llm: AgentLlmService,
        selector: DocRulesSelector | None = None,
        prompt_builder: DocChangePromptBuilder | None = None,
    ) -> None:
        self._llm = llm
        self._selector = selector or DocRulesSelector()
        self._prompt_builder = prompt_builder or DocChangePromptBuilder()

    def generate(
        self,
        *,
        bundle: DocRulesBundle,
        item: PlannedChange,
        domain: str,
        subdomain: str,
        project_root: str,
        trace: ModuleTrace | None = None,
    ) -> tuple[ChangeItem | None, str | None]:
        selected = self._selector.select(bundle, item.doc_type)
        if selected is None:
            return None, f"Для doc_type '{item.doc_type}' не найдено полных doc_rules (artifact/template)."
        prompt_input = self._prompt_builder.build(item=item, rules=selected, domain=domain, subdomain=subdomain)
        if trace is not None:
            trace.log(
                "changeset_prompt_built",
                {
                    "doc_type": item.doc_type,
                    "path": item.path,
                    "prompt_chars": len(prompt_input),
                    "rules_chars": len(selected.render_for_prompt()),
                },
            )
        raw = self._llm.generate(
            "v2_docs_update.build_doc_changeset",
            prompt_input,
            log_context="workflow.v2.docs_update.from_feature.changeset",
            trace=trace,
        )
        payload = self._parse_json(raw)
        if payload is None:
            if trace is not None:
                trace.log("changeset_json_parse_failed", {"path": item.path, "raw_chars": len(str(raw or ""))})
            repaired_raw = self._llm.generate(
                "v2_docs_update.repair_doc_changeset_json",
                self._build_repair_input(raw=raw, item=item),
                log_context="workflow.v2.docs_update.from_feature.changeset_repair",
                trace=trace,
            )
            payload = self._parse_json(repaired_raw)
            if payload is None:
                return None, f"LLM вернул невалидный JSON changeset для {item.path} даже после repair."
        payload["op"] = item.op
        payload["path"] = item.path
        payload["reason"] = str(payload.get("reason") or item.reason)[:500]
        if item.op in {"update", "delete"}:
            base_hash = self._resolve_base_hash(project_root, item.path)
            if not base_hash:
                return None, f"{item.op.upper()} пропущен: не удалось вычислить base_hash для {item.path}."
            payload["base_hash"] = base_hash
        if item.op == "delete":
            payload.pop("proposed_content", None)
        try:
            return ChangeItem.model_validate(payload), None
        except Exception as exc:
            return None, f"Невалидный changeset item для {item.path}: {exc}"

    def _parse_json(self, raw: str) -> dict[str, object] | None:
        text = str(raw or "").strip()
        if not text:
            return None
        try:
            value = json.loads(text)
            return value if isinstance(value, dict) else None
        except json.JSONDecodeError:
            normalized = self._escape_control_chars_in_json_strings(text)
            if normalized != text:
                try:
                    value = json.loads(normalized)
                    return value if isinstance(value, dict) else None
                except json.JSONDecodeError:
                    return None
            return None

    def _build_repair_input(self, *, raw: str, item: PlannedChange) -> str:
        payload = {
            "expected_contract": {
                "op": item.op,
                "path": item.path,
                "required_keys": ["op", "path", "reason"],
                "proposed_content_required_for": ["create", "update"],
            },
            "raw_llm_output": str(raw or ""),
        }
        return json.dumps(payload, ensure_ascii=False, indent=2)

    def _escape_control_chars_in_json_strings(self, text: str) -> str:
        escaped: list[str] = []
        in_string = False
        backslash = False
        for char in text:
            if not in_string:
                escaped.append(char)
                if char == '"':
                    in_string = True
                continue
            if backslash:
                escaped.append(char)
                backslash = False
                continue
            if char == "\\":
                escaped.append(char)
                backslash = True
                continue
            if char == '"':
                escaped.append(char)
                in_string = False
                continue
            codepoint = ord(char)
            if codepoint < 0x20:
                if char == "\n":
                    escaped.append("\\n")
                elif char == "\r":
                    escaped.append("\\r")
                elif char == "\t":
                    escaped.append("\\t")
                else:
                    escaped.append(f"\\u{codepoint:04x}")
                continue
            escaped.append(char)
        return "".join(escaped)

    def _resolve_base_hash(self, project_root: str, rel_path: str) -> str:
        root = Path(project_root or "").expanduser()
        if not root.is_absolute():
            return ""
        target = root / rel_path
        if not target.exists() or not target.is_file():
            return ""
        try:
            content = target.read_text(encoding="utf-8")
        except Exception:
            return ""
        return hashlib.sha256(content.encode("utf-8")).hexdigest()
