"""Tests for TES3MP server hardening — multi-player stability & anti-grief.

Covers:
- MultiClientStressRunner (Phase 8: 6+ concurrent clients)
- QuestArbiter (conflict resolution)
- AntiGriefPolicy (rate limiting, blocked actions)
- RecoveryManager (snapshot / restore)
- WorldStateBackup (create / restore / rotate)
- ResourceMonitor (sampling, peak, summary)
"""

from __future__ import annotations

import pytest

from infrastructure.world.adapters.mock import MockWorldAdapter
from infrastructure.world.benchmark.scenarios import BenchmarkScenario
from infrastructure.world.hardening.anti_grief import AntiGriefPolicy
from infrastructure.world.hardening.backup import BackupRecord, WorldStateBackup
from infrastructure.world.hardening.monitor import ResourceMonitor, ResourceSnapshot
from infrastructure.world.hardening.quest_arbiter import (
    QuestArbiter,
    QuestStage,
)
from infrastructure.world.hardening.recovery import RecoveryManager, WorldSnapshot
from infrastructure.world.hardening.stress import (
    MultiClientStressRunner,
    StressTestReport,
)
from infrastructure.world.types import CommandInput

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_SIMPLE_SCENARIO = BenchmarkScenario(
    name="Stress Smoke",
    description="Minimal scenario for stress testing",
    start_location="Seyda Neen",
    entities=["Guard"],
    events=["player_spawned"],
    max_cycles=3,
    tags=["stress"],
)


# ---------------------------------------------------------------------------
# MultiClientStressRunner
# ---------------------------------------------------------------------------


class TestMultiClientStressRunner:
    def test_phase8_requirement_met(self):
        runner = MultiClientStressRunner(client_count=6)
        assert runner.meets_phase8_requirement is True

    def test_phase8_requirement_not_met(self):
        runner = MultiClientStressRunner(client_count=5)
        assert runner.meets_phase8_requirement is False

    def test_invalid_client_count(self):
        with pytest.raises(ValueError):
            MultiClientStressRunner(client_count=0)

    @pytest.mark.asyncio
    async def test_run_six_clients(self):
        runner = MultiClientStressRunner(client_count=6, cycles_per_client=3)
        report = await runner.run(_SIMPLE_SCENARIO)

        assert isinstance(report, StressTestReport)
        assert report.client_count == 6
        assert len(report.results) == 6
        assert report.all_passed is True

    @pytest.mark.asyncio
    async def test_all_clients_complete_cycles(self):
        runner = MultiClientStressRunner(client_count=6, cycles_per_client=4)
        report = await runner.run(_SIMPLE_SCENARIO)

        for result in report.results:
            assert result.cycles_completed == 4
            assert result.actions_taken == 4
            assert result.errors == []

    @pytest.mark.asyncio
    async def test_report_has_timestamp(self):
        runner = MultiClientStressRunner(client_count=2, cycles_per_client=1)
        report = await runner.run(_SIMPLE_SCENARIO)
        assert report.timestamp

    @pytest.mark.asyncio
    async def test_report_summary_string(self):
        runner = MultiClientStressRunner(client_count=2, cycles_per_client=1)
        report = await runner.run(_SIMPLE_SCENARIO)
        summary = report.summary()
        assert "Stress Smoke" in summary
        assert "Clients: 2" in summary

    @pytest.mark.asyncio
    async def test_single_client(self):
        runner = MultiClientStressRunner(client_count=1, cycles_per_client=2)
        report = await runner.run(_SIMPLE_SCENARIO)
        assert report.success_count == 1

    @pytest.mark.asyncio
    async def test_client_ids_are_unique(self):
        runner = MultiClientStressRunner(client_count=6, cycles_per_client=1)
        report = await runner.run(_SIMPLE_SCENARIO)
        ids = [r.client_id for r in report.results]
        assert len(ids) == len(set(ids))


# ---------------------------------------------------------------------------
# QuestArbiter
# ---------------------------------------------------------------------------


class TestQuestArbiter:
    def test_first_claim_granted(self):
        arbiter = QuestArbiter()
        assert arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE) is True

    def test_conflict_rejected(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        assert arbiter.claim("bob", "fargoth_ring", QuestStage.ACTIVE) is False

    def test_conflict_recorded(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        arbiter.claim("bob", "fargoth_ring", QuestStage.ACTIVE)
        assert arbiter.conflict_count == 1
        assert arbiter.conflicts[0].winner == "alice"
        assert arbiter.conflicts[0].loser == "bob"

    def test_same_player_can_update_own_lock(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        # Alice updates her own lock — no conflict
        assert arbiter.claim("alice", "fargoth_ring", QuestStage.COMPLETED) is True
        assert arbiter.conflict_count == 0

    def test_release_frees_quest(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        arbiter.release("alice", "fargoth_ring")
        # Bob can now claim
        assert arbiter.claim("bob", "fargoth_ring", QuestStage.ACTIVE) is True

    def test_release_wrong_player_fails(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        assert arbiter.release("bob", "fargoth_ring") is False
        assert arbiter.active_lock_count == 1

    def test_advance_updates_stage(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        assert arbiter.advance("alice", "fargoth_ring", QuestStage.COMPLETED) is True
        # Lock should be released after COMPLETED
        assert arbiter.active_lock_count == 0

    def test_advance_failed_releases_lock(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        arbiter.advance("alice", "fargoth_ring", QuestStage.FAILED)
        assert arbiter.active_lock_count == 0

    def test_advance_wrong_player_fails(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        assert arbiter.advance("bob", "fargoth_ring", QuestStage.COMPLETED) is False

    def test_get_stage(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        assert arbiter.get_stage("fargoth_ring") == QuestStage.ACTIVE

    def test_get_stage_unknown_quest(self):
        assert QuestArbiter().get_stage("nonexistent") is None

    def test_lock_holder(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "fargoth_ring", QuestStage.ACTIVE)
        assert arbiter.lock_holder("fargoth_ring") == "alice"

    def test_active_lock_count(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "quest_a", QuestStage.ACTIVE)
        arbiter.claim("bob", "quest_b", QuestStage.ACTIVE)
        assert arbiter.active_lock_count == 2

    def test_multiple_quests_independent(self):
        arbiter = QuestArbiter()
        arbiter.claim("alice", "quest_a", QuestStage.ACTIVE)
        # Bob can claim a different quest without conflict
        assert arbiter.claim("bob", "quest_b", QuestStage.ACTIVE) is True
        assert arbiter.conflict_count == 0


# ---------------------------------------------------------------------------
# AntiGriefPolicy
# ---------------------------------------------------------------------------


class TestAntiGriefPolicy:
    def test_permitted_action_returns_none(self):
        policy = AntiGriefPolicy()
        cmd = CommandInput(action="move", target="north")
        assert policy.check("player-01", cmd) is None

    def test_blocked_action_rejected(self):
        policy = AntiGriefPolicy()
        cmd = CommandInput(action="destroy", target="barrel")
        result = policy.check("player-01", cmd)
        assert result is not None
        assert "destroy" in result.message
        assert policy.violation_count == 1

    def test_custom_blocked_action(self):
        policy = AntiGriefPolicy(blocked_actions={"teleport"})
        cmd = CommandInput(action="teleport")
        result = policy.check("player-01", cmd)
        assert result is not None

    def test_is_blocked_action(self):
        policy = AntiGriefPolicy()
        assert policy.is_blocked_action("kill_npc") is True
        assert policy.is_blocked_action("move") is False

    def test_rate_limit_exceeded(self):
        policy = AntiGriefPolicy(max_actions_per_window=3, window_seconds=60.0)
        cmd = CommandInput(action="move")
        # First 3 actions should pass
        for _ in range(3):
            assert policy.check("player-01", cmd) is None
        # 4th action should be blocked
        result = policy.check("player-01", cmd)
        assert result is not None
        assert "Rate limit" in result.message

    def test_rate_limit_per_player(self):
        policy = AntiGriefPolicy(max_actions_per_window=2, window_seconds=60.0)
        cmd = CommandInput(action="move")
        # player-01 exhausts limit
        policy.check("player-01", cmd)
        policy.check("player-01", cmd)
        assert policy.check("player-01", cmd) is not None
        # player-02 is unaffected
        assert policy.check("player-02", cmd) is None

    def test_reset_player_clears_bucket(self):
        policy = AntiGriefPolicy(max_actions_per_window=2, window_seconds=60.0)
        cmd = CommandInput(action="move")
        policy.check("player-01", cmd)
        policy.check("player-01", cmd)
        policy.reset_player("player-01")
        # Should be allowed again
        assert policy.check("player-01", cmd) is None

    def test_violations_list(self):
        policy = AntiGriefPolicy()
        policy.check("player-01", CommandInput(action="steal"))
        assert len(policy.violations) == 1
        assert policy.violations[0].player_id == "player-01"
        assert policy.violations[0].action == "steal"

    def test_all_default_blocked_actions(self):
        policy = AntiGriefPolicy()
        for action in ("destroy", "kill_npc", "steal", "grief", "cheat", "spawn_item"):
            assert policy.is_blocked_action(action), f"{action!r} should be blocked"


# ---------------------------------------------------------------------------
# RecoveryManager
# ---------------------------------------------------------------------------


class TestRecoveryManager:
    def test_snapshot_creates_file(self, tmp_path):
        path = tmp_path / "recovery.jsonl"
        mgr = RecoveryManager(path)
        adapter = MockWorldAdapter(location="Vivec")
        adapter.connect()
        snap = mgr.snapshot(adapter)
        assert path.exists()
        assert snap.location == "Vivec"

    def test_snapshot_returns_world_snapshot(self, tmp_path):
        mgr = RecoveryManager(tmp_path / "recovery.jsonl")
        adapter = MockWorldAdapter(location="Balmora", entities=["Guard"])
        adapter.connect()
        snap = mgr.snapshot(adapter)
        assert isinstance(snap, WorldSnapshot)
        assert snap.location == "Balmora"
        assert "Guard" in snap.entities

    def test_restore_latest(self, tmp_path):
        mgr = RecoveryManager(tmp_path / "recovery.jsonl")
        adapter = MockWorldAdapter(location="Seyda Neen")
        adapter.connect()
        mgr.snapshot(adapter)

        # Change location and restore
        adapter._location = "Somewhere Else"
        result = mgr.restore(adapter)
        assert result is not None
        assert adapter._location == "Seyda Neen"

    def test_restore_by_id(self, tmp_path):
        mgr = RecoveryManager(tmp_path / "recovery.jsonl")
        adapter = MockWorldAdapter(location="Ald'ruhn")
        adapter.connect()
        mgr.snapshot(adapter, snapshot_id="snap-001")
        mgr.snapshot(adapter)  # second snapshot

        adapter._location = "Elsewhere"
        result = mgr.restore(adapter, snapshot_id="snap-001")
        assert result is not None
        assert result.snapshot_id == "snap-001"

    def test_restore_missing_id_returns_none(self, tmp_path):
        mgr = RecoveryManager(tmp_path / "recovery.jsonl")
        adapter = MockWorldAdapter()
        adapter.connect()
        mgr.snapshot(adapter)
        result = mgr.restore(adapter, snapshot_id="nonexistent")
        assert result is None

    def test_restore_empty_history_returns_none(self, tmp_path):
        mgr = RecoveryManager(tmp_path / "recovery.jsonl")
        adapter = MockWorldAdapter()
        adapter.connect()
        assert mgr.restore(adapter) is None

    def test_load_history_most_recent_first(self, tmp_path):
        mgr = RecoveryManager(tmp_path / "recovery.jsonl")
        for i in range(3):
            adapter = MockWorldAdapter(location=f"location-{i}")
            adapter.connect()
            mgr.snapshot(adapter)

        history = mgr.load_history()
        assert len(history) == 3
        # Most recent was location-2
        assert history[0]["location"] == "location-2"

    def test_latest_returns_snapshot(self, tmp_path):
        mgr = RecoveryManager(tmp_path / "recovery.jsonl")
        adapter = MockWorldAdapter(location="Gnisis")
        adapter.connect()
        mgr.snapshot(adapter)
        latest = mgr.latest()
        assert latest is not None
        assert latest.location == "Gnisis"

    def test_max_snapshots_trim(self, tmp_path):
        mgr = RecoveryManager(tmp_path / "recovery.jsonl", max_snapshots=3)
        for i in range(5):
            adapter = MockWorldAdapter(location=f"loc-{i}")
            adapter.connect()
            mgr.snapshot(adapter)
        assert mgr.snapshot_count == 3

    def test_snapshot_count(self, tmp_path):
        mgr = RecoveryManager(tmp_path / "recovery.jsonl")
        adapter = MockWorldAdapter()
        adapter.connect()
        for _ in range(4):
            mgr.snapshot(adapter)
        assert mgr.snapshot_count == 4


# ---------------------------------------------------------------------------
# WorldStateBackup
# ---------------------------------------------------------------------------


class TestWorldStateBackup:
    def test_create_writes_file(self, tmp_path):
        backup = WorldStateBackup(tmp_path / "backups")
        adapter = MockWorldAdapter(location="Tel Vos")
        adapter.connect()
        record = backup.create(adapter)
        assert (tmp_path / "backups" / f"{record.backup_id}.json").exists()

    def test_create_returns_record(self, tmp_path):
        backup = WorldStateBackup(tmp_path / "backups")
        adapter = MockWorldAdapter(location="Caldera", entities=["Merchant"])
        adapter.connect()
        record = backup.create(adapter, notes="test note")
        assert isinstance(record, BackupRecord)
        assert record.location == "Caldera"
        assert record.entity_count == 1
        assert record.notes == "test note"
        assert record.size_bytes > 0

    def test_restore_from_backup(self, tmp_path):
        backup = WorldStateBackup(tmp_path / "backups")
        adapter = MockWorldAdapter(location="Ald-ruhn")
        adapter.connect()
        record = backup.create(adapter)

        adapter._location = "Nowhere"
        assert backup.restore(adapter, record.backup_id) is True
        assert adapter._location == "Ald-ruhn"

    def test_restore_missing_backup(self, tmp_path):
        backup = WorldStateBackup(tmp_path / "backups")
        adapter = MockWorldAdapter()
        adapter.connect()
        assert backup.restore(adapter, "backup_nonexistent") is False

    def test_list_backups_most_recent_first(self, tmp_path):
        backup = WorldStateBackup(tmp_path / "backups")
        adapter = MockWorldAdapter()
        adapter.connect()
        ids = []
        for i in range(3):
            adapter._location = f"loc-{i}"
            r = backup.create(adapter)
            ids.append(r.backup_id)

        listed = backup.list_backups()
        assert len(listed) == 3
        # Most recent last created → first in list
        assert listed[0].backup_id == ids[-1]

    def test_latest_returns_most_recent(self, tmp_path):
        backup = WorldStateBackup(tmp_path / "backups")
        adapter = MockWorldAdapter(location="Vivec")
        adapter.connect()
        backup.create(adapter)
        adapter._location = "Molag Mar"
        record = backup.create(adapter)
        assert backup.latest().backup_id == record.backup_id

    def test_empty_list_returns_empty(self, tmp_path):
        backup = WorldStateBackup(tmp_path / "backups")
        assert backup.list_backups() == []
        assert backup.latest() is None

    def test_rotation_removes_oldest(self, tmp_path):
        backup = WorldStateBackup(tmp_path / "backups", max_backups=3)
        adapter = MockWorldAdapter()
        adapter.connect()
        records = [backup.create(adapter) for _ in range(5)]
        listed = backup.list_backups()
        assert len(listed) == 3
        # Oldest two should be gone
        listed_ids = {r.backup_id for r in listed}
        assert records[0].backup_id not in listed_ids
        assert records[1].backup_id not in listed_ids
        # Newest three should be present
        for rec in records[2:]:
            assert rec.backup_id in listed_ids


# ---------------------------------------------------------------------------
# ResourceMonitor
# ---------------------------------------------------------------------------


class TestResourceMonitor:
    def test_sample_returns_snapshot(self):
        monitor = ResourceMonitor()
        snap = monitor.sample()
        assert isinstance(snap, ResourceSnapshot)
        assert snap.timestamp

    def test_snapshot_has_disk_fields(self):
        monitor = ResourceMonitor(watch_path=".")
        snap = monitor.sample()
        # Disk should be available on any OS
        assert snap.disk_used_gb >= 0
        assert snap.disk_total_gb > 0

    def test_history_grows(self):
        monitor = ResourceMonitor()
        monitor.sample()
        monitor.sample()
        assert len(monitor.history) == 2

    def test_history_capped(self):
        monitor = ResourceMonitor(max_history=3)
        for _ in range(5):
            monitor.sample()
        assert len(monitor.history) == 3

    def test_sample_n(self):
        monitor = ResourceMonitor()
        results = monitor.sample_n(4, interval_s=0)
        assert len(results) == 4
        assert all(isinstance(s, ResourceSnapshot) for s in results)

    def test_peak_cpu_no_samples(self):
        monitor = ResourceMonitor()
        assert monitor.peak_cpu() == -1.0

    def test_peak_memory_no_samples(self):
        monitor = ResourceMonitor()
        assert monitor.peak_memory_mb() == -1.0

    def test_summary_no_samples(self):
        monitor = ResourceMonitor()
        assert "no samples" in monitor.summary()

    def test_summary_with_samples(self):
        monitor = ResourceMonitor()
        monitor.sample()
        summary = monitor.summary()
        assert "ResourceMonitor" in summary
        assert "samples" in summary

    def test_history_is_copy(self):
        monitor = ResourceMonitor()
        monitor.sample()
        history = monitor.history
        history.clear()
        assert len(monitor.history) == 1  # original unaffected


# ---------------------------------------------------------------------------
# Module-level import test
# ---------------------------------------------------------------------------


class TestHardeningModuleImport:
    def test_all_exports_importable(self):
        from infrastructure.world.hardening import (
            AntiGriefPolicy,
            MultiClientStressRunner,
            QuestArbiter,
            RecoveryManager,
            ResourceMonitor,
            WorldStateBackup,
        )

        for cls in (
            AntiGriefPolicy,
            MultiClientStressRunner,
            QuestArbiter,
            RecoveryManager,
            ResourceMonitor,
            WorldStateBackup,
        ):
            assert cls is not None
