"""Preserve externally generated source (an MCP caller's HTML/CSS/JS/TS/...) as an
immutable, checksummed snapshot instead of leaving it only as text inside a prompt.

Why: a prompt is a lossy, disposable carrier. The model had to re-type every file to
"adopt" it, nothing recorded what actually arrived, and a failed run threw the input
away. A snapshot is kept in the project's P2HS-only meta directory on Core disk
(``<repo_workspace>/<workspace_id>/project_meta/<project_id>/imports/<import_id>/`` --
see projects/paths.py: private, per tenant, never web-served, never committed to a
customer repo; NOT the public prompt-media root), keyed by ``import_id`` so a retry
reuses it. At dispatch it is committed into the P2HS-owned project repo under
``.p2hs/imports/<import_id>/`` so the runner can copy from it, with a manifest of sizes
and sha256 so the sender can check what Core really received.

That repo path is platform-owned: the repo guard skips ``.p2hs`` (backend_guard._SKIP), and
it is excluded from every customer-repository publish by path
(repo_export.PLATFORM_OWNED_TOP_DIRS, enforced in merge_and_deploy._skip_publish_path;
tests/test_customer_publish_exclusions.py).

Secrets: stored content goes through ``redact_source``, exactly like the inline prompt copy,
so the snapshot never hands a hardcoded key to an AI provider or to git history.
``received_sha256`` is the hash of what arrived; ``sha256`` is the hash of what is stored
(identical unless ``redacted``).

Only a small manifest travels in the run's plan context; file contents never do (plan
context is spread into the payload JSON that is committed at dispatch).
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4

import structlog

from src.domain.codegen.source_adoption import redact_source
from src.domain.projects.paths import get_project_meta_path

logger = structlog.get_logger()

# Repo-relative directory the dispatch commit uses. Must stay under a platform-owned top
# directory (see repo_export.PLATFORM_OWNED_TOP_DIRS).
IMPORT_DIR = ".p2hs/imports"
SCHEMA = "import_snapshot.v1"
_MAX_PATH_CHARS = 200
_APPENDIX_FILE_LIMIT = 200


class ImportSnapshotError(Exception):
    """The stored snapshot is missing or no longer matches its manifest."""


def normalize_import_path(raw: str) -> str:
    """Return a clean relative POSIX path, or raise ValueError(reason).

    Rejects rather than repairs: a path the sender did not mean (traversal, a Windows
    drive, git internals) must be reported back, never silently rewritten."""
    path = raw[2:] if raw.startswith("./") else raw
    if not path or path != path.strip():
        raise ValueError("empty_or_padded_path")
    if len(path) > _MAX_PATH_CHARS:
        raise ValueError("path_too_long")
    if "\\" in path:
        raise ValueError("backslash_in_path")
    if any(ord(char) < 32 or ord(char) == 127 for char in path):
        raise ValueError("control_character_in_path")
    if path.startswith("/") or re.match(r"^[A-Za-z]:", path):
        raise ValueError("absolute_path")
    parts = path.split("/")
    if any(part in ("", ".", "..") for part in parts):
        raise ValueError("non_normalized_path")
    if any(part.lower() == ".git" for part in parts):
        raise ValueError("git_internals_path")
    return path


def snapshots_dir(settings, workspace_id: UUID, project_id: UUID) -> Path:
    """Where this project's snapshots live: inside its private, P2HS-only meta directory."""
    meta = get_project_meta_path(settings.repo_workspace, workspace_id, project_id)
    return Path(meta) / "imports"


def _line_count(text: str) -> int:
    if not text:
        return 0
    return text.count("\n") + (0 if text.endswith("\n") else 1)


def _sha256(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def write_snapshot(
    base: Path,
    files: list[tuple[str, str]],
    *,
    source: dict[str, Any],
    import_id: str | None = None,
) -> dict[str, Any]:
    """Write (path, text) pairs under ``base/<import_id>/`` and return the manifest.
    Blocking file I/O: call from a thread. Unusable paths are reported in ``rejected``,
    never silently dropped."""
    import_id = import_id or uuid4().hex
    root = base / import_id
    entries: list[dict[str, Any]] = []
    rejected: list[dict[str, str]] = []
    seen: set[str] = set()
    for raw_path, content in files:
        try:
            path = normalize_import_path(raw_path)
        except ValueError as exc:
            rejected.append({"path": raw_path[:_MAX_PATH_CHARS], "reason": str(exc)})
            continue
        if path.lower() in seen:
            rejected.append({"path": path, "reason": "duplicate_path"})
            continue
        seen.add(path.lower())
        received = content.encode("utf-8")
        stored_text = redact_source(content)
        stored = stored_text.encode("utf-8")
        target = root / "files" / path
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(stored)
        entries.append(
            {
                "path": path,
                "size": len(stored),
                "lines": _line_count(stored_text),
                "sha256": _sha256(stored),
                "received_sha256": _sha256(received),
                "redacted": stored != received,
            }
        )
    manifest = {
        "schema": SCHEMA,
        "import_id": import_id,
        "created_at": datetime.now(UTC).isoformat(),
        "source": source,
        "files": entries,
        "rejected": rejected,
    }
    root.mkdir(parents=True, exist_ok=True)
    (root / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True), "utf-8")
    return manifest


def read_snapshot_files(base: Path, manifest: dict[str, Any]) -> dict[str, bytes]:
    """Load every file in the manifest, verifying its sha256: a snapshot that no longer
    matches what was recorded must not be committed as if it were the original."""
    import_id = str(manifest.get("import_id") or "")
    if not re.fullmatch(r"[0-9a-f]{32}", import_id):
        raise ImportSnapshotError("snapshot id is not valid")
    root = base / import_id
    contents: dict[str, bytes] = {}
    for entry in manifest.get("files") or []:
        path = normalize_import_path(str(entry.get("path", "")))
        try:
            data = (root / "files" / path).read_bytes()
        except OSError as exc:
            raise ImportSnapshotError(f"snapshot file missing: {path}") from exc
        if _sha256(data) != entry.get("sha256"):
            raise ImportSnapshotError(f"snapshot file changed on disk: {path}")
        contents[path] = data
    return contents


def repo_files(manifest: dict[str, Any], contents: dict[str, bytes]) -> dict[str, str | bytes]:
    """Map snapshot files to repo paths, plus the manifest itself."""
    prefix = f"{IMPORT_DIR}/{manifest['import_id']}"
    files: dict[str, str | bytes] = {
        f"{prefix}/files/{path}": data for path, data in contents.items()
    }
    files[f"{prefix}/manifest.json"] = json.dumps(manifest, indent=2, sort_keys=True)
    return files


async def load_text_files(
    settings, workspace_id: UUID, project_id: UUID, plan_context: dict[str, Any] | None
) -> dict[str, str]:
    """The snapshot's files as text keyed by their site path, for the static-site conversion.

    Only what decodes as UTF-8 (the snapshot is text by construction). Never raises: a missing or
    changed snapshot means no conversion, and the run continues as it always did."""
    manifest = (plan_context or {}).get("import_snapshot")
    if not isinstance(manifest, dict) or not manifest.get("files"):
        return {}
    try:
        contents = await asyncio.to_thread(
            read_snapshot_files, snapshots_dir(settings, workspace_id, project_id), manifest
        )
    except (ImportSnapshotError, OSError, ValueError, KeyError) as exc:
        logger.warning(
            "import_snapshot_unavailable_for_conversion",
            project_id=str(project_id),
            error_type=type(exc).__name__,
        )
        return {}
    files: dict[str, str] = {}
    for path, data in contents.items():
        try:
            files[path] = data.decode("utf-8")
        except UnicodeDecodeError:
            continue
    return files


def summarize(manifest: dict[str, Any]) -> dict[str, Any]:
    """What the sender needs back: counts and per-file size/hash, never contents."""
    return {
        "import_id": manifest.get("import_id"),
        "files": [
            {
                "path": f["path"],
                "size": f["size"],
                "lines": f["lines"],
                "sha256": f["sha256"],
                "redacted": f["redacted"],
            }
            for f in manifest.get("files") or []
        ],
        "rejected": manifest.get("rejected") or [],
    }


def build_import_appendix(manifest: dict[str, Any]) -> str:
    """Prompt text telling the agent the originals are on disk and authoritative."""
    prefix = f"{IMPORT_DIR}/{manifest['import_id']}"
    files = manifest.get("files") or []
    lines = [
        "## Imported source, preserved unchanged in the repository",
        f"The files sent by the requesting agent are committed at `{prefix}/files/` "
        f"(sizes and sha256 in `{prefix}/manifest.json`). Secrets were redacted before "
        "storing; everything else is byte-for-byte what arrived.",
        "Adopt them by copying from those files and making targeted edits. Do NOT re-type "
        "them from this prompt: retyping is slow and silently drifts from the original. "
        "If a file's content here differs from the prompt copy, the file on disk wins.",
        f"Never modify or delete `{IMPORT_DIR}/`, and never copy anything from it into "
        "`.github/`, `.git*`, `.env*`, lockfiles or package scripts. Keep visual layout and "
        "existing frontend files; change only what P2HS must own (persistence, auth, email, "
        "backend routes, secrets).",
        "Files:",
    ]
    for entry in files[:_APPENDIX_FILE_LIMIT]:
        flag = " (secrets redacted)" if entry.get("redacted") else ""
        lines.append(f"- `{entry['path']}` — {entry['size']} bytes{flag}")
    if len(files) > _APPENDIX_FILE_LIMIT:
        lines.append(f"- … and {len(files) - _APPENDIX_FILE_LIMIT} more (see the manifest)")
    rejected = manifest.get("rejected") or []
    if rejected:
        lines.append(
            "Not preserved (unusable path): "
            + ", ".join(f"`{r['path']}` ({r['reason']})" for r in rejected[:20])
        )
    return "\n".join(lines)


async def load_for_dispatch(
    settings, workspace_id: UUID, project_id: UUID, plan_context: dict[str, Any] | None
) -> tuple[dict[str, str | bytes], str]:
    """(repo files to commit, prompt appendix) for a run's snapshot, or ({}, "").

    Never raises: the inline prompt still carries the source, so a missing or corrupted
    snapshot degrades to the old behaviour instead of failing the run."""
    manifest = (plan_context or {}).get("import_snapshot")
    if not isinstance(manifest, dict) or not manifest.get("files"):
        return {}, ""
    try:
        contents = await asyncio.to_thread(
            read_snapshot_files, snapshots_dir(settings, workspace_id, project_id), manifest
        )
        return repo_files(manifest, contents), build_import_appendix(manifest)
    except (ImportSnapshotError, OSError, ValueError, KeyError) as exc:
        logger.warning(
            "import_snapshot_unavailable_at_dispatch",
            project_id=str(project_id),
            import_id=str(manifest.get("import_id")),
            error_type=type(exc).__name__,
            error=str(exc)[:200],
        )
        return {}, ""
