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

# Django
from django.test import TransactionTestCase

# Alliance Auth (Core)
from allianceauth.eveonline.models import EveFactionInfo
from allianceauth.utils.testing import NoSocketsTestCase
from esi.exceptions import HTTPNotModified

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

from incursions.models.incursion import Incursion, IncursionInfluence
from incursions.tasks import update_incursions


class NoSocketsTransactionTestCase(TransactionTestCase):
    """TransactionTestCase variant of NoSocketsTestCase.

    Needed for the one scenario (a bare `except IntegrityError: pass` around a
    real `.save()`) that only behaves like production under autocommit: under
    the plain `TestCase`, Django's TestCase wraps every test in an outer
    atomic block, so a swallowed IntegrityError still poisons that transaction
    and any later query in the same test raises TransactionManagementError.
    """

    @classmethod
    def setUpClass(cls) -> None:
        cls.socket_original = socket.socket
        socket.socket = NoSocketsTestCase.guard
        super().setUpClass()

    @classmethod
    def tearDownClass(cls) -> None:
        socket.socket = cls.socket_original
        super().tearDownClass()


def _payload(
    constellation_id: int | None,
    state: str = "established",
    has_boss: str = "false",
    faction_id: int = 500019,
    staging_solar_system_id: int = 30000142,
    influence: float = 0.5,
    type_: str = "Incursion",
) -> SimpleNamespace:
    return SimpleNamespace(
        constellation_id=constellation_id,
        state=state,
        has_boss=has_boss,
        faction_id=faction_id,
        staging_solar_system_id=staging_solar_system_id,
        influence=influence,
        type=type_,
    )


def _response(last_modified: str | None = "Mon, 18 May 2026 12:00:00 GMT") -> SimpleNamespace:
    return SimpleNamespace(headers={"Last-Modified": last_modified} if last_modified else {})


class TestUpdateIncursions(NoSocketsTestCase):
    def setUp(self) -> None:
        self.faction = EveFactionInfo.objects.create(faction_id=500019, faction_name="Sansha's Nation")
        self.constellation = Constellation.objects.create(id=20000001, name="Test Constellation")
        self.staging_system = SolarSystem.objects.create(
            id=30000142, name="Jita", constellation=self.constellation, security_status=0.9,
        )

    @patch("incursions.tasks.logger.warning")
    @patch("incursions.tasks.get_incursions_incursions")
    def test_skips_payload_missing_constellation_id(self, mock_get: Mock, mock_warning: Mock) -> None:
        mock_get.return_value = ([_payload(constellation_id=None)], _response())

        update_incursions()

        self.assertFalse(Incursion.objects.exists())
        mock_warning.assert_called_once()

    @patch("incursions.tasks.logger.warning")
    @patch("incursions.tasks.get_incursions_incursions")
    def test_skips_payload_with_unknown_constellation(self, mock_get: Mock, mock_warning: Mock) -> None:
        mock_get.return_value = ([_payload(constellation_id=999999)], _response())

        update_incursions()

        self.assertFalse(Incursion.objects.exists())
        mock_warning.assert_called_once_with("Constellation %s not found in DB", 999999)

    @patch("incursions.tasks.get_incursions_incursions")
    def test_creates_new_established_incursion(self, mock_get: Mock) -> None:
        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, state="established")], _response(),
        )

        update_incursions()

        incursion = Incursion.objects.get(constellation=self.constellation)
        self.assertEqual(incursion.state, Incursion.States.ESTABLISHED)
        self.assertEqual(incursion.faction_id, self.faction.pk)
        self.assertEqual(incursion.staging_solar_system_id, self.staging_system.pk)
        self.assertEqual(incursion.established_timestamp, datetime(2026, 5, 18, 12, 0, 0, tzinfo=timezone.utc))
        self.assertIsNone(incursion.mobilizing_timestamp)

    @patch("incursions.tasks.get_incursions_incursions")
    def test_creates_new_incursion_directly_mobilizing(self, mock_get: Mock) -> None:
        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, state="mobilizing")], _response(),
        )

        update_incursions()

        incursion = Incursion.objects.get(constellation=self.constellation)
        self.assertEqual(incursion.state, "mobilizing")
        self.assertEqual(incursion.mobilizing_timestamp, datetime(2026, 5, 18, 12, 0, 0, tzinfo=timezone.utc))
        self.assertIsNone(incursion.withdrawing_timestamp)

    @patch("incursions.tasks.get_incursions_incursions")
    def test_creates_new_incursion_directly_withdrawing(self, mock_get: Mock) -> None:
        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, state="withdrawing")], _response(),
        )

        update_incursions()

        incursion = Incursion.objects.get(constellation=self.constellation)
        self.assertEqual(incursion.state, "withdrawing")
        self.assertEqual(incursion.mobilizing_timestamp, datetime(2026, 5, 18, 12, 0, 0, tzinfo=timezone.utc))
        self.assertEqual(incursion.withdrawing_timestamp, datetime(2026, 5, 18, 12, 0, 0, tzinfo=timezone.utc))

    @patch("incursions.tasks.get_incursions_incursions")
    def test_creates_incursion_gets_or_creates_faction(self, mock_get: Mock) -> None:
        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, faction_id=987654)], _response(),
        )

        update_incursions()

        self.assertTrue(EveFactionInfo.objects.filter(faction_id=987654).exists())
        incursion = Incursion.objects.get(constellation=self.constellation)
        self.assertEqual(incursion.faction.faction_id, 987654)

    @patch("incursions.tasks.logger.exception")
    @patch("incursions.tasks.get_incursions_incursions")
    def test_creation_failure_is_logged_and_loop_continues(self, mock_get: Mock, mock_exception: Mock) -> None:
        second_constellation = Constellation.objects.create(id=20000002, name="Second Constellation")
        mock_get.return_value = (
            [
                # Missing staging solar system in DB -> SolarSystem.DoesNotExist inside create().
                _payload(constellation_id=self.constellation.pk, staging_solar_system_id=1234567),
                _payload(constellation_id=second_constellation.pk, staging_solar_system_id=self.staging_system.pk),
            ],
            _response(),
        )

        update_incursions()

        self.assertFalse(Incursion.objects.filter(constellation=self.constellation).exists())
        self.assertTrue(Incursion.objects.filter(constellation=second_constellation).exists())
        mock_exception.assert_called_once()

    @patch("incursions.tasks.get_incursions_incursions")
    def test_state_transitions_established_to_mobilizing_to_withdrawing(self, mock_get: Mock) -> None:
        incursion = Incursion.objects.create(
            constellation=self.constellation,
            faction=self.faction,
            staging_solar_system=self.staging_system,
            state=Incursion.States.ESTABLISHED,
            established_timestamp=datetime(2026, 5, 1, tzinfo=timezone.utc),
        )

        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, state="mobilizing")], _response(),
        )
        update_incursions()

        incursion.refresh_from_db()
        self.assertEqual(incursion.state, Incursion.States.MOBILIZING)
        self.assertEqual(incursion.mobilizing_timestamp, datetime(2026, 5, 18, 12, 0, 0, tzinfo=timezone.utc))

        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, state="withdrawing")],
            _response("Tue, 19 May 2026 08:00:00 GMT"),
        )
        update_incursions()

        incursion.refresh_from_db()
        self.assertEqual(incursion.state, Incursion.States.WITHDRAWING)
        self.assertEqual(incursion.withdrawing_timestamp, datetime(2026, 5, 19, 8, 0, 0, tzinfo=timezone.utc))

    @patch("incursions.tasks.get_incursions_incursions")
    def test_state_transition_to_mobilizing_also_flips_has_boss(self, mock_get: Mock) -> None:
        incursion = Incursion.objects.create(
            constellation=self.constellation,
            faction=self.faction,
            staging_solar_system=self.staging_system,
            state=Incursion.States.ESTABLISHED,
            has_boss=False,
        )

        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, state="mobilizing", has_boss="true")], _response(),
        )
        update_incursions()

        incursion.refresh_from_db()
        self.assertEqual(incursion.state, Incursion.States.MOBILIZING)
        self.assertTrue(incursion.has_boss)

    @patch("incursions.tasks.get_incursions_incursions")
    def test_state_transition_to_withdrawing_also_flips_has_boss(self, mock_get: Mock) -> None:
        incursion = Incursion.objects.create(
            constellation=self.constellation,
            faction=self.faction,
            staging_solar_system=self.staging_system,
            state=Incursion.States.MOBILIZING,
            has_boss=False,
        )

        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, state="withdrawing", has_boss="true")], _response(),
        )
        update_incursions()

        incursion.refresh_from_db()
        self.assertEqual(incursion.state, Incursion.States.WITHDRAWING)
        self.assertTrue(incursion.has_boss)

    @patch("incursions.tasks.get_incursions_incursions")
    def test_established_boss_flag_updates_without_state_change(self, mock_get: Mock) -> None:
        incursion = Incursion.objects.create(
            constellation=self.constellation,
            faction=self.faction,
            staging_solar_system=self.staging_system,
            state=Incursion.States.ESTABLISHED,
            has_boss=False,
        )

        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, state="established", has_boss="true")], _response(),
        )
        update_incursions()

        incursion.refresh_from_db()
        self.assertTrue(incursion.has_boss)
        self.assertEqual(incursion.state, Incursion.States.ESTABLISHED)

    @patch("incursions.tasks.get_incursions_incursions")
    def test_influence_rows_bulk_created(self, mock_get: Mock) -> None:
        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, influence=0.75)], _response(),
        )

        update_incursions()

        incursion = Incursion.objects.get(constellation=self.constellation)
        self.assertEqual(IncursionInfluence.objects.filter(incursion=incursion).count(), 1)
        self.assertEqual(IncursionInfluence.objects.get(incursion=incursion).influence, 0.75)

    @patch("incursions.tasks.IncursionInfluence.objects.bulk_create", side_effect=RuntimeError("boom"))
    @patch("incursions.tasks.get_incursions_incursions")
    def test_influence_bulk_create_failure_falls_back_to_individual_saves(
        self, mock_get: Mock, mock_bulk_create: Mock,
    ) -> None:
        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, influence=0.42)], _response(),
        )

        update_incursions()

        incursion = Incursion.objects.get(constellation=self.constellation)
        self.assertEqual(IncursionInfluence.objects.get(incursion=incursion).influence, 0.42)

    @patch("incursions.tasks.get_incursions_incursions")
    def test_marks_incursion_ended_when_no_longer_active(self, mock_get: Mock) -> None:
        incursion = Incursion.objects.create(
            constellation=self.constellation, faction=self.faction, staging_solar_system=self.staging_system,
        )
        other_constellation = Constellation.objects.create(id=20000099, name="Other Constellation")
        other_system = SolarSystem.objects.create(id=30000144, name="Other System", constellation=other_constellation, security_status=0.9)

        mock_get.return_value = (
            [_payload(constellation_id=other_constellation.pk, staging_solar_system_id=other_system.pk)], _response(),
        )

        update_incursions()

        incursion.refresh_from_db()
        self.assertEqual(incursion.state, Incursion.States.ENDED)
        self.assertEqual(incursion.ended_timestamp, datetime(2026, 5, 18, 12, 0, 0, tzinfo=timezone.utc))

    @patch("incursions.tasks.get_incursions_incursions")
    def test_http_not_modified_short_circuits(self, mock_get: Mock) -> None:
        mock_get.side_effect = HTTPNotModified(304, {})

        result = update_incursions()

        self.assertIsNone(result)
        self.assertFalse(Incursion.objects.exists())

    @patch("incursions.tasks.get_incursions_incursions")
    def test_missing_last_modified_header_falls_back_to_now(self, mock_get: Mock) -> None:
        mock_get.return_value = ([_payload(constellation_id=self.constellation.pk)], _response(last_modified=None))

        before = datetime.now(timezone.utc)
        update_incursions()
        after = datetime.now(timezone.utc)

        incursion = Incursion.objects.get(constellation=self.constellation)
        self.assertGreaterEqual(incursion.established_timestamp, before)
        self.assertLessEqual(incursion.established_timestamp, after)

    @patch("incursions.tasks.get_incursions_incursions")
    def test_falsy_response_falls_back_to_now(self, mock_get: Mock) -> None:
        mock_get.return_value = ([_payload(constellation_id=self.constellation.pk)], None)

        before = datetime.now(timezone.utc)
        update_incursions()
        after = datetime.now(timezone.utc)

        incursion = Incursion.objects.get(constellation=self.constellation)
        self.assertGreaterEqual(incursion.established_timestamp, before)
        self.assertLessEqual(incursion.established_timestamp, after)


class TestUpdateIncursionsInfluenceFallbackIntegrityError(NoSocketsTransactionTestCase):
    """Runs outside an ambient TestCase transaction - see NoSocketsTransactionTestCase docstring."""

    def setUp(self) -> None:
        self.faction = EveFactionInfo.objects.create(faction_id=500019, faction_name="Sansha's Nation")
        self.constellation = Constellation.objects.create(id=20000001, name="Test Constellation")
        self.staging_system = SolarSystem.objects.create(
            id=30000142, name="Jita", constellation=self.constellation, security_status=0.9,
        )

    @patch("incursions.tasks.IncursionInfluence.objects.bulk_create", side_effect=RuntimeError("boom"))
    @patch("incursions.tasks.get_incursions_incursions")
    def test_influence_individual_save_swallows_integrity_error(
        self, mock_get: Mock, mock_bulk_create: Mock,
    ) -> None:
        incursion = Incursion.objects.create(
            constellation=self.constellation, faction=self.faction, staging_solar_system=self.staging_system,
        )
        header_time = datetime(2026, 5, 18, 12, 0, 0, tzinfo=timezone.utc)
        IncursionInfluence.objects.create(incursion=incursion, influence=0.1, timestamp=header_time)

        mock_get.return_value = (
            [_payload(constellation_id=self.constellation.pk, influence=0.9)], _response(),
        )

        # Should not raise despite the duplicate (incursion, timestamp) unique constraint.
        update_incursions()

        self.assertEqual(IncursionInfluence.objects.filter(incursion=incursion).count(), 1)
