from __future__ import annotations

import os
import sys
import traceback
import warnings
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from decimal import Decimal
from threading import Event, get_ident
from time import sleep
from timeit import timeit
from typing import Any, ClassVar
from unittest import TestCase
from uuid import UUID, uuid4

from eventsourcing.application import AggregateNotFoundError, Application
from eventsourcing.domain import Aggregate
from eventsourcing.persistence import (
    InfrastructureFactory,
    IntegrityError,
    JSONTranscoder,
    Transcoding,
)
from eventsourcing.tests.domain import BankAccount, EmailAddress
from eventsourcing.utils import EnvType, get_topic

TIMEIT_FACTOR = int(os.environ.get("TEST_TIMEIT_FACTOR", default=10))


class ExampleApplicationTestCase(TestCase):
    timeit_number: ClassVar[int] = TIMEIT_FACTOR
    started_ats: ClassVar[dict[type[TestCase], datetime]] = {}
    counts: ClassVar[dict[type[TestCase], int]] = {}
    expected_factory_topic: str

    def test_example_application(self) -> None:
        app = BankAccounts(env={"IS_SNAPSHOTTING_ENABLED": "y"})

        self.assertEqual(get_topic(type(app.factory)), self.expected_factory_topic)

        # Check AccountNotFound exception.
        with self.assertRaises(BankAccounts.AccountNotFoundError):
            app.get_account(uuid4())

        # Open an account.
        account_id = app.open_account(
            full_name="Alice",
            email_address="alice@example.com",
        )

        # Check balance.
        self.assertEqual(
            app.get_balance(account_id),
            Decimal("0.00"),
        )

        # Credit the account.
        app.credit_account(account_id, Decimal("10.00"))

        # Check balance.
        self.assertEqual(
            app.get_balance(account_id),
            Decimal("10.00"),
        )

        app.credit_account(account_id, Decimal("25.00"))
        app.credit_account(account_id, Decimal("30.00"))

        # Check balance.
        self.assertEqual(
            app.get_balance(account_id),
            Decimal("65.00"),
        )

        sleep(1)  # Added to make eventsourcing-axon tests work, perhaps not necessary.
        section = app.notification_log["1,10"]
        self.assertEqual(len(section.items), 4)

        # Take snapshot (specify version).
        app.take_snapshot(account_id, version=Aggregate.INITIAL_VERSION + 1)

        assert app.snapshots is not None  # for mypy
        snapshots = list(app.snapshots.get(account_id))
        self.assertEqual(len(snapshots), 1)
        self.assertEqual(snapshots[0].originator_version, Aggregate.INITIAL_VERSION + 1)

        from_snapshot1: BankAccount = app.repository.get(
            account_id, version=Aggregate.INITIAL_VERSION + 2
        )
        self.assertIsInstance(from_snapshot1, BankAccount)
        self.assertEqual(from_snapshot1.version, Aggregate.INITIAL_VERSION + 2)
        self.assertEqual(from_snapshot1.balance, Decimal("35.00"))

        # Take snapshot (don't specify version).
        app.take_snapshot(account_id)
        assert app.snapshots is not None  # for mypy
        snapshots = list(app.snapshots.get(account_id))
        self.assertEqual(len(snapshots), 2)
        self.assertEqual(snapshots[0].originator_version, Aggregate.INITIAL_VERSION + 1)
        self.assertEqual(snapshots[1].originator_version, Aggregate.INITIAL_VERSION + 3)

        from_snapshot2: BankAccount = app.repository.get(account_id)
        self.assertIsInstance(from_snapshot2, BankAccount)
        self.assertEqual(from_snapshot2.version, Aggregate.INITIAL_VERSION + 3)
        self.assertEqual(from_snapshot2.balance, Decimal("65.00"))

    def test__put_performance(self) -> None:
        app = BankAccounts()

        # Open an account.
        account_id = app.open_account(
            full_name="Alice",
            email_address="alice@example.com",
        )
        account = app.get_account(account_id)

        def put() -> None:
            # Credit the account.
            account.append_transaction(Decimal("10.00"))
            app.save(account)

        # Warm up.
        number = 10
        timeit(put, number=number)

        duration = timeit(put, number=self.timeit_number)
        self.print_time("store events", duration)

    def test__get_performance_with_snapshotting_enabled(self) -> None:
        print()
        self._test_get_performance(is_snapshotting_enabled=True)

    def test__get_performance_without_snapshotting_enabled(self) -> None:
        self._test_get_performance(is_snapshotting_enabled=False)

    def _test_get_performance(self, *, is_snapshotting_enabled: bool) -> None:
        app = BankAccounts(
            env={"IS_SNAPSHOTTING_ENABLED": "y" if is_snapshotting_enabled else "n"}
        )

        # Open an account.
        account_id = app.open_account(
            full_name="Alice",
            email_address="alice@example.com",
        )

        def read() -> None:
            # Get the account.
            app.get_account(account_id)

        # Warm up.
        timeit(read, number=10)

        duration = timeit(read, number=self.timeit_number)

        if is_snapshotting_enabled:
            test_label = "get with snapshotting"
        else:
            test_label = "get without snapshotting"
        self.print_time(test_label, duration)

    def print_time(self, test_label: str, duration: float) -> None:
        cls = type(self)
        if cls not in self.started_ats:
            self.started_ats[cls] = datetime.now()
            print(f"{cls.__name__: <29} timeit number: {cls.timeit_number}")
            self.counts[cls] = 1
        else:
            self.counts[cls] += 1

        rate = f"{self.timeit_number / duration:.0f} events/s"
        print(
            f"{cls.__name__: <29}",
            f"{test_label: <21}",
            f"{rate: >15}",
            f"  {1000 * duration / self.timeit_number:.3f} ms/event",
        )

        if self.counts[cls] == 3:
            cls_duration = datetime.now() - cls.started_ats[cls]
            print(f"{cls.__name__: <29} timeit duration: {cls_duration}")
            sys.stdout.flush()


class EmailAddressAsStr(Transcoding):
    type = EmailAddress
    name = "email_address_as_str"

    def encode(self, obj: EmailAddress) -> str:
        return obj.address

    def decode(self, data: str) -> EmailAddress:
        return EmailAddress(data)


class BankAccounts(Application):
    is_snapshotting_enabled = True

    def register_transcodings(self, transcoder: JSONTranscoder) -> None:
        super().register_transcodings(transcoder)
        transcoder.register(EmailAddressAsStr())

    def open_account(self, full_name: str, email_address: str) -> UUID:
        account = BankAccount.open(
            full_name=full_name,
            email_address=email_address,
        )
        self.save(account)
        return account.id

    def credit_account(self, account_id: UUID, amount: Decimal) -> None:
        account = self.get_account(account_id)
        account.append_transaction(amount)
        self.save(account)

    def get_balance(self, account_id: UUID) -> Decimal:
        account = self.get_account(account_id)
        return account.balance

    def get_account(self, account_id: UUID) -> BankAccount:
        try:
            aggregate: BankAccount = self.repository.get(account_id)
        except AggregateNotFoundError:
            raise self.AccountNotFoundError(account_id) from None
        else:
            assert isinstance(aggregate, BankAccount)
            return aggregate

    class AccountNotFoundError(Exception):
        pass


class ApplicationTestCase(TestCase):
    def test_name(self) -> None:
        self.assertEqual(Application.name, "Application")

        class MyApplication1(Application):
            pass

        self.assertEqual(MyApplication1.name, "MyApplication1")

        class MyApplication2(Application):
            name = "MyBoundedContext"

        self.assertEqual(MyApplication2.name, "MyBoundedContext")

    def test_resolve_persistence_topics(self) -> None:
        # None specified.
        app = Application()
        self.assertIsInstance(app.factory, InfrastructureFactory)

        # Legacy 'INFRASTRUCTURE_FACTORY'.
        app = Application(env={"INFRASTRUCTURE_FACTORY": "eventsourcing.popo:Factory"})
        self.assertIsInstance(app.factory, InfrastructureFactory)

        # Legacy 'FACTORY_TOPIC'.
        app = Application(env={"FACTORY_TOPIC": "eventsourcing.popo:Factory"})
        self.assertIsInstance(app.factory, InfrastructureFactory)

        # Check 'PERSISTENCE_MODULE' resolves to a class.
        app = Application(env={"PERSISTENCE_MODULE": "eventsourcing.popo"})
        self.assertIsInstance(app.factory, InfrastructureFactory)

        # Check exceptions.
        with self.assertRaises(AssertionError) as cm:
            Application(env={"PERSISTENCE_MODULE": "eventsourcing.application"})
        self.assertEqual(
            cm.exception.args[0],
            "Found 0 infrastructure factory classes in "
            "'eventsourcing.application', expected 1.",
        )

        with self.assertRaises(AssertionError) as cm:
            Application(
                env={"PERSISTENCE_MODULE": "eventsourcing.application:Application"}
            )
        self.assertEqual(
            cm.exception.args[0],
            "Not an infrastructure factory class or module: "
            "eventsourcing.application:Application",
        )

    def test_save_returns_recording_event(self) -> None:
        app = Application()

        recordings = app.save()
        self.assertEqual(recordings, [])

        recordings = app.save(None)
        self.assertEqual(recordings, [])

        recordings = app.save(Aggregate())
        self.assertEqual(len(recordings), 1)
        self.assertEqual(recordings[0].notification.id, 1)

        recordings = app.save(Aggregate())
        self.assertEqual(len(recordings), 1)
        self.assertEqual(recordings[0].notification.id, 2)

        recordings = app.save(Aggregate(), Aggregate())
        self.assertEqual(len(recordings), 2)
        self.assertEqual(recordings[0].notification.id, 3)
        self.assertEqual(recordings[1].notification.id, 4)

    def test_take_snapshot_raises_assertion_error_if_snapshotting_not_enabled(
        self,
    ) -> None:
        app = Application()
        with self.assertRaises(AssertionError) as cm:
            app.take_snapshot(uuid4())
        self.assertEqual(
            cm.exception.args[0],
            "Can't take snapshot without snapshots store. Please "
            "set environment variable IS_SNAPSHOTTING_ENABLED to "
            "a true value (e.g. 'y'), or set 'is_snapshotting_enabled' "
            "on application class, or set 'snapshotting_intervals' on "
            "application class.",
        )

    def test_application_with_cached_aggregates_and_fastforward(self) -> None:
        app = Application(env={"AGGREGATE_CACHE_MAXSIZE": "10"})

        aggregate = Aggregate()
        app.save(aggregate)
        # Should not put the aggregate in the cache.
        assert app.repository.cache is not None  # for mypy
        with self.assertRaises(KeyError):
            self.assertEqual(aggregate, app.repository.cache.get(aggregate.id))

        # Getting the aggregate should put aggregate in the cache.
        app.repository.get(aggregate.id)
        self.assertEqual(aggregate, app.repository.cache.get(aggregate.id))

        # Triggering a subsequent event shouldn't update the cache.
        aggregate.trigger_event(Aggregate.Event)
        app.save(aggregate)
        self.assertNotEqual(aggregate, app.repository.cache.get(aggregate.id))
        self.assertEqual(
            aggregate.version, app.repository.cache.get(aggregate.id).version + 1
        )

        # Getting the aggregate should fastforward the aggregate in the cache.
        app.repository.get(aggregate.id)
        self.assertEqual(aggregate, app.repository.cache.get(aggregate.id))

    def test_check_aggregate_fastforwarding_nonblocking(self) -> None:
        self._check_aggregate_fastforwarding_during_contention(
            env={
                "AGGREGATE_CACHE_MAXSIZE": "10",
                "AGGREGATE_CACHE_FASTFORWARD_SKIPPING": "y",
            }
        )

    def test_check_aggregate_fastforwarding_blocking(self) -> None:
        self._check_aggregate_fastforwarding_during_contention(
            env={"AGGREGATE_CACHE_MAXSIZE": "10"}
        )

    def _check_aggregate_fastforwarding_during_contention(self, env: EnvType) -> None:
        app = Application(env=env)

        self.assertEqual(len(app.repository._fastforward_locks_inuse), 0)

        # Create one aggregate.
        original_aggregate = Aggregate()
        app.save(original_aggregate)
        obj_ids = set()

        # Prime the cache.
        app.repository.get(original_aggregate.id)

        # Remember the aggregate ID.
        aggregate_id = original_aggregate.id

        stopped = Event()
        errors: list[BaseException] = []
        successful_thread_ids = set()

        def trigger_save_get_check() -> None:
            while not stopped.is_set():
                try:
                    # Get the aggregate.
                    aggregate: Aggregate = app.repository.get(aggregate_id)
                    original_version = aggregate.version

                    # Try to record a new event.
                    aggregate.trigger_event(Aggregate.Event)
                    # Give other threads a chance.
                    try:
                        app.save(aggregate)
                    except IntegrityError:
                        # Start again if we didn't record a new event.
                        # print("Got integrity error")
                        sleep(0.001)
                        continue

                    # Get the aggregate from the cache.
                    assert app.repository.cache is not None
                    cached: Any = app.repository.cache.get(aggregate_id)
                    obj_ids.add(id(cached))

                    if len(obj_ids) > 1:
                        stopped.set()
                        continue

                    # Fast-forward the cached aggregate.
                    fastforwarded: Aggregate = app.repository.get(aggregate_id)

                    # Check cached aggregate was fast-forwarded with recorded event.
                    if fastforwarded.version < original_version:
                        try:
                            self.fail(
                                f"Failed to fast-forward at version {original_version}"
                            )
                        except AssertionError as e:
                            errors.append(e)
                            stopped.set()
                            continue

                    # Monitor number of threads getting involved.
                    thread_id = get_ident()
                    successful_thread_ids.add(thread_id)

                    # print("Version:", aggregate.version, thread_id)

                    # See if we have done enough.
                    if len(successful_thread_ids) > 10 and aggregate.version >= 25:
                        stopped.set()
                        continue

                    sleep(0.0001)
                    # sleep(0.001)
                except BaseException as e:
                    errors.append(e)
                    stopped.set()
                    print(traceback.format_exc())
                    raise

        executor = ThreadPoolExecutor(max_workers=100)
        futures = []
        for _ in range(100):
            f = executor.submit(trigger_save_get_check)
            futures.append(f)

        # Run for three seconds.
        stopped.wait(timeout=10)
        for f in futures:
            f.result()
        # print("Got all results, shutting down executor")
        executor.shutdown()

        try:
            if errors:
                raise errors[0]
            if len(obj_ids) > 1:
                self.fail(f"More than one instance used in the cache: {len(obj_ids)}")
            if len(successful_thread_ids) < 3:
                self.fail("Insufficient sharing across contentious threads")

            final_aggregate: Aggregate = app.repository.get(aggregate_id)
            # print("Final aggregate version:", final_aggregate.version)
            if final_aggregate.version < 25:
                self.fail(f"Insufficient version increment: {final_aggregate.version}")

            self.assertEqual(len(app.repository._fastforward_locks_inuse), 0)

        finally:
            # print("Closing application")
            app.close()

    def test_application_with_cached_aggregates_not_fastforward(self) -> None:
        app = Application(
            env={
                "AGGREGATE_CACHE_MAXSIZE": "10",
                "AGGREGATE_CACHE_FASTFORWARD": "f",
            }
        )
        aggregate1 = Aggregate()
        app.save(aggregate1)
        aggregate_id = aggregate1.id

        # Should put the aggregate in the cache.
        assert app.repository.cache is not None  # for mypy
        self.assertEqual(aggregate1, app.repository.cache.get(aggregate_id))
        app.repository.get(aggregate_id)
        self.assertEqual(aggregate1, app.repository.cache.get(aggregate_id))

        aggregate2 = Aggregate()
        aggregate2._id = aggregate_id
        aggregate2.trigger_event(Aggregate.Event)

        # This will replace object in cache.
        app.save(aggregate2)

        self.assertEqual(aggregate2.version, aggregate1.version + 1)
        aggregate3: Aggregate = app.repository.get(aggregate_id)
        self.assertEqual(aggregate3.version, aggregate3.version)
        self.assertEqual(id(aggregate3.version), id(aggregate3.version))

        # This will mess things up because the cache has a stale aggregate.
        aggregate3.trigger_event(Aggregate.Event)
        app.events.put(aggregate3.collect_events())

        # And so using the aggregate to record new events will cause an IntegrityError.
        aggregate4: Aggregate = app.repository.get(aggregate_id)
        aggregate4.trigger_event(Aggregate.Event)
        with self.assertRaises(IntegrityError):
            app.save(aggregate4)

    def test_application_with_deepcopy_from_cache_arg(self) -> None:
        app = Application(
            env={
                "AGGREGATE_CACHE_MAXSIZE": "10",
            }
        )
        aggregate = Aggregate()
        app.save(aggregate)
        self.assertEqual(aggregate.version, 1)
        reconstructed: Aggregate = app.repository.get(aggregate.id)
        reconstructed.version = 101
        assert app.repository.cache is not None  # for mypy
        self.assertEqual(app.repository.cache.get(aggregate.id).version, 1)
        cached: Aggregate = app.repository.get(aggregate.id, deepcopy_from_cache=False)
        cached.version = 101
        self.assertEqual(app.repository.cache.get(aggregate.id).version, 101)

    def test_application_with_deepcopy_from_cache_attribute(self) -> None:
        app = Application(
            env={
                "AGGREGATE_CACHE_MAXSIZE": "10",
            }
        )
        aggregate = Aggregate()
        app.save(aggregate)
        self.assertEqual(aggregate.version, 1)
        reconstructed: Aggregate = app.repository.get(aggregate.id)
        reconstructed.version = 101
        assert app.repository.cache is not None  # for mypy
        self.assertEqual(app.repository.cache.get(aggregate.id).version, 1)
        app.repository.deepcopy_from_cache = False
        cached: Aggregate = app.repository.get(aggregate.id)
        cached.version = 101
        self.assertEqual(app.repository.cache.get(aggregate.id).version, 101)

    def test_application_log(self) -> None:
        # Check the old 'log' attribute presents the 'notification log' object.
        app = Application()

        # Verify deprecation warning.
        with warnings.catch_warnings(record=True) as w:
            self.assertIs(app.log, app.notification_log)

        self.assertEqual(1, len(w))
        self.assertIs(w[-1].category, DeprecationWarning)
        self.assertIn(
            "'log' is deprecated, use 'notifications' instead", str(w[-1].message)
        )
