"""
Comprehensive Unit Test Suite for FreeExile Reactive i18n Event Bus & Chat UI (Milestone 2).
Enforces 23 Test Cases across 5 Groups:
- Group 1: Observer / Event Bus mechanics (Tests 1-5)
- Group 2: Fallback chaining and parameter interpolation (Tests 6-10)
- Group 3: 9-language dictionary parity & key presence (Tests 11-15)
- Group 4: Chat UI reactive DOM integration without page reload (Tests 16-20)
- Group 5: Edge cases, null/empty keys, invalid locales, localStorage persistence (Tests 21-23)
"""

from __future__ import annotations

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

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8")

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

ALL_9_LOCALES = ["vi", "en", "zh", "ja", "ko", "th", "de", "ru", "es"]


def parse_catalog_file(path: Path) -> Dict[str, Set[str]]:
    """Extracts keys for all 9 locales using syntax-aware brace matching."""
    text = path.read_text(encoding="utf-8")
    res: Dict[str, Set[str]] = {}
    for loc in ALL_9_LOCALES:
        m = re.search(rf"\b{loc}\s*:\s*\{{", text)
        if not m:
            continue
        start, braces, curr = m.end(), 1, m.end()
        while curr < len(text) and braces > 0:
            if text[curr] == "{":
                braces += 1
            elif text[curr] == "}":
                braces -= 1
            curr += 1
        block = text[start - 1 : curr]
        keys: Set[str] = set()
        pat = re.compile(r'(?:[{,]\s*)\s*(?:["\']([a-zA-Z0-9_]+)["\']|([a-zA-Z0-9_]+))\s*:')
        for km in pat.finditer(block):
            keys.add(km.group(1) or km.group(2))
        res[loc] = keys
    return res


def execute_node_test_harness() -> Dict[str, Any]:
    """Runs full in-memory Node.js simulation of FreeExile i18n bus and Chat UI."""
    i18n_p = str(I18N_JS).replace("\\", "/")
    chat_ui_p = str(CHAT_UI_JS).replace("\\", "/")
    chat_cat_p = str(CHAT_CATALOG_JS).replace("\\", "/")
    i18n_cat_p = str(I18N_CATALOG_JS).replace("\\", "/")

    node_script = f"""
import fs from 'fs';
import vm from 'vm';

global.Image = class Image {{ constructor() {{ this.src = ''; }} }};
const storage = {{}};
global.localStorage = {{
  getItem: (k) => storage[k] || null,
  setItem: (k, v) => {{ storage[k] = String(v); }},
  removeItem: (k) => {{ delete storage[k]; }}
}};

const windowListeners = {{}};
global.CustomEvent = class CustomEvent {{
  constructor(t, i = {{}}) {{ this.type = t; this.detail = i.detail || {{}}; }}
}};
global.window = {{
  addEventListener: (n, cb) => {{ windowListeners[n] = windowListeners[n] || []; windowListeners[n].push(cb); }},
  removeEventListener: (n, cb) => {{ if (windowListeners[n]) windowListeners[n] = windowListeners[n].filter(f => f !== cb); }},
  dispatchEvent: (e) => {{ (windowListeners[e.type] || []).forEach(f => f(e)); }},
  localStorage: global.localStorage
}};

const createMockEl = (id = '') => ({{
  id, dataset: {{}}, textContent: '', innerText: '', value: '', placeholder: '', disabled: false,
  classList: {{ add: () => {{}}, remove: () => {{}}, contains: () => false }},
  style: {{ setProperty: () => {{}}, getProperty: () => '' }},
  appendChild: (c) => c, querySelectorAll: () => [], querySelector: () => null, addEventListener: () => {{}}
}});

const elements = new Map();
const getEl = (id) => {{ if (!elements.has(id)) elements.set(id, createMockEl(id)); return elements.get(id); }};
const tabs = Array.from({{ length: 8 }}, (_, i) => {{
  const t = createMockEl('chat-tab-' + (i + 1));
  t.dataset.channelId = String(i + 1);
  return t;
}});

global.document = {{
  readyState: 'complete',
  getElementById: (id) => getEl(id),
  createElement: (tag) => createMockEl('', tag),
  querySelectorAll: (sel) => (sel === '.chat-channel-tab' ? tabs : []),
  addEventListener: () => {{}}
}};

const catCode = fs.readFileSync('{i18n_cat_p}', 'utf-8');
const sb = {{ window: global.window }};
vm.createContext(sb);
vm.runInContext(catCode, sb);
global.I18N_CATALOG = global.window.I18N_CATALOG;
const chatCatModule = await import('file:///{chat_cat_p}');
global.CHAT_I18N_CATALOG = chatCatModule.CHAT_I18N_CATALOG;
await import('file:///{i18n_p}');
const i18n = global.window.FreeExileI18n;
const chatUI = await import('file:///{chat_ui_p}');
chatUI.initChatUI();
const out = {{}};

// Group 1: Observer / Event Bus
let g1_received = null;
const unsub1 = i18n.onLocaleChanged((loc, prev) => {{ g1_received = {{ loc, prev }}; }});
i18n.setLocale('en');
out.t01 = {{ received: g1_received, ok: g1_received?.loc === 'en' }};
unsub1();
const seq = [];
const uA = i18n.subscribe(() => seq.push('A')), uB = i18n.subscribe(() => seq.push('B')), uC = i18n.subscribe(() => seq.push('C'));
i18n.setLocale('zh');
out.t02 = {{ seq, ok: seq.join('') === 'ABC' }};
uA(); uB(); uC();
let bExecuted = false;
const errSub = i18n.subscribe(() => {{ throw new Error('sub_error'); }});
const safeSub = i18n.subscribe(() => {{ bExecuted = true; }});
i18n.setLocale('ja');
out.t03 = {{ bExecuted, ok: bExecuted === true }};
errSub(); safeSub();
let unsubCalled = false;
const testUnsub = i18n.subscribe(() => {{ unsubCalled = true; }});
testUnsub();
unsubCalled = false;
i18n.setLocale('ko');
out.t04 = {{ unsubCalled, ok: unsubCalled === false }};
let customEvtDetail = null;
window.addEventListener('freeexile:localeChanged', (e) => {{ customEvtDetail = e.detail; }});
i18n.setLocale('th');
out.t05 = {{ detailLocale: customEvtDetail?.locale, ok: customEvtDetail?.locale === 'th' }};

// Group 2: Fallback Chaining & Interpolation
i18n.setLocale('en');
out.t06 = {{ btn_send: i18n.t('chat_btn_send'), ok: i18n.t('chat_btn_send') === 'Send' }};
i18n.registerCatalog({{ ja: {{}}, en: {{ test_fallback_key: 'EnVal' }}, vi: {{ test_fallback_key: 'ViVal' }} }});
i18n.setLocale('ja');
out.t07 = {{ val: i18n.t('test_fallback_key'), ok: i18n.t('test_fallback_key') === 'EnVal' }};
i18n.registerCatalog({{ ja: {{}}, en: {{}}, vi: {{ test_vi_only: 'ViOnlyVal' }} }});
out.t08 = {{ val: i18n.t('test_vi_only'), ok: i18n.t('test_vi_only') === 'ViOnlyVal' }};
out.t09 = {{ ok: i18n.t('nonexistent_key_123') === 'nonexistent_key_123' && i18n.t('nonexistent_key_123', {{}}, 'CustomFallback') === 'CustomFallback' }};
i18n.setLocale('en');
const pEn = i18n.t('chat_cooldown_wait', {{ sec: 5 }});
i18n.setLocale('vi');
const pVi = i18n.t('chat_cooldown_wait', {{ sec: 5 }});
out.t10 = {{ pEn, pVi, ok: pEn === 'Wait 5s' && pVi === 'Chờ 5s' }};

// Group 4: Chat UI Reactive DOM
out.t16 = {{ hasListeners: i18n.listeners.length > 0, ok: i18n.listeners.length > 0 }};
i18n.setLocale('en');
out.t17 = {{ tab1: tabs[0].textContent, tab2: tabs[1].textContent, tab3: tabs[2].textContent, ok: tabs[0].textContent === 'World' && tabs[1].textContent === 'Zone' && tabs[2].textContent === 'Guild' }};
out.t18 = {{ placeholder: getEl('chat-input').placeholder, ok: getEl('chat-input').placeholder.includes('World') }};
out.t19 = {{ btnSend: getEl('chat-send-btn').textContent, ok: getEl('chat-send-btn').textContent === 'Send' }};
chatUI.addChatMessage({{ channel: 1, senderName: 'Hero', displayContent: 'Msg 1' }});
chatUI.addChatMessage({{ channel: 1, senderName: 'Hero', displayContent: 'Msg 2' }});
const histBefore = chatUI.getChannelHistory(1).length;
i18n.setLocale('zh'); i18n.setLocale('ja'); i18n.setLocale('vi');
const histAfter = chatUI.getChannelHistory(1).length;
out.t20 = {{ ok: histBefore === 2 && histAfter === 2 }};

// Group 5: Edge Cases
const curBefore = i18n.getLocale();
i18n.setLocale(null); i18n.setLocale('');
out.t21 = {{ ok: i18n.getLocale() === curBefore }};
i18n.setLocale('ja');
out.t22 = {{ stored: storage['freeexile_locale'], ok: storage['freeexile_locale'] === 'ja' }};
out.t23 = {{ ok: i18n.t('') === '' && i18n.t(null, {{}}, 'FallbackVal') === 'FallbackVal' }};

console.log(JSON.stringify(out));
"""

    res = subprocess.run(
        ["node", "--input-type=module", "-e", node_script],
        capture_output=True,
        text=True,
        encoding="utf-8",
        cwd=str(PROJECT_ROOT),
    )
    if res.returncode != 0:
        raise RuntimeError(f"Node execution failed (code {res.returncode}):\n{res.stderr}\n{res.stdout}")
    for line in res.stdout.strip().split("\n"):
        line = line.strip()
        if line.startswith("{") and line.endswith("}"):
            return json.loads(line)
    raise ValueError(f"Could not parse JSON output from Node:\n{res.stdout}")


class TestI18nEventBusAndReactivity(unittest.TestCase):
    """23 comprehensive tests verifying the i18n event bus, catalogs, and chat reactivity."""

    results: Dict[str, Any] = {}
    chat_keys: Dict[str, Set[str]] = {}
    i18n_keys: Dict[str, Set[str]] = {}

    @classmethod
    def setUpClass(cls) -> None:
        cls.results = execute_node_test_harness()
        cls.chat_keys = parse_catalog_file(CHAT_CATALOG_JS)
        cls.i18n_keys = parse_catalog_file(I18N_CATALOG_JS)

    # --- Group 1: Observer / Event Bus Mechanics (Tests 1-5) ---
    def test_01_listener_registration_and_callback(self) -> None:
        self.assertTrue(self.results["t01"]["ok"], "onLocaleChanged callback must receive new locale")
        self.assertEqual(self.results["t01"]["received"]["loc"], "en")

    def test_02_multiple_listeners_isolated(self) -> None:
        self.assertTrue(self.results["t02"]["ok"], "Multiple subscribers must execute in sequence")
        self.assertEqual(self.results["t02"]["seq"], ["A", "B", "C"])

    def test_03_listener_exception_resilience(self) -> None:
        self.assertTrue(self.results["t03"]["ok"], "Subsequent subscribers must run if earlier throws")

    def test_04_listener_unsubscription(self) -> None:
        self.assertTrue(self.results["t04"]["ok"], "Unsubscribed callback must not be invoked on locale change")

    def test_05_custom_event_window_dispatch(self) -> None:
        self.assertTrue(self.results["t05"]["ok"], "window freeexile:localeChanged CustomEvent must fire")
        self.assertEqual(self.results["t05"]["detailLocale"], "th")

    # --- Group 2: Fallback Chaining & Parameter Interpolation (Tests 6-10) ---
    def test_06_exact_locale_lookup(self) -> None:
        self.assertTrue(self.results["t06"]["ok"], "Exact lookup for chat_btn_send in EN must return 'Send'")
        self.assertEqual(self.results["t06"]["btn_send"], "Send")

    def test_07_fallback_chain_current_to_en(self) -> None:
        self.assertTrue(self.results["t07"]["ok"], "Missing key in JA must fall back to EN")
        self.assertEqual(self.results["t07"]["val"], "EnVal")

    def test_08_fallback_chain_en_to_vi(self) -> None:
        self.assertTrue(self.results["t08"]["ok"], "Missing key in JA and EN must fall back to VI")
        self.assertEqual(self.results["t08"]["val"], "ViOnlyVal")

    def test_09_fallback_to_raw_key(self) -> None:
        self.assertTrue(self.results["t09"]["ok"], "Non-existent key must return key or fallback string")

    def test_10_parameter_substitution(self) -> None:
        self.assertTrue(self.results["t10"]["ok"], "Parameters like {sec} must interpolate correctly")
        self.assertEqual(self.results["t10"]["pEn"], "Wait 5s")
        self.assertEqual(self.results["t10"]["pVi"], "Chờ 5s")

    # --- Group 3: 9-Language Dictionary Parity & Key Presence (Tests 11-15) ---
    def test_11_all_9_locales_present(self) -> None:
        for loc in ALL_9_LOCALES:
            self.assertIn(loc, self.chat_keys, f"chat_i18n_catalog missing locale {loc}")
            self.assertIn(loc, self.i18n_keys, f"i18n_catalog missing locale {loc}")

    def test_12_vi_en_dictionary_parity(self) -> None:
        self.assertEqual(self.chat_keys["vi"], self.chat_keys["en"], "Chat catalog VI-EN keys must match")
        self.assertEqual(self.i18n_keys["vi"], self.i18n_keys["en"], "i18n catalog VI-EN keys must match")

    def test_13_all_8_channel_keys_present(self) -> None:
        for i in range(1, 9):
            k = f"chat_channel_{i}"
            for loc in ALL_9_LOCALES:
                self.assertIn(k, self.chat_keys[loc], f"Missing {k} in locale {loc}")

    def test_14_chat_permission_keys_present(self) -> None:
        required_perm_keys = [
            "chat_perm_system_only", "chat_perm_guild_required",
            "chat_perm_party_required", "chat_perm_level_world",
            "chat_perm_level_recruit", "chat_perm_muted"
        ]
        for k in required_perm_keys:
            for loc in ALL_9_LOCALES:
                self.assertIn(k, self.chat_keys[loc], f"Permission key {k} missing in locale {loc}")

    def test_15_chat_rarity_and_element_keys_present(self) -> None:
        for r in range(1, 6):
            for loc in ALL_9_LOCALES:
                self.assertIn(f"rarity_{r}", self.chat_keys[loc])
        for elem in ["metal", "wood", "water", "fire", "earth"]:
            for loc in ALL_9_LOCALES:
                self.assertIn(f"element_{elem}", self.chat_keys[loc])

    # --- Group 4: Chat UI Reactive DOM Integration Without Reload (Tests 16-20) ---
    def test_16_chat_ui_subscribes_to_i18n_bus(self) -> None:
        self.assertTrue(self.results["t16"]["ok"], "Chat UI must attach listener to i18n event bus")

    def test_17_language_change_updates_all_channel_tabs(self) -> None:
        self.assertTrue(self.results["t17"]["ok"], "Channel tabs must dynamically update without reload")
        self.assertEqual(self.results["t17"]["tab1"], "World")
        self.assertEqual(self.results["t17"]["tab2"], "Zone")
        self.assertEqual(self.results["t17"]["tab3"], "Guild")

    def test_18_language_change_updates_placeholder_and_cooldown(self) -> None:
        self.assertTrue(self.results["t18"]["ok"], "Placeholder must update dynamically on locale change")

    def test_19_language_change_updates_send_button(self) -> None:
        self.assertTrue(self.results["t19"]["ok"], "Send button must update text content dynamically")
        self.assertEqual(self.results["t19"]["btnSend"], "Send")

    def test_20_language_change_preserves_chat_history(self) -> None:
        self.assertTrue(self.results["t20"]["ok"], "Chat ring buffer history must remain intact")

    # --- Group 5: Edge Cases, Null/Empty Keys, Invalid Locales, Persistence (Tests 21-23) ---
    def test_21_invalid_locale_handled_gracefully(self) -> None:
        self.assertTrue(self.results["t21"]["ok"], "Null or empty locale must be handled gracefully")

    def test_22_localstorage_persistence(self) -> None:
        self.assertTrue(self.results["t22"]["ok"], "Locale change must persist to localStorage")
        self.assertEqual(self.results["t22"]["stored"], "ja")

    def test_23_null_or_empty_key_resilience(self) -> None:
        self.assertTrue(self.results["t23"]["ok"], "Empty or null keys must return fallback without throwing")


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