"""
Empirical Challenger Test Suite for Milestone M1:
Adversarial Verification of Catalog Dictionary Parity & Parameter Interpolation.

Covers:
1. 100% key parity & parameter placeholder parity across all 9 languages in chat_i18n_catalog.js.
2. Microcopy length bounds & HUD ergonomics across channels, badges, buttons, rarities, elements.
3. Item link tooltip localization for rarities, elements, HMAC badges, and dynamic in-place re-translation.
4. Exception resilience: FreeExileI18n.t() missing/null keys, adversarial params, subscriber errors, SSR safety.
"""

from __future__ import annotations

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

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
WEBAPP_DIR = PROJECT_ROOT / "client" / "webapp"
CHAT_CATALOG_JS = WEBAPP_DIR / "js" / "data" / "chat_i18n_catalog.js"
I18N_JS = WEBAPP_DIR / "js" / "data" / "i18n.js"
CHAT_UI_JS = WEBAPP_DIR / "js" / "ui" / "chat_ui.js"


def run_node(code: str, timeout_sec: float = 15.0) -> Any:
    """Helper to execute ES module snippets in Node.js and return parsed output."""
    wrapped = f"(async () => {{ try {{ {code} process.exit(0); }} catch (err) {{ console.error(err); process.exit(1); }} }})();"
    res = subprocess.run(
        ["node", "--input-type=module", "-e", wrapped],
        capture_output=True, text=True, encoding="utf-8", cwd=str(PROJECT_ROOT), timeout=timeout_sec
    )
    if res.returncode != 0:
        raise RuntimeError(f"Node execution failed (code {res.returncode}):\n{res.stderr}")
    for line in reversed(res.stdout.strip().split("\n")):
        line = line.strip()
        if (line.startswith("{") and line.endswith("}")) or (line.startswith("[") and line.endswith("]")):
            return json.loads(line)
        if line.lower() in ("true", "false"):
            return line.lower() == "true"
    return None


class TestChatCatalogKeyParityAndInterpolation(unittest.TestCase):
    """Verifies 100% dictionary parity, non-empty values, and parameter interpolation across 9 languages."""

    @classmethod
    def setUpClass(cls) -> None:
        chat_url = CHAT_CATALOG_JS.resolve().as_uri()
        data = run_node(f"const m = await import('{chat_url}'); console.log(JSON.stringify({{ chatCatalog: m.CHAT_I18N_CATALOG, channelI18n: m.CHANNEL_I18N }}));")
        cls.chat_catalog: Dict[str, Dict[str, str]] = data["chatCatalog"]
        cls.channel_i18n: Dict[str, Dict[str, str]] = data["channelI18n"]
        cls.locales = ["vi", "en", "zh", "ja", "ko", "th", "de", "ru", "es"]

    def test_01_all_nine_languages_present_in_catalogs(self) -> None:
        """Both CHAT_I18N_CATALOG and CHANNEL_I18N must define all 9 canonical languages."""
        for loc in self.locales:
            self.assertIn(loc, self.chat_catalog, f"Locale '{loc}' missing from CHAT_I18N_CATALOG")
            self.assertIn(loc, self.channel_i18n, f"Locale '{loc}' missing from CHANNEL_I18N")

    def test_02_strict_one_hundred_percent_key_parity(self) -> None:
        """Every locale must contain exactly the same set of 77 keys as the master Vietnamese dictionary."""
        master_keys = set(self.chat_catalog["vi"].keys())
        self.assertEqual(len(master_keys), 77, f"Master catalog expected 77 keys, got {len(master_keys)}")
        for loc in self.locales:
            loc_keys = set(self.chat_catalog[loc].keys())
            self.assertEqual(master_keys - loc_keys, set(), f"Locale '{loc}' is missing keys: {master_keys - loc_keys}")
            self.assertEqual(loc_keys - master_keys, set(), f"Locale '{loc}' has extra keys: {loc_keys - master_keys}")

    def test_03_non_empty_trimmed_strings_all_keys(self) -> None:
        """Every translation value across all 9 languages must be a non-empty string."""
        for loc in self.locales:
            for key, val in self.chat_catalog[loc].items():
                self.assertIsInstance(val, str, f"Key '{key}' in [{loc}] must be a string")
                self.assertTrue(len(val.strip()) > 0, f"Key '{key}' in [{loc}] cannot be empty")

    def test_04_parameter_placeholder_exact_parity(self) -> None:
        """Interpolation placeholders like {sec}, {channel}, {level} must match identically across all 9 languages."""
        placeholder_regex = re.compile(r"\{[a-zA-Z0-9_]+\}")
        for key, vi_val in self.chat_catalog["vi"].items():
            vi_placeholders = sorted(placeholder_regex.findall(vi_val))
            if not vi_placeholders:
                continue
            for loc in self.locales:
                loc_placeholders = sorted(placeholder_regex.findall(self.chat_catalog[loc][key]))
                self.assertEqual(vi_placeholders, loc_placeholders, f"Param mismatch for '{key}' in [{loc}]")

    def test_05_channel_i18n_eight_channels_parity(self) -> None:
        """CHANNEL_I18N must define all 8 channels (world, zone, guild, party, whisper, system, recruit, feedback)."""
        expected = {"world", "zone", "guild", "party", "whisper", "system", "recruit", "feedback"}
        for loc in self.locales:
            self.assertEqual(set(self.channel_i18n[loc].keys()), expected, f"CHANNEL_I18N[{loc}] mismatch")


class TestMicrocopyLengthLimitsAndHUDErgonomics(unittest.TestCase):
    """Enforces strict length bounds on compact UI labels to prevent HUD overflow on mobile displays."""

    @classmethod
    def setUpClass(cls) -> None:
        chat_url = CHAT_CATALOG_JS.resolve().as_uri()
        cls.chat_catalog: Dict[str, Dict[str, str]] = run_node(f"const m = await import('{chat_url}'); console.log(JSON.stringify(m.CHAT_I18N_CATALOG));")
        cls.locales = ["vi", "en", "zh", "ja", "ko", "th", "de", "ru", "es"]

    def test_06_channel_tab_lengths_under_mobile_limit(self) -> None:
        """Channel tab labels must not exceed 15 characters to avoid horizontal overflow on mobile viewports."""
        channel_keys = [f"channel_{c}" for c in ["world", "zone", "guild", "party", "whisper", "system", "recruit", "feedback"]]
        for loc in self.locales:
            for k in channel_keys:
                self.assertLessEqual(len(self.chat_catalog[loc][k]), 15, f"Tab '{k}' in [{loc}] exceeds 15 chars")

    def test_07_badges_and_buttons_microcopy_bounds(self) -> None:
        """Badges and buttons must remain concise (locked <= 15 chars, send <= 12 chars, badges <= 25 chars)."""
        for loc in self.locales:
            d = self.chat_catalog[loc]
            self.assertLessEqual(len(d["chat_badge_locked"]), 15, f"Lock badge in [{loc}] too long")
            self.assertLessEqual(len(d["chat_btn_send"]), 12, f"Send button in [{loc}] too long")
            self.assertLessEqual(len(d["chat_item_badge_verified"]), 25, f"Verified badge in [{loc}] too long")
            self.assertLessEqual(len(d["chat_item_badge_unverified"]), 25, f"Unverified badge in [{loc}] too long")

    def test_08_rarity_and_element_microcopy_brevity(self) -> None:
        """Item rarities (<= 15 chars) and elements (<= 10 chars) must satisfy compact HUD specs."""
        for loc in self.locales:
            d = self.chat_catalog[loc]
            for r in range(1, 6):
                self.assertLessEqual(len(d[f"rarity_{r}"]), 15, f"Rarity '{r}' in [{loc}] exceeds 15 chars")
            for elem in ["metal", "wood", "water", "fire", "earth"]:
                self.assertLessEqual(len(d[f"element_{elem}"]), 10, f"Element '{elem}' in [{loc}] exceeds 10 chars")

    def test_09_zero_bilingual_parentheses_in_compact_labels(self) -> None:
        """Tabs, buttons, rarities, elements, and badges must never contain bilingual parenthetical formatting '(' or ')'."""
        compact_keys = [
            "chat_badge_locked", "chat_btn_send", "chat_item_badge_verified", "chat_item_badge_unverified",
            "rarity_1", "rarity_2", "rarity_3", "rarity_4", "rarity_5",
            "element_metal", "element_wood", "element_water", "element_fire", "element_earth"
        ]
        for loc in self.locales:
            for k in compact_keys:
                val = self.chat_catalog[loc][k]
                self.assertNotIn("(", val, f"Forbidden paren '(' in [{loc}] key '{k}'")
                self.assertNotIn(")", val, f"Forbidden paren ')' in [{loc}] key '{k}'")


class TestItemTooltipLocalizationAndReactiveSwitching(unittest.TestCase):
    """Empirically tests tooltip rendering and dynamic in-place re-translation across all 9 languages."""

    def _eval_tooltip_in_node(self) -> Dict[str, Any]:
        chat_url = CHAT_CATALOG_JS.resolve().as_uri()
        ui_url = CHAT_UI_JS.resolve().as_uri()
        i18n_path = str(I18N_JS).replace("\\", "/")
        code = f"""
        const fs = await import('node:fs');
        const elements = {{}};
        const createEl = (id) => (elements[id] = {{
            id, className: '', textContent: '', innerHTML: '', dataset: {{}}, children: [],
            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); }} }},
            appendChild(c) {{ this.children.push(c); return c; }}, addEventListener: () => {{}}, querySelectorAll: () => []
        }});
        ['modal-item-link-tooltip', 'item-tooltip-name', 'item-tooltip-sub', 'item-tooltip-icon', 'item-tooltip-crafter', 'item-tooltip-affixes', 'item-tooltip-hmac-badge', 'chat-messages-container', 'chat-input', 'chat-send-btn', 'chat-cooldown-badge', 'chat-ticker-channel', 'chat-ticker-text'].forEach(createEl);
        global.window = {{ dispatchEvent: () => {{}}, localStorage: {{ getItem: () => null, setItem: () => {{}} }}, addEventListener: () => {{}} }};
        global.document = {{ getElementById: (id) => elements[id] || null, querySelectorAll: () => [], addEventListener: () => {{}}, createElement: () => createEl('c_' + Math.random()) }};
        global.Image = class {{ constructor() {{ this.src = ''; }} }};

        const chatCat = await import('{chat_url}');
        window.CHAT_I18N_CATALOG = chatCat.CHAT_I18N_CATALOG; window.CHANNEL_I18N = chatCat.CHANNEL_I18N;
        eval(fs.readFileSync('{i18n_path}', 'utf-8'));
        const chatUi = await import('{ui_url}');

        const verifiedSnap = {{ itemUuid: 'v-1', signature: 's1', itemName: 'Thái Cổ Thương', rarity: 5, element: 3, quality: 20, itemLevel: 95, crafterName: 'Độc Cô', affixes: [{{ statName: 'Bạo Kích', statValue: 40, isImplicit: true }}], isVerified: true }};
        const unverifiedSnap = {{ itemUuid: 'uv-1', signature: 's2', itemName: 'Phôi Kiếm', rarity: 1, element: 1, isVerified: false }};
        const results = {{}};

        for (const loc of ['vi', 'en', 'zh', 'ja', 'ko', 'th', 'de', 'ru', 'es']) {{
            window.FreeExileI18n.setLocale(loc); chatUi.updateLanguage(loc);
            elements['item-tooltip-affixes'].children.length = 0; chatUi.openItemTooltip(verifiedSnap);
            const vData = {{ sub: elements['item-tooltip-sub'].textContent, badge: elements['item-tooltip-hmac-badge'].textContent, crafter: elements['item-tooltip-crafter'].textContent }};
            elements['item-tooltip-affixes'].children.length = 0; chatUi.openItemTooltip(unverifiedSnap);
            const uvData = {{ sub: elements['item-tooltip-sub'].textContent, badge: elements['item-tooltip-hmac-badge'].textContent, affixes: elements['item-tooltip-affixes'].children.map(c => c.textContent) }};
            results[loc] = {{ verified: vData, unverified: uvData }};
        }}
        chatUi.openItemTooltip(verifiedSnap); window.FreeExileI18n.setLocale('ja'); chatUi.updateLanguage('ja');
        console.log(JSON.stringify({{ results, dynamicJa: elements['item-tooltip-sub'].textContent }}));
        """
        return run_node(code)

    def test_10_item_tooltip_translation_parity_all_nine_languages(self) -> None:
        """Item tooltip sub-header, verified/unverified badges, and affixes notices must localize in all 9 languages."""
        res = self._eval_tooltip_in_node()["results"]
        expected_rarity_5 = {"vi": "Thái Cổ", "en": "Primal", "zh": "太古", "ja": "太古", "ko": "태고", "th": "โบราณกาล", "de": "Uralt", "ru": "Древний", "es": "Primigenio"}
        expected_element_water = {"vi": "Thủy", "en": "Water", "zh": "水", "ja": "水", "ko": "수", "th": "น้ำ", "de": "Wasser", "ru": "Вода", "es": "Agua"}
        expected_badge_verified = {"vi": "✓ HMAC Xác Thực", "en": "✓ HMAC Verified", "zh": "✓ HMAC 已验证", "ja": "✓ HMAC 認証済", "ko": "✓ HMAC 검증됨", "th": "✓ ตรวจสอบ HMAC แล้ว", "de": "✓ HMAC Verifiziert", "ru": "✓ HMAC Подтвержден", "es": "✓ HMAC Verificado"}
        expected_badge_unverified = {"vi": "⚠ Chưa Xác Thực", "en": "⚠ Unverified", "zh": "⚠ 未验证", "ja": "⚠ 未認証", "ko": "⚠ 미검증", "th": "⚠ ยังไม่ยืนยัน", "de": "⚠ Nicht verifiziert", "ru": "⚠ Не подтверждено", "es": "⚠ Sin verificar"}

        for loc, v in res.items():
            self.assertIn(expected_rarity_5[loc], v["verified"]["sub"], f"Sub header in [{loc}] missing rarity_5")
            self.assertIn(expected_element_water[loc], v["verified"]["sub"], f"Sub header in [{loc}] missing element_water")
            self.assertEqual(v["verified"]["badge"], expected_badge_verified[loc])
            self.assertEqual(v["verified"]["crafter"], "Độc Cô")
            self.assertEqual(v["unverified"]["badge"], expected_badge_unverified[loc])
            self.assertEqual(len(v["unverified"]["affixes"]), 1)
            self.assertTrue(len(v["unverified"]["affixes"][0]) > 0)

    def test_11_dynamic_language_switch_retranslates_open_tooltip(self) -> None:
        """Calling updateLanguage while an item tooltip is opened must dynamically re-render it in the new locale."""
        data = self._eval_tooltip_in_node()
        self.assertIn("太古", data["dynamicJa"])
        self.assertIn("Lv. 95", data["dynamicJa"])


class TestExceptionResilienceAndDefensiveBehavior(unittest.TestCase):
    """Stress-tests exception handling, null parameters, missing keys, and subscriber isolation."""

    def test_12_freeexile_i18n_t_missing_and_null_keys_resilience(self) -> None:
        """FreeExileI18n.t() must never throw on missing, null, undefined, or non-string keys."""
        i18n_path = str(I18N_JS).replace("\\", "/")
        code = f"""
        const fs = await import('node:fs');
        global.window = {{ localStorage: {{ getItem: () => null, setItem: () => {{}} }}, dispatchEvent: () => {{}} }};
        global.document = {{ getElementById: () => null, querySelectorAll: () => [], addEventListener: () => {{}} }};
        global.Image = class {{ constructor() {{ this.src = ''; }} }};
        eval(fs.readFileSync('{i18n_path}', 'utf-8'));
        const i18n = window.FreeExileI18n;
        console.log(JSON.stringify({{
            mFb: i18n.t('missing_xyz', {{}}, 'MY_FALLBACK'),
            mNoFb: i18n.t('missing_xyz'),
            nullFb: i18n.t(null, {{}}, 'FALLBACK_NULL'),
            nullNoFb: i18n.t(null),
            undefFb: i18n.t(undefined, {{}}, 'FALLBACK_UNDEF'),
            numKey: i18n.t(404, {{}}, 'NOT_FOUND'),
            nullParams: i18n.t('char_name', null, 'Exile'),
            undefParams: i18n.t('char_name', undefined, 'Exile')
        }}));
        """
        d = run_node(code)
        self.assertEqual(d["mFb"], "MY_FALLBACK")
        self.assertEqual(d["mNoFb"], "missing_xyz")
        self.assertEqual(d["nullFb"], "FALLBACK_NULL")
        self.assertEqual(d["nullNoFb"], "")
        self.assertEqual(d["undefFb"], "FALLBACK_UNDEF")
        self.assertEqual(d["numKey"], "NOT_FOUND")

    def test_13_subscriber_exception_isolation_in_event_bus(self) -> None:
        """Exceptions thrown inside subscribers must be isolated and not prevent subsequent subscribers from firing."""
        i18n_path = str(I18N_JS).replace("\\", "/")
        code = f"""
        const fs = await import('node:fs');
        global.window = {{ localStorage: {{ getItem: () => null, setItem: () => {{}} }}, dispatchEvent: () => {{}} }};
        global.document = {{ getElementById: () => null, querySelectorAll: () => [], addEventListener: () => {{}} }};
        global.Image = class {{ constructor() {{ this.src = ''; }} }};
        eval(fs.readFileSync('{i18n_path}', 'utf-8'));
        const i18n = window.FreeExileI18n;
        let sub1Fired = false, sub2Fired = false, sub3Fired = false;
        i18n.subscribe(() => {{ sub1Fired = true; }});
        i18n.subscribe(() => {{ throw new Error('Explosive subscriber error'); }});
        i18n.subscribe((newLoc, prevLoc) => {{ sub2Fired = true; if (newLoc === 'ru' && prevLoc === 'vi') sub3Fired = true; }});
        i18n.setLocale('ru');
        console.log(JSON.stringify({{ sub1Fired, sub2Fired, sub3Fired, loc: i18n.getLocale() }}));
        """
        d = run_node(code)
        self.assertTrue(d["sub1Fired"])
        self.assertTrue(d["sub2Fired"])
        self.assertTrue(d["sub3Fired"])
        self.assertEqual(d["loc"], "ru")

    def test_14_chat_ui_headless_and_null_dom_immunity(self) -> None:
        """chat_ui.js functions must execute safely without throwing when DOM elements are null or minimal."""
        ui_url = CHAT_UI_JS.resolve().as_uri()
        code = f"""
        global.document = {{ getElementById: () => null, querySelectorAll: () => [], addEventListener: () => {{}} }};
        global.window = {{ dispatchEvent: () => {{}}, localStorage: {{ getItem: () => null, setItem: () => {{}} }}, addEventListener: () => {{}} }};
        const chatUi = await import('{ui_url}');
        chatUi.updateLanguage('de'); chatUi.renderChatLog(); chatUi.updateCooldownUI(); chatUi.updateInputPlaceholder(); chatUi.openItemTooltip(null); chatUi.closeItemTooltip();
        console.log(JSON.stringify({{ headlessSafe: true }}));
        """
        self.assertTrue(run_node(code).get("headlessSafe", False))


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