"""
Unit tests for FreeExile 6-Player Party Engine & Scaling System (Milestone 2).
Covers PartyService lifecycle, PartyScalingCalculator formulas, LootAllocationMode on GroundLootDrop,
LootDropService round-robin allocation, and MonsterScalingEngine integration.
Follows Engineering Standards 2026: Soft cap <= 350 lines, strictly typed, deterministic.
"""

from __future__ import annotations
import math
import os
import sys
import time
import pytest

_server_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../server"))
_root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
if _server_dir not in sys.path:
    sys.path.insert(0, _server_dir)
if _root_dir not in sys.path:
    sys.path.insert(0, _root_dir)

from party.party_types import (
    LootAllocationMode,
    PartyAction,
    PartyMemberState,
    PartyRole,
    PartyState,
)
from party.party_scaling_calculator import PartyScalingCalculator
from party.party_service import PartyService
from inventory.inventory_types import InventoryItem, ItemRarity, ItemType
from inventory.loot_filter_types import GroundLootDrop, LootFilterConfig
from inventory.loot_drop_service import LootDropService
from combat.combat_types import CombatantType
from combat.monster_scaling import MonsterScalingEngine
from world.game_design_matrix_service import GameDesignMatrixService


class TestPartyLifecycleUnit:
    """Tests 6-player party lifecycle in PartyService."""

    def test_create_party_and_double_create_prevention(self) -> None:
        svc = PartyService()
        p = svc.create_party("p1", "LeaderOne", 35, "zone_sanctuary", "inst_1")
        assert p.leader_id == "p1"
        assert len(p.members) == 1
        assert p.members["p1"].role == PartyRole.LEADER
        assert p.loot_mode == LootAllocationMode.SHORT

        with pytest.raises(ValueError, match="already in a party"):
            svc.create_party("p1", "LeaderOne", 35)

    def test_invite_accept_and_max_capacity_boundary(self) -> None:
        svc = PartyService()
        p = svc.create_party("lead", "Leader", 40)
        for i in range(2, 7):
            assert svc.invite_player(p.party_id, "lead", f"m{i}") is True
            assert svc.accept_invite(p.party_id, f"m{i}", f"Member{i}", 38) is True
        assert len(p.members) == 6

        # 7th member invite must be rejected (max 6)
        assert svc.invite_player(p.party_id, "lead", "m7") is False
        assert len(p.members) == 6

    def test_decline_invite_and_expiration(self) -> None:
        svc = PartyService()
        p = svc.create_party("lead", "Leader", 40)
        assert svc.invite_player(p.party_id, "lead", "guest") is True
        assert svc.decline_invite(p.party_id, "guest") is True
        assert svc.accept_invite(p.party_id, "guest", "Guest", 30) is False

    def test_promote_leader_and_non_leader_rejection(self) -> None:
        svc = PartyService()
        p = svc.create_party("lead", "Leader", 40)
        svc.invite_player(p.party_id, "lead", "sub")
        svc.accept_invite(p.party_id, "sub", "SubLeader", 42)

        # Non-leader cannot promote
        assert svc.promote_leader(p.party_id, "sub", "lead") is False
        # Leader promotes sub
        assert svc.promote_leader(p.party_id, "lead", "sub") is True
        assert p.leader_id == "sub"
        assert p.members["sub"].role == PartyRole.LEADER
        assert p.members["lead"].role == PartyRole.MEMBER

    def test_leave_party_auto_promotion_and_disband(self) -> None:
        svc = PartyService()
        p = svc.create_party("lead", "Leader", 40)
        svc.invite_player(p.party_id, "lead", "m2")
        svc.accept_invite(p.party_id, "m2", "Member2", 40)

        # Leader leaves -> auto-promotes m2
        rem = svc.leave_party("lead")
        assert rem is not None
        assert rem.leader_id == "m2"
        assert rem.members["m2"].role == PartyRole.LEADER

        # m2 leaves -> party disbands
        dis = svc.leave_party("m2")
        assert dis is None
        assert p.party_id not in svc.parties

    def test_kick_member_and_disband_party(self) -> None:
        svc = PartyService()
        p = svc.create_party("lead", "Leader", 40)
        svc.invite_player(p.party_id, "lead", "bad_actor")
        svc.accept_invite(p.party_id, "bad_actor", "Troll", 10)

        # Leader cannot kick self
        assert svc.kick_member(p.party_id, "lead", "lead") is False
        # Kick member
        assert svc.kick_member(p.party_id, "lead", "bad_actor") is True
        assert "bad_actor" not in p.members

        # Disband party
        assert svc.disband_party(p.party_id, "lead") is True
        assert p.party_id not in svc.parties

    def test_set_loot_allocation_mode_leader_only(self) -> None:
        svc = PartyService()
        p = svc.create_party("lead", "Leader", 50)
        svc.invite_player(p.party_id, "lead", "m2")
        svc.accept_invite(p.party_id, "m2", "M2", 50)

        assert svc.set_loot_allocation_mode(p.party_id, "m2", LootAllocationMode.PERMANENT) is False
        assert svc.set_loot_allocation_mode(p.party_id, "lead", LootAllocationMode.PERMANENT) is True
        assert p.loot_mode == LootAllocationMode.PERMANENT


class TestPartyScalingCalculatorUnit:
    """Tests PoE2 mathematical formulas in PartyScalingCalculator."""

    def test_nearby_spatial_membership_and_filtering(self) -> None:
        m1 = PartyMemberState("p1", "A", 50, "zone_wild", "inst_1", wx=0.0, wy=0.0)
        m2 = PartyMemberState("p2", "B", 50, "zone_wild", "inst_1", wx=10.0, wy=11.18)  # hypot = ~15.0m
        m3 = PartyMemberState("p3", "C", 50, "zone_wild", "inst_1", wx=15.01, wy=0.0)    # > 15.0m
        m4 = PartyMemberState("p4", "D", 50, "zone_wild", "inst_2", wx=0.0, wy=0.0)      # diff instance
        m5 = PartyMemberState("p5", "E", 50, "zone_town", "inst_1", wx=0.0, wy=0.0)      # diff zone
        m6 = PartyMemberState("p6", "F", 50, "zone_wild", "inst_1", wx=0.0, wy=0.0, is_online=False) # offline

        members = [m1, m2, m3, m4, m5, m6]
        nearby = PartyScalingCalculator.calculate_nearby_members(members, 0.0, 0.0, "zone_wild", "inst_1", radius=15.0)
        assert len(nearby) == 2
        pids = [m.player_id for m in nearby]
        assert "p1" in pids and "p2" in pids

    def test_exp_scaling_pool_and_level_weighting(self) -> None:
        m1 = PartyMemberState("low", "Low", 20, "z1", "i1")
        m2 = PartyMemberState("high", "High", 80, "z1", "i1")
        res = PartyScalingCalculator.calculate_party_exp(10000, [m1, m2])
        # Pool = 10000 * 1.30 = 13000
        assert sum(res.values()) <= 13000
        assert res["high"] > res["low"] * 10

    def test_exp_scaling_level_gap_decay(self) -> None:
        m = [PartyMemberState("p1", "Solo", 20, "z1", "i1")]
        # Monster level 50 -> gap = 20 - 50 = -30 -> severe penalty
        res = PartyScalingCalculator.calculate_party_exp(1000, m, monster_level=50)
        assert res["p1"] < 100

    def test_loot_multipliers_linear_growth(self) -> None:
        for n in range(1, 7):
            item_q, curr_q, rar = PartyScalingCalculator.calculate_loot_multipliers(n)
            assert math.isclose(item_q, 1.0 + 0.50 * (n - 1))
            assert math.isclose(curr_q, 1.0 + 0.50 * (n - 1))
            assert math.isclose(rar, 1.0 + 0.30 * (n - 1))

    def test_monster_scaling_hp_armor_and_poise(self) -> None:
        c = PartyScalingCalculator.calculate_monster_scaling("COMMON", party_size=6)
        r = PartyScalingCalculator.calculate_monster_scaling("RARE", party_size=6)
        b = PartyScalingCalculator.calculate_monster_scaling("BOSS", party_size=6)
        assert math.isclose(c["hp_mult"], 3.50)
        assert math.isclose(r["hp_mult"], 4.50)
        assert math.isclose(b["hp_mult"], 6.00)
        assert math.isclose(b["armor_mult"], 1.50)
        assert math.isclose(b["res_bonus"], 10.0)

        # Base HP scaling calculation
        s = PartyScalingCalculator.calculate_monster_scaling(base_hp=10000, rank="BOSS", party_count=6)
        assert s["scaled_hp"] == 60000
        assert s["poise_max"] == 9000.0


class TestLootAllocationAndDropServiceUnit:
    """Tests GroundLootDrop allocation rules and LootDropService integration."""

    def _make_item(self) -> InventoryItem:
        return InventoryItem(
            item_uuid="item_test_1",
            item_id="wpn_sword_1",
            name="Thần Kiếm",
            item_type=ItemType.WEAPON,
            rarity=ItemRarity.CUC_PHAM,
            item_level=75,
            width=1,
            height=3,
        )

    def test_permanent_allocation_pickup_permission(self) -> None:
        drop = GroundLootDrop(
            drop_id="d1",
            item=self._make_item(),
            world_x=0.0,
            world_y=0.0,
            beam_color="#fbbf24",
            beam_height=120,
            allocated_player_id="p1",
            allocation_mode=LootAllocationMode.PERMANENT,
        )
        assert drop.can_player_pickup("p1", current_time=100.0) is True
        assert drop.can_player_pickup("p2", current_time=9999.0) is False

    def test_short_allocation_5s_timer_transition(self) -> None:
        t0 = 100.0
        drop = GroundLootDrop(
            drop_id="d2",
            item=self._make_item(),
            world_x=0.0,
            world_y=0.0,
            beam_color="#fbbf24",
            beam_height=120,
            allocated_player_id="p1",
            allocation_mode=LootAllocationMode.SHORT,
            allocation_expires_at=t0 + 5.0,
            spawned_at=t0,
        )
        assert drop.can_player_pickup("p1", current_time=t0 + 1.0) is True
        assert drop.can_player_pickup("p2", current_time=t0 + 4.99) is False
        assert drop.can_player_pickup("p2", current_time=t0 + 5.00) is True

    def test_loot_drop_service_round_robin_assignment(self) -> None:
        svc = LootDropService()
        party = ["hero_1", "hero_2", "hero_3"]
        drops = svc.generate_monster_drops(
            monster_id="mon_boss_1",
            monster_level=80,
            is_boss=True,
            drop_pos=(10.0, 10.0),
            allocation_mode=LootAllocationMode.SHORT,
            party_members=party,
            item_quantity_mult=2.0,
        )
        assert len(drops) >= 3
        # Check round-robin assignment
        assigned = [d.allocated_player_id for d in drops]
        assert assigned[0] == "hero_1"
        assert assigned[1] == "hero_2"
        assert assigned[2] == "hero_3"
        for d in drops:
            assert d.allocation_mode == "SHORT"
            assert d.allocation_expires_at > 0.0


class TestMonsterScalingAndMatrixServiceUnit:
    """Tests MonsterScalingEngine party scaling and GameDesignMatrixService integrity."""

    def test_monster_scaling_engine_party_defense(self) -> None:
        engine = MonsterScalingEngine()
        solo_def = engine.create_monster_defense(level=50, rank=CombatantType.BOSS, party_size=1)
        party_def = engine.create_monster_defense(level=50, rank=CombatantType.BOSS, party_size=6)
        # 6-player boss HP is 6.0x solo boss HP
        assert math.isclose(party_def.max_life, solo_def.max_life * 6.0, rel_tol=1e-3)
        assert math.isclose(party_def.armour, solo_def.armour * 1.5, rel_tol=1e-3)

    def test_game_design_matrix_service_verification_alias(self) -> None:
        matrix_svc = GameDesignMatrixService()
        report = matrix_svc.verify_matrix_integrity()
        assert report.is_valid is True
        assert len(report.violations) == 0
        matrix_svc.close()
