"""The composer's typing affordances, checked without a runtime.

There is no JavaScript test runner here and hard rule 1 keeps Node out of the
project, so the behaviour is driven by hand under a DOM stub before committing.
That is not a nicety, and this file is the reason it is written down: `refresh()`
wrote to `list` *before* anything had built it, so the first `/` or `@` ever
typed threw on a null and took the whole handler with it. The menu never
appeared, in any browser, for the entire life of the feature, and `node --check`
parses that file happily.

What the suite can pin are the properties that failure depended on, and the ones
beside it whose absence is equally silent: a listener registered in a phase the
event does not reach, a repaint bound to the htmx event that fires too early,
and a mirror sized differently from the box it sits behind. Every one of those
looks exactly like working.
"""

from __future__ import annotations

import re
from pathlib import Path

import lembas

from .conftest import js_code, js_function, js_says, script

SOURCE = script("composer.js")
CODE = js_code(SOURCE)
CSS = (Path(lembas.__file__).parent / "web/static/css/chat.css").read_text(encoding="utf-8")


def _rule(css: str, opening: str) -> str:
    """One declaration block, by the text that opens it.

    The match has to *start* a rule: `.composer__mirror .tok-command {` appears
    twice, once as the tail of a grouped selector, and reading the group's block
    when the single one was asked for is a test that answers a question nobody
    put.
    """
    at = -1
    while True:
        at = css.index(opening, at + 1)
        if not css[:at].rstrip().endswith(","):
            return css[at : css.index("}", at)]


# --- The menu, and the null it used to throw on -------------------------------
def test_the_menu_is_built_before_anything_writes_to_it():
    """The bug this module exists for. `refresh()` sets `list.innerHTML` and
    appends to it, and `list` is null until `build()` has run -- so with the
    build left to `show()`, which is called *after* those writes, the first
    keystroke threw and the menu never opened for anybody.

    Asserted as the order inside `refresh`, with comments stripped: the file
    explains this at length in prose, and prose satisfies a substring test.
    """
    refresh = js_function(SOURCE, "refresh")

    assert "build()" in refresh, "refresh no longer builds the menu"
    assert refresh.index("build()") < refresh.index("list"), (
        "something reads or writes `list` before the menu exists"
    )


def test_building_it_twice_costs_nothing():
    """Which is what lets `refresh` and `show` both call it unconditionally.
    Without the guard, every keystroke would insert another menu."""
    assert js_function(SOURCE, "build").index("if (menu) return;") < 40


def test_the_late_mention_reply_checks_the_menu_is_still_there():
    """A mention list is a round trip late, and the caret may have moved off the
    token -- or the menu may have been dismissed, which drops `list`. Writing
    into it anyway is the same null a second time, on a slower path."""
    load = js_function(SOURCE, "loadMentions")
    assert load.index("!list") < load.index("list.innerHTML")
    # And the kind, or a reply for `@` lands in a menu now showing commands.
    assert 'kind !== "@"' in load


def test_choosing_with_the_mouse_holds_the_caret():
    """`mousedown` is cancelled inside the menu. Without it the composer loses
    focus before the click lands, and the caret position the choice is about to
    be written at is already gone -- so the token is replaced at the wrong
    offset, or not at all."""
    build = js_function(SOURCE, "build")
    assert '"mousedown"' in build
    assert build.index('"mousedown"') < build.index('"click"')
    assert "preventDefault" in build


# --- Listeners, and the phases they must be in --------------------------------
def test_the_key_listener_is_registered_in_the_capture_phase():
    """app.js already has a document-level Enter handler that submits the form,
    and listeners on the same element in the same phase fire in registration
    order -- app.js loads first. In the bubble phase this one would never get to
    say "that Enter chose a menu item, it did not send the message", so choosing
    from the menu would send the half-typed line as well.

    The property rather than the markup, for the reason `tests/test_ui_js.py`
    gives: a trigger bound where the event does not go is silent.
    """
    found = re.search(r'"keydown",[\s\S]*?\n\s*(true|false)\n\s*\);', SOURCE)
    assert found, "the keydown listener is gone"
    assert found.group(1) == "true"


def test_the_scroll_listener_is_too():
    """`scroll` does not bubble. Registered without the third argument this is
    never called, and the mirror stops following the textarea the moment the box
    grows past `data-max-height` -- the rectangles then sit over the wrong
    words, which is the one failure the mirror exists to avoid."""
    found = re.search(r'"scroll",[\s\S]*?\n\s*(true|false)\n\s*\);', SOURCE)
    assert found, "the scroll listener is gone"
    assert found.group(1) == "true"


def test_the_mirror_repaints_after_the_request_as_well_as_after_the_swap():
    """htmx fires afterSwap and afterSettle *before* afterRequest, and the
    composer empties itself from `hx-on::after-request` -- so every repaint
    bound to the first two ran while the box still held the message, and the
    highlighting sat over an empty field until the next keystroke.

    Both late events go through the same deferred helper, and that is the whole
    assertion: `paint` bound directly to `reset` reads the value in the same
    turn the event fires, which is *before* a form's fields are actually
    cleared, so it paints the text that is about to vanish.
    """
    after = re.search(r'addEventListener\("htmx:afterRequest",\s*(\w+)\)', CODE)
    reset = re.search(r'addEventListener\("reset",\s*(\w+)\)', CODE)
    assert after and reset, "the late repaints are gone"
    assert after.group(1) == reset.group(1), "the two late events repaint differently"
    assert after.group(1) != "paint", "a repaint in the same turn paints the old value"

    deferred = js_function(SOURCE, after.group(1))
    assert "requestAnimationFrame" in deferred
    assert "paint" in deferred

    # The early ones stay: a swap that brings a new composer in has to paint it.
    for event in ("htmx:afterSwap", "htmx:afterSettle"):
        assert re.search(rf'addEventListener\("{event}",\s*paint\)', CODE), event


# --- What may become markup ---------------------------------------------------
def test_nothing_typed_into_the_box_can_become_markup():
    """The mirror holds the one string a person controls exactly, character for
    character. Hard rule 6 covers model output; this is the same rule pointed
    the other way, and it is the only place in the application where somebody's
    raw keystrokes are re-rendered as they type.

    `list.innerHTML` in the mention path is deliberately not covered: that is a
    server-rendered fragment, escaped where it was built.
    """
    paint = js_function(SOURCE, "paint")
    assert "innerHTML" not in paint
    assert "replaceChildren" in paint
    assert "createTextNode" in paint
    assert "textContent" in js_function(SOURCE, "token")


# --- The mirror and the box are one shape -------------------------------------
METRICS = (
    "font-family",
    "font-size",
    "line-height",
    "letter-spacing",
    "padding",
    "white-space",
    "overflow-wrap",
    "word-break",
    "width",
)


def test_the_mirror_and_the_box_are_sized_by_one_rule():
    """A textarea cannot style its own contents, so the mirror sits behind it
    holding the same text with every character transparent. Every property that
    decides where a character lands has to be declared once, for both: a single
    difference and the rectangles slide off the words they are marking, and the
    drift grows with the length of the line rather than showing up as an obvious
    misalignment at the start.

    So the shared rule is asserted to carry all of them, and neither of the
    per-element rules to redeclare any -- which is the version of this that
    breaks silently, because a second declaration looks like a local tweak.
    """
    shared = _rule(CSS, ".composer__input,\n.composer__mirror {")
    for property_ in METRICS:
        assert f"{property_}:" in shared, f"{property_} is not shared"

    for selector in ("\n.composer__input {", "\n.composer__mirror {"):
        own = _rule(CSS, selector)
        for property_ in METRICS:
            assert f"{property_}:" not in own, f"{selector.strip()} redeclares {property_}"


def test_a_token_restates_what_it_would_otherwise_inherit():
    """`color: transparent` on the mirror is an inherited value, and any colour
    a span declares itself beats it. The transcript's rule used to do exactly
    that from across the file, painting the token in accent-coloured monospace
    at 0.95em on top of the textarea's own text: doubled, and shifted from that
    point on because the metrics differ."""
    tokens = _rule(CSS, ".composer__mirror .tok-mention,\n.composer__mirror .tok-command {")
    assert "color: transparent" in tokens
    assert "font: inherit" in tokens


def test_a_token_bleeds_with_a_shadow_rather_than_with_padding():
    """The rectangle has to be a little wider than the text it sits behind.
    Padding and a negative margin do that by *moving the glyph*, which is the
    one thing nothing in the mirror is allowed to do -- a shadow cannot."""
    tokens = _rule(CSS, ".composer__mirror .tok-mention,\n.composer__mirror .tok-command {")
    assert "padding: 0" in tokens
    assert "margin: 0" in tokens

    for selector in (".composer__mirror .tok-mention {", ".composer__mirror .tok-command {"):
        rule = _rule(CSS, selector)
        assert "box-shadow" in rule, f"{selector} does not bleed"
        assert "padding" not in rule, f"{selector} moves the glyph"
        assert "margin" not in rule, f"{selector} moves the glyph"


def test_every_token_style_names_where_it_applies():
    """A rule written for one context matches every context. `.tok-mention`
    unscoped was written for the transcript and also hit the mirror's spans,
    which is the bug above. Both places qualify their selectors now, and an
    unqualified one added later would do it again with nothing to notice."""
    for line in CSS.splitlines():
        if ".tok-" not in line:
            continue
        selector = line.strip()
        assert selector.startswith((".msg ", ".composer__mirror ")), selector


# --- Slash commands, from the composer's side ---------------------------------
def test_an_unrecognised_slash_is_never_swallowed():
    """Eating somebody's message because it began with a slash is a far worse
    failure than an unknown command. Enter only cancels the send when the value
    resolves to a command -- so the `preventDefault` has to sit *inside* that
    test, not beside it."""
    window = CODE[CODE.index('event.key === "Enter" && !event.shiftKey') :][:600]
    assert window.index("commandIn(") < window.index("preventDefault")
    assert "if (command) {" in window


def test_choosing_a_command_clears_the_box_before_running_it():
    """A command is the whole message, never part of one. `/help` left sitting
    in the box after running means the next Enter runs it again -- which is what
    driving this under a DOM stub found, along with Tab not completing."""
    choose = js_function(SOURCE, "choose")
    assert js_says(choose, "input.value", '""', "runCommand")


def test_tab_completes_and_enter_runs():
    """The difference matters for a command that takes an argument: Tab writes
    `/mode ` and leaves the caret after it, Enter runs what is there. Both are
    one call, so the key is what decides -- and `choose` has to branch on it
    before it reaches the run."""
    assert js_says(CODE, "choose(all[active]", 'event.key === "Tab"')
    choose = js_function(SOURCE, "choose")
    assert choose.index("if (complete)") < choose.index("runCommand")


def test_the_at_menu_works_on_a_page_without_commands_js():
    """`@` and `/` share one menu but not one dependency. commands.js is absent
    from some pages and may fail to parse on any of them, and either would
    otherwise take the mention picker down with it."""
    fallback = js_function(SOURCE, "commands")
    assert "window.lembasCommands ||" in fallback
    for method in ("list:", "find:", "run:"):
        assert method in fallback, f"the fallback has no {method}"

    # Read at call time rather than captured once, or a page whose commands.js
    # loads second gets the fallback forever.
    for helper in ("commandList", "commandIn", "runCommand"):
        assert "commands()." in js_function(SOURCE, helper)


def test_the_directory_is_read_from_the_field_that_is_submitted():
    """The chip beside the connection shows the directory's own name so it stops
    eating the composer's one row; the whole path lives in the hidden field. A
    mention picker reading the label would ask the server about `worker` rather
    than about `/srv/projects/…/worker` -- shortening a label must never shorten
    a value, on this path either."""
    context = js_function(SOURCE, "context")
    assert "[data-dir-value]" in context
    assert "data-dir-label" not in context
