"""
Property-based tests for Report Metadata Completeness (Property 10).

Property 10: Report Metadata Completeness
For any financial report generated by ReportService (Trial Balance, Income
Statement, or Balance Sheet), the returned data structure SHALL contain
non-null values for `source`, `generated_at`, and either `period`
(with `start` and `end`) or `as_of_date`.

**Validates: Requirements 12.1**

Strategy
--------
- Income Statement and Trial Balance: use both income/expense accounts (IS)
  and asset/liability/equity accounts (TB) with arbitrary balanced entries.
- Balance Sheet (SFP): use ONLY asset/liability/equity journal entries to keep
  net_income == 0.00, avoiding FinancialStatementImbalanceError.
- All three report generators are tested independently for metadata presence.
"""

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 (indices into self.accounts).

    Account index map:
        0 = asset  (current_asset)
        1 = asset2 (fixed_asset)
        2 = liability (current_liability)
        3 = liability2 (borrowings)
        4 = equity (retained_earnings)
    """
    pattern = draw(st.integers(min_value=0, max_value=4))
    amount = draw(_amount_strategy())
    # (debit_index, credit_index)
    patterns = [
        (0, 4),   # Dr Asset / Cr Equity   — capital injection
        (0, 2),   # Dr Asset / Cr Liability — loan received
        (2, 0),   # Dr Liability / Cr Asset — loan repaid
        (0, 1),   # Dr Asset1 / Cr Asset2  — asset swap
        (4, 0),   # Dr Equity / Cr Asset   — owner withdrawal
    ]
    debit_idx, credit_idx = patterns[pattern]
    return (debit_idx, credit_idx, amount)


@st.composite
def ale_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)]


@st.composite
def balanced_is_entry(draw):
    """
    Generate a single balanced journal entry spec using income/expense accounts.

    Account index map (additional accounts beyond A/L/E):
        5 = income (interest_income)
        6 = expense (administrative)
        asset account index 0 is the counterpart.
    """
    pattern = draw(st.integers(min_value=0, max_value=1))
    amount = draw(_amount_strategy())
    patterns = [
        (0, 5),   # Dr Asset / Cr Income  — revenue earned
        (6, 0),   # Dr Expense / Cr Asset — expense incurred
    ]
    debit_idx, credit_idx = patterns[pattern]
    return (debit_idx, credit_idx, amount)


@st.composite
def is_entry_sequence(draw):
    """Generate a non-empty list of 1–4 balanced IS entry specs."""
    num = draw(st.integers(min_value=1, max_value=4))
    return [draw(balanced_is_entry()) for _ in range(num)]


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

class ReportMetadataCompletenessPropertyTest(TestCase):
    """
    Property 10: Report Metadata Completeness
    **Validates: Requirements 12.1**

    For any generated IS, SFP, or TB report, the returned dict MUST contain:
      - non-null `source`
      - non-null `generated_at`
      - either `period` (IS / TB) with non-null `start` and `end`
        OR `as_of_date` (SFP) that is non-null

    SFP tests use only asset/liability/equity entries to keep net_income == 0
    and avoid FinancialStatementImbalanceError.
    """

    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() runs fresh for
        every Hypothesis example — giving each example a clean DB.
        """
        suffix = uuid.uuid4().hex[:8]
        phone_suffix = uuid.uuid4().int % 10 ** 9

        self.user = User.objects.create_user(
            username=f"meta_prop_user_{suffix}",
            email=f"meta_prop_{suffix}@example.com",
            password="testpass123",
            phone_number=f"+254{phone_suffix:09d}",
        )

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

        # Open fiscal period wide enough for any test date
        self.fiscal_period = FiscalPeriod.objects.create(
            name=f"Meta Property Test Period {suffix}",
            period_type="monthly",
            start_date=date(2020, 1, 1),
            end_date=date(2030, 12, 31),
            status="open",
        )

        # --- Chart of Accounts ---
        # Indices 0–4: A/L/E (used by SFP and as counterparts for IS entries)
        # Indices 5–6: Income and Expense (used by IS tests)
        self.accounts = [
            # 0: current asset
            Account.objects.create(
                code=f"10{suffix[:4]}",
                name="Meta Cash",
                account_type="asset",
                subtype="current_asset",
                description="Metadata test cash account",
                is_active=True,
                created_by=self.user,
            ),
            # 1: fixed asset
            Account.objects.create(
                code=f"11{suffix[:4]}",
                name="Meta Fixed Asset",
                account_type="asset",
                subtype="fixed_asset",
                description="Metadata test fixed asset",
                is_active=True,
                created_by=self.user,
            ),
            # 2: current liability
            Account.objects.create(
                code=f"20{suffix[:4]}",
                name="Meta Payable",
                account_type="liability",
                subtype="current_liability",
                description="Metadata test payable",
                is_active=True,
                created_by=self.user,
            ),
            # 3: borrowings (liability)
            Account.objects.create(
                code=f"21{suffix[:4]}",
                name="Meta Borrowings",
                account_type="liability",
                subtype="borrowings",
                description="Metadata test borrowings",
                is_active=True,
                created_by=self.user,
            ),
            # 4: equity / retained earnings
            Account.objects.create(
                code=f"30{suffix[:4]}",
                name="Meta Retained Earnings",
                account_type="equity",
                description="Metadata test retained earnings",
                is_active=True,
                created_by=self.user,
            ),
            # 5: income account
            Account.objects.create(
                code=f"40{suffix[:4]}",
                name="Meta Interest Income",
                account_type="income",
                subtype="interest_income",
                description="Metadata test income",
                is_active=True,
                created_by=self.user,
            ),
            # 6: expense account
            Account.objects.create(
                code=f"50{suffix[:4]}",
                name="Meta Admin Expense",
                account_type="expense",
                subtype="administrative_expense",
                description="Metadata test expense",
                is_active=True,
                created_by=self.user,
            ),
        ]

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

        # Fixed dates used across all property tests
        self.as_of_date = date(2024, 6, 15)
        self.period_start = date(2024, 6, 1)
        self.period_end = 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,
        tx_date: date = None,
    ) -> None:
        """Build and post a single balanced journal entry."""
        if tx_date is None:
            tx_date = self.as_of_date
        je = JournalEntry.objects.create(
            reference_number=self._ref("META"),
            transaction_date=tx_date,
            description=f"Metadata prop test {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)

    # -----------------------------------------------------------------------
    # Shared metadata assertion helpers
    # -----------------------------------------------------------------------

    def _assert_common_metadata(self, result: dict, report_name: str) -> None:
        """
        Assert that `source` and `generated_at` are present and non-null
        in the report dict.
        """
        assert "source" in result, (
            f"{report_name}: report dict is missing 'source' key. "
            f"Keys present: {list(result.keys())}"
        )
        assert result["source"] is not None, (
            f"{report_name}: 'source' is None — expected a non-null string."
        )
        assert result["source"] != "", (
            f"{report_name}: 'source' is an empty string."
        )

        assert "generated_at" in result, (
            f"{report_name}: report dict is missing 'generated_at' key. "
            f"Keys present: {list(result.keys())}"
        )
        assert result["generated_at"] is not None, (
            f"{report_name}: 'generated_at' is None — expected a non-null timestamp."
        )
        assert result["generated_at"] != "", (
            f"{report_name}: 'generated_at' is an empty string."
        )

    def _assert_period_metadata(self, result: dict, report_name: str) -> None:
        """
        Assert that the report dict contains a non-null `period` field with
        non-null `start` and `end` sub-keys (required for IS and TB reports).
        """
        assert "period" in result, (
            f"{report_name}: report dict is missing 'period' key. "
            f"Keys present: {list(result.keys())}"
        )
        period = result["period"]
        assert period is not None, (
            f"{report_name}: 'period' is None."
        )
        assert "start" in period, (
            f"{report_name}: 'period' dict is missing 'start' sub-key."
        )
        assert "end" in period, (
            f"{report_name}: 'period' dict is missing 'end' sub-key."
        )
        assert period["start"] is not None, (
            f"{report_name}: period['start'] is None."
        )
        assert period["end"] is not None, (
            f"{report_name}: period['end'] is None."
        )

    def _assert_as_of_date_metadata(self, result: dict, report_name: str) -> None:
        """
        Assert that the report dict contains a non-null `as_of_date` field
        (required for SFP / Balance Sheet reports).
        """
        assert "as_of_date" in result, (
            f"{report_name}: report dict is missing 'as_of_date' key. "
            f"Keys present: {list(result.keys())}"
        )
        assert result["as_of_date"] is not None, (
            f"{report_name}: 'as_of_date' is None — expected a non-null date."
        )

    # -----------------------------------------------------------------------
    # Income Statement metadata tests
    # -----------------------------------------------------------------------

    @settings(max_examples=40, deadline=None)
    @given(entries=is_entry_sequence())
    def test_income_statement_metadata_present_after_is_entries(self, entries):
        """
        Property 10: IS metadata completeness — with income/expense entries.
        **Validates: Requirements 12.1**

        For any sequence of balanced income/expense journal entries, the
        Income Statement MUST include non-null `source`, `generated_at`,
        and `period` (with `start` and `end`).
        """
        for debit_idx, credit_idx, amount in entries:
            self._post_entry(debit_idx, credit_idx, amount)

        django_cache.clear()
        result = self.report_service.generate_income_statement(
            self.period_start, self.period_end, branch=None
        )

        self._assert_common_metadata(result, "Income Statement")
        self._assert_period_metadata(result, "Income Statement")

    @settings(max_examples=20, deadline=None)
    def test_income_statement_metadata_present_with_no_entries(self):
        """
        Property 10: IS metadata completeness — empty ledger.
        **Validates: Requirements 12.1**

        When no journal entries exist, generate_income_statement MUST still
        return a report dict with non-null `source`, `generated_at`, and
        `period`.
        """
        django_cache.clear()
        result = self.report_service.generate_income_statement(
            self.period_start, self.period_end, branch=None
        )

        self._assert_common_metadata(result, "Income Statement (empty ledger)")
        self._assert_period_metadata(result, "Income Statement (empty ledger)")

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_income_statement_period_matches_requested_range(self, amount):
        """
        Property 10: IS period values match requested start/end.
        **Validates: Requirements 12.1**

        The `period.start` and `period.end` in the returned dict MUST equal
        the `start_date` and `end_date` arguments passed to
        generate_income_statement.
        """
        self._post_entry(0, 5, amount)  # Dr Asset / Cr Income

        django_cache.clear()
        result = self.report_service.generate_income_statement(
            self.period_start, self.period_end, branch=None
        )

        self._assert_period_metadata(result, "Income Statement period values")
        assert result["period"]["start"] == self.period_start, (
            f"Expected period.start={self.period_start}, "
            f"got {result['period']['start']}"
        )
        assert result["period"]["end"] == self.period_end, (
            f"Expected period.end={self.period_end}, "
            f"got {result['period']['end']}"
        )

    # -----------------------------------------------------------------------
    # Balance Sheet (SFP) metadata tests
    # -----------------------------------------------------------------------

    @settings(max_examples=40, deadline=None)
    @given(entries=ale_entry_sequence())
    def test_balance_sheet_metadata_present_after_ale_entries(self, entries):
        """
        Property 10: SFP metadata completeness — with A/L/E entries.
        **Validates: Requirements 12.1**

        For any sequence of balanced A/L/E journal entries, the Balance Sheet
        MUST include non-null `source`, `generated_at`, and `as_of_date`.
        Uses only A/L/E accounts so net_income == 0.00 and no
        FinancialStatementImbalanceError is raised.
        """
        for debit_idx, credit_idx, amount in entries:
            self._post_entry(debit_idx, credit_idx, amount)

        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"generate_balance_sheet raised FinancialStatementImbalanceError "
                f"unexpectedly (difference={exc.difference}). "
                f"Only A/L/E entries were posted — net_income should be 0."
            ) from exc

        self._assert_common_metadata(result, "Balance Sheet")
        self._assert_as_of_date_metadata(result, "Balance Sheet")

    @settings(max_examples=20, deadline=None)
    def test_balance_sheet_metadata_present_with_no_entries(self):
        """
        Property 10: SFP metadata completeness — empty ledger.
        **Validates: Requirements 12.1**

        When no journal entries exist, generate_balance_sheet MUST return a
        report dict with non-null `source`, `generated_at`, and `as_of_date`.
        """
        django_cache.clear()
        result = self.report_service.generate_balance_sheet(
            as_of_date=self.as_of_date, branch=None
        )

        self._assert_common_metadata(result, "Balance Sheet (empty ledger)")
        self._assert_as_of_date_metadata(result, "Balance Sheet (empty ledger)")

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_balance_sheet_as_of_date_matches_requested_date(self, amount):
        """
        Property 10: SFP as_of_date value matches requested date.
        **Validates: Requirements 12.1**

        The `as_of_date` in the returned dict MUST equal the `as_of_date`
        argument passed to generate_balance_sheet.
        """
        self._post_entry(0, 4, amount)  # Dr Asset / Cr Equity

        django_cache.clear()
        result = self.report_service.generate_balance_sheet(
            as_of_date=self.as_of_date, branch=None
        )

        self._assert_as_of_date_metadata(result, "Balance Sheet as_of_date value")
        assert result["as_of_date"] == self.as_of_date, (
            f"Expected as_of_date={self.as_of_date}, got {result['as_of_date']}"
        )

    # -----------------------------------------------------------------------
    # Trial Balance metadata tests
    # -----------------------------------------------------------------------

    @settings(max_examples=40, deadline=None)
    @given(entries=ale_entry_sequence())
    def test_trial_balance_metadata_present_after_ale_entries(self, entries):
        """
        Property 10: TB metadata completeness — with A/L/E entries.
        **Validates: Requirements 12.1**

        For any sequence of balanced A/L/E journal entries, the Trial Balance
        MUST include non-null `source`, `generated_at`, and `period`
        (with `start` and `end`).
        """
        for debit_idx, credit_idx, amount in entries:
            self._post_entry(debit_idx, credit_idx, amount)

        django_cache.clear()
        result = self.report_service.generate_trial_balance_full(
            as_of_date=self.as_of_date,
            branch=None,
            period_start=self.period_start,
        )

        self._assert_common_metadata(result, "Trial Balance")
        self._assert_period_metadata(result, "Trial Balance")

    @settings(max_examples=20, deadline=None)
    def test_trial_balance_metadata_present_with_no_entries(self):
        """
        Property 10: TB metadata completeness — empty ledger.
        **Validates: Requirements 12.1**

        When no journal entries exist, generate_trial_balance_full MUST
        return a report dict with non-null `source`, `generated_at`, and
        `period`.
        """
        django_cache.clear()
        result = self.report_service.generate_trial_balance_full(
            as_of_date=self.as_of_date,
            branch=None,
            period_start=self.period_start,
        )

        self._assert_common_metadata(result, "Trial Balance (empty ledger)")
        self._assert_period_metadata(result, "Trial Balance (empty ledger)")

    @settings(max_examples=30, deadline=None)
    @given(amount=_amount_strategy())
    def test_trial_balance_period_matches_requested_range(self, amount):
        """
        Property 10: TB period values match requested start/end.
        **Validates: Requirements 12.1**

        The `period.start` and `period.end` in the returned dict MUST equal
        the `period_start` and `as_of_date` arguments passed to
        generate_trial_balance_full.
        """
        self._post_entry(0, 4, amount)  # Dr Asset / Cr Equity

        django_cache.clear()
        result = self.report_service.generate_trial_balance_full(
            as_of_date=self.as_of_date,
            branch=None,
            period_start=self.period_start,
        )

        self._assert_period_metadata(result, "Trial Balance period values")
        assert result["period"]["start"] == self.period_start, (
            f"Expected period.start={self.period_start}, "
            f"got {result['period']['start']}"
        )
        assert result["period"]["end"] == self.as_of_date, (
            f"Expected period.end={self.as_of_date}, "
            f"got {result['period']['end']}"
        )

    # -----------------------------------------------------------------------
    # Cross-report: all three reports in one scenario
    # -----------------------------------------------------------------------

    @settings(max_examples=25, deadline=None)
    @given(entries=ale_entry_sequence())
    def test_all_three_reports_have_metadata(self, entries):
        """
        Property 10: all three reports (IS, SFP, TB) have complete metadata.
        **Validates: Requirements 12.1**

        For any set of balanced A/L/E entries (net_income == 0), all three
        ReportService methods MUST return dicts containing `source`,
        `generated_at`, and the appropriate date field (`period` or
        `as_of_date`).

        Uses only A/L/E entries so generate_balance_sheet does not raise
        FinancialStatementImbalanceError due to non-zero net income.
        """
        for debit_idx, credit_idx, amount in entries:
            self._post_entry(debit_idx, credit_idx, amount)

        django_cache.clear()

        # 1. Income Statement
        is_result = self.report_service.generate_income_statement(
            self.period_start, self.period_end, branch=None
        )
        self._assert_common_metadata(is_result, "Income Statement (combined test)")
        self._assert_period_metadata(is_result, "Income Statement (combined test)")

        # 2. Balance Sheet (SFP)
        try:
            sfp_result = self.report_service.generate_balance_sheet(
                as_of_date=self.as_of_date, branch=None
            )
        except FinancialStatementImbalanceError as exc:
            raise AssertionError(
                f"generate_balance_sheet raised FinancialStatementImbalanceError "
                f"(difference={exc.difference}) — only A/L/E entries were posted."
            ) from exc
        self._assert_common_metadata(sfp_result, "Balance Sheet (combined test)")
        self._assert_as_of_date_metadata(sfp_result, "Balance Sheet (combined test)")

        # 3. Trial Balance
        tb_result = self.report_service.generate_trial_balance_full(
            as_of_date=self.as_of_date,
            branch=None,
            period_start=self.period_start,
        )
        self._assert_common_metadata(tb_result, "Trial Balance (combined test)")
        self._assert_period_metadata(tb_result, "Trial Balance (combined test)")

    @settings(max_examples=20, deadline=None)
    def test_all_three_reports_have_metadata_empty_ledger(self):
        """
        Property 10: all three reports have complete metadata even with an
        empty ledger (no journal entries posted).
        **Validates: Requirements 12.1**
        """
        django_cache.clear()

        is_result = self.report_service.generate_income_statement(
            self.period_start, self.period_end, branch=None
        )
        self._assert_common_metadata(is_result, "Income Statement (empty)")
        self._assert_period_metadata(is_result, "Income Statement (empty)")

        sfp_result = self.report_service.generate_balance_sheet(
            as_of_date=self.as_of_date, branch=None
        )
        self._assert_common_metadata(sfp_result, "Balance Sheet (empty)")
        self._assert_as_of_date_metadata(sfp_result, "Balance Sheet (empty)")

        tb_result = self.report_service.generate_trial_balance_full(
            as_of_date=self.as_of_date,
            branch=None,
            period_start=self.period_start,
        )
        self._assert_common_metadata(tb_result, "Trial Balance (empty)")
        self._assert_period_metadata(tb_result, "Trial Balance (empty)")
