"""
Property-based tests for GL Linkage Completeness (Property 1).

Property 1: GL Linkage Completeness
For any JournalEntry that is successfully posted via
AccountingService.post_journal_entry, every JournalEntryLine in that entry
SHALL have exactly one corresponding GeneralLedger record with:
  - the same account
  - the same debit_amount
  - the same credit_amount
  - the same reference_number (equal to JournalEntry.reference_number)
  - a non-null journal_entry FK pointing to the parent JournalEntry
  - a non-null journal_entry_line FK pointing to the originating line

**Validates: Requirements 1.1, 1.3**

Uses Hypothesis to generate arbitrary sets of balanced journal entries (1–5
lines per entry, up to 4 entries per test run) and posts each via
AccountingService.post_journal_entry.  The assertions check the GL table
directly rather than going through any higher-level service, so the test
exercises the raw ledger-creation guarantee.
"""

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 accounting.models import (
    Account,
    FiscalPeriod,
    GeneralLedger,
    JournalEntry,
    JournalEntryLine,
)
from accounting.services.accounting_service import AccountingService
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 positive Decimals 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_entry_lines(draw):
    """
    Generate a list of (debit_account_index, credit_account_index, amount)
    tuples that together form a balanced journal entry (total debits ==
    total credits).

    Strategy: generate N matched pairs — each pair contributes an equal
    debit and credit leg — then flatten into a per-line list.

    Account indices correspond to the four accounts created in setUp:
        0 = asset, 1 = liability, 2 = income, 3 = expense
    """
    n_pairs = draw(st.integers(min_value=1, max_value=3))
    lines = []
    for _ in range(n_pairs):
        # Debit side: asset or expense
        debit_idx = draw(st.sampled_from([0, 3]))
        # Credit side: liability or income
        credit_idx = draw(st.sampled_from([1, 2]))
        amount = draw(_amount_strategy())
        lines.append((debit_idx, credit_idx, amount))
    return lines


@st.composite
def entry_batch(draw):
    """Generate a non-empty list of balanced entry line-specs (up to 4 entries)."""
    num_entries = draw(st.integers(min_value=1, max_value=4))
    return [draw(balanced_entry_lines()) for _ in range(num_entries)]


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

class GLLinkageCompletenessPropertyTest(TestCase):
    """
    Property 1: GL Linkage Completeness
    **Validates: Requirements 1.1, 1.3**

    For any successfully posted JournalEntry, every JournalEntryLine MUST
    have exactly one GeneralLedger record whose:
      - account     matches the line's account
      - debit_amount  matches the line's debit_amount
      - credit_amount matches the line's credit_amount
      - reference_number matches the journal entry's reference_number
      - journal_entry FK is non-null and points to the parent JournalEntry
      - journal_entry_line FK is non-null and points to the originating line
    """

    def setUp(self):
        """Create the minimal shared fixture for each hypothesis example."""
        suffix = uuid.uuid4().hex[:8]
        # phone_number has a unique constraint in the User model; supply a
        # unique value so that multiple Hypothesis examples 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"gl_link_user_{suffix}",
            email=f"gl_link_{suffix}@example.com",
            password="testpass123",
            phone_number=phone_number,
        )

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

        # Open fiscal period wide enough that all generated dates fall inside.
        self.fiscal_period = FiscalPeriod.objects.create(
            name=f"GL Linkage Property Period {suffix}",
            period_type="monthly",
            start_date=date(2020, 1, 1),
            end_date=date(2030, 12, 31),
            status="open",
        )

        # Four accounts: one per account type used by the strategies.
        self.accounts = [
            Account.objects.create(
                code=f"1{suffix[:5]}",
                name="GLLink Asset",
                account_type="asset",
                subtype="current_asset",
                description="GL linkage test — asset",
                is_active=True,
                created_by=self.user,
            ),
            Account.objects.create(
                code=f"2{suffix[:5]}",
                name="GLLink Liability",
                account_type="liability",
                subtype="current_liability",
                description="GL linkage test — liability",
                is_active=True,
                created_by=self.user,
            ),
            Account.objects.create(
                code=f"3{suffix[:5]}",
                name="GLLink Income",
                account_type="income",
                description="GL linkage test — income",
                is_active=True,
                created_by=self.user,
            ),
            Account.objects.create(
                code=f"4{suffix[:5]}",
                name="GLLink Expense",
                account_type="expense",
                description="GL linkage test — expense",
                is_active=True,
                created_by=self.user,
            ),
        ]

        self.accounting_service = AccountingService()

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

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

    def _build_and_post_entry(
        self, line_specs: list, tx_date: date = date(2024, 7, 15)
    ) -> JournalEntry:
        """
        Build a JournalEntry from line_specs and post it.

        Each element of line_specs is
            (debit_account_index, credit_account_index, amount).

        Returns the posted JournalEntry instance.
        """
        ref = self._unique_ref()

        je = JournalEntry.objects.create(
            reference_number=ref,
            transaction_date=tx_date,
            description=f"GL linkage property test entry {ref}",
            branch=self.branch,
            created_by=self.user,
            status="draft",
        )

        line_number = 1
        for debit_idx, credit_idx, amount in line_specs:
            # Debit leg
            JournalEntryLine.objects.create(
                journal_entry=je,
                account=self.accounts[debit_idx],
                description=f"Debit leg {line_number}",
                debit_amount=amount,
                credit_amount=Decimal("0.00"),
                line_number=line_number,
            )
            line_number += 1

            # Credit leg
            JournalEntryLine.objects.create(
                journal_entry=je,
                account=self.accounts[credit_idx],
                description=f"Credit leg {line_number}",
                debit_amount=Decimal("0.00"),
                credit_amount=amount,
                line_number=line_number,
            )
            line_number += 1

        self.accounting_service.post_journal_entry(je, self.user)
        je.refresh_from_db()
        return je

    def _assert_gl_linkage_for_entry(self, je: JournalEntry) -> None:
        """
        Core assertion: for every JournalEntryLine in `je`, there MUST be
        exactly one GeneralLedger record satisfying the linkage contract.
        """
        lines = list(je.lines.all())

        for jel in lines:
            # Query GL records that are linked to this specific line
            matching_gl = GeneralLedger.objects.filter(
                journal_entry=je,
                journal_entry_line=jel,
                account=jel.account,
                debit_amount=jel.debit_amount,
                credit_amount=jel.credit_amount,
                reference_number=je.reference_number,
            )

            count = matching_gl.count()
            assert count == 1, (
                f"Expected exactly 1 GL record for JournalEntryLine "
                f"(line {jel.line_number}, account {jel.account.code}, "
                f"debit={jel.debit_amount}, credit={jel.credit_amount}), "
                f"but found {count}.\n"
                f"  JournalEntry ref: {je.reference_number}\n"
                f"  All GL records for this entry: "
                f"{list(je.ledger_postings.values('id', 'account_id', 'debit_amount', 'credit_amount'))}"
            )

            gl_record = matching_gl.first()

            # FK linkage — non-null check
            assert gl_record.journal_entry_id is not None, (
                f"GL record {gl_record.id} has null journal_entry FK "
                f"(line {jel.line_number}, account {jel.account.code})"
            )
            assert gl_record.journal_entry_line_id is not None, (
                f"GL record {gl_record.id} has null journal_entry_line FK "
                f"(line {jel.line_number}, account {jel.account.code})"
            )

            # FK integrity — points to the correct objects
            assert gl_record.journal_entry_id == je.pk, (
                f"GL record {gl_record.id} journal_entry FK ({gl_record.journal_entry_id}) "
                f"does not match expected JournalEntry pk ({je.pk})"
            )
            assert gl_record.journal_entry_line_id == jel.pk, (
                f"GL record {gl_record.id} journal_entry_line FK "
                f"({gl_record.journal_entry_line_id}) does not match "
                f"JournalEntryLine pk ({jel.pk})"
            )

            # Amount fidelity
            assert gl_record.debit_amount == jel.debit_amount, (
                f"GL debit_amount {gl_record.debit_amount} != "
                f"line debit_amount {jel.debit_amount} "
                f"(account {jel.account.code})"
            )
            assert gl_record.credit_amount == jel.credit_amount, (
                f"GL credit_amount {gl_record.credit_amount} != "
                f"line credit_amount {jel.credit_amount} "
                f"(account {jel.account.code})"
            )

            # Reference number fidelity
            assert gl_record.reference_number == je.reference_number, (
                f"GL reference_number '{gl_record.reference_number}' != "
                f"JournalEntry reference_number '{je.reference_number}'"
            )

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

    @settings(max_examples=50, deadline=None)
    @given(batch=entry_batch())
    def test_every_line_has_exactly_one_gl_record(self, batch):
        """
        Property 1: GL Linkage Completeness
        **Validates: Requirements 1.1, 1.3**

        For any batch of posted JournalEntry records, every JournalEntryLine
        MUST have exactly one GeneralLedger record with matching account,
        debit_amount, credit_amount, reference_number, and non-null FKs.
        """
        for line_specs in batch:
            je = self._build_and_post_entry(line_specs)
            self._assert_gl_linkage_for_entry(je)

    @settings(max_examples=40, deadline=None)
    @given(amount=_amount_strategy())
    def test_single_line_entry_has_one_gl_record_per_leg(self, amount):
        """
        Property 1 (single-pair entry): a journal entry with exactly one
        debit leg and one credit leg MUST produce exactly two GL records —
        one per leg — each with correct linkage.

        **Validates: Requirements 1.1, 1.3**
        """
        je = self._build_and_post_entry(
            line_specs=[(0, 2, amount)],  # asset debit / income credit
        )

        # Two lines → two GL records total for this entry
        total_gl = GeneralLedger.objects.filter(journal_entry=je).count()
        assert total_gl == 2, (
            f"Expected 2 GL records for a 2-line entry (amount={amount}), "
            f"got {total_gl}."
        )

        self._assert_gl_linkage_for_entry(je)

    @settings(max_examples=30, deadline=None)
    @given(line_specs=balanced_entry_lines())
    def test_gl_count_equals_line_count(self, line_specs):
        """
        Property 1 (count invariant): the number of GeneralLedger records
        created for a JournalEntry MUST equal the number of JournalEntryLines
        in that entry (one GL record per line, no duplicates, no omissions).

        **Validates: Requirements 1.1, 1.3**
        """
        je = self._build_and_post_entry(line_specs)
        line_count = je.lines.count()
        gl_count = GeneralLedger.objects.filter(journal_entry=je).count()

        assert gl_count == line_count, (
            f"GL record count ({gl_count}) does not match "
            f"JournalEntryLine count ({line_count}) "
            f"for entry {je.reference_number}."
        )

        self._assert_gl_linkage_for_entry(je)

    @settings(max_examples=30, deadline=None)
    @given(batch=entry_batch())
    def test_gl_entry_account_matches_line_account(self, batch):
        """
        Property 1 (account fidelity): the account FK on each GeneralLedger
        record MUST match the account FK on the originating JournalEntryLine.

        **Validates: Requirements 1.1, 1.3**
        """
        for line_specs in batch:
            je = self._build_and_post_entry(line_specs)

            for jel in je.lines.all():
                gl = GeneralLedger.objects.filter(
                    journal_entry=je,
                    journal_entry_line=jel,
                ).first()

                assert gl is not None, (
                    f"No GL record found for JournalEntryLine "
                    f"(line {jel.line_number}, account {jel.account.code})"
                )
                assert gl.account_id == jel.account_id, (
                    f"GL account ({gl.account_id}) does not match "
                    f"line account ({jel.account_id}) for line {jel.line_number}"
                )
