import json
import re
import sqlite3
import subprocess
import textwrap
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
COMPACT_UI = re.sub(r"\s+", "", UI_JS)


def test_cli_tool_result_diff_snippet_is_not_cut_to_200_chars():
    """Diff-like CLI tool results should reach the existing tool-card expander."""
    assert "function _cliToolResultSnippet" in UI_JS
    assert "function _cliLooksLikePatchDiff" in UI_JS
    assert r"\*\*\* Begin Patch" in UI_JS
    assert "diff --git" in UI_JS
    assert (
        "if(_cliLooksLikePatchDiff(fullText))return_clipCliToolSnippet(fullText);"
        in COMPACT_UI
    )
    assert "returnString(fullText||'').slice(0,4000);" in COMPACT_UI


def test_cli_tool_fallback_promotes_apply_patch_args_to_tool_card_snippet():
    """A successful apply_patch result may only say 'Success'; keep the patch visible."""
    assert "function _cliPatchSnippetFromArgs" in UI_JS
    assert "toolName==='apply_patch'" in COMPACT_UI
    assert "'old_string'" in UI_JS
    assert "'new_string'" in UI_JS
    assert "constpatchSnippet=_cliPatchSnippetFromArgs(name,args);" in COMPACT_UI
    assert "snippet:_cliToolCardSnippet(resultSnippet,patchSnippet)" in COMPACT_UI
    assert "is_diff:_cliToolCardHasDiffSnippet(resultSnippet,patchSnippet)" in COMPACT_UI


def test_diff_tool_cards_use_show_diff_expander_label():
    assert "const moreLabel=tc.is_diff?'Show diff':'Show more';" in UI_JS
    assert "const lessLabel=tc.is_diff?'Hide diff':'Show less';" in UI_JS
    assert 'data-more-label="${esc(moreLabel)}"' in UI_JS


def _function_source(src: str, name: str) -> str:
    match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
    assert match, f"{name}() not found"
    brace = src.find("{", match.end())
    assert brace != -1, f"{name}() has no body"
    depth = 1
    i = brace + 1
    in_string = None
    escaped = False
    in_line_comment = False
    in_block_comment = False
    while i < len(src) and depth:
        ch = src[i]
        nxt = src[i + 1] if i + 1 < len(src) else ""
        if in_line_comment:
            if ch == "\n":
                in_line_comment = False
            i += 1
            continue
        if in_block_comment:
            if ch == "*" and nxt == "/":
                in_block_comment = False
                i += 2
                continue
            i += 1
            continue
        if in_string:
            if escaped:
                escaped = False
            elif ch == "\\":
                escaped = True
            elif ch == in_string:
                in_string = None
            i += 1
            continue
        if ch == "/" and nxt == "/":
            in_line_comment = True
            i += 2
            continue
        if ch == "/" and nxt == "*":
            in_block_comment = True
            i += 2
            continue
        if ch in "'\"`":
            in_string = ch
            i += 1
            continue
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
        i += 1
    assert depth == 0, f"{name}() body did not close"
    return src[match.start() : i]


def test_rendered_apply_patch_tool_card_html_contains_diff_lines():
    """Drive the actual snippet helpers and buildToolCard() through Node."""
    function_names = [
        "_clipCliToolSnippet",
        "_cliToolResultText",
        "_cliLooksLikePatchDiff",
        "_cliToolResultSnippet",
        "_prefixedCliDiffLines",
        "_firstOwnedValue",
        "_cliPatchSnippetFromArgs",
        "_cliToolCardSnippet",
        "_cliToolCardHasDiffSnippet",
        "_toolArgPreviewValue",
        "_toolArgPreviewKeyIsHidden",
        "_formatToolArgPreview",
        "_toolCardPreviewText",
        # #3336: buildToolCard now wraps diff snippets via these helpers.
        "_snippetLooksLikeDiff",
        "_colorDiffLines",
        "_worklogDetailsExpandedDefault",
        # #3544: buildToolCard stamps durable memory/skill-save flags via these.
        "_tcAction",
        "_isMemorySave",
        "_isSkillUpdate",
        "buildToolCard",
    ]
    functions = "\n".join(_function_source(UI_JS, name) for name in function_names)
    script = textwrap.dedent(
        f"""
        function esc(s){{return String(s||'').replace(/[&<>]/g,c=>({{'&':'&amp;','<':'&lt;','>':'&gt;'}}[c]));}}
        function li(){{return '';}}
        function toolIcon(){{return '';}}
        function _toolDisplayName(tc){{return tc.name||'tool';}}
        const window={{_worklogDetailsExpandedByDefault:false}};
        // #3544: const Sets the _isMemorySave/_isSkillUpdate predicates close over
        // (extracted helpers reference these module-level constants).
        const _MEMORY_SAVE_ACTIONS=new Set(['add','replace']);
        const _SKILL_UPDATE_ACTIONS=new Set(['create','patch','edit','write_file']);
        const document={{
          createElement(){{return {{className:'', innerHTML:'', setAttribute(){{}}, removeAttribute(){{}}}};}}
        }};
        {functions}

        const longPatch = [
          '*** Begin Patch',
          '*** Update File: app.py',
          '@@',
          '-old',
          '+new',
          ...Array.from({{length: 150}}, (_, i) => '+line ' + i),
          '*** End Patch'
        ].join('\\n');
        const resultSnippet = _cliToolResultSnippet(JSON.stringify({{output:'Success'}}));
        const patchSnippet = _cliPatchSnippetFromArgs('apply_patch', {{patch: longPatch}});
        const row = buildToolCard({{
          name: 'apply_patch',
          snippet: _cliToolCardSnippet(resultSnippet, patchSnippet),
          is_diff: _cliToolCardHasDiffSnippet(resultSnippet, patchSnippet),
          args: {{patch: '(shown in diff)'}},
          done: true
        }});
        const errorSnippet = _cliToolCardSnippet('Patch failed: context not found', patchSnippet);
        process.stdout.write(JSON.stringify({{html: row.innerHTML, errorSnippet}}));
        """
    )
    proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
    payload = json.loads(proc.stdout)
    html = payload["html"]
    assert "-old" in html
    assert "+new" in html
    assert "Show diff" in html
    assert "Patch failed: context not found" in payload["errorSnippet"]
    assert "-old" in payload["errorSnippet"]


def _make_state_db(path: Path) -> None:
    patch = "\n".join(
        [
            "*** Begin Patch",
            "*** Update File: app.py",
            "@@",
            "-old",
            "+new",
            "*** End Patch",
        ]
    )
    tool_calls = [
        {
            "id": "call_patch",
            "type": "function",
            "function": {
                "name": "apply_patch",
                "arguments": json.dumps({"patch": patch}),
            },
        }
    ]
    conn = sqlite3.Connection(str(path))
    try:
        conn.executescript(
            """
            CREATE TABLE messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                session_id TEXT,
                role TEXT,
                content TEXT,
                timestamp TEXT,
                tool_call_id TEXT,
                tool_calls TEXT,
                tool_name TEXT
            );
            """
        )
        conn.execute(
            """
            INSERT INTO messages (session_id, role, content, timestamp, tool_calls)
            VALUES (?, ?, ?, ?, ?)
            """,
            ("issue1824", "assistant", "", "2026-01-01T00:00:01Z", json.dumps(tool_calls)),
        )
        conn.execute(
            """
            INSERT INTO messages (session_id, role, content, timestamp, tool_call_id, tool_name)
            VALUES (?, ?, ?, ?, ?, ?)
            """,
            (
                "issue1824",
                "tool",
                json.dumps({"output": "Success"}),
                "2026-01-01T00:00:02Z",
                "call_patch",
                "apply_patch",
            ),
        )
        conn.commit()
    finally:
        conn.close()


def test_cli_session_reader_preserves_apply_patch_metadata(tmp_path, monkeypatch):
    """The API payload should keep tool_calls/tool rows for the UI renderer."""
    _make_state_db(tmp_path / "state.db")
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))

    import api.profiles
    from api.models import get_cli_session_messages

    monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: str(tmp_path))

    messages = get_cli_session_messages("issue1824")
    assert [m["role"] for m in messages] == ["assistant", "tool"]

    assistant = messages[0]
    assert assistant["tool_calls"][0]["function"]["name"] == "apply_patch"
    args = json.loads(assistant["tool_calls"][0]["function"]["arguments"])
    assert "*** Begin Patch" in args["patch"]
    assert "-old" in args["patch"]
    assert "+new" in args["patch"]

    tool = messages[1]
    assert tool["tool_call_id"] == "call_patch"
    assert tool["tool_name"] == "apply_patch"
    assert tool["name"] == "apply_patch"
    assert json.loads(tool["content"])["output"] == "Success"
