# Standard Library
from datetime import datetime, timezone
from unittest.mock import Mock, patch

# Django
from django.test import SimpleTestCase, TestCase

# Alliance Auth (Core)
from allianceauth.eveonline.models import EveFactionInfo

# Alliance Auth (External Libs)
from eve_sde.models import SolarSystem

from incursions.models.incursion import Focus, Incursion, IncursionInfluence, Webhook


class TestIncursionUtilization(SimpleTestCase):
    def test_established_utilization_none_when_established_timestamp_missing(self) -> None:
        incursion = Incursion(mobilizing_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc))
        self.assertIsNone(incursion.established_utilization)

    def test_established_utilization_none_when_mobilizing_timestamp_missing(self) -> None:
        incursion = Incursion(established_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc))
        self.assertIsNone(incursion.established_utilization)

    def test_established_utilization_clamps_reversed_timestamps_to_zero(self) -> None:
        incursion = Incursion(
            established_timestamp=datetime(2026, 1, 5, tzinfo=timezone.utc),
            mobilizing_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
        )
        self.assertEqual(incursion.established_utilization, 0.0)

    def test_established_utilization_clamps_overshoot_to_one(self) -> None:
        incursion = Incursion(
            established_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
            mobilizing_timestamp=datetime(2026, 1, 20, tzinfo=timezone.utc),
        )
        self.assertEqual(incursion.established_utilization, 1.0)

    def test_established_utilization_midpoint_value(self) -> None:
        incursion = Incursion(
            established_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
            mobilizing_timestamp=datetime(2026, 1, 3, 12, tzinfo=timezone.utc),  # 2.5 / 5 days
        )
        self.assertEqual(incursion.established_utilization, 0.5)

    def test_mobilizing_utilization_none_when_timestamps_missing(self) -> None:
        incursion = Incursion()
        self.assertIsNone(incursion.mobilizing_utilization)

    def test_mobilizing_utilization_clamps_reversed_timestamps_to_zero(self) -> None:
        incursion = Incursion(
            mobilizing_timestamp=datetime(2026, 1, 5, tzinfo=timezone.utc),
            withdrawing_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
        )
        self.assertEqual(incursion.mobilizing_utilization, 0.0)

    def test_mobilizing_utilization_clamps_overshoot_to_one(self) -> None:
        incursion = Incursion(
            mobilizing_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
            withdrawing_timestamp=datetime(2026, 1, 10, tzinfo=timezone.utc),
        )
        self.assertEqual(incursion.mobilizing_utilization, 1.0)

    def test_mobilizing_utilization_midpoint_value(self) -> None:
        incursion = Incursion(
            mobilizing_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
            withdrawing_timestamp=datetime(2026, 1, 2, tzinfo=timezone.utc),  # 1 / 2 days
        )
        self.assertEqual(incursion.mobilizing_utilization, 0.5)


class TestIncursionSecurityBands(SimpleTestCase):
    def _incursion_with_security(self, security_status: float | None) -> Incursion:
        incursion = Incursion()
        if security_status is None:
            incursion.staging_solar_system = None
        else:
            incursion.staging_solar_system = SolarSystem(
                id=30000142, name="Jita", security_status=security_status,
            )
        return incursion

    def test_is_high_sec_true_at_boundary(self) -> None:
        incursion = self._incursion_with_security(0.45)
        self.assertTrue(incursion.is_high_sec)
        self.assertFalse(incursion.is_low_sec)
        self.assertFalse(incursion.is_null_sec)

    def test_is_low_sec_just_below_boundary(self) -> None:
        incursion = self._incursion_with_security(0.4499)
        self.assertFalse(incursion.is_high_sec)
        self.assertTrue(incursion.is_low_sec)
        self.assertFalse(incursion.is_null_sec)

    def test_is_null_sec_at_zero_boundary(self) -> None:
        incursion = self._incursion_with_security(0.0)
        self.assertFalse(incursion.is_high_sec)
        self.assertFalse(incursion.is_low_sec)
        self.assertTrue(incursion.is_null_sec)

    def test_is_low_sec_just_above_zero(self) -> None:
        incursion = self._incursion_with_security(0.0001)
        self.assertFalse(incursion.is_high_sec)
        self.assertTrue(incursion.is_low_sec)
        self.assertFalse(incursion.is_null_sec)

    def test_is_null_sec_negative_security(self) -> None:
        incursion = self._incursion_with_security(-0.5)
        self.assertTrue(incursion.is_null_sec)

    def test_all_bands_false_without_staging_system(self) -> None:
        incursion = self._incursion_with_security(None)
        self.assertFalse(incursion.is_high_sec)
        self.assertFalse(incursion.is_low_sec)
        self.assertFalse(incursion.is_null_sec)

    def test_security_string_dispatches_by_band(self) -> None:
        self.assertEqual(str(self._incursion_with_security(0.9).security_string), "High-Sec")
        self.assertEqual(str(self._incursion_with_security(0.3).security_string), "Low-Sec")
        self.assertEqual(str(self._incursion_with_security(-0.2).security_string), "Null-Sec")

    def test_security_string_unknown_without_staging_system(self) -> None:
        incursion = self._incursion_with_security(None)
        self.assertEqual(str(incursion.security_string), "Unknown")


class TestIncursionIsIsland(SimpleTestCase):
    def test_is_island_true_when_no_staging_system(self) -> None:
        incursion = Incursion(staging_solar_system=None)
        self.assertTrue(incursion.is_island)

    @patch("incursions.models.incursion.route_path_nodes")
    def test_is_island_true_when_route_has_low_sec_node(self, mock_route: Mock) -> None:
        incursion = Incursion(staging_solar_system=SolarSystem(id=30000142, name="Jita", security_status=0.9))
        mock_route.return_value = [Mock(security_status=0.9), Mock(security_status=0.3)]

        self.assertTrue(incursion.is_island)

    @patch("incursions.models.incursion.route_path_nodes")
    def test_is_island_false_when_route_all_high_sec(self, mock_route: Mock) -> None:
        incursion = Incursion(staging_solar_system=SolarSystem(id=30000142, name="Jita", security_status=0.9))
        mock_route.return_value = [Mock(security_status=0.9), Mock(security_status=0.6)]

        self.assertFalse(incursion.is_island)

    @patch("incursions.models.incursion.logger.debug")
    @patch("incursions.models.incursion.route_path_nodes")
    def test_is_island_true_when_route_path_nodes_raises(self, mock_route: Mock, mock_debug: Mock) -> None:
        incursion = Incursion(staging_solar_system=SolarSystem(id=30000142, name="Jita", security_status=0.9))
        mock_route.side_effect = RuntimeError("no route")

        self.assertTrue(incursion.is_island)
        mock_debug.assert_called_once()

    @patch("incursions.models.incursion.route_path_nodes")
    def test_is_island_skips_nodes_missing_security_status(self, mock_route: Mock) -> None:
        incursion = Incursion(staging_solar_system=SolarSystem(id=30000142, name="Jita", security_status=0.9))
        node_without_security = object()
        mock_route.return_value = [node_without_security, Mock(security_status=0.9)]

        self.assertFalse(incursion.is_island)


class TestIncursionInfluenceProperty(TestCase):
    def setUp(self) -> None:
        self.faction = EveFactionInfo.objects.create(faction_id=500019, faction_name="Sansha's Nation")
        self.incursion = Incursion.objects.create(faction=self.faction)

    def test_influence_returns_latest_value(self) -> None:
        IncursionInfluence.objects.create(
            incursion=self.incursion, influence=0.2, timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
        )
        IncursionInfluence.objects.create(
            incursion=self.incursion, influence=0.8, timestamp=datetime(2026, 1, 2, tzinfo=timezone.utc),
        )

        self.assertEqual(self.incursion.influence, 0.8)

    def test_influence_returns_zero_when_no_rows(self) -> None:
        self.assertEqual(self.incursion.influence, 0.0)

    @patch("incursions.models.incursion.IncursionInfluence.objects.filter", side_effect=RuntimeError("boom"))
    def test_influence_swallows_generic_exception(self, mock_filter: Mock) -> None:
        self.assertEqual(self.incursion.influence, 0.0)
        mock_filter.assert_called_once()


class TestWebhookSendEmbed(SimpleTestCase):
    def setUp(self) -> None:
        self.webhook = Webhook(
            url="https://discord.com/api/webhooks/1/token",
            name="Test Webhook",
            security_high=True,
            security_low=False,
            security_null=False,
        )
        self.embed = Mock()

    @patch("incursions.models.incursion.SyncWebhook.from_url")
    def test_send_embed_sends_via_discord_webhook(self, mock_from_url: Mock) -> None:
        discord_webhook = Mock()
        mock_from_url.return_value = discord_webhook

        self.webhook.send_embed(embed=self.embed)

        mock_from_url.assert_called_once_with(self.webhook.url)
        discord_webhook.send.assert_called_once_with(embed=self.embed, username="AA Incursions")

    @patch("incursions.models.incursion.logger.warning")
    @patch("incursions.models.incursion.SyncWebhook.from_url", side_effect=RuntimeError("network down"))
    def test_send_embed_swallows_and_logs_exception(self, mock_from_url: Mock, mock_warning: Mock) -> None:
        self.webhook.send_embed(embed=self.embed)

        mock_warning.assert_called_once()


class TestFocusStr(SimpleTestCase):
    def test_str_reports_none_incursion_directly(self) -> None:
        # No exception is raised for a nullable, unset FK - Django returns None outright.
        focus = Focus(incursion=None)
        self.assertEqual(str(focus), "Current Focus: None")

    def test_str_falls_back_when_incursion_lookup_raises_does_not_exist(self) -> None:
        # A "dangling" incursion_id can't be persisted under sqlite FK enforcement
        # (Django's TestCase teardown runs `PRAGMA foreign_key_check` and would
        # fail the test), so the DoesNotExist is triggered directly on the
        # descriptor to exercise __str__'s bare except the same way a stale FK
        # would in production (FK constraint not enforced there).
        focus = Focus(pk=1)

        def _raise_does_not_exist(self: Focus) -> Incursion:
            raise Incursion.DoesNotExist

        with patch.object(Focus, "incursion", property(_raise_does_not_exist)):
            self.assertEqual(str(focus), "No Focus Set")
