"""
Property-based tests for TB Reflects Posted Amounts (Property 2).

Property 2: TB Reflects Posted Amounts
After posting a JournalEntry with a debit/credit of amount A on account X,
the Trial Balance generated for that date SHALL include account X with a
closing balance that incorporates A.

**Validates: Requirements 1.2, 11.4**

Uses Hypothesis to generate arbitrary (account type, amount, debit/credit side)
combinations, posts each via AccountingService.post_journal_entry, then
asserts both the simple TB (get_trial_balance) and the 8-column TB
(generate_trial_balance_full) include account X with a non-zero closing
balance that incorporates the posted amount A.
"""

import uuid
from datetime import date
from decimal import Decimal

from django.core.cache import cache

from hypothesis import given, settings, strategies as st
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 = "10000.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 debit_posting_spec(draw):
    """
    Generate a spec for a posting where account X is the *debit* leg.

    Returns (account_index, amount) where account_index is 0 (asset) or
    3 (expense) — both debit-normal accounts — and amount is a positive
    Decimal.

    The counterpart leg always uses a credit-normal account (index 1 or 2)
    to keep the entry balanced.
    """
    account_index = draw(st.sampled_from([0, 3]))  # asset or expense
    amount = draw(_amount_strategy())
    return account_index, amount


@st.composite
def credit_posting_spec(draw):
    """
    Generate a spec for a posting where account X is the *credit* leg.

    Returns (account_index, amount) where account_index is 1 (liability),
    2 (equity), or 4 (income) — all credit-normal accounts.

    The counterpart leg always uses a debit-normal account (asset, index 0)
    to keep the entry balanced.
    """
    account_index = draw(st.sampled_from([1, 2, 4]))  # liability, equity, income
    amount = draw(_amount_strategy())
    return account_index, amount


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

class TBReflectsPostedAmountsPropertyTest(TestCase):
    """
    Property 2: TB Reflects Posted Amounts
    **Validates: Requirements 1.2, 11.4**

    For any posted JournalEntry containing a line with debit or credit amount A
    on account X, the Trial Balance generated for the transaction date SHALL
    include account X with a closing balance that incorporates A.

    Two TB views are checked:
    - AccountingService.get_trial_balance        (simple 2-column TB)
    - ReportService.generate_trial_balance_full  (8-column auditor's TB)
    """

    def setUp(self):
        """
        Create the minimal fixture shared across all Hypothesis examples.

        Hypothesis (via hypothesis.extra.django.TestCase) wraps each example
        in a transaction savepoint, rolling it back after each run so the
        database is clean for the next example.

        NOTE: The in-memory Django cache is NOT rolled back by the savepoint,
        so we clear it at the start of each test method to avoid stale cached
        balances leaking between Hypothesis examples.
        """
        suffix = uuid.uuid4().hex[:8]
        # phone_number has a unique constraint in the User model; supply a
        # unique value so that multiple test methods in this class don't collide.
        phone_suffix = uuid.uuid4().int % 10 ** 9  # 9-digit integer
        phone_number = f"+254{phone_suffix:09d}"

        self.user = User.objects.create_user(
            username=f"tb_posted_{suffix}",
            email=f"tb_posted_{suffix}@example.com",
            password="testpass123",
            phone_number=phone_number,
        )

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

        # Open fiscal period wide enough for any generated transaction date.
        self.fiscal_period = FiscalPeriod.objects.create(
            name="TB Posted Property Period",
            period_type="monthly",
            start_date=date(2020, 1, 1),
            end_date=date(2030, 12, 31),
            status="open",
        )

        # Five accounts — one per account type — indexed as:
        #   0 = asset      (debit-normal)
        #   1 = liability  (credit-normal)
        #   2 = equity     (credit-normal)
        #   3 = expense    (debit-normal)
        #   4 = income     (credit-normal)
        self.accounts = [
            Account.objects.create(
                code=f"10{suffix[:4]}",
                name="PropTest Asset",
                account_type="asset",
                subtype="current_asset",
                description="Property test asset account",
                is_active=True,
                created_by=self.user,
            ),
            Account.objects.create(
                code=f"20{suffix[:4]}",
                name="PropTest Liability",
                account_type="liability",
                subtype="current_liability",
                description="Property test liability account",
                is_active=True,
                created_by=self.user,
            ),
            Account.objects.create(
                code=f"30{suffix[:4]}",
                name="PropTest Equity",
                account_type="equity",
                description="Property test equity account",
                is_active=True,
                created_by=self.user,
            ),
            Account.objects.create(
                code=f"50{suffix[:4]}",
                name="PropTest Expense",
                account_type="expense",
                description="Property test expense account",
                is_active=True,
                created_by=self.user,
            ),
            Account.objects.create(
                code=f"40{suffix[:4]}",
                name="PropTest Income",
                account_type="income",
                description="Property test income account",
                is_active=True,
                created_by=self.user,
            ),
        ]

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

        # Fixed transaction date within the fiscal period.
        self.tx_date = date(2024, 6, 15)

    # -----------------------------------------------------------------------
    # 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_entry(
        self,
        debit_account: Account,
        credit_account: Account,
        amount: Decimal,
        tx_date: date,
    ) -> JournalEntry:
        """
        Create and post a balanced two-legged journal entry.

        Returns the posted JournalEntry instance.
        """
        je = JournalEntry.objects.create(
            reference_number=self._ref(),
            transaction_date=tx_date,
            description=f"Property test entry {uuid.uuid4().hex[:6]}",
            branch=self.branch,
            created_by=self.user,
            status="draft",
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=debit_account,
            description="Debit leg",
            debit_amount=amount,
            credit_amount=Decimal("0.00"),
            line_number=1,
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=credit_account,
            description="Credit leg",
            debit_amount=Decimal("0.00"),
            credit_amount=amount,
            line_number=2,
        )
        self.accounting_service.post_journal_entry(je, self.user)
        return je

    def _account_in_simple_tb(
        self, tb: dict, account: Account
    ) -> dict | None:
        """Return the account row from a simple TB dict, or None."""
        for row in tb.get("accounts", []):
            if row["code"] == account.code:
                return row
        return None

    def _account_in_full_tb(
        self, tb: dict, account: Account
    ) -> dict | None:
        """Return the account row from a full (8-column) TB dict, or None."""
        for row in tb.get("accounts", []):
            if row["code"] == account.code:
                return row
        return None

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

    @settings(max_examples=50, deadline=None)
    @given(spec=debit_posting_spec())
    def test_debit_account_appears_in_tb_after_posting(self, spec):
        """
        Property 2 — debit leg:
        After posting an entry that debits account X (a debit-normal account)
        with amount A, the simple Trial Balance for the transaction date SHALL
        contain account X with debit_balance >= A.

        **Validates: Requirements 1.2, 11.4**
        """
        # Clear in-memory cache so no stale balance from a previous Hypothesis
        # example leaks into this one (DB state is reset by savepoint rollback,
        # but Django's LocMemCache is not).
        cache.clear()

        account_index, amount = spec
        account_x = self.accounts[account_index]          # debit-normal (asset or expense)
        counterpart = self.accounts[1]                     # liability (credit-normal)

        self._post_entry(account_x, counterpart, amount, self.tx_date)

        tb = self.accounting_service.get_trial_balance(as_of_date=self.tx_date)

        row = self._account_in_simple_tb(tb, account_x)
        assert row is not None, (
            f"Account {account_x.code} ({account_x.account_type}) not found in TB "
            f"after posting debit of {amount}.\n"
            f"TB accounts: {[r['code'] for r in tb.get('accounts', [])]}"
        )

        # Debit-normal account: posted amount must be reflected in debit_balance
        assert row["debit_balance"] >= amount, (
            f"Account {account_x.code} debit_balance ({row['debit_balance']}) "
            f"is less than posted debit amount ({amount})."
        )

    @settings(max_examples=50, deadline=None)
    @given(spec=credit_posting_spec())
    def test_credit_account_appears_in_tb_after_posting(self, spec):
        """
        Property 2 — credit leg:
        After posting an entry that credits account X (a credit-normal account)
        with amount A, the simple Trial Balance for the transaction date SHALL
        contain account X with credit_balance >= A.

        **Validates: Requirements 1.2, 11.4**
        """
        cache.clear()

        account_index, amount = spec
        account_x = self.accounts[account_index]    # credit-normal (liability, equity, income)
        counterpart = self.accounts[0]              # asset (debit-normal)

        self._post_entry(counterpart, account_x, amount, self.tx_date)

        tb = self.accounting_service.get_trial_balance(as_of_date=self.tx_date)

        row = self._account_in_simple_tb(tb, account_x)
        assert row is not None, (
            f"Account {account_x.code} ({account_x.account_type}) not found in TB "
            f"after posting credit of {amount}.\n"
            f"TB accounts: {[r['code'] for r in tb.get('accounts', [])]}"
        )

        # Credit-normal account: posted amount must be reflected in credit_balance
        assert row["credit_balance"] >= amount, (
            f"Account {account_x.code} credit_balance ({row['credit_balance']}) "
            f"is less than posted credit amount ({amount})."
        )

    @settings(max_examples=40, deadline=None)
    @given(spec=debit_posting_spec())
    def test_debit_account_appears_in_full_tb_after_posting(self, spec):
        """
        Property 2 — 8-column TB, debit leg:
        After posting a debit of A on account X, the 8-column Trial Balance
        (generate_trial_balance_full) SHALL include account X with
        closing_debit >= A and activity_debit >= A.

        **Validates: Requirements 1.2, 11.4**
        """
        cache.clear()

        account_index, amount = spec
        account_x = self.accounts[account_index]    # debit-normal
        counterpart = self.accounts[1]              # liability (credit-normal)

        self._post_entry(account_x, counterpart, amount, self.tx_date)

        # Use the same date as both period_start and as_of_date so opening = 0
        # and activity = the posted entry.
        full_tb = self.report_service.generate_trial_balance_full(
            as_of_date=self.tx_date,
            period_start=self.tx_date,
        )

        row = self._account_in_full_tb(full_tb, account_x)
        assert row is not None, (
            f"Account {account_x.code} ({account_x.account_type}) not found in "
            f"8-column TB after posting debit of {amount}.\n"
            f"TB accounts: {[r['code'] for r in full_tb.get('accounts', [])]}"
        )

        # The closing debit column must incorporate the posted amount
        assert row["closing_debit"] >= amount, (
            f"Account {account_x.code} closing_debit ({row['closing_debit']}) "
            f"is less than posted debit amount ({amount}) in 8-column TB."
        )

        # The period activity debit column must also show the amount
        assert row["activity_debit"] >= amount, (
            f"Account {account_x.code} activity_debit ({row['activity_debit']}) "
            f"is less than posted debit amount ({amount}) in 8-column TB."
        )

    @settings(max_examples=40, deadline=None)
    @given(spec=credit_posting_spec())
    def test_credit_account_appears_in_full_tb_after_posting(self, spec):
        """
        Property 2 — 8-column TB, credit leg:
        After posting a credit of A on account X, the 8-column Trial Balance
        SHALL include account X with closing_credit >= A and activity_credit >= A.

        **Validates: Requirements 1.2, 11.4**
        """
        cache.clear()

        account_index, amount = spec
        account_x = self.accounts[account_index]    # credit-normal
        counterpart = self.accounts[0]              # asset (debit-normal)

        self._post_entry(counterpart, account_x, amount, self.tx_date)

        full_tb = self.report_service.generate_trial_balance_full(
            as_of_date=self.tx_date,
            period_start=self.tx_date,
        )

        row = self._account_in_full_tb(full_tb, account_x)
        assert row is not None, (
            f"Account {account_x.code} ({account_x.account_type}) not found in "
            f"8-column TB after posting credit of {amount}.\n"
            f"TB accounts: {[r['code'] for r in full_tb.get('accounts', [])]}"
        )

        assert row["closing_credit"] >= amount, (
            f"Account {account_x.code} closing_credit ({row['closing_credit']}) "
            f"is less than posted credit amount ({amount}) in 8-column TB."
        )

        assert row["activity_credit"] >= amount, (
            f"Account {account_x.code} activity_credit ({row['activity_credit']}) "
            f"is less than posted credit amount ({amount}) in 8-column TB."
        )

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_balance_incorporates_exact_amount_for_fresh_account(self, amount):
        """
        Property 2 — exact amount check on a fresh account:
        For an account with no prior postings, after posting exactly A, the
        closing balance in the simple TB MUST equal exactly A (not just >= A).

        The in-memory cache is cleared at the start of each example so that
        stale balances from prior Hypothesis examples do not interfere.

        **Validates: Requirements 1.2, 11.4**
        """
        # Clear in-memory cache to prevent stale cached balances leaking
        # between Hypothesis examples (DB is rolled back by savepoint, but
        # Django's LocMemCache is not).
        cache.clear()

        # Use the asset account (index 0) as the fresh account X
        account_x = self.accounts[0]   # asset (debit-normal), clean slate each example
        counterpart = self.accounts[2]  # equity (credit-normal)

        self._post_entry(account_x, counterpart, amount, self.tx_date)

        tb = self.accounting_service.get_trial_balance(as_of_date=self.tx_date)

        row = self._account_in_simple_tb(tb, account_x)
        assert row is not None, (
            f"Asset account {account_x.code} not found in TB after posting {amount}."
        )

        # The debit balance should equal exactly the posted amount since the
        # account had no prior postings in this example's clean DB state.
        assert row["debit_balance"] == amount, (
            f"Expected debit_balance == {amount} for fresh asset account, "
            f"got {row['debit_balance']}."
        )

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_both_legs_appear_in_tb_after_single_balanced_entry(self, amount):
        """
        Property 2 — full entry coverage:
        After posting a single balanced entry (debit asset A, credit income A),
        BOTH accounts SHALL appear in the Trial Balance.

        **Validates: Requirements 1.2, 11.4**
        """
        cache.clear()

        debit_account = self.accounts[0]    # asset  (debit-normal)
        credit_account = self.accounts[4]   # income (credit-normal)

        self._post_entry(debit_account, credit_account, amount, self.tx_date)

        tb = self.accounting_service.get_trial_balance(as_of_date=self.tx_date)

        debit_row = self._account_in_simple_tb(tb, debit_account)
        credit_row = self._account_in_simple_tb(tb, credit_account)

        assert debit_row is not None, (
            f"Debit account {debit_account.code} (asset) not found in TB "
            f"after posting {amount}."
        )
        assert credit_row is not None, (
            f"Credit account {credit_account.code} (income) not found in TB "
            f"after posting {amount}."
        )

        # Verify correct side placement
        assert debit_row["debit_balance"] == amount, (
            f"Asset debit_balance expected {amount}, got {debit_row['debit_balance']}."
        )
        assert credit_row["credit_balance"] == amount, (
            f"Income credit_balance expected {amount}, got {credit_row['credit_balance']}."
        )
