"""Agent-token-authenticated binary asset uploads (photos/video for start_build).

Two-step flow, mirroring the project-imports R2 presign pattern (see
src/integrations/r2_import_client.py) instead of routing bytes through the
JSON-only MCP Worker / Dashboard proxy relay -- that chain fully buffers every
body through a fatal UTF-8 TextDecoder and rejects non-JSON content types, so
it cannot carry binary data without corrupting or rejecting it.

1. request_asset_upload -- returns a presigned R2 PUT URL scoped to a fresh
   asset id. The agent's own HTTP client uploads bytes directly to R2, never
   touching Core or the MCP relay.
2. confirm_asset_upload -- Core downloads the object from R2 to local disk
   under the same layout prompt_media already uses, creates a
   PromptMediaAsset row, and deletes the R2 staging object. From here on this
   is an ordinary PromptMediaAsset, consumed by start_build's attachment_ids
   exactly like a Dashboard-uploaded one (see workers/prompt_media_sync.py).

Neither tool goes through AgentOperationService's idempotent mutation queue --
that pattern defers execution to a worker (status starts "queued"), which
doesn't fit "return a URL right now." request_asset_upload has no durable
side effect to be idempotent about; confirm_asset_upload is idempotent by
asset_id existence instead.
"""
from __future__ import annotations

import asyncio
import mimetypes
import os
import re
import tempfile
import uuid
from typing import Any
from uuid import UUID

from src.core.exceptions import NotFoundError, ValidationError
from src.domain.prompt_media.paths import build_storage_relative_path
from src.domain.prompt_media.repository import PromptMediaRepository
from src.domain.prompt_media.schemas import PromptMediaUploadResponse
from src.domain.prompt_media.validate import assert_allowed_mime, kind_from_mime

_ZIP_MIME_TYPES = {"application/zip", "application/x-zip-compressed"}


def _is_zip(filename: str, mime_type: str) -> bool:
    return filename.lower().endswith(".zip") or mime_type in _ZIP_MIME_TYPES


def _safe_filename(name: str) -> str:
    base = os.path.basename(name or "") or "upload.bin"
    base = re.sub(r"[^a-zA-Z0-9._-]+", "_", base).strip("._")[:180]
    return base or "upload.bin"


def _r2_key(workspace_id: UUID, project_id: UUID, asset_id: UUID, filename: str) -> str:
    return f"agent-uploads/{workspace_id}/{project_id}/{asset_id}/{_safe_filename(filename)}"


async def request_upload(access, principal, body) -> dict[str, Any]:
    """Handle the request_asset_upload tool: validate and return a presigned PUT URL."""
    from src.integrations.r2_import_client import generate_single_part_upload_url

    workspace_id = await access.require_scope(principal, "builds.run", body.project_id)
    assert_allowed_mime(body.content_type, body.filename)
    max_bytes = access.settings.agent_asset_upload_max_bytes
    if body.byte_size > max_bytes:
        raise ValidationError(f"File too large (max {max_bytes // (1024 * 1024)} MiB)")

    asset_id = uuid.uuid4()
    key = _r2_key(workspace_id, body.project_id, asset_id, body.filename)
    ttl = access.settings.agent_asset_upload_presign_ttl_seconds
    presign = generate_single_part_upload_url(
        access.settings,
        filename=body.filename,
        key=key,
        content_type=body.content_type,
        expires_in=ttl,
    )
    return {
        "asset_id": str(asset_id),
        "upload_url": presign["upload_url"],
        "upload_method": "PUT",
        "required_headers": {"Content-Type": body.content_type},
        "expires_in": ttl,
    }


async def _materialize_asset(
    repo,
    *,
    settings,
    workspace_id,
    project_id,
    data: bytes,
    filename: str,
    mime_type: str,
    asset_id=None,
):
    """Write bytes to the normal prompt_media disk layout and create the row.
    Shared by the single-file and zip-extraction paths below."""
    storage_fn = f"{asset_id or uuid.uuid4()}_{_safe_filename(filename)}"
    rel = build_storage_relative_path(workspace_id, project_id, storage_fn)
    upload_root = os.path.normpath(settings.prompt_media_upload_root)
    abs_path = os.path.join(upload_root, rel.replace("/", os.sep))
    os.makedirs(os.path.dirname(abs_path), exist_ok=True)
    await asyncio.to_thread(_write_bytes, abs_path, data)
    return await repo.create(
        project_id=project_id,
        uploaded_by=None,
        kind=kind_from_mime(mime_type),
        storage_relative_path=rel,
        original_filename=filename,
        mime_type=mime_type,
        byte_size=len(data),
        asset_id=asset_id,
    )


def _write_bytes(path: str, data: bytes) -> None:
    with open(path, "wb") as f:
        f.write(data)


async def confirm_upload(access, principal, body) -> dict[str, Any]:
    """Handle the confirm_asset_upload tool: materialize PromptMediaAsset(s) from R2.

    A single file becomes one asset, unchanged from before. A .zip is
    extracted server-side (see safe_extract_zip_entries) into one asset per
    entry, so an agent can upload many files in one request_asset_upload +
    confirm_asset_upload round trip instead of one per file.
    """
    from src.integrations.r2_import_client import delete_object, download_object, head_object

    workspace_id = await access.require_scope(principal, "builds.run", body.project_id)
    repo = PromptMediaRepository(access.repo.db)

    existing = await repo.get_by_id(body.asset_id)
    if existing:
        if existing.project_id != body.project_id:
            raise NotFoundError("Asset not found")
        return PromptMediaUploadResponse.model_validate(existing).model_dump(mode="json")

    project = await access.projects.get_by_id(body.project_id)
    if not project or project.workspace_id != workspace_id:
        raise NotFoundError("Project not found")

    key = _r2_key(workspace_id, body.project_id, body.asset_id, body.filename)
    meta = head_object(access.settings, r2_key=key)
    if not meta:
        raise NotFoundError(
            "Upload not found -- PUT the file to the URL from request_asset_upload first"
        )

    byte_size = int(meta.get("content_length") or 0)
    mime_type = meta.get("content_type") or "application/octet-stream"
    max_bytes = access.settings.agent_asset_upload_max_bytes
    if byte_size <= 0 or byte_size > max_bytes:
        delete_object(access.settings, r2_key=key)
        raise ValidationError(
            f"Uploaded file is empty or exceeds the {max_bytes // (1024 * 1024)} MiB limit"
        )
    assert_allowed_mime(mime_type, body.filename)

    if not _is_zip(body.filename, mime_type):
        storage_fn = f"{body.asset_id}_{_safe_filename(body.filename)}"
        rel = build_storage_relative_path(workspace_id, body.project_id, storage_fn)
        upload_root = os.path.normpath(access.settings.prompt_media_upload_root)
        abs_path = os.path.join(upload_root, rel.replace("/", os.sep))
        os.makedirs(os.path.dirname(abs_path), exist_ok=True)
        # Downloaded straight to its final resting place -- unlike the zip path
        # below, there's no need to hold the whole file in memory here.
        await asyncio.to_thread(download_object, access.settings, r2_key=key, dest_path=abs_path)
        delete_object(access.settings, r2_key=key)
        row = await repo.create(
            project_id=body.project_id,
            uploaded_by=None,
            kind=kind_from_mime(mime_type),
            storage_relative_path=rel,
            original_filename=body.filename,
            mime_type=mime_type,
            byte_size=byte_size,
            asset_id=body.asset_id,
        )
        return PromptMediaUploadResponse.model_validate(row).model_dump(mode="json")

    return await _confirm_zip_upload(access, repo, workspace_id, body, key)


async def _confirm_zip_upload(access, repo, workspace_id, body, key: str) -> dict[str, Any]:
    from src.integrations.archive_extract import safe_extract_zip_entries
    from src.integrations.r2_import_client import delete_object, download_object

    fd, tmp_zip_path = tempfile.mkstemp(suffix=".zip")
    os.close(fd)
    try:
        await asyncio.to_thread(
            download_object, access.settings, r2_key=key, dest_path=tmp_zip_path
        )
        try:
            entries = await asyncio.to_thread(
                safe_extract_zip_entries,
                tmp_zip_path,
                max_entries=access.settings.agent_asset_upload_max_zip_entries,
                max_entry_bytes=access.settings.agent_asset_upload_max_bytes,
                max_total_bytes=access.settings.agent_asset_upload_total_bytes,
            )
        except ValueError as exc:
            raise ValidationError(str(exc)) from exc

        extracted: list[dict[str, Any]] = []
        skipped: list[dict[str, Any]] = []
        for rel_name, data in entries:
            entry_mime = mimetypes.guess_type(rel_name)[0] or "application/octet-stream"
            try:
                assert_allowed_mime(entry_mime, rel_name)
            except ValidationError as exc:
                skipped.append({"path": rel_name, "reason": str(exc)})
                continue
            row = await _materialize_asset(
                repo,
                settings=access.settings,
                workspace_id=workspace_id,
                project_id=body.project_id,
                data=data,
                filename=rel_name,
                mime_type=entry_mime,
            )
            extracted.append(
                PromptMediaUploadResponse.model_validate(row).model_dump(mode="json")
            )
    finally:
        os.unlink(tmp_zip_path)
    delete_object(access.settings, r2_key=key)
    return {"archive": True, "extracted": extracted, "skipped": skipped}
