"""
Empirical Challenger Test Suite for Milestone M2:
- TypeScript compilation & ClientItemSnapshot protobuf schema field parity
- Async fallback query callback (setQueryItemSnapshotCallback) & anti-spoofing
- Unusual UUIDs, special characters, and hyphenated HMAC hex strings
- WebApp UI 200 consecutive messages across 8 channels & 100-msg ring buffer
- Auto-scroll lock state transitions & mobile viewport ergonomics
"""

from __future__ import annotations

import json
import re
import subprocess
import unittest
from pathlib import Path
from typing import Any

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
CLIENT_DIR = PROJECT_ROOT / "client"
WEBAPP_DIR = CLIENT_DIR / "webapp"
CHAT_MGR_TS = CLIENT_DIR / "src" / "chat" / "ChatManager.ts"
CHAT_MGR_JS = CLIENT_DIR / "dist" / "chat" / "ChatManager.js"
CHAT_UI_JS = WEBAPP_DIR / "js" / "ui" / "chat_ui.js"
INDEX_HTML = WEBAPP_DIR / "index.html"
PROTO_FILE = PROJECT_ROOT / "proto" / "chat.proto"


def run_node_eval(js_code: str) -> Any:
    """Executes ES module code in Node.js with mock DOM and returns parsed JSON output."""
    mgr_path = str(CHAT_MGR_JS).replace("\\", "/")
    ui_path = str(CHAT_UI_JS).replace("\\", "/")
    bootstrap = f"""
const createMockElement = (id = '') => {{
  let _st = 0, _sh = 1000, _ch = 200, _html = '';
  const children = [];
  return {{
    id, tagName: 'DIV', dataset: {{}}, innerText: '', textContent: '',
    value: '', disabled: false,
    get innerHTML() {{ return _html; }},
    set innerHTML(v) {{
      _html = v;
      if (!v) children.length = 0;
    }},
    classList: {{
      _c: new Set(),
      add(...c) {{ c.forEach(x => this._c.add(x)); }},
      remove(...c) {{ c.forEach(x => this._c.delete(x)); }},
      contains(c) {{ return this._c.has(c); }}
    }},
    style: {{ _p: {{}}, setProperty(k, v) {{ this._p[k] = v; }}, getProperty(k) {{ return this._p[k]; }} }},
    get scrollTop() {{ return _st; }}, set scrollTop(v) {{ _st = v; }},
    get scrollHeight() {{ return _sh; }}, set scrollHeight(v) {{ _sh = v; }},
    get clientHeight() {{ return _ch; }}, set clientHeight(v) {{ _ch = v; }},
    appendChild(child) {{ children.push(child); return child; }},
    querySelectorAll(sel) {{ return children.filter(c => c._sel === sel); }},
    addEventListener: () => {{}},
    focus: () => {{}}
  }};
}};

const mockElements = new Map();
const getOrCreate = (id) => {{
  if (!mockElements.has(id)) mockElements.set(id, createMockElement(id));
  return mockElements.get(id);
}};

global.document = {{
  getElementById: (id) => getOrCreate(id),
  createElement: (tag) => createMockElement(tag),
  querySelectorAll: (sel) => [],
  addEventListener: () => {{}},
  readyState: 'complete'
}};
global.window = {{ addEventListener: () => {{}}, chatUI: null }};

async function main() {{
  const ChatMgr = await import('file:///{mgr_path}');
  const ChatUI = await import('file:///{ui_path}');
  {js_code}
}}
main().then(() => {{
  process.exit(0);
}}).catch(err => {{
  console.error(err);
  process.exit(1);
}});
"""
    res = subprocess.run(
        ["node", "--input-type=module", "-e", bootstrap],
        capture_output=True, text=True, encoding="utf-8", cwd=str(PROJECT_ROOT), timeout=10
    )
    if res.returncode != 0:
        raise RuntimeError(f"Node execution failed (code {res.returncode}):\n{res.stderr}")
    out = res.stdout.strip()
    return json.loads(out) if out else None


class TestChallengerChatM2(unittest.TestCase):
    """Adversarial stress harness for M2 Chat Engine & WebApp UI."""

    @classmethod
    def setUpClass(cls) -> None:
        cls.proto_text = PROTO_FILE.read_text(encoding="utf-8")
        cls.ts_text = CHAT_MGR_TS.read_text(encoding="utf-8")
        cls.html_text = INDEX_HTML.read_text(encoding="utf-8")

    # --- Group 1: TypeScript Compilation & Protobuf Parity ---

    def test_01_typescript_compilation_and_proto_field_parity(self) -> None:
        """Verify TypeScript builds cleanly and ClientItemSnapshot has 100% proto coverage."""
        build_res = subprocess.run(
            ["npm", "run", "build"],
            capture_output=True, text=True, encoding="utf-8", cwd=str(CLIENT_DIR), shell=True, timeout=15
        )
        self.assertEqual(build_res.returncode, 0, f"tsc failed: {build_res.stderr}")
        self.assertTrue(CHAT_MGR_JS.exists(), "ChatManager.js was not generated by tsc")

        expected_fields = [
            "itemUuid: string;", "itemName: string;", "rarity: number;",
            "element: number;", "quality: number;", "itemLevel: number;",
            "affixes: ClientItemAffix[];", "crafterName: string;",
            "signature: string;", "createdAtMs: number;"
        ]
        for field in expected_fields:
            self.assertIn(field, self.ts_text, f"Missing field in ClientItemSnapshot: {field}")

    def test_02_snapshot_storage_and_exact_field_retrieval(self) -> None:
        """Empirically test storing and retrieving full snapshot in ClientChatManager."""
        code = """
        const mgr = new ChatMgr.ClientChatManager();
        const fullSnap = {
            itemUuid: 'uuid-full-777',
            itemName: 'Thái Cổ Tru Tiên Kiếm',
            rarity: 5,
            element: 4,
            quality: 20,
            itemLevel: 100,
            affixes: [
                { affixId: 'aff_1', statName: 'Sát Thương Cổ Võ', statValue: 120, isImplicit: true },
                { affixId: 'aff_2', statName: 'Chí Mạng Ngũ Hành', statValue: 35, isImplicit: false }
            ],
            crafterName: 'Vô Danh Kiếm Thánh',
            signature: 'hmac-sha256-abcdef0123456789',
            createdAtMs: 1775000000000
        };
        mgr.registerItemSnapshot(fullSnap);
        const retrieved = mgr.getCachedSnapshot('uuid-full-777');
        console.log(JSON.stringify(retrieved));
        """
        data = run_node_eval(code)
        self.assertEqual(data["itemUuid"], "uuid-full-777")
        self.assertEqual(data["itemLevel"], 100)
        self.assertEqual(data["createdAtMs"], 1775000000000)
        self.assertEqual(len(data["affixes"]), 2)
        self.assertEqual(data["affixes"][0]["isImplicit"], True)

    # --- Group 2: Async Fallback Query Callback & Anti-Spoofing ---

    def test_03_async_query_fallback_when_snapshot_missing_locally(self) -> None:
        """Empirically verify async fallback callback is triggered and cached."""
        code = """
        const mgr = new ChatMgr.ClientChatManager();
        let queriedUuid = '', queriedSig = '', openedItem = null;

        mgr.setItemTooltipHandler((item) => { openedItem = item; });
        mgr.setQueryItemSnapshotCallback(async (uuid, sig) => {
            queriedUuid = uuid;
            queriedSig = sig;
            return {
                itemUuid: uuid,
                itemName: 'Bí Cảnh Tàn Cốt',
                rarity: 4,
                element: 3,
                quality: 18,
                itemLevel: 88,
                affixes: [],
                crafterName: 'Dị Vực Cổ Ma',
                signature: sig,
                createdAtMs: Date.now()
            };
        });

        await mgr.onTappedItemLink('item-remote-99', 'sig-verified-99');
        const cached = mgr.getCachedSnapshot('item-remote-99');
        console.log(JSON.stringify({ queriedUuid, queriedSig, openedName: openedItem?.itemName, cachedPresent: !!cached }));
        """
        data = run_node_eval(code)
        self.assertEqual(data["queriedUuid"], "item-remote-99")
        self.assertEqual(data["queriedSig"], "sig-verified-99")
        self.assertEqual(data["openedName"], "Bí Cảnh Tàn Cốt")
        self.assertTrue(data["cachedPresent"])

    def test_04_async_query_anti_spoofing_and_exception_resilience(self) -> None:
        """Empirically verify fallback rejects mismatching signature or network error."""
        code = """
        const mgr = new ChatMgr.ClientChatManager();
        let openedCount = 0;
        mgr.setItemTooltipHandler(() => { openedCount++; });

        // Sub-case A: Callback returns snapshot with mismatching signature
        mgr.setQueryItemSnapshotCallback(async (uuid, sig) => ({
            itemUuid: uuid, itemName: 'Spoofed Item', rarity: 5, element: 1,
            quality: 20, itemLevel: 100, affixes: [], crafterName: 'Hacker',
            signature: 'wrong-sig', createdAtMs: Date.now()
        }));
        await mgr.onTappedItemLink('item-spoofed', 'correct-sig');

        // Sub-case B: Callback throws network error
        mgr.setQueryItemSnapshotCallback(async () => {
            throw new Error('Connection timeout 504 Gateway Timeout');
        });
        await mgr.onTappedItemLink('item-err', 'sig-err');

        console.log(JSON.stringify({ openedCount }));
        """
        data = run_node_eval(code)
        self.assertEqual(data["openedCount"], 0, "Tooltip must not open on invalid signature or network error")

    # --- Group 3: Unusual UUIDs, Special Characters & Tag Parsing ---

    def test_05_unusual_uuids_hyphenated_hmac_and_vietnamese_names(self) -> None:
        """Verify parseItemTags handles RFC4122 UUIDs, hyphenated HMACs, and complex names."""
        code = """
        const input = 'Vật phẩm 1: [item:550e8400-e29b-41d4-a716-446655440000:hmac-sha256-abc_def-0123:Cổ Thần Trảm Ma Đao +15 (Thái Cổ):5] và 2: [item:c0a80101_item_99:hmac_sig_v3:Băng / Hỏa Song Cực Giáp:4]';
        const tsParsed = ChatMgr.ClientChatManager.parseItemTags(input);
        const jsParsed = ChatUI.parseItemTags(input);
        console.log(JSON.stringify({ tsParsed, jsParsed }));
        """
        data = run_node_eval(code)
        self.assertEqual(len(data["tsParsed"]), 2)
        self.assertEqual(data["tsParsed"], data["jsParsed"], "TS and JS parser outputs must be identical")
        self.assertEqual(data["tsParsed"][0]["itemUuid"], "550e8400-e29b-41d4-a716-446655440000")
        self.assertEqual(data["tsParsed"][0]["signature"], "hmac-sha256-abc_def-0123")
        self.assertEqual(data["tsParsed"][0]["itemName"], "Cổ Thần Trảm Ma Đao +15 (Thái Cổ)")
        self.assertEqual(data["tsParsed"][0]["rarity"], 5)
        self.assertEqual(data["tsParsed"][1]["itemName"], "Băng / Hỏa Song Cực Giáp")

    def test_06_malformed_tags_resilience(self) -> None:
        """Verify parseItemTags safely ignores malformed brackets and missing fields."""
        code = """
        const malformed = 'Lỗi [item:only_uuid] và [item:::name:1] và [item:uuid:sig:name:notanumber] và []';
        const res = ChatUI.parseItemTags(malformed);
        console.log(JSON.stringify(res));
        """
        data = run_node_eval(code)
        self.assertEqual(len(data), 0, "Malformed tags must safely parse to empty list")

    # --- Group 4: WebApp UI 200 Consecutive Messages Across 8 Channels ---

    def test_07_200_consecutive_messages_fifo_ring_buffer(self) -> None:
        """Verify 200 consecutive messages in one channel evicts older 100 messages."""
        code = """
        for (let i = 1; i <= 200; i++) {
            ChatUI.addChatMessage({
                messageId: `msg_c1_${i}`,
                senderName: `HiệpKhách_${i}`,
                channel: 1,
                displayContent: `Tin nhắn thứ ${i}`,
                linkedItems: [],
                timestampMs: Date.now() + i
            });
        }
        const hist = ChatUI.getChannelHistory(1);
        console.log(JSON.stringify({
            length: hist.length,
            firstMsgId: hist[0].messageId,
            lastMsgId: hist[hist.length - 1].messageId
        }));
        """
        data = run_node_eval(code)
        self.assertEqual(data["length"], 100, "Channel history ring buffer must cap at 100")
        self.assertEqual(data["firstMsgId"], "msg_c1_101", "Messages 1-100 must be evicted")
        self.assertEqual(data["lastMsgId"], "msg_c1_200", "Message 200 must be the latest")

    def test_08_200_consecutive_messages_across_8_channels_responsiveness(self) -> None:
        """Verify adding 200 consecutive messages across 8 channels renders within SLA."""
        code = """
        const start = Date.now();
        for (let i = 1; i <= 200; i++) {
            const ch = ((i - 1) % 8) + 1;
            ChatUI.addChatMessage({
                messageId: `msg_seq_${i}`,
                senderName: `HiệpKhách_${i}`,
                channel: ch,
                displayContent: `Thông điệp ngũ hành liên kênh ${i}`,
                linkedItems: [],
                timestampMs: Date.now() + i
            });
        }
        const durationMs = Date.now() - start;
        const totalStored = Array.from({ length: 8 }, (_, idx) => ChatUI.getChannelHistory(idx + 1).length);
        console.log(JSON.stringify({ durationMs, totalStored }));
        """
        data = run_node_eval(code)
        self.assertEqual(sum(data["totalStored"]), 200, "Total messages across 8 channels must equal 200")
        self.assertLess(data["durationMs"], 350, "200 messages across 8 channels must render in < 350ms")

    # --- Group 5: Auto-Scroll Lock State Transitions ---

    def test_09_auto_scroll_lock_state_transitions(self) -> None:
        """Empirically test auto-scroll lock toggle at 35px threshold."""
        code = """
        const container = { scrollHeight: 1000, clientHeight: 200, scrollTop: 800 };
        ChatUI.updateScrollLockState(container);
        const lockedAtBottom = ChatUI.isAutoScrollLocked();

        container.scrollTop = 766; // diff = 1000 - 766 - 200 = 34 <= 35
        ChatUI.updateScrollLockState(container);
        const lockedWithinThreshold = ChatUI.isAutoScrollLocked();

        container.scrollTop = 764; // diff = 1000 - 764 - 200 = 36 > 35
        ChatUI.updateScrollLockState(container);
        const lockedBeyondThreshold = ChatUI.isAutoScrollLocked();

        container.scrollTop = 800; // back to bottom
        ChatUI.updateScrollLockState(container);
        const lockedReset = ChatUI.isAutoScrollLocked();

        console.log(JSON.stringify({ lockedAtBottom, lockedWithinThreshold, lockedBeyondThreshold, lockedReset }));
        """
        data = run_node_eval(code)
        self.assertFalse(data["lockedAtBottom"], "At bottom must not be locked")
        self.assertFalse(data["lockedWithinThreshold"], "Within 35px threshold must not be locked")
        self.assertTrue(data["lockedBeyondThreshold"], "Beyond 35px threshold must be locked")
        self.assertFalse(data["lockedReset"], "Returning to bottom must unlock")

    # --- Group 6: Mobile Ergonomics & Microcopy Brevity ---

    def test_10_mobile_viewport_and_microcopy_compliance(self) -> None:
        """Verify viewport ergonomics in index.html and strict 1-2 word brevity."""
        self.assertIn('viewport-fit=cover', self.html_text)
        self.assertIn('maximum-scale=1.0', self.html_text)
        self.assertIn('user-scalable=no', self.html_text)

        chat_btns = re.findall(r'<button\b[^>]*id="chat-[^"]*"[^>]*>(.*?)</button>', self.html_text)
        for btn in chat_btns:
            clean = re.sub(r'<[^>]+>', '', btn).strip()
            if clean:
                self.assertLessEqual(len(clean.split()), 2, f"Button label exceeds 2 words: {clean}")
                self.assertNotIn(" (", clean, f"Button label has bilingual parens: {clean}")


if __name__ == "__main__":
    unittest.main()
