from difflib import SequenceMatcher


class ContractPatcher:
    def apply(self, current_content: str, contract: dict, hunks: list[dict]) -> tuple[str | None, str | None]:
        if not hunks:
            return None, "No hunks were generated."

        max_hunks = int(contract.get("max_hunks", 1) or 1)
        if len(hunks) > max_hunks:
            return None, f"Too many hunks: got={len(hunks)} allowed={max_hunks}."

        allowed_blocks = contract.get("allowed_blocks", [])
        if not isinstance(allowed_blocks, list) or not allowed_blocks:
            return None, "No allowed blocks in edit contract."

        result = current_content
        total_changed_lines = 0
        for idx, hunk in enumerate(hunks, start=1):
            applied, changed_lines, error = self._apply_hunk(result, hunk, allowed_blocks)
            if error:
                return None, f"Hunk {idx} rejected: {error}"
            result = applied
            total_changed_lines += changed_lines

        max_changed_lines = int(contract.get("max_changed_lines", 8) or 8)
        if total_changed_lines > max_changed_lines:
            return (
                None,
                f"Changed lines exceed contract limit: changed={total_changed_lines} allowed={max_changed_lines}.",
            )
        return result, None

    def _apply_hunk(
        self,
        content: str,
        hunk: dict,
        allowed_blocks: list[dict],
    ) -> tuple[str, int, str | None]:
        if not isinstance(hunk, dict):
            return content, 0, "Invalid hunk payload."
        kind = str(hunk.get("type", "")).strip().lower()
        if kind not in {"append_end", "replace_between", "replace_line_equals"}:
            return content, 0, f"Unsupported hunk type: {kind or '(empty)'}."

        block = self._find_matching_block(hunk, allowed_blocks)
        if block is None:
            return content, 0, "Hunk does not match allowed contract blocks."

        if kind == "append_end":
            return self._apply_append_end(content, hunk, block)
        if kind == "replace_between":
            return self._apply_replace_between(content, hunk, block)
        return self._apply_replace_line_equals(content, hunk, block)

    def _find_matching_block(self, hunk: dict, allowed_blocks: list[dict]) -> dict | None:
        kind = str(hunk.get("type", "")).strip().lower()
        for block in allowed_blocks:
            if not isinstance(block, dict):
                continue
            block_type = str(block.get("type", "")).strip().lower()
            if block_type != kind:
                continue
            if kind == "replace_between":
                start = str(hunk.get("start_anchor", "")).strip()
                end = str(hunk.get("end_anchor", "")).strip()
                if start != str(block.get("start_anchor", "")).strip():
                    continue
                if end != str(block.get("end_anchor", "")).strip():
                    continue
            if kind == "replace_line_equals":
                old_line = str(hunk.get("old_line", "")).strip()
                if old_line != str(block.get("old_line", "")).strip():
                    continue
            return block
        return None

    def _apply_append_end(self, content: str, hunk: dict, block: dict) -> tuple[str, int, str | None]:
        new_text = str(hunk.get("new_text", ""))
        if not new_text.strip():
            return content, 0, "append_end new_text is empty."
        changed_lines = self._changed_line_count("", new_text)
        block_limit = int(block.get("max_changed_lines", 6) or 6)
        if changed_lines > block_limit:
            return content, 0, f"append_end is too large: changed={changed_lines} allowed={block_limit}."
        base = content.rstrip("\n")
        suffix = new_text.strip("\n")
        if not suffix:
            return content, 0, "append_end resolved to empty suffix."
        merged = f"{base}\n\n{suffix}\n" if base else f"{suffix}\n"
        return merged, changed_lines, None

    def _apply_replace_between(self, content: str, hunk: dict, block: dict) -> tuple[str, int, str | None]:
        start_anchor = str(hunk.get("start_anchor", "")).strip()
        end_anchor = str(hunk.get("end_anchor", "")).strip()
        new_text = str(hunk.get("new_text", ""))
        if not start_anchor or not end_anchor:
            return content, 0, "replace_between anchors are required."
        start_pos = content.find(start_anchor)
        if start_pos < 0:
            return content, 0, "start_anchor not found in file."
        middle_start = start_pos + len(start_anchor)
        end_pos = content.find(end_anchor, middle_start)
        if end_pos < 0:
            return content, 0, "end_anchor not found after start_anchor."
        old_segment = content[middle_start:end_pos]
        changed_lines = self._changed_line_count(old_segment, new_text)
        block_limit = int(block.get("max_changed_lines", 6) or 6)
        if changed_lines > block_limit:
            return content, 0, f"replace_between is too large: changed={changed_lines} allowed={block_limit}."
        merged = content[:middle_start] + new_text + content[end_pos:]
        return merged, changed_lines, None

    def _apply_replace_line_equals(self, content: str, hunk: dict, block: dict) -> tuple[str, int, str | None]:
        old_line = str(hunk.get("old_line", "")).strip()
        new_text = str(hunk.get("new_text", ""))
        if not old_line:
            return content, 0, "replace_line_equals old_line is required."
        lines = content.splitlines(keepends=True)
        matches = [idx for idx, line in enumerate(lines) if line.rstrip("\n") == old_line]
        if len(matches) != 1:
            return content, 0, f"replace_line_equals expected exactly one match, got={len(matches)}."
        replacement = new_text.rstrip("\n") + "\n"
        changed_lines = self._changed_line_count(old_line + "\n", replacement)
        block_limit = int(block.get("max_changed_lines", 6) or 6)
        if changed_lines > block_limit:
            return content, 0, f"replace_line_equals is too large: changed={changed_lines} allowed={block_limit}."
        lines[matches[0] : matches[0] + 1] = [replacement]
        return "".join(lines), changed_lines, None

    def _changed_line_count(self, old_text: str, new_text: str) -> int:
        old_lines = (old_text or "").splitlines()
        new_lines = (new_text or "").splitlines()
        if not old_lines and not new_lines:
            return 0
        matcher = SequenceMatcher(None, old_lines, new_lines)
        changed = 0
        for tag, i1, i2, j1, j2 in matcher.get_opcodes():
            if tag == "equal":
                continue
            changed += max(i2 - i1, j2 - j1)
        return max(changed, 1)
