"""Live workspace file operations and unified text patch helpers."""

from __future__ import annotations

import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Literal

WORKSPACE_ROOT = PurePosixPath("/workspace")
DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES = 65536
WORKSPACE_FILE_MAX_BYTES = 1024 * 1024
WORKSPACE_PATCH_MAX_BYTES = 1024 * 1024

WorkspaceFileArtifactType = Literal["file", "directory", "symlink"]
WorkspacePatchStatus = Literal["added", "modified", "deleted"]

_PATCH_HUNK_RE = re.compile(
    r"^@@ -(?P<old_start>\d+)(?:,(?P<old_count>\d+))? "
    r"\+(?P<new_start>\d+)(?:,(?P<new_count>\d+))? @@"
)


@dataclass(frozen=True)
class WorkspaceFileEntry:
    path: str
    artifact_type: WorkspaceFileArtifactType
    size_bytes: int
    link_target: str | None = None

    def to_payload(self) -> dict[str, str | int | None]:
        return {
            "path": self.path,
            "artifact_type": self.artifact_type,
            "size_bytes": self.size_bytes,
            "link_target": self.link_target,
        }


@dataclass(frozen=True)
class WorkspacePathListing:
    path: str
    artifact_type: WorkspaceFileArtifactType
    entries: list[WorkspaceFileEntry]


@dataclass(frozen=True)
class WorkspaceFileReadResult:
    path: str
    size_bytes: int
    content_bytes: bytes


@dataclass(frozen=True)
class WorkspaceFileWriteResult:
    path: str
    size_bytes: int
    bytes_written: int


@dataclass(frozen=True)
class WorkspaceFileDeleteResult:
    path: str
    deleted: bool


@dataclass(frozen=True)
class WorkspacePatchHunk:
    old_start: int
    old_count: int
    new_start: int
    new_count: int
    lines: list[str]


@dataclass(frozen=True)
class WorkspaceTextPatch:
    path: str
    status: WorkspacePatchStatus
    hunks: list[WorkspacePatchHunk]


def list_workspace_files(
    workspace_dir: Path,
    *,
    workspace_path: str,
    recursive: bool,
) -> WorkspacePathListing:
    normalized_path, host_path = _workspace_host_path(workspace_dir, workspace_path)
    entry = _entry_for_host_path(normalized_path, host_path)
    if entry.artifact_type != "directory":
        return WorkspacePathListing(
            path=entry.path,
            artifact_type=entry.artifact_type,
            entries=[entry],
        )

    entries: list[WorkspaceFileEntry] = []

    def walk(current_path: str, current_host_path: Path) -> None:
        children: list[WorkspaceFileEntry] = []
        with os.scandir(current_host_path) as iterator:
            for child in iterator:
                child_entry = _entry_for_host_path(
                    _join_workspace_path(current_path, child.name),
                    Path(child.path),
                )
                children.append(child_entry)
        children.sort(key=lambda item: item.path)
        for child_entry in children:
            entries.append(child_entry)
            if recursive and child_entry.artifact_type == "directory":
                walk(child_entry.path, workspace_host_path(workspace_dir, child_entry.path))

    walk(normalized_path, host_path)
    return WorkspacePathListing(path=normalized_path, artifact_type="directory", entries=entries)


def read_workspace_file(
    workspace_dir: Path,
    *,
    workspace_path: str,
    max_bytes: int = WORKSPACE_FILE_MAX_BYTES,
) -> WorkspaceFileReadResult:
    _validate_max_bytes(max_bytes)
    normalized_path, host_path = _workspace_host_path(workspace_dir, workspace_path)
    entry = _entry_for_host_path(normalized_path, host_path)
    if entry.artifact_type != "file":
        raise RuntimeError("workspace file read only supports regular files")
    raw_bytes = host_path.read_bytes()
    if len(raw_bytes) > max_bytes:
        raise RuntimeError(
            f"workspace file exceeds the maximum supported size of {max_bytes} bytes"
        )
    return WorkspaceFileReadResult(
        path=normalized_path,
        size_bytes=len(raw_bytes),
        content_bytes=raw_bytes,
    )


def write_workspace_file(
    workspace_dir: Path,
    *,
    workspace_path: str,
    text: str,
) -> WorkspaceFileWriteResult:
    encoded = text.encode("utf-8")
    if len(encoded) > WORKSPACE_FILE_MAX_BYTES:
        raise ValueError(
            f"text must be at most {WORKSPACE_FILE_MAX_BYTES} bytes when encoded as UTF-8"
        )
    normalized_path, host_path = _workspace_host_path(workspace_dir, workspace_path)
    _ensure_no_symlink_parents(workspace_dir, host_path, normalized_path)
    if host_path.exists() or host_path.is_symlink():
        entry = _entry_for_host_path(normalized_path, host_path)
        if entry.artifact_type != "file":
            raise RuntimeError("workspace file write only supports regular file targets")
    host_path.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(
        prefix=".pyro-workspace-write-",
        dir=host_path.parent,
        delete=False,
    ) as handle:
        temp_path = Path(handle.name)
        handle.write(encoded)
    os.replace(temp_path, host_path)
    return WorkspaceFileWriteResult(
        path=normalized_path,
        size_bytes=len(encoded),
        bytes_written=len(encoded),
    )


def delete_workspace_path(
    workspace_dir: Path,
    *,
    workspace_path: str,
) -> WorkspaceFileDeleteResult:
    normalized_path, host_path = _workspace_host_path(workspace_dir, workspace_path)
    entry = _entry_for_host_path(normalized_path, host_path)
    if entry.artifact_type == "directory":
        raise RuntimeError("workspace file delete does not support directories")
    host_path.unlink(missing_ok=False)
    return WorkspaceFileDeleteResult(path=normalized_path, deleted=True)


def parse_unified_text_patch(patch_text: str) -> list[WorkspaceTextPatch]:
    encoded = patch_text.encode("utf-8")
    if len(encoded) > WORKSPACE_PATCH_MAX_BYTES:
        raise ValueError(
            f"patch must be at most {WORKSPACE_PATCH_MAX_BYTES} bytes when encoded as UTF-8"
        )
    if patch_text.strip() == "":
        raise ValueError("patch must not be empty")

    lines = patch_text.splitlines(keepends=True)
    patches: list[WorkspaceTextPatch] = []
    index = 0

    while index < len(lines):
        line = lines[index]
        if line.startswith("diff --git "):
            index += 1
            continue
        if line.startswith("index "):
            index += 1
            continue
        if _is_unsupported_patch_prelude(line):
            raise ValueError(f"unsupported patch feature: {line.rstrip()}")
        if not line.startswith("--- "):
            if line.strip() == "":
                index += 1
                continue
            raise ValueError(f"invalid patch header: {line.rstrip()}")
        old_path = _parse_patch_label(line[4:].rstrip("\n"))
        index += 1
        if index >= len(lines) or not lines[index].startswith("+++ "):
            raise ValueError("patch is missing '+++' header")
        new_path = _parse_patch_label(lines[index][4:].rstrip("\n"))
        index += 1
        if old_path is not None and new_path is not None and old_path != new_path:
            raise ValueError("rename and copy patches are not supported")
        patch_path = new_path or old_path
        if patch_path is None:
            raise ValueError("patch must target a workspace path")
        if old_path is None:
            status: WorkspacePatchStatus = "added"
        elif new_path is None:
            status = "deleted"
        else:
            status = "modified"

        hunks: list[WorkspacePatchHunk] = []
        while index < len(lines):
            line = lines[index]
            if line.startswith("diff --git ") or line.startswith("--- "):
                break
            if line.startswith("index "):
                index += 1
                continue
            if _is_unsupported_patch_prelude(line):
                raise ValueError(f"unsupported patch feature: {line.rstrip()}")
            header_match = _PATCH_HUNK_RE.match(line.rstrip("\n"))
            if header_match is None:
                raise ValueError(f"invalid patch hunk header: {line.rstrip()}")
            old_count = int(header_match.group("old_count") or "1")
            new_count = int(header_match.group("new_count") or "1")
            hunk_lines: list[str] = []
            index += 1
            while index < len(lines):
                hunk_line = lines[index]
                if hunk_line.startswith(("diff --git ", "--- ", "@@ ")):
                    break
                if hunk_line.startswith("@@"):
                    break
                if hunk_line.startswith("\\ No newline at end of file"):
                    index += 1
                    continue
                if not hunk_line.startswith((" ", "+", "-")):
                    raise ValueError(f"invalid patch hunk line: {hunk_line.rstrip()}")
                hunk_lines.append(hunk_line)
                index += 1
            _validate_hunk_counts(old_count, new_count, hunk_lines)
            hunks.append(
                WorkspacePatchHunk(
                    old_start=int(header_match.group("old_start")),
                    old_count=old_count,
                    new_start=int(header_match.group("new_start")),
                    new_count=new_count,
                    lines=hunk_lines,
                )
            )
        if not hunks:
            raise ValueError(f"patch for {patch_path} has no hunks")
        patches.append(WorkspaceTextPatch(path=patch_path, status=status, hunks=hunks))

    if not patches:
        raise ValueError("patch must contain at least one file change")
    return patches


def apply_unified_text_patch(
    *,
    path: str,
    patch: WorkspaceTextPatch,
    before_text: str | None,
) -> str | None:
    before_lines = [] if before_text is None else before_text.splitlines(keepends=True)
    output_lines: list[str] = []
    cursor = 0
    for hunk in patch.hunks:
        start_index = 0 if hunk.old_start == 0 else hunk.old_start - 1
        if start_index < cursor or start_index > len(before_lines):
            raise RuntimeError(f"patch hunk is out of range for {path}")
        output_lines.extend(before_lines[cursor:start_index])
        local_index = start_index
        for hunk_line in hunk.lines:
            prefix = hunk_line[:1]
            payload = hunk_line[1:]
            if prefix in {" ", "-"}:
                if local_index >= len(before_lines):
                    raise RuntimeError(f"patch context does not match for {path}")
                if before_lines[local_index] != payload:
                    raise RuntimeError(f"patch context does not match for {path}")
                if prefix == " ":
                    output_lines.append(payload)
                local_index += 1
                continue
            if prefix == "+":
                output_lines.append(payload)
                continue
            raise RuntimeError(f"invalid patch line prefix for {path}")
        cursor = local_index
    output_lines.extend(before_lines[cursor:])
    after_text = "".join(output_lines)
    if patch.status == "deleted":
        if after_text != "":
            raise RuntimeError(f"delete patch did not remove all content for {path}")
        return None
    encoded = after_text.encode("utf-8")
    if len(encoded) > WORKSPACE_FILE_MAX_BYTES:
        raise RuntimeError(
            f"patched file {path} exceeds the maximum supported size of "
            f"{WORKSPACE_FILE_MAX_BYTES} bytes"
        )
    return after_text


def workspace_host_path(workspace_dir: Path, workspace_path: str) -> Path:
    _, host_path = _workspace_host_path(workspace_dir, workspace_path)
    return host_path


def _workspace_host_path(workspace_dir: Path, workspace_path: str) -> tuple[str, Path]:
    normalized = normalize_workspace_path(workspace_path)
    suffix = PurePosixPath(normalized).relative_to(WORKSPACE_ROOT)
    host_path = workspace_dir if str(suffix) in {"", "."} else workspace_dir.joinpath(*suffix.parts)
    return normalized, host_path


def normalize_workspace_path(path: str) -> str:
    candidate = path.strip()
    if candidate == "":
        raise ValueError("workspace path must not be empty")
    raw_path = PurePosixPath(candidate)
    if any(part == ".." for part in raw_path.parts):
        raise ValueError("workspace path must stay inside /workspace")
    if not raw_path.is_absolute():
        raw_path = WORKSPACE_ROOT / raw_path
    parts = [part for part in raw_path.parts if part not in {"", "."}]
    normalized = PurePosixPath("/") / PurePosixPath(*parts)
    if normalized == PurePosixPath("/"):
        raise ValueError("workspace path must stay inside /workspace")
    if normalized.parts[: len(WORKSPACE_ROOT.parts)] != WORKSPACE_ROOT.parts:
        raise ValueError("workspace path must stay inside /workspace")
    return str(normalized)


def _entry_for_host_path(guest_path: str, host_path: Path) -> WorkspaceFileEntry:
    try:
        stat_result = os.lstat(host_path)
    except FileNotFoundError as exc:
        raise RuntimeError(f"workspace path does not exist: {guest_path}") from exc
    if os.path.islink(host_path):
        return WorkspaceFileEntry(
            path=guest_path,
            artifact_type="symlink",
            size_bytes=stat_result.st_size,
            link_target=os.readlink(host_path),
        )
    if host_path.is_dir():
        return WorkspaceFileEntry(
            path=guest_path,
            artifact_type="directory",
            size_bytes=0,
            link_target=None,
        )
    if host_path.is_file():
        return WorkspaceFileEntry(
            path=guest_path,
            artifact_type="file",
            size_bytes=stat_result.st_size,
            link_target=None,
        )
    raise RuntimeError(f"unsupported workspace path type: {guest_path}")


def _join_workspace_path(base: str, child_name: str) -> str:
    base_path = PurePosixPath(base)
    return str(base_path / child_name) if str(base_path) != "/" else f"/{child_name}"


def _ensure_no_symlink_parents(workspace_dir: Path, target_path: Path, guest_path: str) -> None:
    relative_path = target_path.relative_to(workspace_dir)
    current = workspace_dir
    for part in relative_path.parts[:-1]:
        current = current / part
        if current.is_symlink():
            raise RuntimeError(
                f"workspace path would traverse through a symlinked parent: {guest_path}"
            )


def _validate_max_bytes(max_bytes: int) -> None:
    if max_bytes <= 0:
        raise ValueError("max_bytes must be positive")
    if max_bytes > WORKSPACE_FILE_MAX_BYTES:
        raise ValueError(
            f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes"
        )


def _is_unsupported_patch_prelude(line: str) -> bool:
    return line.startswith(
        (
            "old mode ",
            "new mode ",
            "deleted file mode ",
            "new file mode ",
            "rename from ",
            "rename to ",
            "copy from ",
            "copy to ",
            "similarity index ",
            "dissimilarity index ",
            "GIT binary patch",
            "Binary files ",
        )
    )


def _parse_patch_label(label: str) -> str | None:
    raw = label.split("\t", 1)[0].strip()
    if raw == "/dev/null":
        return None
    if raw.startswith(("a/", "b/")):
        raw = raw[2:]
    if raw.startswith("/workspace/"):
        return normalize_workspace_path(raw)
    return normalize_workspace_path(raw)


def _validate_hunk_counts(old_count: int, new_count: int, hunk_lines: list[str]) -> None:
    old_seen = 0
    new_seen = 0
    for hunk_line in hunk_lines:
        prefix = hunk_line[:1]
        if prefix in {" ", "-"}:
            old_seen += 1
        if prefix in {" ", "+"}:
            new_seen += 1
    if old_seen != old_count or new_seen != new_count:
        raise ValueError("patch hunk line counts do not match the header")
