import json

from app.modules.agent.engine.graphs.project_edits_contract import ContractParser
from app.modules.agent.engine.graphs.project_edits_patcher import ContractPatcher
from app.modules.agent.engine.graphs.project_edits_support import ProjectEditsSupport
from app.modules.agent.engine.graphs.state import AgentGraphState
from app.modules.agent.llm import AgentLlmService
from app.schemas.changeset import ChangeItem


class ProjectEditsLogic:
    def __init__(self, llm: AgentLlmService) -> None:
        self._llm = llm
        self._support = ProjectEditsSupport()
        self._contracts = ContractParser()
        self._patcher = ContractPatcher()

    def collect_context(self, state: AgentGraphState) -> dict:
        message = state.get("message", "")
        files_map = state.get("files_map", {}) or {}
        requested_path = self._support.lookup_file(files_map, self._extract_path_hint(message))
        candidates = self._support.pick_relevant_files(message, files_map)
        if requested_path and not any(x["path"] == requested_path.get("path") for x in candidates):
            candidates.insert(0, self._support.as_candidate(requested_path))
        return {
            "edits_requested_path": str((requested_path or {}).get("path", "")).strip() or self._extract_path_hint(message),
            "edits_context_files": candidates[:12],
            "validation_attempts": 0,
        }

    def plan_changes(self, state: AgentGraphState) -> dict:
        user_input = json.dumps(
            {
                "request": state.get("message", ""),
                "requested_path": state.get("edits_requested_path", ""),
                "context_files": [
                    {
                        "path": item.get("path", ""),
                        "content_preview": self._support.shorten(str(item.get("content", "")), 2200),
                    }
                    for item in (state.get("edits_context_files", []) or [])
                ],
                "contract_requirements": {
                    "must_define_allowed_blocks": True,
                    "max_hunks_per_file": 5,
                    "default_intent": "update",
                },
            },
            ensure_ascii=False,
        )
        parsed = self._support.parse_json(
            self._llm.generate("project_edits_plan", user_input, log_context="graph.project_edits.plan_changes")
        )
        contracts = self._contracts.parse(
            parsed,
            request=str(state.get("message", "")),
            requested_path=str(state.get("edits_requested_path", "")),
        )
        plan = [{"path": item.get("path", ""), "reason": item.get("reason", "")} for item in contracts]
        return {"edits_contracts": contracts, "edits_plan": plan}

    def generate_changeset(self, state: AgentGraphState) -> dict:
        files_map = state.get("files_map", {}) or {}
        contracts = state.get("edits_contracts", []) or []
        changeset: list[ChangeItem] = []
        feedback: list[str] = []

        for contract in contracts:
            if not isinstance(contract, dict):
                continue
            path = str(contract.get("path", "")).replace("\\", "/").strip()
            if not path:
                continue
            intent = str(contract.get("intent", "update")).strip().lower() or "update"
            source = self._support.lookup_file(files_map, path)
            if intent == "update" and source is None:
                feedback.append(f"{path}: update requested but source file was not provided.")
                continue
            current_content = str((source or {}).get("content", ""))
            hunks, error = self._generate_hunks_for_contract(state, contract, current_content)
            if error:
                feedback.append(f"{path}: {error}")
                continue
            proposed, apply_error = self._patcher.apply(current_content, contract, hunks)
            if apply_error:
                feedback.append(f"{path}: {apply_error}")
                continue
            if proposed is None:
                feedback.append(f"{path}: patch application returned empty result.")
                continue
            if intent == "update":
                if proposed == current_content:
                    feedback.append(f"{path}: no-op update produced by model.")
                    continue
                if self._support.collapse_whitespace(proposed) == self._support.collapse_whitespace(current_content):
                    feedback.append(f"{path}: whitespace-only update is not allowed.")
                    continue
            reason = str(contract.get("reason", "")).strip() or "Requested user adjustment."
            if source and source.get("content_hash"):
                changeset.append(
                    ChangeItem(
                        op="update",
                        path=str(source.get("path") or path),
                        base_hash=str(source.get("content_hash", "")),
                        proposed_content=proposed,
                        reason=reason,
                        hunks=hunks,
                    )
                )
            else:
                changeset.append(
                    ChangeItem(
                        op="create",
                        path=path,
                        proposed_content=proposed,
                        reason=reason,
                        hunks=hunks,
                    )
                )

        return {"changeset": changeset, "edits_generation_feedback": " | ".join(feedback)}

    def self_check(self, state: AgentGraphState) -> dict:
        attempts = int(state.get("validation_attempts", 0) or 0) + 1
        changeset = state.get("changeset", []) or []
        files_map = state.get("files_map", {}) or {}
        if not changeset:
            feedback = str(state.get("edits_generation_feedback", "")).strip() or "Generated changeset is empty."
            return {"validation_attempts": attempts, "validation_passed": False, "validation_feedback": feedback}

        broad = self._support.is_broad_rewrite_request(str(state.get("message", "")))
        for item in changeset:
            if item.op.value != "update":
                continue
            source = self._support.lookup_file(files_map, item.path)
            if not source:
                continue
            original = str(source.get("content", ""))
            proposed = item.proposed_content or ""
            similarity = self._support.similarity(original, proposed)
            change_ratio = self._support.line_change_ratio(original, proposed)
            added_headings = self._support.added_headings(original, proposed)
            min_similarity = 0.75 if broad else 0.9
            max_change_ratio = 0.7 if broad else 0.35
            if similarity < min_similarity:
                return {
                    "validation_attempts": attempts,
                    "validation_passed": False,
                    "validation_feedback": f"File {item.path} changed too aggressively (similarity={similarity:.2f}).",
                }
            if change_ratio > max_change_ratio:
                return {
                    "validation_attempts": attempts,
                    "validation_passed": False,
                    "validation_feedback": f"File {item.path} changed too broadly (change_ratio={change_ratio:.2f}).",
                }
            if not broad and added_headings > 0:
                return {
                    "validation_attempts": attempts,
                    "validation_passed": False,
                    "validation_feedback": f"File {item.path} adds new sections outside requested scope.",
                }

        payload = {
            "request": state.get("message", ""),
            "contracts": state.get("edits_contracts", []),
            "changeset": [{"op": x.op.value, "path": x.path, "reason": x.reason} for x in changeset[:20]],
            "rule": "Changes must stay inside contract blocks and not affect unrelated sections.",
        }
        parsed = self._support.parse_json(
            self._llm.generate(
                "project_edits_self_check",
                json.dumps(payload, ensure_ascii=False),
                log_context="graph.project_edits.self_check",
            )
        )
        passed = bool(parsed.get("pass")) if isinstance(parsed, dict) else False
        feedback = str(parsed.get("feedback", "")).strip() if isinstance(parsed, dict) else ""
        return {
            "validation_attempts": attempts,
            "validation_passed": passed,
            "validation_feedback": feedback or "No validation feedback provided.",
        }

    def build_result(self, state: AgentGraphState) -> dict:
        changeset = state.get("changeset", []) or []
        return {"changeset": changeset, "answer": self._support.build_summary(state, changeset)}

    def _generate_hunks_for_contract(
        self,
        state: AgentGraphState,
        contract: dict,
        current_content: str,
    ) -> tuple[list[dict], str | None]:
        prompt_payload = {
            "request": state.get("message", ""),
            "contract": contract,
            "current_content": self._support.shorten(current_content, 18000),
            "previous_validation_feedback": state.get("validation_feedback", ""),
            "rag_context": self._support.shorten(state.get("rag_context", ""), 5000),
            "confluence_context": self._support.shorten(state.get("confluence_context", ""), 5000),
        }
        raw = self._llm.generate(
            "project_edits_hunks",
            json.dumps(prompt_payload, ensure_ascii=False),
            log_context="graph.project_edits.generate_changeset",
        )
        parsed = self._support.parse_json(raw)
        hunks = parsed.get("hunks", []) if isinstance(parsed, dict) else []
        if not isinstance(hunks, list) or not hunks:
            return [], "Model did not return contract hunks."
        normalized: list[dict] = []
        for hunk in hunks:
            if not isinstance(hunk, dict):
                continue
            kind = str(hunk.get("type", "")).strip().lower()
            if kind not in {"append_end", "replace_between", "replace_line_equals"}:
                continue
            normalized.append(
                {
                    "type": kind,
                    "start_anchor": str(hunk.get("start_anchor", "")),
                    "end_anchor": str(hunk.get("end_anchor", "")),
                    "old_line": str(hunk.get("old_line", "")),
                    "new_text": str(hunk.get("new_text", "")),
                }
            )
        if not normalized:
            return [], "Model hunks are empty or invalid."
        return normalized, None

    def _extract_path_hint(self, message: str) -> str:
        words = (message or "").replace("\\", "/").split()
        for token in words:
            cleaned = token.strip("`'\".,:;()[]{}")
            if "/" in cleaned and "." in cleaned:
                return cleaned
            if cleaned.lower().startswith("readme"):
                return "README.md"
        return ""
