"""Turn what a person uploads in the dashboard (files, a folder, a .zip) into the text files P2HS builds from.

The same store and the same checks as the MCP path: the files end up in an import snapshot
(import_snapshot.py), and starting a build from it goes through ``AgentCodegenFacade.start`` (adoption plan,
markup check, prompt history, conversion). This module only does the part that is new: reading an upload.

What is imported is *text source*: HTML, CSS, JavaScript/TypeScript, Vue/Svelte/JSX, JSON, Markdown and the like.
Images, video and fonts are not source; they are reported as skipped and belong in the builder's media upload.
Archives are read defensively (entry count, sizes, path traversal are checked before any byte is used; nothing is
ever extracted to disk).
"""

from __future__ import annotations

import io
import zipfile
from dataclasses import dataclass, field
from typing import Any

from src.core.exceptions import ValidationError
from src.domain.agent_access.operations import (
    MAX_ATTACHMENT_CHARS,
    MAX_ATTACHMENTS,
    MAX_ATTACHMENTS_TOTAL_CHARS,
)
from src.domain.codegen.import_snapshot import normalize_import_path
from src.domain.codegen.source_adoption import UI_MARKUP_SUFFIXES, has_ui_markup
from src.integrations.archive_extract import safe_extract_zip_entries

MAX_UPLOAD_BYTES = 50 * 1024 * 1024  # the whole request
MAX_ZIP_ENTRIES = 2_000
MAX_ZIP_ENTRY_BYTES = 5 * 1024 * 1024
MAX_ZIP_TOTAL_BYTES = 100 * 1024 * 1024

KINDS: dict[str, tuple[str, ...]] = {
    "markup": UI_MARKUP_SUFFIXES,
    "style": (".css", ".scss", ".sass", ".less"),
    "script": (".js", ".mjs", ".cjs", ".ts"),
    "data": (".json", ".yml", ".yaml", ".toml", ".xml", ".webmanifest"),
    "doc": (".md", ".txt"),
    "image": (".svg",),
}
_TEXT_SUFFIXES = tuple(s for group in KINDS.values() for s in group)

# Never source: dependency folders, build output, editor/OS clutter, lockfiles.
_SKIP_DIRS = frozenset(
    {"node_modules", "__macosx", ".git", ".svn", ".idea", ".vscode", "dist", "build", ".nuxt", ".output", ".next",
     ".cache", "coverage"}
)
_SKIP_NAMES = frozenset(
    {".ds_store", "thumbs.db", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "composer.lock"}
)
_BINARY_HINT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".mp4", ".webm", ".mov", ".mp3", ".wav",
                ".woff", ".woff2", ".ttf", ".otf", ".eot", ".pdf", ".zip", ".gz")


def kind_of(path: str) -> str:
    lowered = path.lower()
    for kind, suffixes in KINDS.items():
        if lowered.endswith(suffixes):
            return kind
    return "other"


@dataclass
class Extracted:
    files: list[tuple[str, str]] = field(default_factory=list)  # (path, text)
    skipped: list[dict[str, str]] = field(default_factory=list)  # {path, reason}


def _is_zip(filename: str, data: bytes) -> bool:
    return filename.lower().endswith(".zip") or data[:4] == b"PK\x03\x04"


def _is_clutter(path: str) -> bool:
    parts = [p.lower() for p in path.split("/")]
    return any(p in _SKIP_DIRS for p in parts[:-1]) or (bool(parts) and parts[-1] in _SKIP_NAMES)


def _strip_wrapper(entries: list[tuple[str, bytes]]) -> list[tuple[str, bytes]]:
    """A zip of ``site/index.html, site/assets/...`` should import as ``index.html, assets/...``.

    Only when every real entry sits under one folder that itself holds the site's entry (an ``index.html`` or a
    ``package.json``): a zip whose only top folder is ``src/`` must keep it. Clutter such as the ``__MACOSX/``
    folder every macOS zip carries is ignored for the decision and reported as skipped later."""
    real = [(p, d) for p, d in entries if not _is_clutter(p)]
    tops = {p.split("/", 1)[0] for p, _ in real if "/" in p}
    if len(tops) != 1 or any("/" not in p for p, _ in real):
        return entries
    top = next(iter(tops))
    root_files = {p[len(top) + 1:].lower() for p, _ in real if p.count("/") == 1}
    if not ({"index.html", "package.json"} & root_files):
        return entries
    return [(p[len(top) + 1:] if p.startswith(top + "/") else p, d) for p, d in entries]

def _entries_of(filename: str, data: bytes) -> list[tuple[str, bytes]]:
    if not _is_zip(filename, data):
        return [((filename or "").replace("\\", "/").lstrip("/"), data)]
    try:
        entries = safe_extract_zip_entries(
            io.BytesIO(data),  # type: ignore[arg-type]  (ZipFile takes a file object)
            max_entries=MAX_ZIP_ENTRIES,
            max_entry_bytes=MAX_ZIP_ENTRY_BYTES,
            max_total_bytes=MAX_ZIP_TOTAL_BYTES,
        )
    except ValueError as exc:  # our own limit and path checks: the message says what is wrong
        raise ValidationError(f"Could not read {filename or 'the archive'}: {exc}") from exc
    except RuntimeError as exc:  # zipfile reports a password-protected archive this way
        raise ValidationError(
            f"Could not read {filename or 'the archive'}: it is password protected or uses an unsupported format"
        ) from exc
    except (zipfile.BadZipFile, NotImplementedError, OSError, EOFError) as exc:
        raise ValidationError(f"Could not read {filename or 'the archive'}: it is not a valid zip file") from exc
    return _strip_wrapper(entries)


def extract_sources(uploads: list[tuple[str, bytes]]) -> Extracted:
    """Read uploaded files and archives into (path, text) pairs; anything else is reported, never dropped silently.

    Raises ValidationError when the request is over a hard limit or an archive is unreadable."""
    if not uploads:
        raise ValidationError("Choose at least one file to import.")
    if sum(len(d) for _, d in uploads) > MAX_UPLOAD_BYTES:
        raise ValidationError(f"The upload is larger than {MAX_UPLOAD_BYTES // (1024 * 1024)} MB.")
    out = Extracted()
    seen: set[str] = set()
    # Loose files (a dragged folder arrives as ``site/index.html, site/assets/...``) get the same wrapper rule as a zip.
    loose_at = [i for i, (f, d) in enumerate(uploads) if not _is_zip(f, d)]
    loose = dict(zip(loose_at, _strip_wrapper([_entries_of(*uploads[i])[0] for i in loose_at])))
    entries: list[tuple[str, bytes]] = []
    for i, (filename, data) in enumerate(uploads):  # the order the person chose is kept
        entries.extend([loose[i]] if i in loose else _entries_of(filename, data))
    for raw_path, blob in entries:
        path = raw_path
        if not path:
            continue
        if _is_clutter(path):
            out.skipped.append({"path": path, "reason": "not source (dependencies, build output or clutter)"})
            continue
        if not path.lower().endswith(_TEXT_SUFFIXES):
            reason = (
                "media or binary: upload it with the builder's media button"
                if path.lower().endswith(_BINARY_HINT)
                else "unsupported file type"
            )
            out.skipped.append({"path": path, "reason": reason})
            continue
        try:
            text = blob.decode("utf-8")
        except UnicodeDecodeError:
            out.skipped.append({"path": path, "reason": "not UTF-8 text"})
            continue
        if not text.strip():
            out.skipped.append({"path": path, "reason": "empty file"})
            continue
        try:
            normalized = normalize_import_path(path)
        except ValueError as exc:
            out.skipped.append({"path": path[:200], "reason": f"unusable path ({exc})"})
            continue
        if normalized.lower() in seen:
            out.skipped.append({"path": normalized, "reason": "duplicate path"})
            continue
        seen.add(normalized.lower())
        out.files.append((normalized, text))
    check_limits(out.files)
    return out


def check_limits(files: list[tuple[str, str]]) -> None:
    """The same bounds as a build started by an AI connection (the files travel with the build's prompt)."""
    if not files:
        raise ValidationError(
            "No importable source files were found. P2HS imports text source (HTML, CSS, JS/TS, Vue, JSON, "
            "Markdown); images and video are uploaded with the builder's media button."
        )
    if len(files) > MAX_ATTACHMENTS:
        raise ValidationError(
            f"{len(files)} source files is more than the limit of {MAX_ATTACHMENTS} per import. Remove files you "
            "do not need (for example a second copy of the site) or split the import."
        )
    biggest = max(files, key=lambda f: len(f[1]))
    if len(biggest[1]) > MAX_ATTACHMENT_CHARS:
        raise ValidationError(
            f"{biggest[0]} is {len(biggest[1]):,} characters; the limit is {MAX_ATTACHMENT_CHARS:,} per file."
        )
    total = sum(len(t) for _, t in files)
    if total > MAX_ATTACHMENTS_TOTAL_CHARS:
        raise ValidationError(
            f"The files add up to {total:,} characters; the limit is {MAX_ATTACHMENTS_TOTAL_CHARS:,} per import."
        )


def describe_import(manifest: dict[str, Any], skipped: list[dict[str, str]] | None = None) -> dict[str, Any]:
    """What the person sees after an upload: each file with its kind, what was left out, and what to fix."""
    files = [
        {
            "path": f["path"], "size": f["size"], "lines": f["lines"], "kind": kind_of(f["path"]),
            "redacted": bool(f.get("redacted")),
        }
        for f in manifest.get("files") or []
    ]
    left_out = list(skipped or []) + [
        {"path": r.get("path", ""), "reason": r.get("reason", "rejected")} for r in manifest.get("rejected") or []
    ]
    counts: dict[str, int] = {}
    for f in files:
        counts[f["kind"]] = counts.get(f["kind"], 0) + 1
    paths = [f["path"] for f in files]
    pages = sorted(p for p in paths if "/" not in p and p.lower().endswith((".html", ".htm")))
    warnings: list[str] = []
    if not has_ui_markup(paths):
        warnings.append(
            "No HTML, Vue, React or Svelte pages were found. Without them P2HS cannot keep your design; it can only "
            "add these files to the project."
        )
    if any(f["redacted"] for f in files):
        warnings.append("Secrets in some files were replaced with a placeholder; add the real values in Project Variables.")
    return {
        "import_id": manifest.get("import_id"),
        "created_at": manifest.get("created_at"),
        "source": (manifest.get("source") or {}).get("kind"),
        "files": files,
        "counts": counts,
        "pages": pages,
        "has_ui_markup": has_ui_markup(paths),
        "skipped": left_out,
        "warnings": warnings,
    }
