"""Source-structure regression tests for the create_script -> attach_script
import-settle fix (issue #261).

Without this guard, an agent that calls `script_create` followed immediately
by `script_attach` for the same `.gd` file races the editor's filesystem
scan: `ResourceLoader.exists(path)` can return false while Godot is still
recognising the new resource. The fix is to defer the `script_create`
response until either the resource is visible or a bounded settle window
elapses, so a successful response means an immediate `script_attach` will
succeed.

These tests pin the structure so a future refactor can't silently regress
the guarantee.
"""

from __future__ import annotations

from pathlib import Path

from tests.unit._gdscript_text import get_func_block

PLUGIN_ROOT = Path(__file__).resolve().parents[2] / "plugin" / "addons" / "godot_ai"
SCRIPT_HANDLER = PLUGIN_ROOT / "handlers" / "script_handler.gd"
PLUGIN_GD = PLUGIN_ROOT / "plugin.gd"


def test_script_handler_holds_connection_for_deferred_replies() -> None:
    """ScriptHandler needs an McpConnection ref to push the deferred response."""
    source = SCRIPT_HANDLER.read_text(encoding="utf-8")

    assert "var _connection: McpConnection" in source, (
        "ScriptHandler must hold an McpConnection so create_script can defer "
        "its reply until the editor's filesystem scan settles. Without this "
        "field a fresh script_create -> script_attach pair races the import "
        "pipeline (issue #261)."
    )
    # _init must accept the connection. Default null keeps batch_execute and
    # unit-test contexts working on the synchronous fallback path.
    expected_init = "func _init(undo_redo: EditorUndoRedoManager, connection: McpConnection = null)"
    assert expected_init in source, (
        "ScriptHandler._init must accept the connection as an optional "
        "second parameter so test contexts can keep using the sync fallback."
    )


def test_create_script_defers_for_freshly_created_files() -> None:
    """The new-file path returns DEFERRED_RESPONSE; existing-file path replies sync."""
    source = SCRIPT_HANDLER.read_text(encoding="utf-8")

    # The deferred handoff must be guarded by `not existed_before` so that
    # overwriting an already-known resource still returns immediately —
    # ResourceLoader already knows it, no scan to wait for.
    assert "not existed_before and _connection != null and not request_id.is_empty()" in source, (
        "create_script must only defer when the file was newly created AND a "
        "connection is available AND a request_id is present. Overwrites and "
        "batch_execute / unit-test contexts must keep the synchronous reply."
    )
    assert "return McpDispatcher.DEFERRED_RESPONSE" in source, (
        "create_script must return the DEFERRED_RESPONSE sentinel on the "
        "deferred path so the dispatcher skips auto-sending the reply."
    )


def test_finish_create_script_deferred_polls_resourceloader_with_bounded_loop() -> None:
    """The settle loop must be bounded and check ResourceLoader.exists each frame."""
    source = SCRIPT_HANDLER.read_text(encoding="utf-8")

    # The bounded counter prevents an indefinite hang if the editor's
    # filesystem pipeline never reports the new resource.
    assert "_IMPORT_SETTLE_MAX_FRAMES" in source, (
        "The deferred loop must use a named bounded-frame constant so the "
        "wait can't run forever if the filesystem scan stalls."
    )
    assert "_IMPORT_SETTLE_MAX_MSEC := 3500" in source, (
        "The deferred loop must be capped well below the dispatcher's "
        "create_script deferred timeout. If this window reaches the dispatcher "
        "timeout, a committed file can still surface to callers as "
        "DEFERRED_TIMEOUT."
    )
    deferred_block = get_func_block(source, "static func _finish_create_script_deferred")
    assert "var deadline_ms := Time.get_ticks_msec() + _IMPORT_SETTLE_MAX_MSEC" in deferred_block
    assert "Time.get_ticks_msec() < deadline_ms" in deferred_block
    assert "ResourceLoader.exists(path)" in deferred_block, (
        "The deferred loop must poll ResourceLoader.exists(path) — that's "
        "the precise check script_attach uses, so settling on it gives the "
        "guarantee #261 wants."
    )
    assert "await tree.process_frame" in deferred_block, (
        "The deferred loop must yield via process_frame between polls so the "
        "editor can actually run the import pipeline between checks."
    )
    assert deferred_block.find(
        "var deadline_ms := Time.get_ticks_msec() + _IMPORT_SETTLE_MAX_MSEC"
    ) < deferred_block.find("await tree.process_frame"), (
        "The deferred coroutine must start its deadline before the registration "
        "handoff await. Otherwise a slow first frame is outside the bounded "
        "window and a committed write can still hit the dispatcher timeout (#324)."
    )
    # The reply must use send_deferred_response with a {"data": ...} payload.
    assert "connection.send_deferred_response(request_id" in deferred_block, (
        "After settling, the handler must push the response over the "
        "connection's send_deferred_response — the dispatcher won't do it."
    )
    assert 'payload["import_settle"] = "settled" if settled else "timeout"' in deferred_block
    assert 'payload["import_pending"] = not settled' in deferred_block
    # Match the project_handler.stop_project pattern: drop the response if
    # the plugin tore down during the await. The static refactor passes
    # `connection` explicitly instead of relying on `self._connection`.
    assert "is_instance_valid(connection)" in deferred_block, (
        "If _exit_tree fires during the await the connection is freed; the "
        "deferred reply must check is_instance_valid and bail silently."
    )


def test_create_script_reports_committed_status_even_when_import_wait_times_out() -> None:
    """A committed file must not be indistinguishable from a failed mutation."""
    source = SCRIPT_HANDLER.read_text(encoding="utf-8")

    assert '"committed": true' in source, (
        "create_script writes the file before waiting for ResourceLoader; the "
        "response must expose committed=true so callers know retrying is not a "
        "plain safe retry."
    )
    assert '"import_settle": "already_known" if existed_before else "not_waited"' in source
    deferred_block = get_func_block(source, "static func _finish_create_script_deferred")
    assert 'payload["import_settle"] = "settled" if settled else "timeout"' in deferred_block, (
        "Deferred completion must distinguish import success from import-settle "
        "timeout while still returning a success payload for the committed file."
    )
    assert 'payload["import_pending"] = not settled' in deferred_block, (
        "When import settling times out, callers need an explicit import_pending "
        "flag instead of interpreting a transport timeout as write failure."
    )


def test_finish_create_script_deferred_is_static() -> None:
    """Source-pin: the deferred completion must be a `static func`.

    Under concurrent script_create storms (e.g. /tmp/shitstorm2.py) combined
    with editor_reload_plugin firing during the burst, the ScriptHandler
    RefCounted was being freed mid-await, producing "Resumed function
    '_finish_create_script_deferred()' after await, but class instance is
    gone" and dropping the deferred response. The fix is to declare the
    coroutine `static` so it captures no `self` reference, surviving handler
    GC. This source check prevents a silent revert.
    """
    source = SCRIPT_HANDLER.read_text(encoding="utf-8")
    assert "static func _finish_create_script_deferred(" in source, (
        "_finish_create_script_deferred must be declared `static` so the "
        "deferred coroutine doesn't capture self. Without this, a handler "
        "freed mid-await produces 'class instance is gone' errors and drops "
        "the response."
    )
    # And the connection must be passed in explicitly, not pulled from self.
    assert "connection: McpConnection," in source, (
        "The static function must take the connection as an explicit "
        "parameter — referencing self._connection would re-introduce the "
        "implicit `self` capture the static refactor avoids."
    )
    # The caller in create_script must thread _connection through.
    assert "_finish_create_script_deferred(_connection, request_id, path, data)" in source, (
        "create_script must pass `_connection` explicitly to the static "
        "deferred completion. A bare call with no args would silently "
        "regress to depending on instance state."
    )


def test_plugin_gd_passes_connection_to_script_handler() -> None:
    """plugin.gd must wire _connection into ScriptHandler — the field is null otherwise."""
    source = PLUGIN_GD.read_text(encoding="utf-8")

    assert "ScriptHandler.new(get_undo_redo(), _connection)" in source, (
        "plugin.gd must construct ScriptHandler with the connection so the "
        "deferred-reply path is reachable in production. Without this, every "
        "create_script falls back to the synchronous reply and #261 returns."
    )
