from datasette.app import Datasette
import pytest
import json
import tempfile
import os


@pytest.fixture
def upload_dir(tmp_path):
    """Create a temporary directory for filesystem storage uploads."""
    d = tmp_path / "uploads"
    d.mkdir()
    return str(d)


def _make_datasette(upload_dir, permissions=None, extra_sources=None):
    """Create a Datasette instance configured with file sources and optional permissions."""
    sources = {
        "test-uploads": {
            "storage": "filesystem",
            "config": {
                "root": upload_dir,
            },
        }
    }
    if extra_sources:
        sources.update(extra_sources)

    config = {
        "plugins": {
            "datasette-files": {
                "sources": sources,
            }
        },
    }
    if permissions:
        config["permissions"] = permissions

    return Datasette(memory=True, config=config)


@pytest.fixture
def datasette_with_files(upload_dir):
    """Datasette with a filesystem source but no browse permissions (default deny)."""
    return _make_datasette(upload_dir)


@pytest.fixture
def datasette_browse_allowed(upload_dir):
    """Datasette with files-browse granted to all actors."""
    return _make_datasette(
        upload_dir,
        permissions={
            "files-browse": True,
        },
    )


async def _upload_file(
    ds,
    source="test-uploads",
    filename="test.txt",
    content=b"Hello from test!",
    content_type="text/plain",
):
    """Helper to upload a file and return the response JSON."""
    response = await ds.client.post(
        f"/-/files/upload/{source}",
        content=(
            b"--boundary\r\n"
            b'Content-Disposition: form-data; name="file"; filename="'
            + filename.encode()
            + b'"\r\n'
            b"Content-Type: " + content_type.encode() + b"\r\n"
            b"\r\n" + content + b"\r\n"
            b"--boundary--\r\n"
        ),
        headers={
            "Content-Type": "multipart/form-data; boundary=boundary",
        },
    )
    assert response.status_code == 200, response.text
    return response.json()


# --- Plugin installation ---


@pytest.mark.asyncio
async def test_plugin_is_installed():
    datasette = Datasette(memory=True)
    response = await datasette.client.get("/-/plugins.json")
    assert response.status_code == 200
    installed_plugins = {p["name"] for p in response.json()}
    assert "datasette-files" in installed_plugins


# --- Base classes ---


def test_file_metadata_dataclass():
    from datasette_files.base import FileMetadata

    fm = FileMetadata(path="abc/photo.jpg", filename="photo.jpg")
    assert fm.path == "abc/photo.jpg"
    assert fm.filename == "photo.jpg"
    assert fm.content_type is None
    assert fm.content_hash is None
    assert fm.size is None
    assert fm.metadata == {}


def test_upload_instructions_dataclass():
    from datasette_files.base import UploadInstructions

    ui = UploadInstructions(upload_url="/-/files/upload/test")
    assert ui.upload_url == "/-/files/upload/test"
    assert ui.upload_method == "POST"
    assert ui.upload_headers == {}
    assert ui.upload_fields == {}


def test_storage_capabilities_defaults():
    from datasette_files.base import StorageCapabilities

    caps = StorageCapabilities()
    assert caps.can_upload is False
    assert caps.can_delete is False
    assert caps.can_list is False
    assert caps.can_generate_signed_urls is False
    assert caps.requires_proxy_download is False


# --- Filesystem storage ---


@pytest.mark.asyncio
async def test_filesystem_storage_configure(upload_dir):
    from datasette_files.filesystem import FilesystemStorage

    storage = FilesystemStorage()
    await storage.configure({"root": upload_dir}, get_secret=None)
    assert storage.storage_type == "filesystem"
    assert storage.capabilities.can_upload is True
    assert storage.capabilities.can_list is True
    assert storage.capabilities.requires_proxy_download is True


@pytest.mark.asyncio
async def test_filesystem_storage_receive_and_read(upload_dir):
    from datasette_files.filesystem import FilesystemStorage

    storage = FilesystemStorage()
    await storage.configure({"root": upload_dir}, get_secret=None)

    metadata = await storage.receive_upload(
        path="abc123/hello.txt",
        content=b"Hello, world!",
        content_type="text/plain",
    )
    assert metadata.filename == "hello.txt"
    assert metadata.size == 13
    assert metadata.content_type == "text/plain"
    assert metadata.content_hash is not None
    assert metadata.content_hash.startswith("sha256:")

    # Verify we can read it back
    content = await storage.read_file("abc123/hello.txt")
    assert content == b"Hello, world!"


@pytest.mark.asyncio
async def test_filesystem_storage_list_files(upload_dir):
    from datasette_files.filesystem import FilesystemStorage

    storage = FilesystemStorage()
    await storage.configure({"root": upload_dir}, get_secret=None)

    # Upload a file
    await storage.receive_upload("a/file1.txt", b"one", "text/plain")
    await storage.receive_upload("b/file2.txt", b"two", "text/plain")

    files, cursor = await storage.list_files()
    assert len(files) == 2
    filenames = {f.filename for f in files}
    assert "file1.txt" in filenames
    assert "file2.txt" in filenames


@pytest.mark.asyncio
async def test_filesystem_storage_delete(upload_dir):
    from datasette_files.filesystem import FilesystemStorage

    storage = FilesystemStorage()
    await storage.configure({"root": upload_dir}, get_secret=None)

    await storage.receive_upload("del/gone.txt", b"bye", "text/plain")
    content = await storage.read_file("del/gone.txt")
    assert content == b"bye"

    await storage.delete_file("del/gone.txt")
    with pytest.raises(FileNotFoundError):
        await storage.read_file("del/gone.txt")


# --- Internal database schema ---


@pytest.mark.asyncio
async def test_startup_creates_tables(datasette_with_files):
    ds = datasette_with_files
    # Trigger startup
    await ds.invoke_startup()

    db = ds.get_internal_database()

    # Check tables exist
    tables = await db.table_names()
    assert "datasette_files_sources" in tables
    assert "datasette_files" in tables
    assert "datasette_files_fts" in tables

    # Check source was registered
    rows = (await db.execute("select * from datasette_files_sources")).rows
    assert len(rows) == 1
    assert rows[0]["slug"] == "test-uploads"
    assert rows[0]["storage_type"] == "filesystem"


@pytest.mark.asyncio
async def test_startup_creates_fts_triggers(datasette_with_files):
    ds = datasette_with_files
    await ds.invoke_startup()

    db = ds.get_internal_database()

    # Check triggers exist
    triggers = (
        await db.execute(
            "SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE 'datasette_files_%'"
        )
    ).rows
    trigger_names = {row["name"] for row in triggers}
    assert "datasette_files_ai" in trigger_names
    assert "datasette_files_ad" in trigger_names
    assert "datasette_files_au" in trigger_names


# --- Actions registration ---


@pytest.mark.asyncio
async def test_actions_registered(datasette_with_files):
    ds = datasette_with_files
    await ds.invoke_startup()

    assert "files-browse" in ds.actions
    assert "files-upload" in ds.actions
    assert "files-delete" in ds.actions

    browse = ds.actions["files-browse"]
    assert browse.abbr == "fb"
    assert browse.resource_class is not None
    assert browse.resource_class.name == "file-source"


# --- Upload endpoint ---


@pytest.mark.asyncio
async def test_upload_file(datasette_with_files, upload_dir):
    ds = datasette_with_files

    data = await _upload_file(ds)
    assert "file_id" in data
    assert data["file_id"].startswith("df-")
    assert data["filename"] == "test.txt"
    assert data["content_type"] == "text/plain"

    # Verify file exists on disk
    file_id = data["file_id"]
    ulid_part = file_id[3:]  # strip "df-"
    expected_path = os.path.join(upload_dir, ulid_part, "test.txt")
    assert os.path.exists(expected_path)
    with open(expected_path) as f:
        assert f.read() == "Hello from test!"


@pytest.mark.asyncio
async def test_upload_requires_valid_source(datasette_with_files):
    ds = datasette_with_files
    response = await ds.client.post(
        "/-/files/upload/nonexistent",
        content=b"--boundary\r\n"
        b'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n'
        b"Content-Type: text/plain\r\n"
        b"\r\n"
        b"data\r\n"
        b"--boundary--\r\n",
        headers={
            "Content-Type": "multipart/form-data; boundary=boundary",
        },
    )
    assert response.status_code == 404


# --- Permission enforcement (default deny) ---


@pytest.mark.asyncio
async def test_file_info_denied_without_permission(datasette_with_files):
    """Without files-browse permission, file info returns 403."""
    ds = datasette_with_files
    data = await _upload_file(ds)
    file_id = data["file_id"]

    response = await ds.client.get(f"/-/files/{file_id}")
    assert response.status_code == 403


@pytest.mark.asyncio
async def test_file_json_denied_without_permission(datasette_with_files):
    ds = datasette_with_files
    data = await _upload_file(ds)
    file_id = data["file_id"]

    response = await ds.client.get(f"/-/files/{file_id}.json")
    assert response.status_code == 403


@pytest.mark.asyncio
async def test_file_download_denied_without_permission(datasette_with_files):
    ds = datasette_with_files
    data = await _upload_file(ds)
    file_id = data["file_id"]

    response = await ds.client.get(f"/-/files/{file_id}/download")
    assert response.status_code == 403


@pytest.mark.asyncio
async def test_batch_json_denied_without_permission(datasette_with_files):
    """batch.json excludes files from sources the actor cannot browse."""
    ds = datasette_with_files
    data = await _upload_file(ds)
    file_id = data["file_id"]

    response = await ds.client.get(f"/-/files/batch.json?id={file_id}")
    assert response.status_code == 200
    assert response.json()["files"] == {}


# --- File info and download (with permission) ---


@pytest.mark.asyncio
async def test_file_info_page(datasette_browse_allowed, upload_dir):
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="info.txt", content=b"File info test")
    file_id = data["file_id"]

    info_response = await ds.client.get(f"/-/files/{file_id}")
    assert info_response.status_code == 200
    assert "text/html" in info_response.headers["content-type"]
    assert "info.txt" in info_response.text


@pytest.mark.asyncio
async def test_file_json_metadata(datasette_browse_allowed, upload_dir):
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="meta.txt", content=b"Metadata test")
    file_id = data["file_id"]

    meta_response = await ds.client.get(f"/-/files/{file_id}.json")
    assert meta_response.status_code == 200
    meta = meta_response.json()
    assert meta["id"] == file_id
    assert meta["filename"] == "meta.txt"
    assert meta["content_type"] == "text/plain"
    assert "content_hash" in meta


@pytest.mark.asyncio
async def test_file_download(datasette_browse_allowed, upload_dir):
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="dl.txt", content=b"Download me!")
    file_id = data["file_id"]

    dl_response = await ds.client.get(f"/-/files/{file_id}/download")
    assert dl_response.status_code == 200
    assert dl_response.text == "Download me!"
    assert dl_response.headers["content-type"] == "text/plain"


@pytest.mark.asyncio
async def test_file_not_found(datasette_browse_allowed):
    ds = datasette_browse_allowed
    response = await ds.client.get("/-/files/df-nonexistent")
    assert response.status_code == 404


@pytest.mark.asyncio
async def test_batch_json_with_permission(datasette_browse_allowed, upload_dir):
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="batch.txt", content=b"batch test")
    file_id = data["file_id"]

    response = await ds.client.get(f"/-/files/batch.json?id={file_id}")
    assert response.status_code == 200
    files = response.json()["files"]
    assert file_id in files
    assert files[file_id]["filename"] == "batch.txt"


# --- Sources API ---


@pytest.mark.asyncio
async def test_sources_json(datasette_with_files):
    ds = datasette_with_files
    response = await ds.client.get("/-/files/sources.json")
    assert response.status_code == 200
    data = response.json()
    assert "sources" in data
    assert len(data["sources"]) == 1
    source = data["sources"][0]
    assert source["slug"] == "test-uploads"
    assert source["storage_type"] == "filesystem"
    assert "capabilities" in source


# --- FTS ---


@pytest.mark.asyncio
async def test_fts_populated_on_upload(datasette_browse_allowed, upload_dir):
    """Uploading a file populates the FTS index."""
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="searchable.txt")
    file_id = data["file_id"]

    db = ds.get_internal_database()
    rows = (
        await db.execute(
            "SELECT id, filename FROM datasette_files_fts WHERE datasette_files_fts MATCH ?",
            ["searchable"],
        )
    ).rows
    assert len(rows) == 1
    assert rows[0]["id"] == file_id
    assert rows[0]["filename"] == "searchable.txt"


# --- Search endpoint ---


@pytest.mark.asyncio
async def test_search_json_empty(datasette_browse_allowed):
    """Search with no files returns empty results."""
    ds = datasette_browse_allowed
    response = await ds.client.get("/-/files/search.json")
    assert response.status_code == 200
    data = response.json()
    assert data["files"] == []
    assert data["q"] == ""


@pytest.mark.asyncio
async def test_search_json_with_query(datasette_browse_allowed, upload_dir):
    """Search with a query finds matching files."""
    ds = datasette_browse_allowed
    await _upload_file(
        ds,
        filename="report.pdf",
        content=b"pdf content",
        content_type="application/pdf",
    )
    await _upload_file(
        ds, filename="photo.jpg", content=b"jpg content", content_type="image/jpeg"
    )

    # Search for "report"
    response = await ds.client.get("/-/files/search.json?q=report")
    assert response.status_code == 200
    data = response.json()
    assert len(data["files"]) == 1
    assert data["files"][0]["filename"] == "report.pdf"
    assert data["q"] == "report"


@pytest.mark.asyncio
async def test_search_json_prefix_match(datasette_browse_allowed, upload_dir):
    """Search with a partial prefix finds matching files."""
    ds = datasette_browse_allowed
    await _upload_file(
        ds, filename="projects5.png", content=b"png data", content_type="image/png"
    )
    await _upload_file(
        ds, filename="report.txt", content=b"txt data", content_type="text/plain"
    )

    # "proj" should match "projects5.png" via prefix
    response = await ds.client.get("/-/files/search.json?q=proj")
    assert response.status_code == 200
    data = response.json()
    assert len(data["files"]) == 1
    assert data["files"][0]["filename"] == "projects5.png"

    # "pro 5" (multiple terms) should also match "projects5.png"
    response = await ds.client.get("/-/files/search.json?q=pro+5")
    assert response.status_code == 200
    data = response.json()
    filenames = {f["filename"] for f in data["files"]}
    assert "projects5.png" in filenames


@pytest.mark.asyncio
async def test_search_json_no_query_lists_recent(datasette_browse_allowed, upload_dir):
    """Search with no query lists recent files."""
    ds = datasette_browse_allowed
    await _upload_file(ds, filename="first.txt", content=b"1")
    await _upload_file(ds, filename="second.txt", content=b"2")

    response = await ds.client.get("/-/files/search.json")
    assert response.status_code == 200
    data = response.json()
    assert len(data["files"]) == 2
    filenames = {f["filename"] for f in data["files"]}
    assert "first.txt" in filenames
    assert "second.txt" in filenames


@pytest.mark.asyncio
async def test_search_html_page(datasette_browse_allowed, upload_dir):
    """Search HTML endpoint renders a page."""
    ds = datasette_browse_allowed
    await _upload_file(ds, filename="findme.txt", content=b"content")

    response = await ds.client.get("/-/files/search?q=findme")
    assert response.status_code == 200
    assert "text/html" in response.headers["content-type"]
    assert "findme.txt" in response.text


@pytest.mark.asyncio
async def test_search_source_filter(datasette_browse_allowed, upload_dir):
    """Search can filter by source slug."""
    ds = datasette_browse_allowed
    await _upload_file(ds, filename="filtered.txt", content=b"data")

    # Filter by the correct source
    response = await ds.client.get("/-/files/search.json?source=test-uploads")
    assert response.status_code == 200
    assert len(response.json()["files"]) == 1

    # Filter by nonexistent source
    response = await ds.client.get("/-/files/search.json?source=nonexistent")
    assert response.status_code == 200
    assert len(response.json()["files"]) == 0


# --- Permission-filtered search ---


@pytest.mark.asyncio
async def test_search_denied_without_permission(datasette_with_files, upload_dir):
    """Search returns empty results when actor lacks files-browse permission."""
    ds = datasette_with_files
    await _upload_file(ds, filename="secret.txt", content=b"secret data")

    response = await ds.client.get("/-/files/search.json")
    assert response.status_code == 200
    data = response.json()
    assert data["files"] == []
    assert data["sources"] == []


@pytest.mark.asyncio
async def test_search_with_fts_query_denied(datasette_with_files, upload_dir):
    """FTS search also returns empty when no browse permission."""
    ds = datasette_with_files
    await _upload_file(ds, filename="secret.txt", content=b"secret data")

    response = await ds.client.get("/-/files/search.json?q=secret")
    assert response.status_code == 200
    assert response.json()["files"] == []


@pytest.mark.asyncio
async def test_search_multi_source_permission(tmp_path):
    """Search only returns files from sources the actor can browse."""
    public_dir = str(tmp_path / "public")
    private_dir = str(tmp_path / "private")
    os.makedirs(public_dir)
    os.makedirs(private_dir)

    ds = Datasette(
        memory=True,
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "public-files": {
                            "storage": "filesystem",
                            "config": {"root": public_dir},
                        },
                        "private-files": {
                            "storage": "filesystem",
                            "config": {"root": private_dir},
                        },
                    }
                }
            },
            "permissions": {
                "files-browse": {
                    "public-files": {"allow": True},
                    # private-files: no allow = default deny
                },
            },
        },
    )

    # Upload to both sources
    await _upload_file(
        ds, source="public-files", filename="public-doc.txt", content=b"public"
    )
    await _upload_file(
        ds, source="private-files", filename="private-doc.txt", content=b"private"
    )

    # Search should only return public files
    response = await ds.client.get("/-/files/search.json")
    assert response.status_code == 200
    data = response.json()
    filenames = {f["filename"] for f in data["files"]}
    assert "public-doc.txt" in filenames
    assert "private-doc.txt" not in filenames

    # Only public-files should be in browsable sources
    assert "public-files" in data["sources"]
    assert "private-files" not in data["sources"]


@pytest.mark.asyncio
async def test_search_multi_source_fts_filtered(tmp_path):
    """FTS search is also filtered by source permissions."""
    public_dir = str(tmp_path / "public")
    private_dir = str(tmp_path / "private")
    os.makedirs(public_dir)
    os.makedirs(private_dir)

    ds = Datasette(
        memory=True,
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "public-files": {
                            "storage": "filesystem",
                            "config": {"root": public_dir},
                        },
                        "private-files": {
                            "storage": "filesystem",
                            "config": {"root": private_dir},
                        },
                    }
                }
            },
            "permissions": {
                "files-browse": {
                    "public-files": {"allow": True},
                },
            },
        },
    )

    # Both sources have a file named "report"
    await _upload_file(
        ds, source="public-files", filename="report.txt", content=b"public report"
    )
    await _upload_file(
        ds, source="private-files", filename="report.txt", content=b"private report"
    )

    # FTS search for "report" should only return the public one
    response = await ds.client.get("/-/files/search.json?q=report")
    assert response.status_code == 200
    data = response.json()
    assert len(data["files"]) == 1
    assert data["files"][0]["source_slug"] == "public-files"


@pytest.mark.asyncio
async def test_search_actor_specific_permission(tmp_path):
    """Search respects actor-specific permissions."""
    upload_dir = str(tmp_path / "uploads")
    os.makedirs(upload_dir)

    ds = Datasette(
        memory=True,
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "team-files": {
                            "storage": "filesystem",
                            "config": {"root": upload_dir},
                        }
                    }
                }
            },
            "permissions": {
                "files-browse": {
                    "team-files": {
                        "allow": {"id": "alice"},
                    },
                },
            },
        },
    )

    await _upload_file(
        ds, source="team-files", filename="team-doc.txt", content=b"team data"
    )

    # Anonymous user (no actor) gets empty results
    response = await ds.client.get("/-/files/search.json")
    assert response.status_code == 200
    assert response.json()["files"] == []

    # Alice can browse
    response = await ds.client.get(
        "/-/files/search.json",
        cookies={"ds_actor": ds.sign({"a": {"id": "alice"}}, "actor")},
    )
    assert response.status_code == 200
    assert len(response.json()["files"]) == 1
    assert response.json()["files"][0]["filename"] == "team-doc.txt"

    # Bob cannot
    response = await ds.client.get(
        "/-/files/search.json",
        cookies={"ds_actor": ds.sign({"a": {"id": "bob"}}, "actor")},
    )
    assert response.status_code == 200
    assert response.json()["files"] == []


# --- File info/download permission with specific actors ---


@pytest.mark.asyncio
async def test_file_info_actor_permission(tmp_path):
    """File info respects actor-specific browse permission."""
    upload_dir = str(tmp_path / "uploads")
    os.makedirs(upload_dir)

    ds = Datasette(
        memory=True,
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "restricted": {
                            "storage": "filesystem",
                            "config": {"root": upload_dir},
                        }
                    }
                }
            },
            "permissions": {
                "files-browse": {
                    "restricted": {
                        "allow": {"id": "alice"},
                    },
                },
            },
        },
    )

    data = await _upload_file(
        ds, source="restricted", filename="secret.txt", content=b"secret"
    )
    file_id = data["file_id"]

    # Anonymous: 403
    response = await ds.client.get(f"/-/files/{file_id}")
    assert response.status_code == 403

    # Alice: 200
    response = await ds.client.get(
        f"/-/files/{file_id}",
        cookies={"ds_actor": ds.sign({"a": {"id": "alice"}}, "actor")},
    )
    assert response.status_code == 200
    assert "secret.txt" in response.text

    # Bob: 403
    response = await ds.client.get(
        f"/-/files/{file_id}",
        cookies={"ds_actor": ds.sign({"a": {"id": "bob"}}, "actor")},
    )
    assert response.status_code == 403


# --- search_text column ---


@pytest.mark.asyncio
async def test_search_text_column_exists(datasette_with_files):
    ds = datasette_with_files
    await ds.invoke_startup()

    db = ds.get_internal_database()
    columns = (await db.execute("PRAGMA table_info(datasette_files)")).rows
    col_names = {row["name"] for row in columns}
    assert "search_text" in col_names


@pytest.mark.asyncio
async def test_search_text_in_fts(datasette_browse_allowed, upload_dir):
    """FTS index includes search_text column."""
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="notes.txt", content=b"some file")
    file_id = data["file_id"]

    # Directly update search_text
    db = ds.get_internal_database()
    await db.execute_write(
        "UPDATE datasette_files SET search_text = ? WHERE id = ?",
        ["quarterly revenue analysis financial", file_id],
    )

    # FTS should now match on the search_text
    response = await ds.client.get("/-/files/search.json?q=revenue")
    assert response.status_code == 200
    files = response.json()["files"]
    assert len(files) == 1
    assert files[0]["id"] == file_id


@pytest.mark.asyncio
async def test_search_text_not_in_filename_match(datasette_browse_allowed, upload_dir):
    """search_text doesn't interfere with filename-only searches."""
    ds = datasette_browse_allowed
    await _upload_file(ds, filename="report.txt", content=b"data")
    await _upload_file(ds, filename="other.txt", content=b"data")

    # Update search_text for other.txt (shouldn't match "report" query)
    db = ds.get_internal_database()
    rows = (
        await db.execute("SELECT id FROM datasette_files WHERE filename = 'other.txt'")
    ).rows
    other_id = rows[0]["id"]
    await db.execute_write(
        "UPDATE datasette_files SET search_text = ? WHERE id = ?",
        ["unrelated content", other_id],
    )

    response = await ds.client.get("/-/files/search.json?q=report")
    assert response.status_code == 200
    files = response.json()["files"]
    assert len(files) == 1
    assert files[0]["filename"] == "report.txt"


# --- files-edit action ---


@pytest.mark.asyncio
async def test_files_edit_action_registered(datasette_with_files):
    ds = datasette_with_files
    await ds.invoke_startup()
    assert "files-edit" in ds.actions


@pytest.mark.asyncio
async def test_edit_search_text_denied_without_permission(
    datasette_browse_allowed, upload_dir
):
    """POST to file info page without files-edit permission returns 403."""
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="noedit.txt", content=b"content")
    file_id = data["file_id"]

    response = await ds.client.post(
        f"/-/files/{file_id}",
        data={"search_text": "test"},
    )
    assert response.status_code == 403


@pytest.mark.asyncio
async def test_edit_search_text_with_permission(tmp_path):
    """POST to file info page with files-edit permission updates search_text."""
    upload_dir = str(tmp_path / "uploads")
    os.makedirs(upload_dir)

    ds = Datasette(
        memory=True,
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "editable": {
                            "storage": "filesystem",
                            "config": {"root": upload_dir},
                        }
                    }
                }
            },
            "permissions": {
                "files-browse": True,
                "files-edit": {
                    "editable": {
                        "allow": {"id": "editor"},
                    },
                },
            },
        },
    )

    data = await _upload_file(
        ds, source="editable", filename="doc.txt", content=b"hello"
    )
    file_id = data["file_id"]

    # Editor can update search_text
    editor_cookie = {"ds_actor": ds.sign({"a": {"id": "editor"}}, "actor")}
    response = await ds.client.post(
        f"/-/files/{file_id}",
        data={"search_text": "quarterly financial report Q4 2025"},
        cookies=editor_cookie,
    )
    assert response.status_code == 200
    assert "Search text saved" in response.text

    # Verify the search_text was persisted
    db = ds.get_internal_database()
    row = (
        await db.execute(
            "SELECT search_text FROM datasette_files WHERE id = ?", [file_id]
        )
    ).first()
    assert row["search_text"] == "quarterly financial report Q4 2025"

    # Verify FTS can find the file by search_text
    response = await ds.client.get("/-/files/search.json?q=quarterly")
    assert response.status_code == 200
    files = response.json()["files"]
    assert len(files) == 1
    assert files[0]["id"] == file_id


@pytest.mark.asyncio
async def test_edit_form_visible_with_permission(tmp_path):
    """File info page shows edit form when actor has files-edit permission."""
    upload_dir = str(tmp_path / "uploads")
    os.makedirs(upload_dir)

    ds = Datasette(
        memory=True,
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "editable": {
                            "storage": "filesystem",
                            "config": {"root": upload_dir},
                        }
                    }
                }
            },
            "permissions": {
                "files-browse": True,
                "files-edit": {
                    "editable": {
                        "allow": {"id": "editor"},
                    },
                },
            },
        },
    )

    data = await _upload_file(
        ds, source="editable", filename="doc.txt", content=b"hello"
    )
    file_id = data["file_id"]

    # Editor sees the textarea form
    editor_cookie = {"ds_actor": ds.sign({"a": {"id": "editor"}}, "actor")}
    response = await ds.client.get(f"/-/files/{file_id}", cookies=editor_cookie)
    assert response.status_code == 200
    assert "<textarea" in response.text
    assert "search_text" in response.text

    # Anonymous user sees "No search text set" instead of form
    response = await ds.client.get(f"/-/files/{file_id}")
    assert response.status_code == 200
    assert "<textarea" not in response.text
    assert "No search text set" in response.text


@pytest.mark.asyncio
async def test_edit_search_text_non_editor_denied(tmp_path):
    """Non-editor actor cannot POST search_text even with browse permission."""
    upload_dir = str(tmp_path / "uploads")
    os.makedirs(upload_dir)

    ds = Datasette(
        memory=True,
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "editable": {
                            "storage": "filesystem",
                            "config": {"root": upload_dir},
                        }
                    }
                }
            },
            "permissions": {
                "files-browse": True,
                "files-edit": {
                    "editable": {
                        "allow": {"id": "editor"},
                    },
                },
            },
        },
    )

    data = await _upload_file(
        ds, source="editable", filename="doc.txt", content=b"hello"
    )
    file_id = data["file_id"]

    # Bob can browse but not edit
    bob_cookie = {"ds_actor": ds.sign({"a": {"id": "bob"}}, "actor")}
    response = await ds.client.post(
        f"/-/files/{file_id}",
        data={"search_text": "hacked"},
        cookies=bob_cookie,
    )
    assert response.status_code == 403

    # Verify search_text was NOT changed
    db = ds.get_internal_database()
    row = (
        await db.execute(
            "SELECT search_text FROM datasette_files WHERE id = ?", [file_id]
        )
    ).first()
    assert row["search_text"] == ""


# --- render_cell with data-column ---


@pytest.mark.asyncio
async def test_render_cell_includes_data_column(datasette_browse_allowed, upload_dir):
    """render_cell output includes data-column attribute when table context is present."""
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="cell.txt", content=b"cell test")
    file_id = data["file_id"]

    from datasette_files import render_cell

    result = render_cell(
        value=file_id,
        column="document",
        table="projects",
        database="demo",
        datasette=ds,
        request=None,
    )
    assert result is not None
    assert f'data-column="document"' in result
    assert f'file-id="{file_id}"' in result


@pytest.mark.asyncio
async def test_render_cell_no_data_column_without_table(
    datasette_browse_allowed, upload_dir
):
    """render_cell output omits data-column when table is None (e.g. SQL views)."""
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="cell.txt", content=b"cell test")
    file_id = data["file_id"]

    from datasette_files import render_cell

    result = render_cell(
        value=file_id,
        column="document",
        table=None,
        database="demo",
        datasette=ds,
        request=None,
    )
    assert result is not None
    assert "data-column" not in result


@pytest.mark.asyncio
async def test_render_cell_no_match_for_non_file_id(datasette_browse_allowed):
    """render_cell returns None for values that don't match the file ID pattern."""
    ds = datasette_browse_allowed

    from datasette_files import render_cell

    result = render_cell(
        value="not-a-file-id",
        column="name",
        table="projects",
        database="demo",
        datasette=ds,
        request=None,
    )
    assert result is None


@pytest.mark.asyncio
async def test_render_cell_escapes_column_name(datasette_browse_allowed, upload_dir):
    """render_cell escapes column names to prevent XSS."""
    ds = datasette_browse_allowed
    data = await _upload_file(ds, filename="cell.txt", content=b"cell test")
    file_id = data["file_id"]

    from datasette_files import render_cell

    result = render_cell(
        value=file_id,
        column='"><script>alert(1)</script>',
        table="projects",
        database="demo",
        datasette=ds,
        request=None,
    )
    assert result is not None
    assert "<script>" not in result
    assert "&lt;script&gt;" in result or "&#" in result


# --- extra_body_script ---


@pytest.mark.asyncio
async def test_extra_body_script_on_table_page(tmp_path):
    """extra_body_script emits window.__datasette_files on table pages."""
    upload_dir = str(tmp_path / "uploads")
    os.makedirs(upload_dir)

    ds = Datasette(
        [str(tmp_path / "test.db")],
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "test-src": {
                            "storage": "filesystem",
                            "config": {"root": upload_dir},
                        }
                    }
                }
            },
            "permissions": {
                "files-browse": True,
            },
        },
    )
    db = ds.get_database("test")
    await db.execute_write(
        "CREATE TABLE IF NOT EXISTS projects (id INTEGER PRIMARY KEY, name TEXT, logo TEXT)"
    )

    # Visit the table page as anonymous user
    response = await ds.client.get("/test/projects")
    assert response.status_code == 200
    assert "window.__datasette_files" in response.text
    # Anonymous user cannot update rows by default
    assert '"canUpdate": false' in response.text
    assert '"database": "test"' in response.text
    assert '"table": "projects"' in response.text


@pytest.mark.asyncio
async def test_extra_body_script_with_update_permission(tmp_path):
    """extra_body_script sets canUpdate true when actor has update-row permission."""
    upload_dir = str(tmp_path / "uploads")
    os.makedirs(upload_dir)

    ds = Datasette(
        [str(tmp_path / "test.db")],
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "test-src": {
                            "storage": "filesystem",
                            "config": {"root": upload_dir},
                        }
                    }
                }
            },
            "permissions": {
                "files-browse": True,
                "update-row": True,
            },
        },
    )
    db = ds.get_database("test")
    await db.execute_write(
        "CREATE TABLE IF NOT EXISTS projects (id INTEGER PRIMARY KEY, name TEXT)"
    )

    response = await ds.client.get("/test/projects")
    assert response.status_code == 200
    assert '"canUpdate": true' in response.text


@pytest.mark.asyncio
async def test_extra_body_script_not_on_non_table_pages(tmp_path):
    """extra_body_script does not emit on non-table pages like database index."""
    upload_dir = str(tmp_path / "uploads")
    os.makedirs(upload_dir)

    ds = Datasette(
        [str(tmp_path / "test.db")],
        config={
            "plugins": {
                "datasette-files": {
                    "sources": {
                        "test-src": {
                            "storage": "filesystem",
                            "config": {"root": upload_dir},
                        }
                    }
                }
            },
        },
    )
    db = ds.get_database("test")
    await db.execute_write("CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY)")

    # Database page
    response = await ds.client.get("/test")
    assert response.status_code == 200
    assert "window.__datasette_files" not in response.text
