"""End-to-end Playwright tests for the files-overlay file manager.

The files_overlay_server fixture (conftest.py) seeds user "alice"/"secret",
a files-type Overlay owned by alice, and an overlay root pre-populated
with one editable text file, one binary file, and one nested folder.
Each test logs in, opens /overlays/<id>, drives the UI through real
clicks + HTMX swaps, and asserts on DOM + filesystem state.

Running locally: `uv run pytest -m e2e l4d2web/tests/e2e/test_files_overlay.py`.
Requires `uv run playwright install chromium` once.

Pattern model: test_editor.py (CM6 controller flow) + the handoff doc at
docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.
"""
from __future__ import annotations

import urllib.parse

import pytest
from playwright.sync_api import Page, expect

from .conftest import login

pytestmark = pytest.mark.e2e


def _open_overlay(page: Page, base_url: str, overlay_id: int) -> None:
    login(page, base_url)
    page.goto(f"{base_url}/overlays/{overlay_id}")


def _wait_for_routed_editor(page: Page) -> None:
    """Wait until the routed modal's CM6 surface is mounted.

    The textarea itself is `display:none` (editor.css pre-hides + CM6's
    mount sets `style.display = "none"`), so we cannot assert visibility
    on it directly. The `.cm-content` element appearing under
    `#files-editor-fragment` is the canonical "editor is ready, the
    `window.__filesEditor` controller is wired" signal.
    """
    expect(page.locator("#files-editor-fragment")).to_be_visible(timeout=5000)
    expect(page.locator("#files-editor-fragment .cm-content")).to_be_visible(timeout=5000)


def test_edit_text_file_save_round_trip(page: Page, files_overlay_server) -> None:
    """Open server.cfg via the file-row name button, drive the CM6
    controller to a new doc, save, and assert the disk reflects it.
    Exercises the full edit path: row click → openRouted → htmx swap →
    CM6 mount → save POST → modal close. Failure modes guarded against:
      * /overlays/<id>/files/edit returns the wrong fragment (textarea
        gets the wrong data-rel-path, or none).
      * editor.js's htmx:afterSwap re-init doesn't fire / fails — then
        window.__filesEditor stays unset and the save reads the stale
        textarea.value instead of the user's edits.
      * routedSaveClicked stops short of closeRouted() — modal stays
        open and the test's hidden-assertion catches it.
      * The POST /files/save endpoint doesn't actually write — covered
        by reading the file back from disk.
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]
    overlay_root = files_overlay_server["overlay_root"]
    _open_overlay(page, base, overlay_id)

    page.click('button.file-tree-name-button[data-target-path="server.cfg"]')
    _wait_for_routed_editor(page)
    # Sanity: the modal opened for the file we asked for, not some other.
    assert page.locator('textarea[data-rel-path="server.cfg"]').count() == 1

    new_content = 'hostname "edited by e2e"\nmp_gamemode coop\n'
    page.evaluate("(text) => window.__filesEditor.setContent(text)", new_content)

    page.click(".files-editor-save")

    # Modal close is async (await postJson then closeRouted()).
    expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
    assert (overlay_root / "server.cfg").read_text() == new_content


def test_create_new_file_routed(page: Page, files_overlay_server) -> None:
    """Click `+ new file` at the overlay root, fill the routed
    new-file editor, click Create, and assert the file lands on disk
    AND the row appears in the tree.

    The routed new-file modal is the SAME template as the edit modal
    (overlay_file_editor.html with is_new=True). The differences this
    test exercises:
      * textarea has data-rel-path="" — routedSaveClicked branches on
        the empty value into the "compose fullPath from filename"
        path, not the rename-on-save path.
      * Save button reads "Create", not "Save".
      * scheduleRefresh(parentOf(fullPath)) fires after POST — for a
        root-level file that's refreshFolder("") which re-fetches the
        whole root listing; the new row should appear without a page
        reload.
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]
    overlay_root = files_overlay_server["overlay_root"]
    _open_overlay(page, base, overlay_id)

    page.click('button[data-action="new-file"][data-target-path=""]')
    _wait_for_routed_editor(page)
    # Confirm the fragment opened in is_new mode (empty rel-path).
    assert page.locator('textarea[data-rel-path=""]').count() == 1

    new_filename = "new-file.cfg"
    new_content = 'sv_cheats 1\nmp_gamemode versus\n'
    page.fill('input[data-editor-filename]', new_filename)
    page.evaluate("(text) => window.__filesEditor.setContent(text)", new_content)

    page.click(".files-editor-save")

    expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
    assert (overlay_root / new_filename).read_text() == new_content
    # Tree refresh is debounced 50ms then HTMX-fetched — wait for the
    # new row to appear rather than asserting synchronously.
    expect(
        page.locator(f'.file-tree-row-file[data-target-path="{new_filename}"]')
    ).to_be_visible(timeout=5000)


def test_create_new_file_409_askConflict_keep_both(page: Page, files_overlay_server) -> None:
    """Try to create a file named `cfg`, which collides with the
    seeded directory of the same name. /files/save returns 409 because
    the target exists and is not a file; routedSaveClicked routes that
    409 through fo.askConflict, which opens the INLINE
    #files-conflict-modal on top of the still-open routed editor.
    Clicking "keep-both" causes a second POST with the suffixed path
    (cfg → "cfg (1)"), the routed modal closes, and the new row
    appears in the tree.

    This is the F4 path from commit 8dc14f0 ("wire askConflict into
    the routed new-file 409 path"). Without coverage it can regress
    silently — the legacy create-new flow went through a different code
    path before the rewrite, and a missing call site would only
    manifest as a confusing alert() instead of the conflict dialog.

    Key invariants this asserts:
      * The conflict dialog is inline, NOT routed — it appears WITHOUT
        a URL change (we don't navigate, we just wait for the dialog).
      * .files-conflict-path shows the original colliding path, not
        the suffixed one.
      * withCollisionSuffix("cfg") returns "cfg (1)" (single-extension
        path with no dot falls into the trailing-suffix branch).
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]
    overlay_root = files_overlay_server["overlay_root"]
    _open_overlay(page, base, overlay_id)

    page.click('button[data-action="new-file"][data-target-path=""]')
    _wait_for_routed_editor(page)

    page.fill('input[data-editor-filename]', "cfg")
    new_content = "STEAM_1:0:5\n"
    page.evaluate("(text) => window.__filesEditor.setContent(text)", new_content)
    page.click(".files-editor-save")

    # The conflict dialog should pop up because `cfg` is a directory.
    conflict = page.locator("#files-conflict-modal")
    expect(conflict).to_be_visible(timeout=5000)
    # The dialog must echo the ORIGINAL colliding path, not the suffix.
    expect(conflict.locator(".files-conflict-path")).to_have_text("cfg")

    page.click('[data-files-conflict-action="keep-both"]')

    # Both modals should close (conflict dialog first, routed modal
    # after the second /files/save resolves).
    expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
    expect(conflict).to_be_hidden(timeout=5000)

    # The seeded `cfg/` directory must still be intact + the new
    # suffixed file landed alongside it.
    assert (overlay_root / "cfg").is_dir()
    assert (overlay_root / "cfg (1)").read_text() == new_content
    expect(
        page.locator('.file-tree-row-file[data-target-path="cfg (1)"]')
    ).to_be_visible(timeout=5000)


def test_open_binary_file_renders_replace_ui(page: Page, files_overlay_server) -> None:
    """Open icon.png and assert the routed editor renders the binary
    branch of overlay_file_editor.html — replace-zone present, save
    button labelled "Replace" and disabled until a file is queued or
    the filename is edited.

    The same `/files/edit?path=...` route serves both text and binary
    modes; the server picks the template branch based on is_editable +
    a magic-byte check. This test pins the binary contract: the user
    cannot save by accident on a fresh open, and the queue UI is wired
    correctly.

    Pins:
      * .files-editor-binary[data-rel-path="icon.png"] exists (the
        data-rel-path stable hook for save logic).
      * Save button has visible text "Replace" + the `disabled`
        attribute is set on open.
      * .files-editor-replace-zone + .files-editor-replace-browse +
        the hidden file <input> exist.
      * Download link points back at /files/download?path=icon.png.
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]
    _open_overlay(page, base, overlay_id)

    page.click('button.file-tree-name-button[data-target-path="icon.png"]')

    fragment = page.locator("#files-editor-fragment")
    expect(fragment).to_be_visible(timeout=5000)

    binary_panel = page.locator('.files-editor-binary[data-rel-path="icon.png"]')
    expect(binary_panel).to_be_visible(timeout=5000)

    save_btn = page.locator(".files-editor-save")
    expect(save_btn).to_have_text("Replace")
    expect(save_btn).to_be_disabled()

    expect(page.locator(".files-editor-replace-zone")).to_be_visible()
    expect(page.locator(".files-editor-replace-browse")).to_be_visible()
    # The file input is type=file hidden — Playwright's "visible"
    # assertions treat hidden inputs as not visible, so check
    # attached + the type instead.
    expect(page.locator(".files-editor-replace-input")).to_have_attribute(
        "type", "file"
    )
    # Download link points back at the overlay's /files/download.
    download = page.locator("a.files-editor-download")
    expect(download).to_have_attribute(
        "href", f"/overlays/{overlay_id}/files/download?path=icon.png"
    )
    # In binary mode there's no <textarea data-rel-path> — only the
    # binary panel carries the rel-path. Catches a regression where
    # the server accidentally rendered the text branch for a binary
    # file (which would mean an editable textarea full of PNG bytes).
    assert page.locator("textarea[data-rel-path]").count() == 0


def test_binary_replace_via_browse_writes_new_bytes(page: Page, files_overlay_server) -> None:
    """Open icon.png, click "browse" to attach a new file buffer via
    Playwright's file chooser, click Replace, and assert disk reflects
    the new bytes. Exercises:
      * .files-editor-replace-browse triggers the hidden file input's
        click() (line ~140 in files-overlay/editor.js).
      * `change` event on the input flows into setRoutedReplacement,
        which enables the save button + paints the queued-state UI.
      * routedReplaceClicked posts FormData (multipart) to
        /files/replace with the original rel-path — NOT a rename —
        and the server streams the upload into place.
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]
    overlay_root = files_overlay_server["overlay_root"]
    _open_overlay(page, base, overlay_id)

    page.click('button.file-tree-name-button[data-target-path="icon.png"]')
    expect(page.locator('.files-editor-binary[data-rel-path="icon.png"]')).to_be_visible(timeout=5000)

    save_btn = page.locator(".files-editor-save")
    expect(save_btn).to_be_disabled()

    # The browse button calls fileInput.click() in the JS handler —
    # Playwright's expect_file_chooser intercepts the resulting OS
    # picker so we can hand back a buffer instead of an OS path.
    new_bytes = b"\x89PNG\r\n\x1a\nE2E_REPLACED" + b"\x00" * 20
    with page.expect_file_chooser() as fc_info:
        page.click(".files-editor-replace-browse")
    fc_info.value.set_files({
        "name": "new-icon.png",
        "mimeType": "image/png",
        "buffer": new_bytes,
    })

    # Queueing a file flips the save button enabled.
    expect(save_btn).to_be_enabled(timeout=2000)
    save_btn.click()

    expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
    # The filename on disk stays the same (no rename) — only the bytes
    # changed.
    assert (overlay_root / "icon.png").read_bytes() == new_bytes


def test_new_folder_then_delete(page: Page, files_overlay_server) -> None:
    """Create a folder via the inline new-folder dialog (Enter-to-submit
    keydown path), then delete it via the inline delete-confirm dialog.
    Each step asserts disk + tree state, so a regression in either the
    mkdir or delete route — or in the modal close + refresh wiring —
    fails loudly.

    The new-folder dialog has TWO submit paths (click on
    .files-new-folder-create + Enter keydown on the name input); this
    test pins the keydown path, which is direct-bound rather than
    delegated, because it's the one most likely to break under future
    refactors.
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]
    overlay_root = files_overlay_server["overlay_root"]
    _open_overlay(page, base, overlay_id)

    # --- Create folder via Enter-to-submit -----------------------------
    page.click('button[data-action="new-folder"][data-target-path=""]')
    new_folder_modal = page.locator("#files-new-folder-modal")
    expect(new_folder_modal).to_be_visible(timeout=5000)

    folder_name = "sourcemod"
    page.fill(".files-new-folder-name", folder_name)
    page.locator(".files-new-folder-name").press("Enter")

    expect(new_folder_modal).to_be_hidden(timeout=5000)
    assert (overlay_root / folder_name).is_dir()
    folder_row = page.locator(
        f'.file-tree-row-dir[data-target-path="{folder_name}"]'
    )
    expect(folder_row).to_be_visible(timeout=5000)

    # --- Delete folder via inline confirm ------------------------------
    # `force=True`: the row-action buttons are positioned inside a
    # `<li draggable="true">`, and Playwright's hit-test treats the
    # draggable ancestor as intercepting pointer events even though
    # real browsers dispatch the click correctly. Forcing skips the
    # hit-test and dispatches on the target — matches user behavior.
    page.locator(
        f'button[data-action="delete"]'
        f'[data-target-path="{folder_name}"]'
        f'[data-row-kind="dir"]'
    ).click(force=True)
    delete_modal = page.locator("#files-delete-modal")
    expect(delete_modal).to_be_visible(timeout=5000)
    # The dialog should name the row being deleted — the user must see
    # what they're confirming.
    expect(delete_modal.locator(".files-delete-name")).to_have_text(folder_name)

    page.click(".files-delete-confirm")

    expect(delete_modal).to_be_hidden(timeout=5000)
    assert not (overlay_root / folder_name).exists()
    # The tree refresh removes the row.
    expect(folder_row).to_have_count(0, timeout=5000)


def test_filename_rename_on_save(page: Page, files_overlay_server) -> None:
    """Open server.cfg, change the filename input to server-renamed.cfg,
    and click Save. The single POST /files/save call performs an atomic
    rename + write; the test asserts the disk reflects both the new
    name and the original content, AND that the tree row swaps over.

    This pins the rename-on-save branch in routedSaveClicked (the
    non-is_new code path that diffs originalLeaf vs editedFilename
    and emits `new_path`). A regression that dropped the rename
    payload would silently leave the original file in place — the
    "old name gone" + "new name present" assertion pair catches that.

    We do NOT call __filesEditor.setContent here: the rename should
    preserve the original textarea-seeded content. If the save handler
    accidentally writes an empty body, the content-equality assertion
    fails.
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]
    overlay_root = files_overlay_server["overlay_root"]
    original_content = (overlay_root / "server.cfg").read_text()
    _open_overlay(page, base, overlay_id)

    page.click('button.file-tree-name-button[data-target-path="server.cfg"]')
    _wait_for_routed_editor(page)

    new_name = "server-renamed.cfg"
    page.fill("input[data-editor-filename]", new_name)
    page.click(".files-editor-save")

    expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)

    # Disk: old name gone, new name present with the original content.
    assert not (overlay_root / "server.cfg").exists()
    assert (overlay_root / new_name).read_text() == original_content

    # Tree refresh runs at parentOf(new_path) = "" (root) — both rows
    # come from that same listing, so we wait for the new one and
    # assert the old one is gone in the same refresh.
    expect(
        page.locator(f'.file-tree-row-file[data-target-path="{new_name}"]')
    ).to_be_visible(timeout=5000)
    expect(
        page.locator('.file-tree-row-file[data-target-path="server.cfg"]')
    ).to_have_count(0)


def test_share_url_deep_link_reopens_editor(page: Page, files_overlay_server) -> None:
    """Navigate directly to /overlays/<id>?modal=<urlencoded edit path>
    and assert the routed editor auto-opens for the right file. This
    is the central guarantee of the URL-addressable modals spec — copy
    the current URL, paste it in a new tab (or share it), and the
    modal reopens. Without this test, a regression in modals.js's
    DOMContentLoaded bootstrap (the `URLSearchParams.get("modal") →
    fetchAndShowRouted` chain) would silently make every shared link
    dead.

    The `?modal=` param holds the UNENCODED routed path; URLSearchParams
    decodes it for us. We pass the URL-encoded version through quote()
    so the surrounding URL stays valid.
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]

    login(page, base)
    modal_path = f"/overlays/{overlay_id}/files/edit?path=server.cfg"
    page.goto(
        f"{base}/overlays/{overlay_id}?modal={urllib.parse.quote(modal_path)}"
    )

    _wait_for_routed_editor(page)
    assert page.locator('textarea[data-rel-path="server.cfg"]').count() == 1


def test_modal_close_on_escape_preserves_no_state(page: Page, files_overlay_server) -> None:
    """Open server.cfg, drive the CM6 controller to a dirty buffer,
    press Escape to close the modal without saving, then reopen the
    same file and assert the editor shows the original disk content
    — not the dirty buffer the user just discarded.

    Pins two invariants:
      * modals.js's `cancel` → preventDefault → close() path on
        `<dialog id="modal-container">` actually closes the modal on
        Escape (no JS shortcuts away from the native behavior).
      * The reopened editor fetches a fresh fragment via htmx.ajax;
        the CM6 controller re-mounts on the FRESH textarea (seeded
        from disk content), not a stale cache of the prior session.

    A regression that survived the close event — e.g., a future commit
    that cached buffer state in a module-scope variable to "feel
    snappy" — would surface here as the dirty content reappearing.
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]
    overlay_root = files_overlay_server["overlay_root"]
    original_content = (overlay_root / "server.cfg").read_text()
    _open_overlay(page, base, overlay_id)

    # --- First open: type something but don't save ----------------------
    page.click('button.file-tree-name-button[data-target-path="server.cfg"]')
    _wait_for_routed_editor(page)
    dirty = "DIRTY UNSAVED BUFFER " + "x" * 40
    page.evaluate("(t) => window.__filesEditor.setContent(t)", dirty)
    page.keyboard.press("Escape")
    expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
    # Disk must still hold the original — never saved.
    assert (overlay_root / "server.cfg").read_text() == original_content

    # --- Reopen and confirm CM6 starts fresh from disk ------------------
    page.click('button.file-tree-name-button[data-target-path="server.cfg"]')
    _wait_for_routed_editor(page)
    reopened = page.evaluate("() => window.__filesEditor.getValue()")
    assert reopened == original_content


def test_drag_row_to_folder_moves_file(page: Page, files_overlay_server) -> None:
    """Drag the server.cfg row from the overlay root onto the cfg/
    folder row. Asserts the file actually moved on disk AND the tree
    reflects the move after the debounced HTMX refresh.

    Exercises the internal-drag path in uploads.js:332-366 — the
    `dataTransfer.setData("application/x-files-overlay", path)` →
    `getData` round-trip → POST /files/move → schedule refresh of
    both parents. Playwright's `locator.drag_to()` synthesizes the
    `setData/getData` contract that this path relies on (it does NOT
    require `webkitGetAsEntry`, which Playwright cannot fake).

    Tree DOM gotcha: the cfg folder is collapsed by default, so its
    children div is `hidden`. `scheduleRefresh("cfg")` still swaps
    HTML into the hidden div — the new row exists in the DOM, just
    not visible to a `to_be_visible` assertion. Using `to_have_count`
    instead of `to_be_visible` accommodates this.
    """
    base = files_overlay_server["base_url"]
    overlay_id = files_overlay_server["overlay_id"]
    overlay_root = files_overlay_server["overlay_root"]
    original_content = (overlay_root / "server.cfg").read_text()
    _open_overlay(page, base, overlay_id)

    # Scope to the row <li>, not any inner button — delete-action
    # buttons also carry data-target-path + data-row-kind, so a bare
    # attribute selector resolves to multiple elements and trips
    # strict mode.
    source = page.locator('li.file-tree-row-file[data-target-path="server.cfg"]')
    target = page.locator('li.file-tree-row-dir[data-target-path="cfg"]')
    source.drag_to(target)

    # Wait for both refreshes to land: old row gone from root, new row
    # present (though hidden) inside cfg's children div.
    expect(
        page.locator('.file-tree-row-file[data-target-path="server.cfg"]')
    ).to_have_count(0, timeout=5000)
    expect(
        page.locator('.file-tree-row-file[data-target-path="cfg/server.cfg"]')
    ).to_have_count(1, timeout=5000)

    # Disk is the load-bearing assertion — proves /files/move actually
    # moved bytes, not just shuffled DOM rows.
    assert not (overlay_root / "server.cfg").exists()
    assert (overlay_root / "cfg" / "server.cfg").read_text() == original_content
