"""
Property-based tests for the SFP Balance Equation (Property 5).

Property 5: SFP Balance Equation
For any set of posted JournalEntry records (which individually satisfy the
double-entry invariant), the Statement of Financial Position SHALL satisfy:
    total_assets == total_liabilities + total_equity  (within ±0.01 KES)

**Validates: Requirements 5.1, 5.2**

Uses Hypothesis to generate arbitrary sets of balanced journal entries that
only touch asset, liability, and equity accounts (so that YTD net income is
zero and does not disturb the balance equation), then asserts that
ReportService.generate_balance_sheet() returns balanced=True and
difference == 0.00 without raising FinancialStatementImbalanceError.

Strategy rationale
------------------
`generate_balance_sheet` includes YTD net income from `generate_income_statement`
in the equity section.  To keep that contribution at zero — so the property can
be tested purely with A/L/E entries — we use ONLY asset, liability, and equity
accounts as entry counterparts.  Each generated entry keeps debits == credits,
so after any number of such entries:

    Assets = Liabilities + Equity  (exactly, by double-entry)

because the YTD net income component is always 0.00.
"""

import uuid
from datetime import date
from decimal import Decimal

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

from django.contrib.auth import get_user_model
from django.core.cache import cache as django_cache

from accounting.exceptions import FinancialStatementImbalanceError
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 balanced_ale_entry(draw):
    """
    Generate a single balanced journal entry spec using only
    asset / liability / equity accounts.

    Account index map (mirrors self.accounts list in setUp):
        0 = asset   (current_asset)
        1 = asset2  (fixed_asset)
        2 = liability (current_liability)
        3 = liability2 (borrowings)
        4 = equity  (retained_earnings)

    Entry patterns that satisfy debits == credits AND involve only A/L/E:
        A: Dr Asset    / Cr Equity       — cash injection / capital contribution
        B: Dr Asset    / Cr Liability    — loan received (asset up, liability up)
        C: Dr Liability / Cr Asset       — loan repaid (both down)
        D: Dr Asset1   / Cr Asset2       — asset swap (net effect on A = 0)
        E: Dr Equity   / Cr Asset        — owner withdrawal
    """
    # Pick one of the 5 entry patterns
    pattern = draw(st.integers(min_value=0, max_value=4))
    amount = draw(_amount_strategy())

    # (debit_account_index, credit_account_index)
    patterns = [
        (0, 4),  # A: Dr Asset / Cr Equity
        (0, 2),  # B: Dr Asset / Cr Liability
        (2, 0),  # C: Dr Liability / Cr Asset
        (0, 1),  # D: Dr Asset1 / Cr Asset2
        (4, 0),  # E: Dr Equity / Cr Asset
    ]
    debit_idx, credit_idx = patterns[pattern]
    return (debit_idx, credit_idx, amount)


@st.composite
def entry_sequence(draw):
    """Generate a non-empty list of 1–6 balanced A/L/E entry specs."""
    num = draw(st.integers(min_value=1, max_value=6))
    return [draw(balanced_ale_entry()) for _ in range(num)]


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

class SFPBalanceEquationPropertyTest(TestCase):
    """
    Property 5: SFP Balance Equation
    **Validates: Requirements 5.1, 5.2**

    For any set of posted balanced journal entries (using only A/L/E accounts
    so that YTD net income remains zero), generate_balance_sheet MUST:
      - NOT raise FinancialStatementImbalanceError
      - Return balanced == True
      - Return difference == 0.00

    This guarantees that  Assets = Liabilities + Equity  holds (within ±0.01 KES)
    for any combination of valid balance-sheet-only transactions.
    """

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

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

        phone_suffix = uuid.uuid4().int % 10 ** 9  # 9-digit integer
        self.user = User.objects.create_user(
            username=f"sfp_prop_user_{suffix}",
            email=f"sfp_prop_{suffix}@example.com",
            password="testpass123",
            phone_number=f"+254{phone_suffix:09d}",
        )

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

        # Open fiscal period wide enough for any test date.
        # Name is unique per test instance to avoid unique-constraint conflicts
        # when multiple test methods run in the same suite.
        self.fiscal_period = FiscalPeriod.objects.create(
            name=f"SFP Property Test Period {suffix}",
            period_type="monthly",
            start_date=date(2020, 1, 1),
            end_date=date(2030, 12, 31),
            status="open",
        )

        # --- Chart of Accounts (A/L/E only — no income or expense accounts) ---
        # Indices must match the patterns in balanced_ale_entry():
        #   0 = asset (current_asset)
        #   1 = asset2 (fixed_asset)
        #   2 = liability (current_liability)
        #   3 = liability2 (borrowings)
        #   4 = equity

        self.accounts = [
            # 0: current asset
            Account.objects.create(
                code=f"10{suffix[:4]}",
                name="PropTest Cash",
                account_type="asset",
                subtype="current_asset",
                description="Property test cash account",
                is_active=True,
                created_by=self.user,
            ),
            # 1: fixed asset
            Account.objects.create(
                code=f"11{suffix[:4]}",
                name="PropTest Fixed Asset",
                account_type="asset",
                subtype="fixed_asset",
                description="Property test fixed asset account",
                is_active=True,
                created_by=self.user,
            ),
            # 2: current liability
            Account.objects.create(
                code=f"20{suffix[:4]}",
                name="PropTest Payable",
                account_type="liability",
                subtype="current_liability",
                description="Property test payable account",
                is_active=True,
                created_by=self.user,
            ),
            # 3: borrowings (liability)
            Account.objects.create(
                code=f"21{suffix[:4]}",
                name="PropTest Borrowings",
                account_type="liability",
                subtype="borrowings",
                description="Property test borrowings account",
                is_active=True,
                created_by=self.user,
            ),
            # 4: equity / retained earnings
            Account.objects.create(
                code=f"30{suffix[:4]}",
                name="PropTest Retained Earnings",
                account_type="equity",
                description="Property test retained earnings",
                is_active=True,
                created_by=self.user,
            ),
        ]

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

        # Fixed as_of_date used in all property tests
        self.as_of_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_idx: int, credit_idx: int, amount: Decimal
    ) -> None:
        """
        Build and post a single balanced journal entry between two A/L/E accounts.
        Uses self.as_of_date as the transaction date so the entry falls within
        the as_of_date used for balance sheet generation.
        """
        je = JournalEntry.objects.create(
            reference_number=self._ref("SFP"),
            transaction_date=self.as_of_date,
            description=f"SFP prop test entry {uuid.uuid4().hex[:6]}",
            branch=self.branch,
            created_by=self.user,
            status="draft",
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=self.accounts[debit_idx],
            description="Debit leg",
            debit_amount=amount,
            credit_amount=Decimal("0.00"),
            line_number=1,
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=self.accounts[credit_idx],
            description="Credit leg",
            debit_amount=Decimal("0.00"),
            credit_amount=amount,
            line_number=2,
        )
        self.accounting_service.post_journal_entry(je, self.user)

    def _assert_sfp_balanced(self) -> dict:
        """
        Core assertion for Property 5.

        Clears the cache first to ensure a fresh GL-based calculation, then
        calls generate_balance_sheet and asserts:
          - No FinancialStatementImbalanceError is raised
          - result['balanced'] is True
          - result['difference'] == 0.00
        """
        django_cache.clear()

        try:
            result = self.report_service.generate_balance_sheet(
                as_of_date=self.as_of_date, branch=None
            )
        except FinancialStatementImbalanceError as exc:
            raise AssertionError(
                f"Property 5 violated: generate_balance_sheet raised "
                f"FinancialStatementImbalanceError with difference={exc.difference}.\n"
                f"Assets != Liabilities + Equity."
            ) from exc

        assert result["balanced"] is True, (
            f"Property 5 violated: balanced flag is False.\n"
            f"  total_assets:       {result['totals']['total_assets']}\n"
            f"  total_liabilities:  {result['totals']['total_liabilities']}\n"
            f"  total_equity:       {result['totals']['total_equity']}\n"
            f"  difference:         {result.get('difference')}"
        )
        assert result["difference"] == Decimal("0.00"), (
            f"Property 5 violated: difference is {result['difference']}, expected 0.00.\n"
            f"  total_assets:       {result['totals']['total_assets']}\n"
            f"  total_liabilities:  {result['totals']['total_liabilities']}\n"
            f"  total_equity:       {result['totals']['total_equity']}"
        )
        return result

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

    @settings(max_examples=50, deadline=None)
    @given(entries=entry_sequence())
    def test_sfp_balanced_after_arbitrary_ale_entry_sequence(self, entries):
        """
        Property 5: SFP Balance Equation — arbitrary A/L/E entry sequence.
        **Validates: Requirements 5.1, 5.2**

        For any sequence of 1–6 balanced journal entries that only touch
        asset, liability, and equity accounts, generate_balance_sheet MUST
        return balanced=True and difference=0.00.

        Because no income or expense accounts are involved, YTD net income
        is always zero, so the balance equation holds purely from the A/L/E
        double-entry postings.
        """
        for debit_idx, credit_idx, amount in entries:
            self._post_entry(debit_idx, credit_idx, amount)

        result = self._assert_sfp_balanced()

        # Extra sanity: verify totals are consistent
        totals = result["totals"]
        assert abs(
            totals["total_assets"] - (totals["total_liabilities"] + totals["total_equity"])
        ) < Decimal("0.01"), (
            f"Totals inconsistency: assets={totals['total_assets']}, "
            f"liabilities={totals['total_liabilities']}, "
            f"equity={totals['total_equity']}"
        )

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_sfp_balanced_after_single_capital_injection(self, amount):
        """
        Property 5: SFP Balance Equation — single capital contribution.
        **Validates: Requirements 5.1, 5.2**

        A single entry Dr Asset / Cr Equity increases both sides equally.
        The SFP MUST remain balanced (Assets == Equity, no liabilities).
        """
        # Dr Cash (asset) / Cr Retained Earnings (equity)
        self._post_entry(debit_idx=0, credit_idx=4, amount=amount)

        result = self._assert_sfp_balanced()

        # With only one entry: assets == equity == amount, liabilities == 0
        totals = result["totals"]
        assert abs(totals["total_assets"] - amount) < Decimal("0.01"), (
            f"Expected total_assets ≈ {amount}, got {totals['total_assets']}"
        )
        assert abs(totals["total_equity"] - amount) < Decimal("0.01"), (
            f"Expected total_equity ≈ {amount}, got {totals['total_equity']}"
        )
        assert totals["total_liabilities"] == Decimal("0.00"), (
            f"Expected total_liabilities == 0, got {totals['total_liabilities']}"
        )

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_sfp_balanced_after_loan_received(self, amount):
        """
        Property 5: SFP Balance Equation — loan received.
        **Validates: Requirements 5.1, 5.2**

        Dr Asset / Cr Liability (loan received): both assets and liabilities
        increase by the same amount; equity unchanged.
        The SFP MUST remain balanced.
        """
        # Dr Cash (asset) / Cr Payable (liability)
        self._post_entry(debit_idx=0, credit_idx=2, amount=amount)

        result = self._assert_sfp_balanced()

        totals = result["totals"]
        assert abs(totals["total_assets"] - amount) < Decimal("0.01"), (
            f"Expected total_assets ≈ {amount}, got {totals['total_assets']}"
        )
        assert abs(totals["total_liabilities"] - amount) < Decimal("0.01"), (
            f"Expected total_liabilities ≈ {amount}, got {totals['total_liabilities']}"
        )
        assert totals["total_equity"] == Decimal("0.00"), (
            f"Expected total_equity == 0 after loan received, got {totals['total_equity']}"
        )

    @settings(max_examples=30, deadline=None)
    @given(
        inject_amount=_amount_strategy(),
        repay_amount=_amount_strategy("1.00", "5000.00"),
    )
    def test_sfp_balanced_after_capital_then_loan_repayment(
        self, inject_amount, repay_amount
    ):
        """
        Property 5: SFP Balance Equation — capital injection + partial loan repayment.
        **Validates: Requirements 5.1, 5.2**

        Two-step scenario:
          1. Dr Cash / Cr Equity          (capital injection)
          2. Dr Liability / Cr Cash       (loan repayment, only if liability exists)

        Repayment is capped at the injected capital to keep asset balances
        non-negative (avoid balance going negative, which would still balance
        the equation but could cause unexpected accounting behaviour).
        """
        from hypothesis import assume

        # Repayment cannot exceed injection in this simple two-step scenario
        assume(repay_amount <= inject_amount)

        # Step 1: capital injection
        self._post_entry(debit_idx=0, credit_idx=4, amount=inject_amount)

        # Post a liability first so we can repay it:
        # Dr Cash / Cr Borrowings (liability)
        self._post_entry(debit_idx=0, credit_idx=3, amount=repay_amount)

        # Step 2: repay the liability
        # Dr Borrowings (liability) / Cr Cash
        self._post_entry(debit_idx=3, credit_idx=0, amount=repay_amount)

        self._assert_sfp_balanced()

    @settings(max_examples=20, deadline=None)
    def test_sfp_balanced_with_no_entries(self):
        """
        Property 5: SFP Balance Equation — empty ledger.
        **Validates: Requirements 5.1, 5.2**

        When no journal entries have been posted (all balances are zero),
        generate_balance_sheet MUST return balanced=True and difference=0.00.
        Assets (0) == Liabilities (0) + Equity (0) is trivially true.
        """
        result = self._assert_sfp_balanced()

        totals = result["totals"]
        assert totals["total_assets"] == Decimal("0.00"), (
            f"Expected zero total_assets for empty ledger, got {totals['total_assets']}"
        )
        assert totals["total_liabilities"] == Decimal("0.00"), (
            f"Expected zero total_liabilities for empty ledger, got {totals['total_liabilities']}"
        )
        assert totals["total_equity"] == Decimal("0.00"), (
            f"Expected zero total_equity for empty ledger, got {totals['total_equity']}"
        )

    @settings(max_examples=30, deadline=None)
    @given(entries=entry_sequence())
    def test_sfp_report_metadata_present(self, entries):
        """
        Property 5 + Property 10 (metadata): for any set of balanced A/L/E
        entries, the returned report MUST include 'source', 'generated_at',
        and 'as_of_date' metadata fields.

        **Validates: Requirements 5.1, 5.2**
        """
        for debit_idx, credit_idx, amount in entries:
            self._post_entry(debit_idx, credit_idx, amount)

        result = self._assert_sfp_balanced()

        assert "source" in result, "Report missing 'source' metadata field"
        assert "generated_at" in result, "Report missing 'generated_at' metadata field"
        assert "as_of_date" in result, "Report missing 'as_of_date' metadata field"
        assert result["source"] == "accounting_system", (
            f"Expected source='accounting_system', got {result['source']!r}"
        )
        assert result["as_of_date"] == self.as_of_date, (
            f"Expected as_of_date={self.as_of_date}, got {result['as_of_date']}"
        )
