"""request_asset_upload / confirm_asset_upload: the agent-token binary media
upload path (photos/video for start_build), and StartBuild's attachment_ids."""

import os
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

import pytest
from pydantic import ValidationError as SchemaError
from src.core.exceptions import NotFoundError, ValidationError
from src.domain.agent_access.asset_upload import confirm_upload, request_upload
from src.domain.agent_access.operations import (
    MAX_ATTACHMENT_IDS,
    ConfirmAssetUpload,
    RequestAssetUpload,
    StartBuild,
)
from src.domain.prompt_media.models import PromptMediaAsset


def _settings(**overrides):
    defaults = dict(
        agent_asset_upload_max_bytes=150 * 1024 * 1024,
        agent_asset_upload_total_bytes=300 * 1024 * 1024,
        agent_asset_upload_presign_ttl_seconds=900,
        agent_asset_upload_max_zip_entries=150,
        prompt_media_upload_root="/tmp/p2hs-prompt-media-test",
        r2_imports_account_id="acct",
        r2_imports_bucket="bucket",
        r2_imports_access_key_id="key",
        r2_imports_secret_access_key="secret",
    )
    defaults.update(overrides)
    return SimpleNamespace(**defaults)


# require_scope() now resolves and returns the workspace a call targets; the fake
# principal carries the same id only so tests can assert on it.
WORKSPACE_ID = uuid4()


def _access(**overrides):
    defaults = dict(
        require_scope=AsyncMock(return_value=WORKSPACE_ID),
        settings=_settings(),
        repo=SimpleNamespace(db=MagicMock()),
        projects=SimpleNamespace(get_by_id=AsyncMock()),
    )
    defaults.update(overrides)
    return SimpleNamespace(**defaults)


def _principal():
    return SimpleNamespace(id=uuid4(), workspace_id=WORKSPACE_ID)


def _asset(**overrides):
    defaults = dict(
        id=uuid4(),
        project_id=uuid4(),
        uploaded_by=None,
        kind="image",
        storage_relative_path="ws/proj/asset_file.png",
        original_filename="file.png",
        mime_type="image/png",
        byte_size=1024,
        transcript=None,
        created_at=datetime.now(timezone.utc),
    )
    defaults.update(overrides)
    return PromptMediaAsset(**defaults)


# ---- request_asset_upload --------------------------------------------------


async def test_request_upload_rejects_disallowed_mime():
    access = _access()
    body = RequestAssetUpload(
        project_id=uuid4(), filename="virus.exe", content_type="application/x-msdownload", byte_size=100
    )
    with pytest.raises(ValidationError):
        await request_upload(access, _principal(), body)


async def test_request_upload_rejects_oversized_file():
    access = _access()
    body = RequestAssetUpload(
        project_id=uuid4(),
        filename="clip.mp4",
        content_type="video/mp4",
        byte_size=access.settings.agent_asset_upload_max_bytes + 1,
    )
    with pytest.raises(ValidationError):
        await request_upload(access, _principal(), body)


async def test_request_upload_checks_scope_before_generating_a_url():
    access = _access(require_scope=AsyncMock(side_effect=PermissionError()))
    body = RequestAssetUpload(
        project_id=uuid4(), filename="a.png", content_type="image/png", byte_size=100
    )
    with pytest.raises(PermissionError):
        await request_upload(access, _principal(), body)


async def test_request_upload_returns_presigned_url_and_required_content_type():
    access = _access()
    principal = _principal()
    project_id = uuid4()
    body = RequestAssetUpload(
        project_id=project_id, filename="photo.jpg", content_type="image/jpeg", byte_size=2048
    )
    with patch(
        "src.integrations.r2_import_client.generate_single_part_upload_url",
        return_value={"upload_type": "single", "upload_url": "https://r2.example/signed", "r2_key": "k"},
    ) as presign:
        result = await request_upload(access, principal, body)
    assert result["upload_url"] == "https://r2.example/signed"
    assert result["upload_method"] == "PUT"
    assert result["required_headers"] == {"Content-Type": "image/jpeg"}
    assert result["expires_in"] == 900
    kwargs = presign.call_args.kwargs
    assert kwargs["content_type"] == "image/jpeg"
    assert str(principal.workspace_id) in kwargs["key"]
    assert str(project_id) in kwargs["key"]
    assert result["asset_id"] in kwargs["key"]


# ---- confirm_asset_upload ---------------------------------------------------


async def test_confirm_upload_is_idempotent_for_an_already_materialized_asset():
    project_id = uuid4()
    existing = _asset(project_id=project_id)
    access = _access()
    with patch("src.domain.agent_access.asset_upload.PromptMediaRepository") as repo_cls:
        repo_cls.return_value = SimpleNamespace(get_by_id=AsyncMock(return_value=existing))
        result = await confirm_upload(
            access,
            _principal(),
            ConfirmAssetUpload(project_id=project_id, asset_id=existing.id, filename="file.png"),
        )
    assert result["id"] == str(existing.id)
    access.projects.get_by_id.assert_not_awaited()


async def test_confirm_upload_rejects_asset_belonging_to_a_different_project():
    existing = _asset()
    access = _access()
    with patch("src.domain.agent_access.asset_upload.PromptMediaRepository") as repo_cls:
        repo_cls.return_value = SimpleNamespace(get_by_id=AsyncMock(return_value=existing))
        with pytest.raises(NotFoundError):
            await confirm_upload(
                access,
                _principal(),
                ConfirmAssetUpload(project_id=uuid4(), asset_id=existing.id, filename="file.png"),
            )


async def test_confirm_upload_rejects_foreign_project():
    access = _access(projects=SimpleNamespace(get_by_id=AsyncMock(return_value=None)))
    with patch("src.domain.agent_access.asset_upload.PromptMediaRepository") as repo_cls:
        repo_cls.return_value = SimpleNamespace(get_by_id=AsyncMock(return_value=None))
        with pytest.raises(NotFoundError):
            await confirm_upload(
                access,
                _principal(),
                ConfirmAssetUpload(project_id=uuid4(), asset_id=uuid4(), filename="file.png"),
            )


async def test_confirm_upload_requires_the_object_to_exist_in_r2_first():
    project_id = uuid4()
    principal = _principal()
    access = _access(
        projects=SimpleNamespace(
            get_by_id=AsyncMock(return_value=SimpleNamespace(workspace_id=principal.workspace_id))
        )
    )
    with patch("src.domain.agent_access.asset_upload.PromptMediaRepository") as repo_cls, patch(
        "src.integrations.r2_import_client.head_object", return_value=None
    ):
        repo_cls.return_value = SimpleNamespace(get_by_id=AsyncMock(return_value=None))
        with pytest.raises(NotFoundError):
            await confirm_upload(
                access,
                principal,
                ConfirmAssetUpload(project_id=project_id, asset_id=uuid4(), filename="clip.mp4"),
            )


async def test_confirm_upload_materializes_asset_and_cleans_up_r2_object():
    import shutil
    import tempfile

    upload_root = tempfile.mkdtemp(prefix="p2hs-prompt-media-test-")
    try:
        await _assert_confirm_upload_materializes(upload_root)
    finally:
        shutil.rmtree(upload_root, ignore_errors=True)


async def _assert_confirm_upload_materializes(upload_root):
    project_id = uuid4()
    principal = _principal()
    access = _access(settings=_settings(prompt_media_upload_root=upload_root))
    access.projects.get_by_id.return_value = SimpleNamespace(workspace_id=principal.workspace_id)
    created = {}

    async def fake_create(**kwargs):
        created.update(kwargs)
        return _asset(
            id=kwargs["asset_id"],
            project_id=kwargs["project_id"],
            uploaded_by=kwargs["uploaded_by"],
            kind=kwargs["kind"],
            storage_relative_path=kwargs["storage_relative_path"],
            original_filename=kwargs["original_filename"],
            mime_type=kwargs["mime_type"],
            byte_size=kwargs["byte_size"],
        )

    with patch("src.domain.agent_access.asset_upload.PromptMediaRepository") as repo_cls, patch(
        "src.integrations.r2_import_client.head_object",
        return_value={"content_length": 4096, "content_type": "image/png"},
    ), patch("src.integrations.r2_import_client.delete_object") as delete_object, patch(
        "src.integrations.r2_import_client.download_object"
    ) as download_object:
        repo = SimpleNamespace(get_by_id=AsyncMock(return_value=None), create=AsyncMock(side_effect=fake_create))
        repo_cls.return_value = repo
        result = await confirm_upload(
            access,
            principal,
            ConfirmAssetUpload(project_id=project_id, asset_id=uuid4(), filename="photo.png"),
        )
    assert result["mime_type"] == "image/png"
    assert result["byte_size"] == 4096
    repo.create.assert_awaited_once()
    assert created["project_id"] == project_id
    assert created["uploaded_by"] is None
    assert created["kind"] == "image"
    download_object.assert_called_once()
    delete_object.assert_called_once()


async def test_confirm_upload_extracts_a_zip_into_multiple_assets():
    import shutil
    import tempfile
    import zipfile

    upload_root = tempfile.mkdtemp(prefix="p2hs-prompt-media-test-")
    src_zip = os.path.join(tempfile.mkdtemp(prefix="p2hs-zip-src-"), "site.zip")
    with zipfile.ZipFile(src_zip, "w") as zf:
        zf.writestr("index.html", b"<h1>Hi</h1>")
        zf.writestr("styles.css", b"body { color: red; }")
    try:
        await _assert_confirm_upload_extracts_zip(upload_root, src_zip)
    finally:
        shutil.rmtree(upload_root, ignore_errors=True)
        shutil.rmtree(os.path.dirname(src_zip), ignore_errors=True)


async def _assert_confirm_upload_extracts_zip(upload_root, src_zip):
    import shutil

    project_id = uuid4()
    principal = _principal()
    access = _access(settings=_settings(prompt_media_upload_root=upload_root))
    access.projects.get_by_id.return_value = SimpleNamespace(workspace_id=principal.workspace_id)

    async def fake_create(**kwargs):
        return _asset(
            id=kwargs["asset_id"] or uuid4(),
            project_id=kwargs["project_id"],
            uploaded_by=kwargs["uploaded_by"],
            kind=kwargs["kind"],
            storage_relative_path=kwargs["storage_relative_path"],
            original_filename=kwargs["original_filename"],
            mime_type=kwargs["mime_type"],
            byte_size=kwargs["byte_size"],
        )

    def fake_download(settings, *, r2_key, dest_path):
        shutil.copyfile(src_zip, dest_path)

    zip_meta = {"content_length": os.path.getsize(src_zip), "content_type": "application/zip"}
    with patch("src.domain.agent_access.asset_upload.PromptMediaRepository") as repo_cls, patch(
        "src.integrations.r2_import_client.head_object", return_value=zip_meta
    ), patch("src.integrations.r2_import_client.delete_object") as delete_object, patch(
        "src.integrations.r2_import_client.download_object", side_effect=fake_download
    ):
        repo = SimpleNamespace(
            get_by_id=AsyncMock(return_value=None), create=AsyncMock(side_effect=fake_create)
        )
        repo_cls.return_value = repo
        result = await confirm_upload(
            access,
            principal,
            ConfirmAssetUpload(project_id=project_id, asset_id=uuid4(), filename="site.zip"),
        )
    assert result["archive"] is True
    assert result["skipped"] == []
    assert {e["original_filename"] for e in result["extracted"]} == {"index.html", "styles.css"}
    assert repo.create.await_count == 2
    delete_object.assert_called_once()


async def test_confirm_upload_zip_reports_disallowed_entries_as_skipped_not_fatal():
    import shutil
    import tempfile
    import zipfile

    upload_root = tempfile.mkdtemp(prefix="p2hs-prompt-media-test-")
    src_dir = tempfile.mkdtemp(prefix="p2hs-zip-src-")
    src_zip = os.path.join(src_dir, "site.zip")
    with zipfile.ZipFile(src_zip, "w") as zf:
        zf.writestr("index.html", b"<h1>Hi</h1>")
        zf.writestr("virus.exe", b"MZ...")
    try:
        project_id = uuid4()
        principal = _principal()
        access = _access(settings=_settings(prompt_media_upload_root=upload_root))
        project_ws = SimpleNamespace(workspace_id=principal.workspace_id)
        access.projects.get_by_id.return_value = project_ws

        async def fake_create(**kwargs):
            return _asset(
                id=kwargs["asset_id"] or uuid4(),
                project_id=kwargs["project_id"],
                uploaded_by=kwargs["uploaded_by"],
                kind=kwargs["kind"],
                storage_relative_path=kwargs["storage_relative_path"],
                original_filename=kwargs["original_filename"],
                mime_type=kwargs["mime_type"],
                byte_size=kwargs["byte_size"],
            )

        def fake_download(settings, *, r2_key, dest_path):
            shutil.copyfile(src_zip, dest_path)

        zip_meta = {"content_length": os.path.getsize(src_zip), "content_type": "application/zip"}
        with patch("src.domain.agent_access.asset_upload.PromptMediaRepository") as repo_cls, patch(
            "src.integrations.r2_import_client.head_object", return_value=zip_meta
        ), patch("src.integrations.r2_import_client.delete_object"), patch(
            "src.integrations.r2_import_client.download_object", side_effect=fake_download
        ):
            repo_cls.return_value = SimpleNamespace(
                get_by_id=AsyncMock(return_value=None), create=AsyncMock(side_effect=fake_create)
            )
            result = await confirm_upload(
                access,
                principal,
                ConfirmAssetUpload(project_id=project_id, asset_id=uuid4(), filename="site.zip"),
            )
        assert {e["original_filename"] for e in result["extracted"]} == {"index.html"}
        assert [s["path"] for s in result["skipped"]] == ["virus.exe"]
    finally:
        shutil.rmtree(upload_root, ignore_errors=True)
        shutil.rmtree(src_dir, ignore_errors=True)


async def test_confirm_upload_zip_that_fails_extraction_caps_raises_validation_error():
    project_id = uuid4()
    principal = _principal()
    access = _access()
    access.projects.get_by_id.return_value = SimpleNamespace(workspace_id=principal.workspace_id)
    with patch("src.domain.agent_access.asset_upload.PromptMediaRepository") as repo_cls, patch(
        "src.integrations.r2_import_client.head_object",
        return_value={"content_length": 1024, "content_type": "application/zip"},
    ), patch("src.integrations.r2_import_client.download_object"), patch(
        "src.integrations.archive_extract.safe_extract_zip_entries",
        side_effect=ValueError("Archive has more than 150 files"),
    ):
        repo_cls.return_value = SimpleNamespace(get_by_id=AsyncMock(return_value=None))
        with pytest.raises(ValidationError):
            await confirm_upload(
                access,
                principal,
                ConfirmAssetUpload(project_id=project_id, asset_id=uuid4(), filename="site.zip"),
            )


# ---- StartBuild.attachment_ids ----------------------------------------------


def test_start_build_accepts_attachment_ids():
    build = StartBuild(
        request_key="k" * 8, project_id=uuid4(), prompt="Rebuild", attachment_ids=[uuid4()]
    )
    assert len(build.attachment_ids) == 1


def test_start_build_rejects_more_than_the_attachment_id_limit():
    with pytest.raises(SchemaError):
        StartBuild(
            request_key="k" * 8,
            project_id=uuid4(),
            prompt="Rebuild",
            attachment_ids=[uuid4() for _ in range(MAX_ATTACHMENT_IDS + 1)],
        )
