#!/usr/bin/env python3
# File: src/noveler/infrastructure/services/langsmith_artifact_manager.py
# Purpose: Manage LangSmith artifact ingestion, dataset updates, and patch application workflows.
# Context: Infrastructure service used by LangSmith tooling, CLI commands, and MCP handlers.

"""LangSmith artifact management utilities with Windows-safe patch fallback."""

from __future__ import annotations

import json
import re
import subprocess
import textwrap
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import Any

from noveler.domain.value_objects.langsmith_artifacts import (
    LangSmithRun,
    PatchResult,
    VerificationResult,
)
from noveler.presentation.shared.shared_utilities import console, get_logger

HUNK_HEADER_RE = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")


class PatchApplyError(Exception):
    """Raised when the Python fallback patch application fails."""


class LangSmithArtifactManager:
    """Coordinate LangSmith artifact ingestion, persistence, and verification."""

    def __init__(self, logger=None, console_service=None) -> None:
        """Initialise the manager with optional logger/console overrides."""
        self.logger = logger or get_logger(__name__)
        self.console = console_service or console

    # ------------------------------------------------------------------ Runs --
    def load_run(self, run_path: Path | str) -> LangSmithRun:
        """Load a LangSmith run JSON file into a domain object."""
        path = Path(run_path)
        with path.open(encoding="utf-8") as fp:
            payload: dict[str, Any] = json.load(fp)

        run_id = payload.get("id") or payload.get("run_id") or "unknown-run"
        name = payload.get("name") or payload.get("run_name") or "unknown"
        status = payload.get("status") or payload.get("state") or "unknown"
        trace_url = payload.get("url") or payload.get("trace_url")
        error_message = self._extract_error_message(payload.get("error"))

        run = LangSmithRun(
            run_id=run_id,
            name=name,
            status=str(status),
            error=error_message,
            trace_url=trace_url,
            inputs=self._ensure_dict(payload.get("inputs")),
            outputs=self._ensure_dict(payload.get("outputs")),
            metadata=self._ensure_dict(payload.get("metadata")),
            tags=[str(tag) for tag in self._ensure_list(payload.get("tags"))],
        )
        self.logger.debug("Loaded LangSmith run %s", run.run_id)
        return run

    def _ensure_dict(self, value: Any) -> dict[str, Any]:
        return value if isinstance(value, dict) else {}

    def _ensure_list(self, value: Any) -> list[Any]:
        if isinstance(value, list):
            return value
        if value is None:
            return []
        return [value]

    def _extract_error_message(self, error_value: Any) -> str | None:
        if isinstance(error_value, dict):
            return (
                error_value.get("message")
                or error_value.get("text")
                or json.dumps(error_value, ensure_ascii=False)
            )
        if error_value is None:
            return None
        return str(error_value)

    # --------------------------------------------------------------- Outputs --
    def write_summary(self, run: LangSmithRun, output_dir: Path) -> Path:
        """Render a Markdown summary for the provided run."""
        output_dir.mkdir(parents=True, exist_ok=True)
        summary_path = output_dir / f"{run.run_id}_summary.md"
        summary_path.write_text(self._render_summary(run), encoding="utf-8")
        return summary_path

    def write_prompt(
        self,
        run: LangSmithRun,
        output_dir: Path,
        expected_behavior: str | None = None,
    ) -> Path:
        """Write a prompt file describing the LangSmith run."""
        output_dir.mkdir(parents=True, exist_ok=True)
        prompt_path = output_dir / f"{run.run_id}_prompt.txt"
        prompt_path.write_text(
            self._render_prompt(run, expected_behavior),
            encoding="utf-8",
        )
        return prompt_path

    def update_dataset(
        self,
        run: LangSmithRun,
        dataset_path: Path,
        expected_behavior: str | None = None,
    ) -> Path:
        """Append a run entry to the dataset, ensuring uniqueness by run_id."""
        dataset_path.parent.mkdir(parents=True, exist_ok=True)
        entries = self._read_dataset_entries(dataset_path)
        existing_ids = {entry.get("run_id") for entry in entries}

        if run.run_id not in existing_ids:
            entries.append(run.to_dataset_entry(expected_behavior))
            self._write_dataset_entries(dataset_path, entries)
            self.logger.info("Appended LangSmith run %s to %s", run.run_id, dataset_path)
        else:
            self.logger.debug("Run %s already present in %s", run.run_id, dataset_path)
        return dataset_path

    def _read_dataset_entries(self, dataset_path: Path) -> list[dict[str, Any]]:
        if not dataset_path.exists():
            return []
        entries: list[dict[str, Any]] = []
        for line in dataset_path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                entries.append(json.loads(line))
            except json.JSONDecodeError:
                self.logger.warning("Skipping malformed dataset entry: %s", line)
        return entries

    def _write_dataset_entries(self, dataset_path: Path, entries: Iterable[dict[str, Any]]) -> None:
        with dataset_path.open("w", encoding="utf-8") as fp:
            for entry in entries:
                fp.write(json.dumps(entry, ensure_ascii=False) + "\n")

    # ------------------------------------------------------------- Patch I/O --
    def apply_patch(self, patch_text: str, project_root: Path, strip: int = 1) -> PatchResult:
        """Apply a unified diff via the system `patch` tool with Python fallback."""
        command = ["patch", f"-p{strip}"]
        try:
            completed = subprocess.run(
                command,
                input=patch_text,
                text=True,
                capture_output=True,
                cwd=project_root,
                check=False,
            )
        except FileNotFoundError as exc:  # pragma: no cover - uncommon on CI
            self.logger.error("patch command not available: %s", exc)
            return self._apply_patch_with_python(
                patch_text,
                project_root,
                strip,
                command,
                stdout="",
                stderr=str(exc),
            )

        if completed.returncode == 0:
            self.console.print_success("✅ Patch applied successfully")
            self.logger.info("Patch applied successfully")
            return PatchResult(
                applied=True,
                stdout=completed.stdout,
                stderr=completed.stderr,
                command=command,
            )

        self.console.print_error("⚠️ Patch command failed; attempting Python fallback")
        self.logger.error("Patch apply failed: %s", completed.stderr.strip())
        return self._apply_patch_with_python(
            patch_text,
            project_root,
            strip,
            command,
            stdout=completed.stdout,
            stderr=completed.stderr,
        )

    def _apply_patch_with_python(
        self,
        patch_text: str,
        project_root: Path,
        strip: int,
        command: Sequence[str],
        *,
        stdout: str,
        stderr: str,
    ) -> PatchResult:
        try:
            self._apply_unified_diff(patch_text, project_root, strip)
        except PatchApplyError as exc:
            combined_stderr = "\n".join(filter(None, [stderr, str(exc)])).strip()
            self.logger.error("Fallback patch application failed: %s", exc)
            return PatchResult(
                applied=False,
                stdout=stdout,
                stderr=combined_stderr,
                command=list(command),
            )

        self.console.print_success("✅ Patch applied via Python fallback")
        self.logger.info("Patch applied using Python fallback implementation")
        return PatchResult(
            applied=True,
            stdout=stdout or "Applied via Python fallback",
            stderr="",
            command=list(command),
        )

    def _apply_unified_diff(self, patch_text: str, project_root: Path, strip: int) -> None:
        lines = patch_text.splitlines()
        if not lines:
            raise PatchApplyError("Empty patch payload")

        index = 0
        while index < len(lines):
            line = lines[index]
            if not line.startswith("diff --git"):
                index += 1
                continue

            index += 1
            old_path: str | None = None
            new_path: str | None = None
            while index < len(lines):
                header = lines[index]
                if header.startswith("--- "):
                    old_path = header[4:].strip()
                elif header.startswith("+++ "):
                    new_path = header[4:].strip()
                    index += 1
                    break
                index += 1

            if new_path is None:
                raise PatchApplyError("Missing +++ header in unified diff")

            target_rel = self._strip_patch_path(new_path, strip, fallback_old=old_path)
            hunks: list[tuple[str, list[str]]] = []

            while index < len(lines):
                current = lines[index]
                if current.startswith("diff --git"):
                    break
                if current.startswith("@@"):
                    header = current
                    index += 1
                    hunk_body: list[str] = []
                    while index < len(lines):
                        body_line = lines[index]
                        if body_line.startswith("@@") or body_line.startswith("diff --git"):
                            break
                        hunk_body.append(body_line)
                        index += 1
                    hunks.append((header, hunk_body))
                else:
                    index += 1

            target_path = project_root / target_rel
            self._apply_hunks_to_file(target_path, hunks)

    def _strip_patch_path(self, raw_path: str, strip: int, fallback_old: str | None) -> Path:
        candidate = raw_path.split("\t", 1)[0]
        if candidate == "/dev/null":
            if not fallback_old or fallback_old == "/dev/null":
                raise PatchApplyError("Patch references /dev/null without source path")
            candidate = fallback_old.split("\t", 1)[0]

        parts = [part for part in candidate.split("/") if part]
        if strip > len(parts):
            raise PatchApplyError(f"strip={strip} removes entire path from {raw_path}")

        stripped = parts[strip:] if strip else parts
        if not stripped:
            raise PatchApplyError(f"Unable to compute target path from {raw_path}")
        return Path(*stripped)

    def _apply_hunks_to_file(self, target_path: Path, hunks: list[tuple[str, list[str]]]) -> None:
        original_text = ""
        if target_path.exists():
            original_text = target_path.read_text(encoding="utf-8")

        original_lines = original_text.splitlines()
        original_has_trailing_newline = original_text.endswith("\n")

        final_lines: list[str] = []
        original_index = 0
        ensure_trailing_newline = original_has_trailing_newline

        for header, body in hunks:
            match = HUNK_HEADER_RE.match(header)
            if not match:
                raise PatchApplyError(f"Unsupported hunk header: {header}")

            orig_start = int(match.group(1)) - 1

            while original_index < orig_start:
                if original_index >= len(original_lines):
                    raise PatchApplyError("Hunk exceeds original file length")
                final_lines.append(original_lines[original_index])
                original_index += 1

            for body_line in body:
                if not body_line:
                    continue
                prefix = body_line[0]
                content = body_line[1:]

                if prefix == " ":
                    if original_index >= len(original_lines) or original_lines[original_index] != content:
                        raise PatchApplyError("Context mismatch while applying patch")
                    final_lines.append(content)
                    original_index += 1
                elif prefix == "-":
                    if original_index >= len(original_lines) or original_lines[original_index] != content:
                        raise PatchApplyError("Deletion mismatch while applying patch")
                    original_index += 1
                elif prefix == "+":
                    final_lines.append(content)
                elif prefix == "\\":
                    if "No newline at end of file" in body_line:
                        ensure_trailing_newline = False
                else:
                    raise PatchApplyError(f"Unsupported hunk line prefix: {prefix}")

        final_lines.extend(original_lines[original_index:])

        final_text = "\n".join(final_lines)
        if final_lines and ensure_trailing_newline:
            final_text += "\n"

        target_path.parent.mkdir(parents=True, exist_ok=True)
        target_path.write_text(final_text, encoding="utf-8")

    # ---------------------------------------------------------- Verification --
    def run_command(self, command: Sequence[str], project_root: Path) -> VerificationResult:
        """Execute a verification command and capture stdout/stderr."""
        completed = subprocess.run(
            list(command),
            text=True,
            capture_output=True,
            cwd=project_root,
            check=False,
        )
        if completed.returncode == 0:
            self.console.print_success("✅ Verification command succeeded")
        else:
            self.console.print_error("⚠️ Verification command failed")
        self.logger.info("Verification command finished with code %s", completed.returncode)
        return VerificationResult(
            returncode=completed.returncode,
            stdout=completed.stdout,
            stderr=completed.stderr,
            command=list(command),
        )

    # ------------------------------------------------------------- Rendering --
    def _render_summary(self, run: LangSmithRun) -> str:
        inputs_json = json.dumps(run.inputs, indent=2, ensure_ascii=False)
        outputs_json = json.dumps(run.outputs, indent=2, ensure_ascii=False)
        metadata_json = json.dumps(run.metadata, indent=2, ensure_ascii=False)

        summary = textwrap.dedent(
            f"""
            # {run.headline()}

            - **Status**: {run.status}
            - **Trace URL**: {run.trace_url or 'N/A'}
            - **Captured At**: {run.captured_at.isoformat()}
            - **Tags**: {', '.join(run.tags) if run.tags else 'None'}

            ## Error
            {run.error or 'No error information provided.'}

            ## Inputs
            ```json
            {inputs_json}
            ```

            ## Outputs
            ```json
            {outputs_json}
            ```

            ## Metadata
            ```json
            {metadata_json}
            ```
            """
        ).strip()
        return summary + "\n"

    def _render_prompt(self, run: LangSmithRun, expected_behavior: str | None) -> str:
        inputs_json = json.dumps(run.inputs, indent=2, ensure_ascii=False)
        outputs_json = json.dumps(run.outputs, indent=2, ensure_ascii=False)
        expected = expected_behavior or "Describe the intended fix for this failure."

        prompt = textwrap.dedent(
            f"""
            You are assisting with a regression fix captured by LangSmith.

            Run ID: {run.run_id}
            Name: {run.name}
            Status: {run.status}
            Trace URL: {run.trace_url or 'N/A'}

            ## Current Error
            {run.error or 'No error message available.'}

            ## Inputs
            ```json
            {inputs_json}
            ```

            ## Observed Output
            ```json
            {outputs_json}
            ```

            ## Expected Behaviour
            {expected}

            1. Explain the root cause based on the inputs and error.
            2. Suggest a patch in unified diff format.
            3. List verification commands to confirm the fix.
            """
        ).strip()
        return prompt + "\n"


__all__ = [
    "LangSmithArtifactManager",
    "LangSmithRun",
    "PatchResult",
    "VerificationResult",
]
