"""
Unit Test Suite for Client WebApp UI Modules (Milestone 5)
Empirical verification of PartyUI, SocialUI, TeleportHUD, TownPortalController,
and 9-Language i18n Catalog Parity. Soft Cap <= 350 lines.
"""

from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent


def run_node_harness(script_body: str) -> dict:
    """Runs a simulated DOM test harness in Node.js."""
    bootstrap = f"""
import fs from 'fs';

function setupDOM() {{
  const elMap = {{}}, winListeners = {{}}, docListeners = {{}};
  class HTMLElement {{}}
  class Image {{ constructor() {{ this.src = ''; }} }}
  class CustomEvent {{ constructor(t, i = {{}}) {{ this.type = t; this.detail = i.detail || {{}}; }} }}
  function makeEl(id, tag = 'DIV') {{
    const classes = new Set(), handlers = {{}}, children = [];
    let _html = '';
    return {{
      id, tagName: tag, children, dataset: {{}}, style: {{ width: '0%' }}, value: '',
      classList: {{ add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) }},
      get innerHTML() {{ return _html; }}, set innerHTML(val) {{ _html = val; children.length = 0; }},
      addEventListener: (t, fn) => {{ (handlers[t] = handlers[t] || []).push(fn); }},
      removeEventListener: (t, fn) => {{ if (handlers[t]) handlers[t] = handlers[t].filter(f => f !== fn); }},
      dispatchEvent: function(evt) {{ evt.target = evt.target || this; evt.currentTarget = this; (handlers[evt.type] || []).forEach(fn => fn(evt)); }},
      querySelector: (sel) => makeEl(id + '_' + sel), querySelectorAll: (sel) => [makeEl(id + '_1'), makeEl(id + '_2')],
      appendChild: (c) => {{ children.push(c); return c; }}
    }};
  }}
  const win = {{
    addEventListener: (t, fn) => {{ (winListeners[t] = winListeners[t] || []).push(fn); }},
    removeEventListener: (t, fn) => {{ if (winListeners[t]) winListeners[t] = winListeners[t].filter(f => f !== fn); }},
    dispatchEvent: (evt) => {{ (winListeners[evt.type] || []).forEach(fn => fn(evt)); return true; }},
    CustomEvent, HTMLElement, Image, FreeExileI18n: null
  }};
  const doc = {{
    getElementById: (id) => elMap[id] || (elMap[id] = makeEl(id)),
    querySelector: (sel) => elMap[sel.replace('#', '')] || makeEl(sel),
    querySelectorAll: () => [], createElement: (tag) => makeEl('auto_' + Math.random(), tag),
    addEventListener: (t, fn) => {{ (docListeners[t] = docListeners[t] || []).push(fn); }},
    removeEventListener: (t, fn) => {{ if (docListeners[t]) docListeners[t] = docListeners[t].filter(f => f !== fn); }},
    dispatchEvent: (evt) => {{ (docListeners[evt.type] || []).forEach(fn => fn(evt)); return true; }},
    body: makeEl('body')
  }};
  global.window = win; global.document = doc; global.CustomEvent = CustomEvent;
  global.HTMLElement = HTMLElement; global.Image = Image;
  global.requestAnimationFrame = (fn) => 1; global.cancelAnimationFrame = () => {{}};
  return {{ elMap, win, doc, winListeners }};
}}

(async () => {{
  try {{
    const env = setupDOM();
    {script_body}
  }} catch (err) {{
    console.error(err);
    process.exit(1);
  }}
}})();
"""
    res = subprocess.run(
        ["node", "--input-type=module", "-e", bootstrap],
        capture_output=True, text=True, cwd=str(PROJECT_ROOT)
    )
    if res.returncode != 0:
        raise RuntimeError(f"Node execution failed:\n{res.stderr}\n{res.stdout}")
    return json.loads(res.stdout.strip())


class TestClientPartyUI:
    def test_party_ui_lifecycle_and_leader_controls(self) -> None:
        script = """
        const { PartyUI } = await import('./client/webapp/js/ui/party_ui.js');
        const partyUI = new PartyUI({ currentUserId: 'p1' });
        partyUI.init();
        const partyState = {
          party_id: 'party_101', leader_id: 'p1', loot_allocation_mode: 'SHORT',
          members: [
            { player_id: 'p1', character_name: 'HeroOne', level: 50, hp: 100, max_hp: 100, mana: 50, max_mana: 50, role: 'LEADER', is_online: true },
            { player_id: 'p2', character_name: 'BuddyTwo', level: 48, hp: 80, max_hp: 100, mana: 40, max_mana: 50, role: 'MEMBER', is_online: true },
            { player_id: 'p3', character_name: 'MageThree', level: 45, hp: 60, max_hp: 100, mana: 50, max_mana: 50, role: 'MEMBER', is_online: false }
          ]
        };
        partyUI.updateParty(partyState);
        const isLeader = partyUI.isLeader();
        const html = partyUI.container.innerHTML;
        let actionDispatched = null;
        partyUI.onActionCallback = (action, payload) => { actionDispatched = { action, payload }; };
        partyUI.setLootMode('PERMANENT');
        const lootAction = actionDispatched;
        partyUI.kickMember('p3');
        const kickAction = actionDispatched;
        console.log(JSON.stringify({
          isLeader, hasPartyTitle: html.includes('👥'), hasLootBadge: html.includes('party-loot-badge'),
          hasFramesList: html.includes('party-frames-list'), hasLeaderControls: html.includes('party-leader-controls'),
          lootAction, kickAction
        }));
        """
        out = run_node_harness(script)
        assert out["isLeader"] is True
        assert out["hasPartyTitle"] is True
        assert out["hasLootBadge"] is True
        assert out["hasFramesList"] is True
        assert out["hasLeaderControls"] is True
        assert out["lootAction"]["action"] == "set_loot_mode"
        assert out["kickAction"]["payload"]["member_id"] == "p3"

    def test_party_ui_member_view_lacks_leader_controls(self) -> None:
        script = """
        const { PartyUI } = await import('./client/webapp/js/ui/party_ui.js');
        const partyUI = new PartyUI({ currentUserId: 'p2' });
        partyUI.init();
        const partyState = {
          party_id: 'party_102', leader_id: 'p1', loot_allocation_mode: 'FREE_FOR_ALL',
          members: [
            { player_id: 'p1', character_name: 'Leader', role: 'LEADER' },
            { player_id: 'p2', character_name: 'MemberMe', role: 'MEMBER' }
          ]
        };
        partyUI.updateParty(partyState);
        console.log(JSON.stringify({
          isLeader: partyUI.isLeader(),
          hasLeaderControls: partyUI.container.innerHTML.includes('party-leader-controls'),
          hasMemberControls: partyUI.container.innerHTML.includes('party-member-controls')
        }));
        """
        out = run_node_harness(script)
        assert out["isLeader"] is False
        assert out["hasLeaderControls"] is False
        assert out["hasMemberControls"] is True


class TestClientSocialUI:
    def test_social_ui_tabs_and_presence_tracking(self) -> None:
        script = """
        const { SocialUI } = await import('./client/webapp/js/ui/social_ui.js');
        const socialUI = new SocialUI({ currentUserId: 'p1' });
        socialUI.init();
        socialUI.open();
        const friends = [
          { player_id: 'f1', character_name: 'Alice', level: 60, is_online: true, zone_name: 'Tàng Kiếm Nhai', note: 'Best Archer' },
          { player_id: 'f2', character_name: 'Bob', level: 30, is_online: false, zone_name: 'Doanh Trại' }
        ];
        socialUI.updatePresence(friends);
        const friendsHtml = socialUI.container.innerHTML;
        socialUI.switchTab('guild');
        const guildMembers = [
          { player_id: 'g1', character_name: 'GuildMaster', rank: 'LEADER', level: 85, is_online: true, zone_name: 'Sào Huyệt' }
        ];
        socialUI.updateGuildRoster(guildMembers);
        const guildHtml = socialUI.container.innerHTML;
        console.log(JSON.stringify({
          isOpen: socialUI.isOpen, activeTab: socialUI.activeTab,
          hasAlice: friendsHtml.includes('Alice'), hasNote: friendsHtml.includes('Best Archer'),
          hasGuildMaster: guildHtml.includes('GuildMaster')
        }));
        """
        out = run_node_harness(script)
        assert out["isOpen"] is True
        assert out["activeTab"] == "guild"
        assert out["hasAlice"] is True
        assert out["hasNote"] is True
        assert out["hasGuildMaster"] is True

    def test_social_ui_friend_actions_and_teleport_dispatch(self) -> None:
        script = """
        const { SocialUI } = await import('./client/webapp/js/ui/social_ui.js');
        const socialUI = new SocialUI({ currentUserId: 'p1' });
        socialUI.init();
        let tpDetail = null;
        window.addEventListener('freeexile:teleportRequest', (e) => { tpDetail = e.detail; });
        socialUI.updatePresence([{ player_id: 'f1', character_name: 'Alice', level: 60, is_online: true }]);
        socialUI.teleportTo('f1', 'FRIEND');
        socialUI.sendFriendRequest('charlie');
        socialUI.saveFriendNote('f1', 'Updated Note');
        const f1Note = socialUI.friends.find(f => f.player_id === 'f1').note;
        console.log(JSON.stringify({ tpDetail, f1Note }));
        """
        out = run_node_harness(script)
        assert out["tpDetail"]["targetId"] == "f1"
        assert out["tpDetail"]["targetType"] == "FRIEND"
        assert out["f1Note"] == "Updated Note"


class TestClientTeleportHUD:
    def test_teleport_channel_and_subthreshold_tolerance(self) -> None:
        script = """
        const { TeleportHUD } = await import('./client/webapp/js/ui/teleport_hud.js');
        const hud = new TeleportHUD({ durationMs: 3500, movementThreshold: 0.05 });
        hud.init();
        hud.startChannel({ targetId: 'p2', targetName: 'Buddy' });
        const startActive = hud.isChanneling;
        // Sub-threshold movement (0.04 <= 0.05) should NOT cancel
        const moveIgnored = hud.onPlayerMoved(0.04);
        const stillChanneling = hud.isChanneling;
        // Movement over threshold (0.06 > 0.05) cancels immediately
        const moveCancelled = hud.onPlayerMoved(0.06);
        const cancelledState = hud.isChanneling;
        const msg = hud.statusMessage;
        console.log(JSON.stringify({
          startActive, moveIgnored, stillChanneling, moveCancelled, cancelledState,
          hasInterruptMsg: msg.length > 0
        }));
        """
        out = run_node_harness(script)
        assert out["startActive"] is True
        assert out["moveIgnored"] is False
        assert out["stillChanneling"] is True
        assert out["moveCancelled"] is True
        assert out["cancelledState"] is False
        assert out["hasInterruptMsg"] is True

    def test_teleport_damage_and_manual_cancel(self) -> None:
        script = """
        const { TeleportHUD } = await import('./client/webapp/js/ui/teleport_hud.js');
        const hud = new TeleportHUD({ durationMs: 3500 });
        hud.init();
        hud.startChannel({ targetName: 'Hideout' });
        hud.onPlayerDamaged(25);
        const damagedState = hud.isChanneling;
        hud.startChannel({ targetName: 'Friend' });
        hud.cancelManual();
        const manualState = hud.isChanneling;
        console.log(JSON.stringify({ damagedState, manualState }));
        """
        out = run_node_harness(script)
        assert out["damagedState"] is False
        assert out["manualState"] is False


class TestClientTownPortalController:
    def test_portal_2way_creation_and_traversal(self) -> None:
        script = """
        const { TownPortalController } = await import('./client/webapp/js/engine/town_portal_controller.js');
        const controller = new TownPortalController();
        controller.init();
        const createRes = controller.requestTownPortal('hero', 'zone_tang_kiem_nhai', 'inst_1', 120.5, 340.5);
        const pId = createRes.portalId;
        const portal = controller.portals.get(pId);
        const trav1 = controller.traversePortal(pId, { id: 'hero', zoneId: 'zone_tang_kiem_nhai' });
        const trav2 = controller.traversePortal(pId, { id: 'hero', zoneId: 'zone_player_hideout' });
        console.log(JSON.stringify({
          success: createRes.success, originZone: portal.originZone, originX: portal.originX,
          hubZone: portal.hubZone, trav1TargetZone: trav1.targetZone,
          trav2TargetZone: trav2.targetZone, trav2TargetX: trav2.targetX
        }));
        """
        out = run_node_harness(script)
        assert out["success"] is True
        assert out["originZone"] == "zone_tang_kiem_nhai"
        assert out["originX"] == 120.5
        assert out["trav1TargetZone"] == "zone_player_hideout"
        assert out["trav2TargetZone"] == "zone_tang_kiem_nhai"
        assert out["trav2TargetX"] == 120.5

    def test_portal_anti_exploit_quarantine_lockouts(self) -> None:
        script = """
        const { TownPortalController } = await import('./client/webapp/js/engine/town_portal_controller.js');
        const controller = new TownPortalController();
        controller.init();
        controller.setQuarantineState('zone_boss', 'inst_b1', { bossActive: true });
        const bossRes = controller.requestTownPortal('hero', 'zone_boss', 'inst_b1');
        controller.setQuarantineState('zone_puzzle', 'inst_s1', { secretActive: true });
        const secretRes = controller.requestTownPortal('hero', 'zone_puzzle', 'inst_s1');
        controller.registerMapDevice('map_empty', 0);
        const mapRes = controller.requestTownPortal('hero', 'zone_wild', 'map_empty');
        console.log(JSON.stringify({
          bossAllowed: bossRes.success, bossReason: bossRes.reason,
          secretAllowed: secretRes.success, secretReason: secretRes.reason,
          mapAllowed: mapRes.success, mapReason: mapRes.reason
        }));
        """
        out = run_node_harness(script)
        assert out["bossAllowed"] is False
        assert out["bossReason"] == "QUARANTINE_BOSS_ENGAGED"
        assert out["secretAllowed"] is False
        assert out["secretReason"] == "QUARANTINE_SECRET_CHAMBER"
        assert out["mapAllowed"] is False
        assert out["mapReason"] == "MAP_SEALED"


class TestClientI18nCatalogParity:
    def test_9_language_parity_across_all_keys(self) -> None:
        script = """
        const { PARTY_SOCIAL_I18N_CATALOG } = await import('./client/webapp/js/data/i18n.js');
        const locales = ['vi', 'en', 'zh', 'ja', 'ko', 'th', 'de', 'ru', 'es'];
        const viKeys = Object.keys(PARTY_SOCIAL_I18N_CATALOG.vi || {}).sort();
        const parity = {};
        for (const loc of locales) {
          const locKeys = Object.keys(PARTY_SOCIAL_I18N_CATALOG[loc] || {}).sort();
          const missing = viKeys.filter(k => !locKeys.includes(k));
          const extra = locKeys.filter(k => !viKeys.includes(k));
          parity[loc] = { keyCount: locKeys.length, missing, extra };
        }
        console.log(JSON.stringify({ viKeyCount: viKeys.length, parity }));
        """
        out = run_node_harness(script)
        total_keys = out["viKeyCount"]
        assert total_keys >= 40
        for loc, info in out["parity"].items():
            assert info["keyCount"] == total_keys, f"Locale {loc} key count mismatch"
            assert len(info["missing"]) == 0, f"Locale {loc} missing keys: {info['missing']}"
            assert len(info["extra"]) == 0, f"Locale {loc} extra keys: {info['extra']}"
