"""
Property-based tests for IS Category Coverage (Property 8).

Property 8: IS Category Coverage
For any income or expense account that has non-zero period activity, the
Income Statement SHALL assign that account to exactly one category from
the defined category set:
  - Income:  interest_income | fee_income | other_income
  - Expense: interest_expense | loan_loss_provision | staff_costs |
             administrative_expenses | depreciation | other_expenses

**Validates: Requirements 3.1, 3.2, 3.3**

Uses Hypothesis (hypothesis.extra.django) to generate arbitrary combinations
of income/expense accounts (varying code prefix and subtype) and transaction
amounts, posts journal entries, then asserts:
  1. Every account with non-zero period activity appears in exactly one
     category bucket in the returned Income Statement.
  2. The assigned category is always one of the defined set (never an
     unknown or missing key).
  3. No account appears in more than one category (mutual exclusion).
  4. No account with non-zero activity is silently dropped (coverage).
"""

import uuid
from datetime import date
from decimal import Decimal
from typing import List, Tuple

from hypothesis import assume, 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()

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

VALID_INCOME_CATEGORIES = frozenset(
    {"interest_income", "fee_income", "other_income"}
)
VALID_EXPENSE_CATEGORIES = frozenset(
    {
        "interest_expense",
        "loan_loss_provision",
        "staff_costs",
        "administrative_expenses",
        "depreciation",
        "other_expenses",
    }
)

# Tax codes excluded from expense grouping (handled separately after operating profit)
TAX_CODES = frozenset({"5070", "5071"})

# Fixed period so all generated accounts share one open fiscal period
_PERIOD_START = date(2020, 1, 1)
_PERIOD_END = date(2030, 12, 31)
_TX_DATE = date(2024, 6, 15)
_IS_START = date(2024, 6, 1)
_IS_END = date(2024, 6, 30)

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


def _amount_strategy() -> st.SearchStrategy:
    """Positive Decimal with exactly 2 decimal places."""
    return st.decimals(
        min_value=Decimal("1.00"),
        max_value=Decimal("9999.00"),
        places=2,
        allow_nan=False,
        allow_infinity=False,
    )


# Income subtypes that the grouping logic recognises
_INCOME_SUBTYPES = [
    "interest_income",
    "fee_income",
    "commission_income",
    "other_income",
    "dividend_income",
    "",           # no subtype → falls through to code/name logic
]

# Expense subtypes that the grouping logic recognises
_EXPENSE_SUBTYPES = [
    "interest_expense",
    "loan_loss_provision",
    "ecl",
    "staff_costs",
    "administrative_expense",
    "depreciation",
    "operating_expense",
    "financial_expenses",
    "",           # no subtype → falls through to code/name logic
]

# Income account code prefixes
_INCOME_CODE_PREFIXES = ["401", "402", "403", "404", "409"]

# Expense account code prefixes (excluding tax 5070/5071)
_EXPENSE_CODE_PREFIXES = ["501", "502", "503", "504", "505", "506", "507", "508", "509"]


@st.composite
def income_account_spec(draw) -> Tuple[str, str]:
    """
    Generate (code_prefix, subtype) for an income account.
    Returns a tuple so Hypothesis can shrink it sensibly.
    """
    code_prefix = draw(st.sampled_from(_INCOME_CODE_PREFIXES))
    subtype = draw(st.sampled_from(_INCOME_SUBTYPES))
    return code_prefix, subtype


@st.composite
def expense_account_spec(draw) -> Tuple[str, str]:
    """
    Generate (code_prefix, subtype) for an expense account.
    Excludes tax codes 5070/5071 from the generated set.
    """
    code_prefix = draw(st.sampled_from(_EXPENSE_CODE_PREFIXES))
    subtype = draw(st.sampled_from(_EXPENSE_SUBTYPES))
    return code_prefix, subtype


@st.composite
def multi_account_specs(draw) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
    """
    Generate 1-4 income account specs and 1-4 expense account specs.
    """
    n_income = draw(st.integers(min_value=1, max_value=4))
    n_expense = draw(st.integers(min_value=1, max_value=4))
    income_specs = [draw(income_account_spec()) for _ in range(n_income)]
    expense_specs = [draw(expense_account_spec()) for _ in range(n_expense)]
    return income_specs, expense_specs


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


class ISCategoryCoveragePropertyTest(TestCase):
    """
    Property 8: IS Category Coverage
    **Validates: Requirements 3.1, 3.2, 3.3**

    For any income or expense account with non-zero period activity, the
    Income Statement MUST assign that account to exactly one category from
    the defined set.
    """

    def setUp(self):
        """Create the minimal shared fixture."""
        suffix = uuid.uuid4().hex[:8]

        self.user = User.objects.create_user(
            username=f"is_cat_prop_{suffix}",
            email=f"is_cat_{suffix}@example.com",
            password="testpass123",
            phone_number=f"+2547{suffix[:8]}",
        )

        self.branch = Branch.objects.create(
            name=f"ISCatBranch_{suffix}",
            code=f"ISC{suffix[:4].upper()}",
        )

        # Open fiscal period wide enough for all generated dates.
        self.fiscal_period, _ = FiscalPeriod.objects.get_or_create(
            name="IS Category Coverage Test Period",
            defaults=dict(
                period_type="monthly",
                start_date=_PERIOD_START,
                end_date=_PERIOD_END,
                status="open",
            ),
        )

        # Reusable cash/asset account as the double-entry counterpart.
        self.cash_account = Account.objects.create(
            code=f"10{suffix[:5]}",
            name=f"Cash_{suffix}",
            account_type="asset",
            subtype="current_asset",
            description="Property test cash account",
            is_active=True,
            created_by=self.user,
        )

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

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

    def _uid(self) -> str:
        return uuid.uuid4().hex[:8]

    def _make_income_account(self, code_prefix: str, subtype: str) -> Account:
        """Create an income account with the given code prefix and subtype."""
        uid = self._uid()
        # Build a 6-char code starting with the requested prefix
        code = f"{code_prefix}{uid[:3]}"
        return Account.objects.create(
            code=code,
            name=f"Income_{uid}",
            account_type="income",
            subtype=subtype or None,
            description="IS coverage property test income account",
            is_active=True,
            created_by=self.user,
        )

    def _make_expense_account(self, code_prefix: str, subtype: str) -> Account:
        """Create an expense account with the given code prefix and subtype."""
        uid = self._uid()
        code = f"{code_prefix}{uid[:3]}"
        # Make absolutely sure it is not a tax code
        assume(code not in TAX_CODES)
        return Account.objects.create(
            code=code,
            name=f"Expense_{uid}",
            account_type="expense",
            subtype=subtype or None,
            description="IS coverage property test expense account",
            is_active=True,
            created_by=self.user,
        )

    def _post_income_entry(self, account: Account, amount: Decimal) -> None:
        """
        Post a balanced income entry:
            Dr Cash (asset)   amount
            Cr Income         amount
        """
        je = JournalEntry.objects.create(
            reference_number=f"INC-{self._uid()}",
            transaction_date=_TX_DATE,
            description="IS category coverage income entry",
            branch=self.branch,
            created_by=self.user,
            status="draft",
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=self.cash_account,
            description="Cash in",
            debit_amount=amount,
            credit_amount=Decimal("0.00"),
            line_number=1,
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=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, account: Account, amount: Decimal) -> None:
        """
        Post a balanced expense entry:
            Dr Expense   amount
            Cr Cash      amount
        """
        je = JournalEntry.objects.create(
            reference_number=f"EXP-{self._uid()}",
            transaction_date=_TX_DATE,
            description="IS category coverage expense entry",
            branch=self.branch,
            created_by=self.user,
            status="draft",
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=account,
            description="Expense incurred",
            debit_amount=amount,
            credit_amount=Decimal("0.00"),
            line_number=1,
        )
        JournalEntryLine.objects.create(
            journal_entry=je,
            account=self.cash_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 _collect_all_account_entries(self, report: dict) -> Tuple[dict, dict]:
        """
        Walk the IS report and build two maps:
            income_map  : { account_code -> [category, ...] }
            expense_map : { account_code -> [category, ...] }

        A code appearing in more than one list indicates a coverage bug.
        """
        income_map: dict = {}
        expense_map: dict = {}

        income_section = report.get("income", {})
        for category, data in income_section.items():
            for entry in data.get("accounts", []):
                code = entry.get("code") or entry.get("account_code")
                income_map.setdefault(code, []).append(category)

        expense_section = report.get("expenses", {})
        for category, data in expense_section.items():
            for entry in data.get("accounts", []):
                code = entry.get("code") or entry.get("account_code")
                expense_map.setdefault(code, []).append(category)

        return income_map, expense_map

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

    @settings(max_examples=60, deadline=None)
    @given(
        income_specs=st.lists(income_account_spec(), min_size=1, max_size=4),
        amounts=st.lists(_amount_strategy(), min_size=1, max_size=4),
    )
    def test_income_accounts_assigned_to_exactly_one_valid_category(
        self, income_specs, amounts
    ):
        """
        Property 8 — Income accounts:
        For any income account with non-zero period activity, the IS MUST
        assign it to exactly one category in {interest_income, fee_income,
        other_income} — never zero, never two.

        **Validates: Requirements 3.1, 3.3**
        """
        from django.core.cache import cache as django_cache

        # Pair each spec with one amount (cycle amounts if needed)
        created_accounts: List[Account] = []
        for i, (code_prefix, subtype) in enumerate(income_specs):
            amount = amounts[i % len(amounts)]
            account = self._make_income_account(code_prefix, subtype)
            created_accounts.append(account)
            self._post_income_entry(account, amount)

        # Clear cache so we read fresh GL data
        django_cache.clear()

        report = self.report_service.generate_income_statement(
            _IS_START, _IS_END, branch=None
        )

        income_map, _ = self._collect_all_account_entries(report)

        for account in created_accounts:
            code = str(account.code)
            categories_for_account = income_map.get(code, [])

            # Property 8: non-zero activity → appears in exactly one category
            assert len(categories_for_account) >= 1, (
                f"Property 8 violated (income, coverage): account '{account.name}' "
                f"(code={code}, subtype={account.subtype!r}) with non-zero activity "
                f"was NOT found in any income category of the IS.\n"
                f"  IS income keys: {list(report.get('income', {}).keys())}"
            )
            assert len(categories_for_account) == 1, (
                f"Property 8 violated (income, exclusion): account '{account.name}' "
                f"(code={code}, subtype={account.subtype!r}) appeared in "
                f"{len(categories_for_account)} categories: {categories_for_account}. "
                f"Each account must appear in exactly ONE category."
            )

            assigned_category = categories_for_account[0]
            assert assigned_category in VALID_INCOME_CATEGORIES, (
                f"Property 8 violated (income, validity): account '{account.name}' "
                f"(code={code}, subtype={account.subtype!r}) was assigned to unknown "
                f"category '{assigned_category}'. "
                f"Valid income categories: {sorted(VALID_INCOME_CATEGORIES)}"
            )

    @settings(max_examples=60, deadline=None)
    @given(
        expense_specs=st.lists(expense_account_spec(), min_size=1, max_size=4),
        amounts=st.lists(_amount_strategy(), min_size=1, max_size=4),
    )
    def test_expense_accounts_assigned_to_exactly_one_valid_category(
        self, expense_specs, amounts
    ):
        """
        Property 8 — Expense accounts:
        For any expense account (excluding tax codes 5070/5071) with non-zero
        period activity, the IS MUST assign it to exactly one category in
        {interest_expense, loan_loss_provision, staff_costs,
        administrative_expenses, depreciation, other_expenses} — never zero,
        never two.

        **Validates: Requirements 3.2, 3.3**
        """
        from django.core.cache import cache as django_cache

        created_accounts: List[Account] = []
        for i, (code_prefix, subtype) in enumerate(expense_specs):
            amount = amounts[i % len(amounts)]
            account = self._make_expense_account(code_prefix, subtype)
            created_accounts.append(account)
            self._post_expense_entry(account, amount)

        django_cache.clear()

        report = self.report_service.generate_income_statement(
            _IS_START, _IS_END, branch=None
        )

        _, expense_map = self._collect_all_account_entries(report)

        for account in created_accounts:
            code = str(account.code)

            # Tax codes are excluded from expense grouping — skip the assertion.
            if code in TAX_CODES:
                continue

            categories_for_account = expense_map.get(code, [])

            # Property 8: non-zero activity → appears in exactly one category
            assert len(categories_for_account) >= 1, (
                f"Property 8 violated (expense, coverage): account '{account.name}' "
                f"(code={code}, subtype={account.subtype!r}) with non-zero activity "
                f"was NOT found in any expense category of the IS.\n"
                f"  IS expense keys: {list(report.get('expenses', {}).keys())}"
            )
            assert len(categories_for_account) == 1, (
                f"Property 8 violated (expense, exclusion): account '{account.name}' "
                f"(code={code}, subtype={account.subtype!r}) appeared in "
                f"{len(categories_for_account)} categories: {categories_for_account}. "
                f"Each account must appear in exactly ONE category."
            )

            assigned_category = categories_for_account[0]
            assert assigned_category in VALID_EXPENSE_CATEGORIES, (
                f"Property 8 violated (expense, validity): account '{account.name}' "
                f"(code={code}, subtype={account.subtype!r}) was assigned to unknown "
                f"category '{assigned_category}'. "
                f"Valid expense categories: {sorted(VALID_EXPENSE_CATEGORIES)}"
            )

    @settings(max_examples=50, deadline=None)
    @given(
        income_specs=st.lists(income_account_spec(), min_size=1, max_size=3),
        expense_specs=st.lists(expense_account_spec(), min_size=1, max_size=3),
        amounts=st.lists(_amount_strategy(), min_size=1, max_size=3),
    )
    def test_income_and_expense_categories_are_mutually_exclusive(
        self, income_specs, expense_specs, amounts
    ):
        """
        Property 8 — Mutual exclusion across account types:
        An income account MUST NOT appear in any expense category and vice
        versa.  The income and expense category sets must be disjoint per
        account.

        **Validates: Requirements 3.1, 3.2, 3.3**
        """
        from django.core.cache import cache as django_cache

        income_accounts: List[Account] = []
        expense_accounts: List[Account] = []

        for i, (code_prefix, subtype) in enumerate(income_specs):
            amount = amounts[i % len(amounts)]
            account = self._make_income_account(code_prefix, subtype)
            income_accounts.append(account)
            self._post_income_entry(account, amount)

        for i, (code_prefix, subtype) in enumerate(expense_specs):
            amount = amounts[i % len(amounts)]
            account = self._make_expense_account(code_prefix, subtype)
            expense_accounts.append(account)
            self._post_expense_entry(account, amount)

        django_cache.clear()

        report = self.report_service.generate_income_statement(
            _IS_START, _IS_END, branch=None
        )

        income_map, expense_map = self._collect_all_account_entries(report)

        # Income accounts must not appear in expense section
        for account in income_accounts:
            code = str(account.code)
            assert code not in expense_map, (
                f"Property 8 violated (cross-section): income account '{account.name}' "
                f"(code={code}) appeared in expense categories: {expense_map[code]}."
            )

        # Expense accounts must not appear in income section
        for account in expense_accounts:
            code = str(account.code)
            assert code not in income_map, (
                f"Property 8 violated (cross-section): expense account '{account.name}' "
                f"(code={code}) appeared in income categories: {income_map[code]}."
            )

    @settings(max_examples=40, deadline=None)
    @given(
        code_prefix=st.sampled_from(_INCOME_CODE_PREFIXES),
        subtype=st.sampled_from(_INCOME_SUBTYPES),
        amount=_amount_strategy(),
    )
    def test_catch_all_income_subtype_goes_to_other_income(
        self, code_prefix, subtype, amount
    ):
        """
        Property 8 — Catch-all (income):
        Any income account whose subtype does NOT match interest_income or
        fee_income/commission_income AND whose code does not start with 401
        or 402 SHALL be assigned to other_income (Requirements 3.3).

        **Validates: Requirements 3.3**
        """
        from django.core.cache import cache as django_cache

        # Only relevant for accounts that would fall through to catch-all
        is_interest = subtype == "interest_income" or code_prefix == "401"
        is_fee = subtype in ("fee_income", "commission_income") or code_prefix == "402"
        assume(not is_interest and not is_fee)

        account = self._make_income_account(code_prefix, subtype)
        self._post_income_entry(account, amount)

        django_cache.clear()

        report = self.report_service.generate_income_statement(
            _IS_START, _IS_END, branch=None
        )

        income_map, _ = self._collect_all_account_entries(report)
        code = str(account.code)
        categories = income_map.get(code, [])

        assert len(categories) == 1, (
            f"Catch-all income account '{account.name}' (code={code}, "
            f"subtype={subtype!r}) should appear in exactly one category, "
            f"got {categories}."
        )
        assert categories[0] == "other_income", (
            f"Catch-all income account '{account.name}' (code={code}, "
            f"subtype={subtype!r}) should map to 'other_income', "
            f"got '{categories[0]}'."
        )

    @settings(max_examples=40, deadline=None)
    @given(
        code_prefix=st.sampled_from(_EXPENSE_CODE_PREFIXES),
        subtype=st.sampled_from(_EXPENSE_SUBTYPES),
        amount=_amount_strategy(),
    )
    def test_catch_all_expense_subtype_goes_to_other_expenses(
        self, code_prefix, subtype, amount
    ):
        """
        Property 8 — Catch-all (expenses):
        Any expense account whose subtype and code prefix do not match any of
        the 5 named categories SHALL be assigned to other_expenses
        (Requirements 3.3).

        **Validates: Requirements 3.3**
        """
        from django.core.cache import cache as django_cache

        # Only test accounts that genuinely fall through to the catch-all
        known_subtypes = {
            "interest_expense", "loan_loss_provision", "ecl",
            "staff_costs", "administrative_expense", "depreciation",
        }
        known_code_prefixes = {"501", "502", "503", "504", "505"}

        falls_through = (
            subtype not in known_subtypes
            and code_prefix not in known_code_prefixes
        )
        assume(falls_through)

        account = self._make_expense_account(code_prefix, subtype)
        code = str(account.code)
        assume(code not in TAX_CODES)

        self._post_expense_entry(account, amount)

        django_cache.clear()

        report = self.report_service.generate_income_statement(
            _IS_START, _IS_END, branch=None
        )

        _, expense_map = self._collect_all_account_entries(report)
        categories = expense_map.get(code, [])

        assert len(categories) == 1, (
            f"Catch-all expense account '{account.name}' (code={code}, "
            f"subtype={subtype!r}) should appear in exactly one category, "
            f"got {categories}."
        )
        assert categories[0] == "other_expenses", (
            f"Catch-all expense account '{account.name}' (code={code}, "
            f"subtype={subtype!r}) should map to 'other_expenses', "
            f"got '{categories[0]}'."
        )

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_zero_activity_accounts_not_included_in_is(self, amount):
        """
        Property 8 — Zero activity exclusion:
        Accounts with zero period activity (no postings) MUST NOT appear
        in any IS category. The report only contains accounts that actually
        moved during the period.

        **Validates: Requirements 3.1, 3.2**
        """
        from django.core.cache import cache as django_cache

        # Create accounts with ZERO activity (no postings)
        zero_income = self._make_income_account("403", "other_income")
        zero_expense = self._make_expense_account("506", "operating_expense")

        # Create accounts WITH activity
        active_income = self._make_income_account("401", "interest_income")
        active_expense = self._make_expense_account("503", "staff_costs")
        self._post_income_entry(active_income, amount)
        self._post_expense_entry(active_expense, amount)

        django_cache.clear()

        report = self.report_service.generate_income_statement(
            _IS_START, _IS_END, branch=None
        )

        income_map, expense_map = self._collect_all_account_entries(report)

        # Zero-activity accounts must not appear
        assert str(zero_income.code) not in income_map, (
            f"Zero-activity income account '{zero_income.name}' "
            f"(code={zero_income.code}) should not appear in IS income section."
        )
        assert str(zero_expense.code) not in expense_map, (
            f"Zero-activity expense account '{zero_expense.name}' "
            f"(code={zero_expense.code}) should not appear in IS expense section."
        )

        # Active accounts must appear
        assert str(active_income.code) in income_map, (
            f"Active income account '{active_income.name}' "
            f"(code={active_income.code}) should appear in IS income section."
        )
        assert str(active_expense.code) in expense_map, (
            f"Active expense account '{active_expense.name}' "
            f"(code={active_expense.code}) should appear in IS expense section."
        )
