"""Reading a dashboard upload (files, a folder, a .zip) into importable text source (source_import.py).

Everything is deterministic and defensive: archives are checked before any byte is used, only text source is kept,
and whatever is left out is reported with a reason instead of disappearing."""

from __future__ import annotations

import io
import zipfile

import pytest
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 source_import
from src.domain.codegen.source_import import describe_import, extract_sources, kind_of


def make_zip(entries: dict[str, bytes | str]) -> bytes:
    buffer = io.BytesIO()
    with zipfile.ZipFile(buffer, "w") as archive:
        for name, data in entries.items():
            archive.writestr(name, data)
    return buffer.getvalue()


def paths(result):
    return [p for p, _ in result.files]


def skipped(result):
    return {s["path"]: s["reason"] for s in result.skipped}


# ---- loose files and folders ---------------------------------------------------------------------------


def test_loose_files_keep_the_relative_path_a_folder_upload_gives_them():
    result = extract_sources([("index.html", b"<h1>x</h1>"), ("assets/css/site.css", b"a{}"), ("a/b.js", b"x=1")])
    assert paths(result) == ["index.html", "assets/css/site.css", "a/b.js"] and result.skipped == []


def test_windows_separators_and_a_leading_slash_are_normalized():
    result = extract_sources([("assets\\css\\site.css", b"a{}"), ("/index.html", b"<p/>")])
    assert paths(result) == ["assets/css/site.css", "index.html"]


def test_only_text_source_is_kept_and_everything_else_is_reported_with_a_reason():
    result = extract_sources([
        ("index.html", b"<p/>"), ("logo.png", b"\x89PNG"), ("clip.mp4", b"\x00"), ("font.woff2", b"w"),
        ("data.bin", b"\x00\x01"), ("empty.css", b"   \n"), ("bad.js", b"\xff\xfe\x00"),
    ])
    assert paths(result) == ["index.html"]
    reasons = skipped(result)
    assert "media" in reasons["logo.png"] and "media" in reasons["clip.mp4"] and "media" in reasons["font.woff2"]
    assert reasons["data.bin"] == "unsupported file type"
    assert reasons["empty.css"] == "empty file" and reasons["bad.js"] == "not UTF-8 text"


def test_dependencies_build_output_lockfiles_and_clutter_are_not_source():
    result = extract_sources([
        ("index.html", b"<p/>"), ("node_modules/x/index.js", b"x"), ("dist/app.js", b"x"), (".git/config", b"x"),
        ("package-lock.json", b"{}"), (".DS_Store", b"x"), ("__MACOSX/._a.css", b"x"),
    ])
    assert paths(result) == ["index.html"]
    assert all("not source" in r for r in skipped(result).values()) and len(result.skipped) == 6


def test_a_repeated_path_is_reported_not_silently_replaced():
    result = extract_sources([("a.css", b"one"), ("A.css", b"two")])
    assert paths(result) == ["a.css"] and skipped(result) == {"A.css": "duplicate path"}


@pytest.mark.parametrize("name", ["../evil.html", "a/../../evil.html", "a//b.html"])
def test_an_unusable_path_is_reported(name):
    result = extract_sources([("index.html", b"<p/>"), (name, b"<p/>")])
    assert paths(result) == ["index.html"] and "unusable path" in list(skipped(result).values())[0]


# ---- archives -------------------------------------------------------------------------------------------


def test_a_zip_is_read_in_memory_and_its_wrapper_folder_is_removed_even_with_macos_junk():
    data = make_zip({
        "site/index.html": "<h1>x</h1>", "site/assets/css/a.css": "a{}", "site/assets/img/p.png": b"\x89PNG",
        "__MACOSX/site/._index.html": b"x", "site/node_modules/a/b.js": "x",
    })
    result = extract_sources([("site.zip", data)])
    assert paths(result) == ["index.html", "assets/css/a.css"]
    assert set(skipped(result)) == {"assets/img/p.png", "__MACOSX/site/._index.html", "node_modules/a/b.js"}


def test_a_folder_that_is_not_a_wrapper_keeps_its_name():
    only_src = extract_sources([("x.zip", make_zip({"src/app.ts": "x", "src/util.ts": "y"}))])
    assert paths(only_src) == ["src/app.ts", "src/util.ts"]  # no index.html or package.json inside: not a wrapper
    mixed = extract_sources([("x.zip", make_zip({"site/index.html": "<p/>", "README.md": "# hi"}))])
    assert paths(mixed) == ["site/index.html", "README.md"]  # something sits at the root: keep the folder


def test_a_package_json_also_marks_a_wrapper():
    result = extract_sources([("x.zip", make_zip({"app/package.json": "{}", "app/src/main.ts": "x"}))])
    assert paths(result) == ["package.json", "src/main.ts"]


def test_a_zip_is_recognized_by_its_bytes_not_only_its_name():
    result = extract_sources([("upload", make_zip({"index.html": "<p/>"}))])
    assert paths(result) == ["index.html"]


def test_a_zip_and_loose_files_can_be_combined():
    result = extract_sources([("a.zip", make_zip({"index.html": "<p/>"})), ("extra/notes.md", b"# hi")])
    assert paths(result) == ["index.html", "extra/notes.md"]


def test_a_traversal_entry_rejects_the_whole_archive_with_a_clear_message():
    with pytest.raises(ValidationError, match="Could not read bad.zip.*unsafe path"):
        extract_sources([("bad.zip", make_zip({"../../etc/passwd.txt": "x"}))])


def test_a_file_that_is_not_a_zip_is_a_clear_error():
    with pytest.raises(ValidationError, match="not a valid zip"):
        extract_sources([("broken.zip", b"this is not a zip")])


def test_an_archive_with_too_many_entries_is_refused_before_anything_is_read(monkeypatch):
    monkeypatch.setattr(source_import, "MAX_ZIP_ENTRIES", 3)
    with pytest.raises(ValidationError, match="more than 3 files"):
        extract_sources([("x.zip", make_zip({f"f{i}.css": "a{}" for i in range(4)}))])


def test_an_archive_that_expands_too_far_is_refused(monkeypatch):
    monkeypatch.setattr(source_import, "MAX_ZIP_TOTAL_BYTES", 100)
    with pytest.raises(ValidationError, match="exceed"):
        extract_sources([("x.zip", make_zip({"a.css": "x" * 200}))])


def test_the_whole_upload_has_a_size_cap(monkeypatch):
    monkeypatch.setattr(source_import, "MAX_UPLOAD_BYTES", 10)
    with pytest.raises(ValidationError, match="larger than"):
        extract_sources([("a.css", b"x" * 11)])


# ---- limits ---------------------------------------------------------------------------------------------


def test_nothing_chosen_or_nothing_importable_is_a_clear_error():
    with pytest.raises(ValidationError, match="at least one file"):
        extract_sources([])
    with pytest.raises(ValidationError, match="No importable source files"):
        extract_sources([("logo.png", b"\x89PNG")])


def test_too_many_files_says_what_to_do():
    uploads = [(f"f{i}.css", b"a{}") for i in range(MAX_ATTACHMENTS + 1)]
    with pytest.raises(ValidationError, match=f"limit of {MAX_ATTACHMENTS}"):
        extract_sources(uploads)


def test_one_file_over_the_per_file_limit_is_named():
    with pytest.raises(ValidationError, match="big.css is"):
        extract_sources([("big.css", b"a" * (MAX_ATTACHMENT_CHARS + 1))])


def test_the_combined_size_limit_applies():
    chunk = b"a" * (MAX_ATTACHMENT_CHARS - 1)
    uploads = [(f"f{i}.css", chunk) for i in range(MAX_ATTACHMENTS_TOTAL_CHARS // len(chunk) + 1)]
    with pytest.raises(ValidationError, match="add up to"):
        extract_sources(uploads)


# ---- what the person sees -------------------------------------------------------------------------------


def manifest(files, rejected=()):
    return {"import_id": "a" * 32, "created_at": "2026-09-25T00:00:00+00:00", "source": {"kind": "dashboard"},
            "files": [{"path": p, "size": 10, "lines": 1, "redacted": r} for p, r in files],
            "rejected": list(rejected)}


@pytest.mark.parametrize("path,kind", [
    ("index.html", "markup"), ("App.vue", "markup"), ("a.tsx", "markup"), ("site.css", "style"), ("a.scss", "style"),
    ("app.js", "script"), ("app.ts", "script"), ("data.json", "data"), ("README.md", "doc"), ("logo.svg", "image"),
    ("Makefile", "other"),
])
def test_files_are_classified_for_display(path, kind):
    assert kind_of(path) == kind


def test_the_description_lists_files_counts_pages_and_what_was_left_out():
    m = manifest([("index.html", False), ("about.html", False), ("blog/post.html", False), ("app.js", False),
                  ("site.css", False)], rejected=[{"path": "x", "reason": "duplicate_path"}])
    d = describe_import(m, [{"path": "logo.png", "reason": "media"}])
    assert d["counts"] == {"markup": 3, "script": 1, "style": 1}
    assert d["pages"] == ["about.html", "index.html"]  # root-level pages only
    assert d["has_ui_markup"] is True and d["warnings"] == [] and d["source"] == "dashboard"
    assert {s["path"] for s in d["skipped"]} == {"logo.png", "x"}
    assert d["files"][0] == {"path": "index.html", "size": 10, "lines": 1, "kind": "markup", "redacted": False}


def test_a_data_only_import_warns_that_the_design_cannot_be_kept():
    d = describe_import(manifest([("data.js", False), ("site.css", False)]))
    assert d["has_ui_markup"] is False
    assert any("No HTML, Vue, React or Svelte pages" in w for w in d["warnings"])


def test_redacted_secrets_are_flagged():
    d = describe_import(manifest([("index.html", True)]))
    assert any("Project Variables" in w for w in d["warnings"])


def test_a_password_protected_zip_is_a_clear_error():
    from unittest.mock import patch

    with patch("src.domain.codegen.source_import.safe_extract_zip_entries", side_effect=RuntimeError("encrypted")):
        with pytest.raises(ValidationError, match="password protected"):
            extract_sources([("locked.zip", b"PK\x03\x04rest")])


def test_a_dragged_folder_loses_its_wrapper_like_a_zip():
    # a folder upload arrives as site/index.html, site/assets/css/a.css ...
    result = extract_sources([("site/index.html", b"<p/>"), ("site/assets/css/a.css", b"a{}"), ("site/logo.png", b"\x89PNG")])
    assert paths(result) == ["index.html", "assets/css/a.css"] and set(skipped(result)) == {"logo.png"}


def test_loose_files_that_do_not_share_a_site_folder_are_left_alone():
    assert paths(extract_sources([("a/index.html", b"<p/>"), ("b/site.css", b"a{}")])) == ["a/index.html", "b/site.css"]
    assert paths(extract_sources([("index.html", b"<p/>"), ("assets/a.css", b"a{}")])) == ["index.html", "assets/a.css"]
    assert paths(extract_sources([("only/notes.md", b"# hi")])) == ["only/notes.md"]  # no index.html: not a wrapper
