"""Unit tests for session file API endpoint logic."""

import base64
import io
from unittest.mock import MagicMock

import pytest
from fastapi import HTTPException
from PIL import Image

import navi.api.routes.sessions as sessions_mod
import navi.session_files as session_files_mod
from navi.auth import User
from navi.core.session import InMemorySessionStore


@pytest.fixture
async def store():
    session_store = InMemorySessionStore()
    await session_store.create("secretary")
    return session_store


@pytest.fixture(autouse=True)
def _session_files_dir(monkeypatch, tmp_path):
    from navi.config import Settings

    async def _to_thread(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(sessions_mod.asyncio, "to_thread", _to_thread)
    _test_settings = Settings(
        session_files_dir=str(tmp_path / "sessions"),
        session_files_max_size_mb=1,
    )
    monkeypatch.setattr(sessions_mod, "settings", _test_settings)
    monkeypatch.setattr(session_files_mod, "settings", _test_settings)


async def _session_id(store: InMemorySessionStore) -> str:
    sessions = await store.list_all()
    return sessions[0].id


class FakeUploadFile:
    def __init__(self, name: str, data: bytes, content_type: str = "application/octet-stream") -> None:
        self.filename = name
        self.content_type = content_type
        self._data = data
        self._offset = 0

    async def read(self, size: int = -1) -> bytes:
        if self._offset >= len(self._data):
            return b""
        if size < 0:
            size = len(self._data) - self._offset
        chunk = self._data[self._offset:self._offset + size]
        self._offset += len(chunk)
        return chunk


def _upload(name: str, data: bytes, content_type: str = "application/octet-stream") -> FakeUploadFile:
    return FakeUploadFile(name, data, content_type)


_FAKE_USER = User(id="test-user", email="test@example.com", display_name="Test", role="admin", permissions=[])


async def test_upload_duplicate_filename_gets_numbered_name(store):
    session_id = await _session_id(store)

    first = await sessions_mod.upload_file(session_id, _upload("report.txt", b"one"), store, _FAKE_USER)
    second = await sessions_mod.upload_file(session_id, _upload("report.txt", b"two"), store, _FAKE_USER)

    assert first["name"] == "report.txt"
    assert second["name"] == "report_1.txt"


async def test_upload_forbidden_extension_rejected(store):
    session_id = await _session_id(store)

    with pytest.raises(HTTPException) as exc:
        await sessions_mod.upload_file(session_id, _upload("run.sh", b"echo nope"), store, _FAKE_USER)

    assert exc.value.status_code == 400
    assert "Executable files" in exc.value.detail


async def test_download_rejects_path_traversal(store):
    session_id = await _session_id(store)

    with pytest.raises(HTTPException) as exc:
        await sessions_mod.download_file(session_id, "../secret.txt", store)

    assert exc.value.status_code == 403


async def test_download_browser_renderable_file_is_inline(store):
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "page.html"
    dest.write_text("<h1>Hello</h1>")

    response = await sessions_mod.download_file(session_id, "page.html", store)

    assert response.media_type == "text/html"
    assert "inline" in response.headers["content-disposition"]


async def test_download_query_forces_attachment(store):
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "page.html"
    dest.write_text("<h1>Hello</h1>")

    response = await sessions_mod.download_file(session_id, "page.html", store, download=True)

    assert response.media_type == "text/html"
    assert "attachment" in response.headers["content-disposition"]


async def test_download_archive_is_attachment(store):
    session_id = await _session_id(store)
    dest = session_files_mod.ensure_session_dir(session_id) / "bundle.zip"
    dest.write_bytes(b"zip")

    response = await sessions_mod.download_file(session_id, "bundle.zip", store)

    assert response.media_type == "application/zip"
    assert "attachment" in response.headers["content-disposition"]


async def test_get_session_content_returns_store_items(store, monkeypatch):
    session_id = await _session_id(store)

    async def fake_list_for_session(sid):
        assert sid == session_id
        return [{"filename": "logo.svg"}]

    monkeypatch.setattr(sessions_mod, "list_for_session", fake_list_for_session)

    result = await sessions_mod.get_session_content(session_id, store)

    assert result == {"session_id": session_id, "content": [{"filename": "logo.svg"}]}


# ── save_images: the images a user pastes into the chat ──────────────────────


def _png_bytes(colour: tuple = (255, 0, 0, 128)) -> bytes:
    """A tiny RGBA PNG — alpha present, so a JPEG round-trip would show up as damage."""
    buf = io.BytesIO()
    Image.new("RGBA", (2, 2), colour).save(buf, format="PNG")
    return buf.getvalue()


def _jpg_bytes() -> bytes:
    buf = io.BytesIO()
    Image.new("RGB", (2, 2), (0, 128, 255)).save(buf, format="JPEG")
    return buf.getvalue()


def _b64(raw: bytes) -> str:
    return base64.b64encode(raw).decode()


async def test_save_images_writes_each_one_under_its_own_format(store):
    png, jpg = _png_bytes(), _jpg_bytes()
    session_id = await _session_id(store)

    names = session_files_mod.save_images(session_id, [_b64(png), _b64(jpg)])

    assert names == ["image_1.png", "image_2.jpg"]
    directory = session_files_mod.session_dir(session_id)
    assert (directory / "image_1.png").read_bytes() == png
    assert (directory / "image_2.jpg").read_bytes() == jpg
    # The bytes are stored as they arrived: an RGBA paste stays RGBA.
    assert Image.open(directory / "image_1.png").mode == "RGBA"


async def test_save_images_tolerates_a_data_url_prefix(store):
    session_id = await _session_id(store)

    names = session_files_mod.save_images(
        session_id, [f"data:image/png;base64,{_b64(_png_bytes())}"]
    )

    assert names == ["image_1.png"]


async def test_save_images_reuses_a_file_that_already_holds_those_bytes(store):
    raw = _png_bytes()
    session_id = await _session_id(store)

    first = session_files_mod.save_images(session_id, [_b64(raw)])
    again = session_files_mod.save_images(session_id, [_b64(raw)])

    assert first == again == ["image_1.png"]
    assert len(list(session_files_mod.session_dir(session_id).glob("image_*"))) == 1


async def test_save_images_gives_a_different_picture_the_next_number(store):
    session_id = await _session_id(store)
    session_files_mod.save_images(session_id, [_b64(_png_bytes())])

    names = session_files_mod.save_images(
        session_id, [_b64(_png_bytes(colour=(0, 255, 0, 255)))]
    )

    assert names == ["image_2.png"]
    assert len(list(session_files_mod.session_dir(session_id).glob("image_*"))) == 2


async def test_save_images_skips_what_it_cannot_read_and_says_so(store, monkeypatch):
    log = MagicMock()
    monkeypatch.setattr(session_files_mod, "log", log)
    session_id = await _session_id(store)

    names = session_files_mod.save_images(
        session_id,
        ["abc", _b64(_png_bytes()), _b64(b"\x00\x01\x02\x03")],
    )

    # One name per input, in order — an unreadable one is an empty slot, not a gap.
    assert names == ["", "image_1.png", ""]
    assert [call.args[0] for call in log.warning.call_args_list] == [
        "session_files.image_not_stored",
        "session_files.image_not_stored",
    ]
    # Nothing was written under a name that would claim it is a PNG.
    assert [p.name for p in session_files_mod.session_dir(session_id).glob("image_*")] == [
        "image_1.png"
    ]
