"""
Property-based tests for IS Period Activity Equals TB Difference (Property 4).

Property 4: IS Period Activity Equals TB Difference
For any active income or expense account and any date range [start, end],
the Income Statement period activity for that account SHALL equal:
    calculate_account_balance(account, end) − calculate_account_balance(account, start − 1 day)

**Validates: Requirements 2.2, 9.1**

Uses Hypothesis to generate arbitrary accounts, date ranges, and GL postings,
then asserts that ReportService._calculate_period_activity produces the same
result as the direct AccountingService.calculate_account_balance difference.
"""

import uuid
from datetime import date, timedelta
from decimal import Decimal

from hypothesis import given, settings, strategies as st, assume
from hypothesis.extra.django import TestCase

from django.contrib.auth import get_user_model

from accounting.models import (
    Account,
    FiscalPeriod,
    JournalEntry,
    JournalEntryLine,
)
from accounting.services.accounting_service import AccountingService
from accounting.services.report_service import ReportService
from users.models import Branch

User = get_user_model()


# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------

def _amount_strategy(
    min_val: str = "1.00", max_val: str = "9999.00"
) -> st.SearchStrategy:
    """Strategy for a positive Decimal with exactly 2 decimal places."""
    return st.decimals(
        min_value=Decimal(min_val),
        max_value=Decimal(max_val),
        places=2,
        allow_nan=False,
        allow_infinity=False,
    )


@st.composite
def date_range_strategy(draw):
    """
    Generate a (start_date, end_date) pair within 2024, ensuring end >= start.
    Periods are constrained to within the fixture's fiscal period (2020–2030).
    """
    # Pick a start day of year (1–330 to leave room for end)
    start_day_offset = draw(st.integers(min_value=0, max_value=330))
    period_length = draw(st.integers(min_value=1, max_value=30))

    base = date(2024, 1, 1)
    start_date = base + timedelta(days=start_day_offset)
    end_date = start_date + timedelta(days=period_length)

    # Ensure end_date stays within 2024
    assume(end_date.year == 2024)

    return start_date, end_date


@st.composite
def posting_amounts(draw):
    """
    Generate a list of 1–5 transaction amounts to post within a period.
    """
    n = draw(st.integers(min_value=1, max_value=5))
    return [draw(_amount_strategy()) for _ in range(n)]


# ---------------------------------------------------------------------------
# Test case
# ---------------------------------------------------------------------------

class PeriodActivityEqualsTBDifferencePropertyTest(TestCase):
    """
    Property 4: IS Period Activity Equals TB Difference
    **Validates: Requirements 2.2, 9.1**

    For any income or expense account and any date range [start, end],
    _calculate_period_activity(account, start, end) MUST equal
    calculate_account_balance(account, end) − calculate_account_balance(account, start − 1 day).

    This guarantees the Income Statement is sourced exclusively from the GL
    (via AccountingService.calculate_account_balance) rather than
    re-implementing aggregation logic.
    """

    def setUp(self):
        """
        Create the minimal shared fixture.

        hypothesis.extra.django.TestCase wraps each @given example in a
        transaction rolled back after the test, so setUp() effectively runs
        fresh for each example.
        """
        suffix = uuid.uuid4().hex[:8]

        self.user = User.objects.create_user(
            username=f"pa_prop_user_{suffix}",
            email=f"pa_prop_{suffix}@example.com",
            password="testpass123",
            phone_number=f"+2547{suffix[:8]}",  # unique phone to avoid UNIQUE constraint collision
        )

        self.branch = Branch.objects.create(
            name=f"PAPropBranch_{suffix}",
            code=f"PA{suffix[:5].upper()}",
        )

        # Open fiscal period wide enough for any date generated by the strategy.
        # Use get_or_create so multiple tests can share the same fiscal period
        # (shared wide date range is idempotent — having one open period is fine).
        self.fiscal_period, _ = FiscalPeriod.objects.get_or_create(
            name="PA Property Test Period",
            defaults=dict(
                period_type="monthly",
                start_date=date(2020, 1, 1),
                end_date=date(2030, 12, 31),
                status="open",
            ),
        )

        # Income account — credit-normal.
        self.income_account = Account.objects.create(
            code=f"40{suffix[:5]}",
            name="PropTest Interest Income",
            account_type="income",
            subtype="interest_income",
            description="Property test income account",
            is_active=True,
            created_by=self.user,
        )

        # Expense account — debit-normal.
        self.expense_account = Account.objects.create(
            code=f"50{suffix[:5]}",
            name="PropTest Staff Cost",
            account_type="expense",
            subtype="staff_costs",
            description="Property test expense account",
            is_active=True,
            created_by=self.user,
        )

        # Asset account — used as the double-entry counterpart for all postings.
        self.asset_account = Account.objects.create(
            code=f"10{suffix[:5]}",
            name="PropTest Cash",
            account_type="asset",
            subtype="current_asset",
            description="Property test asset (cash) account",
            is_active=True,
            created_by=self.user,
        )

        self.accounting_service = AccountingService()
        self.report_service = ReportService()

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

    def _ref(self, prefix: str = "JE") -> str:
        """Generate a unique journal-entry reference number."""
        return f"{prefix}-{uuid.uuid4().hex[:12].upper()}"

    def _post_income_entry(self, amount: Decimal, tx_date: date) -> None:
        """
        Post a balanced income entry:
            Dr Cash (asset)   amount
            Cr Income         amount
        """
        je = JournalEntry.objects.create(
            reference_number=self._ref("INC"),
            transaction_date=tx_date,
            description="PropTest income receipt",
            branch=self.branch,
            created_by=self.user,
            status="draft",
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=self.asset_account,
            description="Cash in",
            debit_amount=amount,
            credit_amount=Decimal("0.00"),
            line_number=1,
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=self.income_account,
            description="Income earned",
            debit_amount=Decimal("0.00"),
            credit_amount=amount,
            line_number=2,
        )
        self.accounting_service.post_journal_entry(je, self.user)

    def _post_expense_entry(self, amount: Decimal, tx_date: date) -> None:
        """
        Post a balanced expense entry:
            Dr Expense   amount
            Cr Cash      amount
        """
        je = JournalEntry.objects.create(
            reference_number=self._ref("EXP"),
            transaction_date=tx_date,
            description="PropTest expense payment",
            branch=self.branch,
            created_by=self.user,
            status="draft",
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=self.expense_account,
            description="Expense incurred",
            debit_amount=amount,
            credit_amount=Decimal("0.00"),
            line_number=1,
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=self.asset_account,
            description="Cash out",
            debit_amount=Decimal("0.00"),
            credit_amount=amount,
            line_number=2,
        )
        self.accounting_service.post_journal_entry(je, self.user)

    def _assert_period_activity_equals_balance_diff(
        self, account: Account, start_date: date, end_date: date
    ) -> None:
        """
        Core assertion for Property 4:
        _calculate_period_activity(account, start, end)
            == calculate_account_balance(account, end)
             - calculate_account_balance(account, start - 1 day)

        Clears the memory cache before each check so that stale cached values
        from prior Hypothesis examples or prior calls within the same example
        do not interfere with the fresh GL-based calculation.
        """
        from django.core.cache import cache as django_cache
        django_cache.clear()  # Ensure fresh GL-based calculation (no stale cache)

        period_activity = self.report_service._calculate_period_activity(
            account, start_date, end_date, branch=None
        )

        # Clear again between the two balance reads to avoid L2 cache pollution
        django_cache.clear()

        closing = self.accounting_service.calculate_account_balance(
            account, end_date, branch=None
        )
        opening = self.accounting_service.calculate_account_balance(
            account, start_date - timedelta(days=1), branch=None
        )
        expected = closing - opening

        assert abs(period_activity - expected) < Decimal("0.01"), (
            f"Property 4 violated for account '{account.name}' "
            f"({account.account_type}) over [{start_date}, {end_date}].\n"
            f"  _calculate_period_activity:           {period_activity}\n"
            f"  closing_balance({end_date}):           {closing}\n"
            f"  opening_balance({start_date - timedelta(days=1)}):  {opening}\n"
            f"  Expected (closing - opening):         {expected}\n"
            f"  Difference:                           {abs(period_activity - expected)}"
        )

    # -----------------------------------------------------------------------
    # Property tests
    # -----------------------------------------------------------------------

    @settings(max_examples=50, deadline=None)
    @given(amounts=posting_amounts(), date_range=date_range_strategy())
    def test_income_period_activity_equals_balance_diff(self, amounts, date_range):
        """
        Property 4 — Income accounts:
        For any income account and any date range [start, end], after posting
        arbitrary income entries within that range, _calculate_period_activity
        MUST equal closing_balance(end) − opening_balance(start − 1 day).

        **Validates: Requirements 2.2, 9.1**
        """
        start_date, end_date = date_range

        # Pick a transaction date within [start_date, end_date]
        mid_date = start_date + (end_date - start_date) // 2

        for amount in amounts:
            self._post_income_entry(amount, mid_date)

        self._assert_period_activity_equals_balance_diff(
            self.income_account, start_date, end_date
        )

    @settings(max_examples=50, deadline=None)
    @given(amounts=posting_amounts(), date_range=date_range_strategy())
    def test_expense_period_activity_equals_balance_diff(self, amounts, date_range):
        """
        Property 4 — Expense accounts:
        For any expense account and any date range [start, end], after posting
        arbitrary expense entries within that range, _calculate_period_activity
        MUST equal closing_balance(end) − opening_balance(start − 1 day).

        **Validates: Requirements 2.2, 9.1**
        """
        start_date, end_date = date_range

        mid_date = start_date + (end_date - start_date) // 2

        for amount in amounts:
            self._post_expense_entry(amount, mid_date)

        self._assert_period_activity_equals_balance_diff(
            self.expense_account, start_date, end_date
        )

    @settings(max_examples=40, deadline=None)
    @given(date_range=date_range_strategy())
    def test_zero_activity_period_returns_zero_for_income(self, date_range):
        """
        Property 4 — Empty period (income):
        When no GL entries exist in [start, end] for an income account,
        both _calculate_period_activity and the balance difference MUST be 0.

        **Validates: Requirements 2.2, 9.1**
        """
        start_date, end_date = date_range

        # No postings — activity MUST be zero
        period_activity = self.report_service._calculate_period_activity(
            self.income_account, start_date, end_date, branch=None
        )

        assert period_activity == Decimal("0.00"), (
            f"Expected zero period activity for empty income period "
            f"[{start_date}, {end_date}], got {period_activity}"
        )

        self._assert_period_activity_equals_balance_diff(
            self.income_account, start_date, end_date
        )

    @settings(max_examples=40, deadline=None)
    @given(date_range=date_range_strategy())
    def test_zero_activity_period_returns_zero_for_expense(self, date_range):
        """
        Property 4 — Empty period (expense):
        When no GL entries exist in [start, end] for an expense account,
        both _calculate_period_activity and the balance difference MUST be 0.

        **Validates: Requirements 2.2, 9.1**
        """
        start_date, end_date = date_range

        period_activity = self.report_service._calculate_period_activity(
            self.expense_account, start_date, end_date, branch=None
        )

        assert period_activity == Decimal("0.00"), (
            f"Expected zero period activity for empty expense period "
            f"[{start_date}, {end_date}], got {period_activity}"
        )

        self._assert_period_activity_equals_balance_diff(
            self.expense_account, start_date, end_date
        )

    @settings(max_examples=40, deadline=None)
    @given(
        pre_amounts=posting_amounts(),
        in_amounts=posting_amounts(),
        date_range=date_range_strategy(),
    )
    def test_period_activity_excludes_entries_outside_range(
        self, pre_amounts, in_amounts, date_range
    ):
        """
        Property 4 — Boundary isolation:
        Entries posted BEFORE start_date − 1 day MUST NOT appear in the period
        activity. The property holds even when there is an opening balance.

        **Validates: Requirements 2.2, 9.1**
        """
        start_date, end_date = date_range

        # Post entries one week before the period (outside the range)
        pre_date = start_date - timedelta(days=7)
        # Ensure pre_date is still within the open fiscal period
        assume(pre_date >= date(2020, 1, 2))

        # Record activity BEFORE in-period entries so we can measure the delta
        # (pre-period postings may already be present in the DB)
        for amount in pre_amounts:
            self._post_income_entry(amount, pre_date)

        # Capture baseline period activity AFTER pre-period postings but BEFORE in-period.
        # Clear cache first so we always get a fresh GL-based calculation, not a stale
        # cached value from a prior Hypothesis example.
        from django.core.cache import cache as django_cache
        django_cache.clear()
        activity_baseline = self.report_service._calculate_period_activity(
            self.income_account, start_date, end_date, branch=None
        )

        # Post entries inside the period
        in_date = start_date + (end_date - start_date) // 2
        for amount in in_amounts:
            self._post_income_entry(amount, in_date)

        # The core property must hold at all times
        self._assert_period_activity_equals_balance_diff(
            self.income_account, start_date, end_date
        )

        # Clear cache before reading activity_after so we get fresh GL data,
        # not a stale cache entry populated during _assert_period_activity_equals_balance_diff.
        django_cache.clear()
        activity_after = self.report_service._calculate_period_activity(
            self.income_account, start_date, end_date, branch=None
        )
        delta = activity_after - activity_baseline
        expected_in_period = sum(in_amounts)

        assert abs(delta - expected_in_period) < Decimal("0.01"), (
            f"Period activity delta {delta} does not match "
            f"sum of in-period amounts {expected_in_period}. "
            f"Pre-period entries appear to be included incorrectly."
        )

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_single_income_entry_period_activity(self, amount):
        """
        Property 4 — Single entry (income):
        After posting one income entry of `amount` within [start, end],
        the change in _calculate_period_activity MUST equal `amount`
        (the credit to the income account raises the balance by `amount`).

        **Validates: Requirements 2.2, 9.1**
        """
        start_date = date(2024, 6, 1)
        end_date = date(2024, 6, 30)
        tx_date = date(2024, 6, 15)

        # Capture period activity BEFORE posting to establish a clean baseline
        from django.core.cache import cache as django_cache
        django_cache.clear()
        activity_before = self.report_service._calculate_period_activity(
            self.income_account, start_date, end_date, branch=None
        )

        self._post_income_entry(amount, tx_date)

        django_cache.clear()
        activity_after = self.report_service._calculate_period_activity(
            self.income_account, start_date, end_date, branch=None
        )
        delta = activity_after - activity_before

        assert abs(delta - amount) < Decimal("0.01"), (
            f"Period activity delta {delta} != posted income amount {amount} "
            f"(before={activity_before}, after={activity_after})"
        )

        # Also confirm the property holds (activity == closing - opening)
        self._assert_period_activity_equals_balance_diff(
            self.income_account, start_date, end_date
        )

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_single_expense_entry_period_activity(self, amount):
        """
        Property 4 — Single entry (expense):
        After posting one expense entry of `amount` within [start, end],
        the change in _calculate_period_activity MUST equal `amount`
        (the debit to the expense account raises the balance by `amount`).

        **Validates: Requirements 2.2, 9.1**
        """
        start_date = date(2024, 7, 1)
        end_date = date(2024, 7, 31)
        tx_date = date(2024, 7, 15)

        # Capture period activity BEFORE posting to establish a clean baseline.
        # Clear cache to avoid stale values from prior Hypothesis examples.
        from django.core.cache import cache as django_cache
        django_cache.clear()
        activity_before = self.report_service._calculate_period_activity(
            self.expense_account, start_date, end_date, branch=None
        )

        self._post_expense_entry(amount, tx_date)

        # Clear cache so we get a fresh GL-based calculation after posting.
        django_cache.clear()
        activity_after = self.report_service._calculate_period_activity(
            self.expense_account, start_date, end_date, branch=None
        )
        delta = activity_after - activity_before

        assert abs(delta - amount) < Decimal("0.01"), (
            f"Period activity delta {delta} != posted expense amount {amount} "
            f"(before={activity_before}, after={activity_after})"
        )

        self._assert_period_activity_equals_balance_diff(
            self.expense_account, start_date, end_date
        )
