"""Tests for #4771: failed gateway approval clicks must keep the card actionable."""

from __future__ import annotations

import json
import shutil
import subprocess
import tempfile
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
NODE = shutil.which("node")

pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")


def _extract_fn(src: str, name: str, prefix: str = "function ") -> str:
    start = src.index(f"{prefix}{name}(")
    paren = src.index("(", start)
    paren_depth = 0
    for i in range(paren, len(src)):
        if src[i] == "(":
            paren_depth += 1
        elif src[i] == ")":
            paren_depth -= 1
            if paren_depth == 0:
                brace = src.index("{", i + 1)
                break
    else:
        raise AssertionError(f"{name} parameter list not closed")
    depth = 0
    for i in range(brace, len(src)):
        if src[i] == "{":
            depth += 1
        elif src[i] == "}":
            depth -= 1
            if depth == 0:
                return src[start:i + 1]
    raise AssertionError(f"{name} body not closed")


def _run_node(script: str) -> dict:
    with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as tf:
        tf.write(script)
        script_path = tf.name
    try:
        result = subprocess.run(
            [NODE, script_path],
            check=False,
            capture_output=True,
            text=True,
            timeout=15,
        )
        assert result.returncode == 0, result.stderr
        return json.loads(result.stdout)
    finally:
        Path(script_path).unlink(missing_ok=True)


def _run_failure_case(api_js: str) -> dict:
    helpers = "\n".join(
        [
            _extract_fn(MESSAGES_JS, "_approvalDismissKey"),
            _extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
            _extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
            _extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
            _extract_fn(MESSAGES_JS, "_promptNotifyKey"),
            _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"),
            _extract_fn(MESSAGES_JS, "_approvalPromptGeneration"),
            _extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"),
            _extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
            _extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
            _extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
            _extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"),
            _extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"),
            _extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"),
            _extract_fn(MESSAGES_JS, "_approvalOwnerForPending"),
            _extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"),
            _extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"),
            _extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"),
            _extract_fn(MESSAGES_JS, "_approvalResponseMatches"),
            _extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"),
            _extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"),
            _extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"),
            _extract_fn(MESSAGES_JS, "showApprovalCard"),
            _extract_fn(MESSAGES_JS, "_restoreFailedApprovalResponse"),
            _extract_fn(MESSAGES_JS, "respondApproval", prefix="async function "),
        ]
    )
    script = f"""
const output = {{}};
const _store = {{}};
const localStorage = {{
  getItem: key => Object.prototype.hasOwnProperty.call(_store, key) ? _store[key] : null,
  setItem: (key, value) => {{ _store[key] = String(value); }},
}};
const S = {{ session: {{ session_id: 'sess-1' }} }};
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
let syncTopbarCalls = 0;
let showToastCalls = [];
let statusCalls = [];
let hideCalls = 0;
let renderCalls = 0;
let _approvalSessionId = 'sess-1';
let _approvalCurrentId = 'appr-1';
let _approvalPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
let _loadSessionGeneration = 1;
let _approvalResponding = null;
let _approvalClearedOwner = null;
let _approvalDisplayedOwner = null;
let _approvalSignature = '';
let _approvalVisibleSince = 0;
let _approvalHideTimer = null;
const _clarifyPendingBySession = new Map();
const _promptNotifySeen = new Map();
const buttons = new Map();
function makeButton(id) {{
  return {{
    id,
    disabled: false,
    classList: {{
      values: new Set(),
      add(value) {{ this.values.add(value); }},
      remove(value) {{ this.values.delete(value); }},
      contains(value) {{ return this.values.has(value); }},
    }},
  }};
}}
['approvalBtnOnce','approvalBtnSession','approvalBtnAlways','approvalBtnDeny'].forEach(id => buttons.set(id, makeButton(id)));
const card = {{
  classList: {{
    values: new Set(['visible']),
    add(value) {{ this.values.add(value); }},
    remove(value) {{ this.values.delete(value); }},
    contains(value) {{ return this.values.has(value); }},
    toggle(value, force) {{
      if (force === undefined) {{
        if (this.values.has(value)) this.values.delete(value);
        else this.values.add(value);
      }} else if (force) this.values.add(value);
      else this.values.delete(value);
    }},
  }},
  hidden: false,
  attributes: {{}},
  setAttribute(name, value) {{ this.attributes[name] = String(value); }},
  removeAttribute(name) {{ delete this.attributes[name]; }},
  querySelector() {{ return null; }},
}};
const approvalDesc = {{ textContent: '' }};
const approvalCmd = {{ textContent: '' }};
const approvalCounter = {{ textContent: '', style: {{ display: '' }} }};
const msg = {{}};
const document = {{ activeElement: null }};
const elements = {{
  approvalCard: card,
  approvalDesc,
  approvalCmd,
  approvalCounter,
  approvalBtnOnce: buttons.get('approvalBtnOnce'),
  approvalBtnSession: buttons.get('approvalBtnSession'),
  approvalBtnAlways: buttons.get('approvalBtnAlways'),
  approvalBtnDeny: buttons.get('approvalBtnDeny'),
  msg,
}};
function $(id) {{ return elements[id] || null; }}
function syncTopbar() {{ syncTopbarCalls += 1; }}
function hideApprovalCard() {{ hideCalls += 1; card.classList.remove('visible'); }}
function _clearApprovalHideTimer() {{}}
function _syncApprovalCollapseButton() {{}}
function _syncApprovalTranscriptSpace() {{}}
function applyLocaleToDOM() {{}}
function setTimeout(fn) {{ return 1; }}
function showToast(msg) {{ showToastCalls.push(msg); }}
function setStatus(msg) {{ statusCalls.push(msg); }}
function t(key) {{ return key; }}
{api_js}
{helpers}
const pending = {{
  approval_id: 'appr-1',
  _session_id: 'sess-1',
  command: 'rm -rf /tmp/test',
  description: 'Dangerous command approval',
  pattern_key: 'dangerous_command',
  pattern_keys: ['dangerous_command'],
}};
_approvalPendingBySession.set('sess-1', {{ pending, pendingCount: 1 }});
showApprovalCard(pending, 1);
const realRenderPendingApprovalForActiveSession = _renderPendingApprovalForActiveSession;
renderCalls = 0;
_renderPendingApprovalForActiveSession = function() {{
  renderCalls += 1;
  return realRenderPendingApprovalForActiveSession();
}};
respondApproval('once').then(() => {{
  output.sessionId = _approvalSessionId;
  output.approvalId = _approvalCurrentId;
  output.pendingApprovalId = _approvalPendingBySession.get('sess-1').pending.approval_id;
  output.hideCalls = hideCalls;
  output.renderCalls = renderCalls;
  output.toast = showToastCalls[0] || null;
  output.status = statusCalls[0] || null;
  output.onceDisabled = buttons.get('approvalBtnOnce').disabled;
  output.onceLoading = buttons.get('approvalBtnOnce').classList.contains('loading');
  output.denyDisabled = buttons.get('approvalBtnDeny').disabled;
  output.cardVisible = card.classList.contains('visible');
  process.stdout.write(JSON.stringify(output));
}});
"""
    return _run_node(script)


def test_failed_gateway_approval_keeps_card_and_reenables_buttons():
    out = _run_failure_case(
        """function api() {
  const err = new Error('Gateway approval could not be relayed because the active run is unavailable. Reopen the session or retry after it reconnects.');
  err.status = 409;
  return Promise.reject(err);
}"""
    )
    assert out["sessionId"] == "sess-1"
    assert out["approvalId"] == "appr-1"
    assert out["pendingApprovalId"] == "appr-1"
    assert out["hideCalls"] == 0
    assert out["renderCalls"] == 1
    assert out["onceDisabled"] is False
    assert out["onceLoading"] is False
    assert out["denyDisabled"] is False
    assert out["cardVisible"] is True
    assert "active run is unavailable" in out["toast"]
    assert "active run is unavailable" in out["status"]


def test_rejected_ok_false_approval_keeps_card_and_reenables_buttons():
    out = _run_failure_case(
        """function api() {
  return Promise.resolve({
    ok: false,
    error: 'Approval response not accepted for this session.',
  });
}"""
    )
    assert out["sessionId"] == "sess-1"
    assert out["approvalId"] == "appr-1"
    assert out["pendingApprovalId"] == "appr-1"
    assert out["hideCalls"] == 0
    assert out["renderCalls"] == 1
    assert out["onceDisabled"] is False
    assert out["onceLoading"] is False
    assert out["denyDisabled"] is False
    assert out["cardVisible"] is True
    assert "Approval response not accepted for this session." == out["toast"]
    assert "Approval response not accepted for this session." == out["status"]


def test_poll_rerender_keeps_inflight_buttons_disabled_and_blocks_duplicates():
    helpers = "\n".join(
        [
            _extract_fn(MESSAGES_JS, "_approvalDismissKey"),
            _extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
            _extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
            _extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
            _extract_fn(MESSAGES_JS, "_promptNotifyKey"),
            _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"),
            _extract_fn(MESSAGES_JS, "_approvalPromptGeneration"),
            _extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"),
            _extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
            _extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
            _extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
            _extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"),
            _extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"),
            _extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"),
            _extract_fn(MESSAGES_JS, "_approvalOwnerForPending"),
            _extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"),
            _extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"),
            _extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"),
            _extract_fn(MESSAGES_JS, "_approvalResponseMatches"),
            _extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"),
            _extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"),
            _extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"),
            _extract_fn(MESSAGES_JS, "showApprovalCard"),
            _extract_fn(MESSAGES_JS, "_restoreFailedApprovalResponse"),
            _extract_fn(MESSAGES_JS, "respondApproval", prefix="async function "),
        ]
    )
    script = f"""
const output = {{}};
const _store = {{}};
const localStorage = {{
  getItem: key => Object.prototype.hasOwnProperty.call(_store, key) ? _store[key] : null,
  setItem: (key, value) => {{ _store[key] = String(value); }},
}};
const S = {{ session: {{ session_id: 'sess-1' }} }};
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
let _approvalSessionId = 'sess-1';
let _approvalCurrentId = 'appr-1';
let _approvalPendingBySession = new Map();
const _approvalPromptGenerationBySession = new Map();
let _loadSessionGeneration = 1;
let _approvalResponding = null;
let _approvalClearedOwner = null;
let _approvalDisplayedOwner = null;
let _approvalSignature = '';
let _approvalVisibleSince = 0;
let _approvalHideTimer = null;
const _clarifyPendingBySession = new Map();
const _promptNotifySeen = new Map();
let resolveApi;
let apiCalls = 0;
const buttons = new Map();
function makeButton(id) {{
  return {{
    id,
    disabled: false,
    classList: {{
      values: new Set(),
      add(value) {{ this.values.add(value); }},
      remove(value) {{ this.values.delete(value); }},
      contains(value) {{ return this.values.has(value); }},
    }},
  }};
}}
['approvalBtnOnce','approvalBtnSession','approvalBtnAlways','approvalBtnDeny'].forEach(id => buttons.set(id, makeButton(id)));
const card = {{
  classList: {{
    values: new Set(['visible']),
    add(value) {{ this.values.add(value); }},
    remove(value) {{ this.values.delete(value); }},
    contains(value) {{ return this.values.has(value); }},
    toggle(value, force) {{
      if (force === undefined) {{
        if (this.values.has(value)) this.values.delete(value);
        else this.values.add(value);
      }} else if (force) this.values.add(value);
      else this.values.delete(value);
    }},
  }},
  hidden: false,
  attributes: {{}},
  setAttribute(name, value) {{ this.attributes[name] = String(value); }},
  removeAttribute(name) {{ delete this.attributes[name]; }},
  querySelector() {{ return null; }},
}};
const approvalDesc = {{ textContent: '' }};
const approvalCmd = {{ textContent: '' }};
const approvalCounter = {{ textContent: '', style: {{ display: '' }} }};
const msg = {{}};
const document = {{ activeElement: null }};
const elements = {{
  approvalCard: card,
  approvalDesc,
  approvalCmd,
  approvalCounter,
  approvalBtnOnce: buttons.get('approvalBtnOnce'),
  approvalBtnSession: buttons.get('approvalBtnSession'),
  approvalBtnAlways: buttons.get('approvalBtnAlways'),
  approvalBtnDeny: buttons.get('approvalBtnDeny'),
  msg,
}};
function $(id) {{ return elements[id] || null; }}
function syncTopbar() {{}}
function hideApprovalCard() {{ card.classList.remove('visible'); }}
function _clearApprovalHideTimer() {{}}
function _syncApprovalCollapseButton() {{}}
function _syncApprovalTranscriptSpace() {{}}
function applyLocaleToDOM() {{}}
function setTimeout(fn) {{ return 1; }}
function showToast() {{}}
function setStatus() {{}}
function t(key) {{ return key; }}
function api() {{
  apiCalls += 1;
  return new Promise(resolve => {{ resolveApi = resolve; }});
}}
{helpers}
const pending = {{
  approval_id: 'appr-1',
  _session_id: 'sess-1',
  command: 'rm -rf /tmp/test',
  description: 'Dangerous command approval',
  pattern_key: 'dangerous_command',
  pattern_keys: ['dangerous_command'],
}};
_approvalPendingBySession.set('sess-1', {{ pending, pendingCount: 1 }});
showApprovalCard(pending, 1);
const first = respondApproval('once');
_renderPendingApprovalForActiveSession();
const second = respondApproval('once');
output.apiCallsDuringFlight = apiCalls;
output.onceDisabledDuringFlight = buttons.get('approvalBtnOnce').disabled;
output.onceLoadingDuringFlight = buttons.get('approvalBtnOnce').classList.contains('loading');
resolveApi({{ ok: false }});
Promise.all([first, second]).then(() => {{
  output.apiCallsFinal = apiCalls;
  output.onceDisabledAfter = buttons.get('approvalBtnOnce').disabled;
  output.onceLoadingAfter = buttons.get('approvalBtnOnce').classList.contains('loading');
  output.cardVisibleAfter = card.classList.contains('visible');
  process.stdout.write(JSON.stringify(output));
}});
"""
    out = _run_node(script)
    assert out["apiCallsDuringFlight"] == 1
    assert out["onceDisabledDuringFlight"] is True
    assert out["onceLoadingDuringFlight"] is True
    assert out["apiCallsFinal"] == 1
    assert out["onceDisabledAfter"] is False
    assert out["onceLoadingAfter"] is False
    assert out["cardVisibleAfter"] is True
