"""
Unit tests for get_profit_and_loss and get_financial_statements in
reports/financial_reports_service.py.

Task 5.3 — Confirms:
  (a) When no accounts exist, both functions return zero-valued dicts
      with source == 'accounting_system'.
  (b) Loan and Expense querysets are NOT touched when GL data is present.

Requirements: 2.1, 2.3, 4.1, 4.2

Patch-target notes
------------------
`reports/financial_reports_service.py` imports `ReportService`, `AccountingService`,
and `Account` at MODULE level (lines ~17-19).  The correct patch targets are
therefore in the SAME module's namespace:

  * ReportService     → 'reports.financial_reports_service.ReportService'
  * AccountingService → 'reports.financial_reports_service.AccountingService'
  * Account           → 'reports.financial_reports_service.Account'
  * Loan / Repayment  → imported locally inside the functions, so patch at
                         'loans.models.Loan' / 'loans.models.Repayment'
  * timezone (module-level) → 'reports.financial_reports_service.timezone'

Cache-isolation note
--------------------
ReportService.generate_income_statement caches results in Django's LocMemCache.
setUp() calls cache.clear() and each test uses a UNIQUE date range so that cache
keys never collide between tests.
"""

import unittest
from unittest.mock import patch, MagicMock
from datetime import date
from decimal import Decimal


def _clear_cache():
    """Clear Django's in-memory cache to prevent test-result leakage."""
    try:
        from django.core.cache import cache
        cache.clear()
    except Exception:
        pass


def _mock_queryset(items=None):
    """Return a minimal mock that behaves like an empty-or-populated queryset."""
    qs = MagicMock()
    items = items or []
    qs.__iter__ = MagicMock(return_value=iter(items))
    qs.filter.return_value = qs
    qs.exclude.return_value = qs
    qs.order_by.return_value = qs
    qs.count.return_value = len(items)
    qs.exists.return_value = bool(items)
    qs.first.return_value = items[0] if items else None
    qs.aggregate.return_value = {'t': Decimal('0.00'), 'principal': Decimal('0.00')}
    qs.values.return_value = qs
    qs.values_list.return_value = qs
    qs.annotate.return_value = qs
    return qs


def _make_account(code, name, account_type, subtype=''):
    """Create a minimal mock Account object."""
    acc = MagicMock()
    acc.code = code
    acc.name = name
    acc.account_type = account_type
    acc.subtype = subtype
    acc.is_active = True
    return acc


# ---------------------------------------------------------------------------
# Tests for get_profit_and_loss
# ---------------------------------------------------------------------------

class GetProfitAndLossNoFallbackTests(unittest.TestCase):
    """
    Requirements 2.1, 2.3: get_profit_and_loss must use ReportService exclusively.
    No Loan/Expense queryset fallback for income/expense figures.
    """

    def setUp(self):
        _clear_cache()

    def _make_loan_repayment_mocks(self):
        mock_loan_qs = _mock_queryset()
        mock_loan_cls = MagicMock()
        mock_loan_cls.objects.filter.return_value = mock_loan_qs

        mock_rep_qs = _mock_queryset()
        mock_rep_cls = MagicMock()
        mock_rep_cls.objects.all.return_value = mock_rep_qs

        return mock_loan_cls, mock_rep_cls

    def test_no_accounts_returns_zero_valued_dict_with_source(self):
        """
        Requirement 2.3: When no income/expense accounts exist (ReportService returns
        zero-valued income statement), get_profit_and_loss must return a dict with
        source == 'accounting_system' and zero totals.
        """
        mock_loan_cls, mock_rep_cls = self._make_loan_repayment_mocks()
        mock_rs_cls = MagicMock()
        mock_rs_instance = mock_rs_cls.return_value
        mock_rs_instance.generate_income_statement.return_value = {
            'source': 'accounting_system',
            'generated_at': '2024-02-01T09:00:00+00:00',
            'period': {'start': date(2024, 2, 1), 'end': date(2024, 2, 29)},
            'income': {},
            'expenses': {},
            'totals': {
                'gross_income': Decimal('0.00'),
                'total_expenses': Decimal('0.00'),
                'net_income': Decimal('0.00'),
            },
        }

        with patch('reports.financial_reports_service.ReportService', mock_rs_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz, \
             patch('loans.models.Loan', mock_loan_cls), \
             patch('loans.models.Repayment', mock_rep_cls):
            mock_tz.now.return_value.isoformat.return_value = '2024-02-01T09:00:00+00:00'
            from reports.financial_reports_service import get_profit_and_loss
            result = get_profit_and_loss(
                start_date=date(2024, 2, 1),
                end_date=date(2024, 2, 29),
            )

        self.assertEqual(result['source'], 'accounting_system')
        self.assertEqual(result['totals']['gross_income'], Decimal('0.00'))
        self.assertEqual(result['totals']['total_expenses'], Decimal('0.00'))
        self.assertEqual(result['totals']['net_income'], Decimal('0.00'))
        self.assertEqual(result['income'], {})
        self.assertEqual(result['expenses'], {})

    def test_generated_at_is_present(self):
        """
        Requirement 12.1: The returned dict must include a non-null generated_at field.
        """
        mock_loan_cls, mock_rep_cls = self._make_loan_repayment_mocks()
        mock_rs_cls = MagicMock()
        mock_rs_instance = mock_rs_cls.return_value
        mock_rs_instance.generate_income_statement.return_value = {
            'source': 'accounting_system',
            'generated_at': '2024-03-01T12:00:00+00:00',
            'period': {'start': date(2024, 3, 1), 'end': date(2024, 3, 31)},
            'income': {},
            'expenses': {},
            'totals': {
                'gross_income': Decimal('0.00'),
                'total_expenses': Decimal('0.00'),
                'net_income': Decimal('0.00'),
            },
        }

        with patch('reports.financial_reports_service.ReportService', mock_rs_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz, \
             patch('loans.models.Loan', mock_loan_cls), \
             patch('loans.models.Repayment', mock_rep_cls):
            mock_tz.now.return_value.isoformat.return_value = '2024-03-01T12:00:00+00:00'
            from reports.financial_reports_service import get_profit_and_loss
            result = get_profit_and_loss(
                start_date=date(2024, 3, 1),
                end_date=date(2024, 3, 31),
            )

        self.assertIn('generated_at', result)
        self.assertIsNotNone(result['generated_at'])

    def test_no_dates_returns_zero_valued_dict_with_source(self):
        """
        When start_date / end_date are not provided, the function must still return
        source == 'accounting_system' with zero totals — without calling
        generate_income_statement.
        """
        mock_loan_cls, mock_rep_cls = self._make_loan_repayment_mocks()
        mock_rs_cls = MagicMock()

        with patch('reports.financial_reports_service.ReportService', mock_rs_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz, \
             patch('loans.models.Loan', mock_loan_cls), \
             patch('loans.models.Repayment', mock_rep_cls):
            mock_tz.now.return_value.isoformat.return_value = '2024-01-15T09:00:00+00:00'
            from reports.financial_reports_service import get_profit_and_loss
            result = get_profit_and_loss()

        self.assertEqual(result['source'], 'accounting_system')
        self.assertEqual(result['totals']['net_income'], Decimal('0.00'))

        # generate_income_statement must NOT be called when no dates provided
        mock_rs_cls.return_value.generate_income_statement.assert_not_called()

    def test_loan_queryset_only_used_for_cash_flow_supplementary(self):
        """
        Requirement 2.1: Loan queries must ONLY appear in the cash_flow_supplementary
        block — never as a source of income or expense figures.

        When GL data is present (income/expense totals non-zero), the income/expenses
        sections must come exclusively from ReportService; the result must still
        contain a cash_flow_supplementary key.
        """
        mock_loan_cls, mock_rep_cls = self._make_loan_repayment_mocks()
        mock_rs_cls = MagicMock()
        mock_rs_instance = mock_rs_cls.return_value

        gl_income_data = {
            'source': 'accounting_system',
            'generated_at': '2024-04-01T09:00:00+00:00',
            'period': {'start': date(2024, 4, 1), 'end': date(2024, 4, 30)},
            'income': {
                'interest_income': {'total': Decimal('50000.00'), 'accounts': []}
            },
            'expenses': {
                'staff_costs': {'total': Decimal('20000.00'), 'accounts': []}
            },
            'totals': {
                'gross_income': Decimal('50000.00'),
                'total_expenses': Decimal('20000.00'),
                'net_income': Decimal('30000.00'),
            },
        }
        mock_rs_instance.generate_income_statement.return_value = gl_income_data

        with patch('reports.financial_reports_service.ReportService', mock_rs_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz, \
             patch('loans.models.Loan', mock_loan_cls), \
             patch('loans.models.Repayment', mock_rep_cls):
            mock_tz.now.return_value.isoformat.return_value = '2024-04-01T09:00:00+00:00'
            from reports.financial_reports_service import get_profit_and_loss
            result = get_profit_and_loss(
                start_date=date(2024, 4, 1),
                end_date=date(2024, 4, 30),
            )

        # Income/expense figures must come from GL / ReportService
        self.assertEqual(result['totals']['gross_income'], Decimal('50000.00'))
        self.assertEqual(result['totals']['net_income'], Decimal('30000.00'))
        self.assertEqual(result['source'], 'accounting_system')

        # ReportService.generate_income_statement MUST have been called
        mock_rs_instance.generate_income_statement.assert_called_once_with(
            start_date=date(2024, 4, 1),
            end_date=date(2024, 4, 30),
            branch=None,
        )

        # cash_flow_supplementary section must exist (informational only)
        self.assertIn('cash_flow_supplementary', result)

    def test_expense_figures_come_from_gl_not_expense_model(self):
        """
        Requirement 2.1: Expense figures must come from GL (ReportService), not from
        any Expense model queryset.
        """
        mock_loan_cls, mock_rep_cls = self._make_loan_repayment_mocks()
        mock_rs_cls = MagicMock()
        mock_rs_instance = mock_rs_cls.return_value
        mock_rs_instance.generate_income_statement.return_value = {
            'source': 'accounting_system',
            'generated_at': '2024-05-01T09:00:00+00:00',
            'period': {'start': date(2024, 5, 1), 'end': date(2024, 5, 31)},
            'income': {},
            'expenses': {
                'administrative_expenses': {
                    'total': Decimal('5000.00'),
                    'accounts': [],
                }
            },
            'totals': {
                'gross_income': Decimal('0.00'),
                'total_expenses': Decimal('5000.00'),
                'net_income': Decimal('-5000.00'),
            },
        }

        with patch('reports.financial_reports_service.ReportService', mock_rs_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz, \
             patch('loans.models.Loan', mock_loan_cls), \
             patch('loans.models.Repayment', mock_rep_cls):
            mock_tz.now.return_value.isoformat.return_value = '2024-05-01T09:00:00+00:00'
            from reports.financial_reports_service import get_profit_and_loss
            result = get_profit_and_loss(
                start_date=date(2024, 5, 1),
                end_date=date(2024, 5, 31),
            )

        # Expenses come from GL, not Expense model
        self.assertEqual(result['totals']['total_expenses'], Decimal('5000.00'))
        self.assertEqual(result['source'], 'accounting_system')
        mock_rs_instance.generate_income_statement.assert_called_once()


# ---------------------------------------------------------------------------
# Tests for get_financial_statements
# ---------------------------------------------------------------------------

class GetFinancialStatementsNoFallbackTests(unittest.TestCase):
    """
    Requirements 4.1, 4.2: get_financial_statements uses AccountingService
    exclusively.  No fallback to Loan or Expense queries.

    Patch strategy: patch 'reports.financial_reports_service.Account' (where the
    module-level import places it) and 'reports.financial_reports_service.AccountingService'
    for the same reason.
    """

    def setUp(self):
        _clear_cache()

    def test_no_accounts_returns_zero_valued_sfp_with_source(self):
        """
        Requirement 4.2: When Account.objects returns an empty queryset (no Chart of
        Accounts records), the function must return a zero-valued SFP dict with
        source == 'accounting_system'.
        """
        mock_account_cls = MagicMock()
        empty_qs = _mock_queryset(items=[])
        mock_account_cls.objects.filter.return_value = empty_qs

        with patch('reports.financial_reports_service.Account', mock_account_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz:
            mock_tz.now.return_value.isoformat.return_value = '2024-01-31T10:00:00+00:00'
            from reports.financial_reports_service import get_financial_statements
            result = get_financial_statements(as_of_date=date(2024, 1, 31))

        self.assertEqual(result['source'], 'accounting_system')
        self.assertEqual(result['assets']['total_assets'], Decimal('0.00'))
        self.assertEqual(result['liabilities']['total_liabilities'], Decimal('0.00'))
        self.assertEqual(result['equity']['total_equity'], Decimal('0.00'))
        self.assertEqual(result['assets']['items'], [])
        self.assertEqual(result['liabilities']['items'], [])
        self.assertEqual(result['equity']['items'], [])

    def test_no_accounts_generated_at_present(self):
        """
        Requirement 12.1: generated_at must be present even when no accounts exist.
        """
        mock_account_cls = MagicMock()
        empty_qs = _mock_queryset(items=[])
        mock_account_cls.objects.filter.return_value = empty_qs

        with patch('reports.financial_reports_service.Account', mock_account_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz:
            mock_tz.now.return_value.isoformat.return_value = '2024-03-31T08:00:00+00:00'
            from reports.financial_reports_service import get_financial_statements
            result = get_financial_statements(as_of_date=date(2024, 3, 31))

        self.assertIn('generated_at', result)
        self.assertIsNotNone(result['generated_at'])

    def test_loan_queryset_not_accessed_when_gl_data_present(self):
        """
        Requirement 4.1: When GL accounts exist, balances must come exclusively from
        AccountingService.calculate_account_balance — Loan querysets must not be
        touched.  get_financial_statements has no Loan import, so any accidental Loan
        access would raise an error, confirming the GL-only path.
        """
        asset_account = _make_account('1001', 'Cash', 'asset', 'current_asset')

        active_qs = MagicMock()
        active_qs.exists.return_value = True

        asset_qs = _mock_queryset(items=[asset_account])
        empty_qs = _mock_queryset(items=[])

        def filter_side_effect(**kwargs):
            if kwargs.get('account_type') == 'asset':
                return asset_qs
            if 'account_type' not in kwargs:
                return active_qs
            return empty_qs

        mock_account_cls = MagicMock()
        mock_account_cls.objects.filter.side_effect = filter_side_effect

        mock_as_cls = MagicMock()
        mock_as_instance = mock_as_cls.return_value
        mock_as_instance.calculate_account_balance.return_value = Decimal('100000.00')

        with patch('reports.financial_reports_service.Account', mock_account_cls), \
             patch('reports.financial_reports_service.AccountingService', mock_as_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz:
            mock_tz.now.return_value.isoformat.return_value = '2024-01-31T10:00:00+00:00'
            from reports.financial_reports_service import get_financial_statements
            result = get_financial_statements(as_of_date=date(2024, 1, 31))

        self.assertEqual(result['source'], 'accounting_system')
        # calculate_account_balance must have been called for the asset account
        mock_as_instance.calculate_account_balance.assert_called()

    def test_expense_queryset_not_accessed_when_gl_data_present(self):
        """
        Requirement 4.1: Expense model querysets must not be consulted when computing
        SFP balances — all figures come from AccountingService.calculate_account_balance.
        """
        liability_account = _make_account(
            '2001', 'Accounts Payable', 'liability', 'current_liability'
        )

        active_qs = MagicMock()
        active_qs.exists.return_value = True

        liability_qs = _mock_queryset(items=[liability_account])
        empty_qs = _mock_queryset(items=[])

        def filter_side_effect(**kwargs):
            if kwargs.get('account_type') == 'liability':
                return liability_qs
            if 'account_type' not in kwargs:
                return active_qs
            return empty_qs

        mock_account_cls = MagicMock()
        mock_account_cls.objects.filter.side_effect = filter_side_effect

        mock_as_cls = MagicMock()
        mock_as_instance = mock_as_cls.return_value
        mock_as_instance.calculate_account_balance.return_value = Decimal('25000.00')

        with patch('reports.financial_reports_service.Account', mock_account_cls), \
             patch('reports.financial_reports_service.AccountingService', mock_as_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz:
            mock_tz.now.return_value.isoformat.return_value = '2024-01-31T10:00:00+00:00'
            from reports.financial_reports_service import get_financial_statements
            result = get_financial_statements(as_of_date=date(2024, 1, 31))

        self.assertEqual(result['source'], 'accounting_system')
        # Balances come from AccountingService, not Expense model
        mock_as_instance.calculate_account_balance.assert_called()

    def test_line_items_include_account_code_and_name(self):
        """
        Requirement 4.3: Each line item in the SFP must include account_code and name
        from the Chart of Accounts.
        """
        equity_account = _make_account('3001', 'Share Capital', 'equity', 'equity')

        active_qs = MagicMock()
        active_qs.exists.return_value = True

        equity_qs = _mock_queryset(items=[equity_account])
        empty_qs = _mock_queryset(items=[])

        def filter_side_effect(**kwargs):
            if kwargs.get('account_type') == 'equity':
                return equity_qs
            if 'account_type' not in kwargs:
                return active_qs
            return empty_qs

        mock_account_cls = MagicMock()
        mock_account_cls.objects.filter.side_effect = filter_side_effect

        mock_as_cls = MagicMock()
        mock_as_instance = mock_as_cls.return_value
        mock_as_instance.calculate_account_balance.return_value = Decimal('500000.00')

        with patch('reports.financial_reports_service.Account', mock_account_cls), \
             patch('reports.financial_reports_service.AccountingService', mock_as_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz:
            mock_tz.now.return_value.isoformat.return_value = '2024-01-31T10:00:00+00:00'
            from reports.financial_reports_service import get_financial_statements
            result = get_financial_statements(as_of_date=date(2024, 1, 31))

        equity_items = result['equity']['items']
        self.assertEqual(len(equity_items), 1)
        item = equity_items[0]
        self.assertEqual(item['account_code'], '3001')
        self.assertEqual(item['name'], 'Share Capital')
        self.assertEqual(item['balance'], Decimal('500000.00'))

    def test_no_accounts_balanced_flag_is_true(self):
        """
        When no accounts exist the zero-valued SFP must include balanced: True
        and difference: 0.00 (0 == 0 + 0).
        """
        mock_account_cls = MagicMock()
        empty_qs = _mock_queryset(items=[])
        mock_account_cls.objects.filter.return_value = empty_qs

        with patch('reports.financial_reports_service.Account', mock_account_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz:
            mock_tz.now.return_value.isoformat.return_value = '2024-01-31T10:00:00+00:00'
            from reports.financial_reports_service import get_financial_statements
            result = get_financial_statements(as_of_date=date(2024, 1, 31))

        self.assertTrue(result['balanced'])
        self.assertEqual(result['difference'], Decimal('0.00'))

    def test_uses_today_when_no_as_of_date_given(self):
        """
        When as_of_date is not provided, the function must default to today without
        raising any errors.
        """
        mock_account_cls = MagicMock()
        empty_qs = _mock_queryset(items=[])
        mock_account_cls.objects.filter.return_value = empty_qs

        with patch('reports.financial_reports_service.Account', mock_account_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz:
            mock_tz.now.return_value.isoformat.return_value = '2024-01-31T10:00:00+00:00'
            from reports.financial_reports_service import get_financial_statements
            result = get_financial_statements()

        self.assertEqual(result['source'], 'accounting_system')
        self.assertIn('as_of_date', result)


# ---------------------------------------------------------------------------
# Edge-case / integration-style tests
# ---------------------------------------------------------------------------

class ProfitAndLossEdgeCaseTests(unittest.TestCase):
    """Edge cases for get_profit_and_loss."""

    def setUp(self):
        _clear_cache()

    def _make_loan_repayment_mocks(self):
        mock_loan_qs = _mock_queryset()
        mock_loan_cls = MagicMock()
        mock_loan_cls.objects.filter.return_value = mock_loan_qs

        mock_rep_qs = _mock_queryset()
        mock_rep_cls = MagicMock()
        mock_rep_cls.objects.all.return_value = mock_rep_qs

        return mock_loan_cls, mock_rep_cls

    def test_source_field_not_overwritten_when_already_set(self):
        """
        setdefault('source', ...) must NOT overwrite 'accounting_system' already
        returned by generate_income_statement.
        """
        mock_loan_cls, mock_rep_cls = self._make_loan_repayment_mocks()
        mock_rs_cls = MagicMock()
        mock_rs_instance = mock_rs_cls.return_value
        mock_rs_instance.generate_income_statement.return_value = {
            'source': 'accounting_system',
            'generated_at': '2024-06-01T09:00:00+00:00',
            'period': {'start': date(2024, 6, 1), 'end': date(2024, 6, 30)},
            'income': {},
            'expenses': {},
            'totals': {
                'gross_income': Decimal('0.00'),
                'total_expenses': Decimal('0.00'),
                'net_income': Decimal('0.00'),
            },
        }

        with patch('reports.financial_reports_service.ReportService', mock_rs_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz, \
             patch('loans.models.Loan', mock_loan_cls), \
             patch('loans.models.Repayment', mock_rep_cls):
            mock_tz.now.return_value.isoformat.return_value = '2024-06-01T09:00:00+00:00'
            from reports.financial_reports_service import get_profit_and_loss
            result = get_profit_and_loss(
                start_date=date(2024, 6, 1),
                end_date=date(2024, 6, 30),
            )

        self.assertEqual(result['source'], 'accounting_system')

    def test_cash_flow_supplementary_block_exists_but_income_does_not_use_it(self):
        """
        The cash_flow_supplementary block (Loan/Repayment queries) must exist for
        informational purposes but must NOT influence income or expense totals.
        """
        mock_rs_cls = MagicMock()
        mock_rs_instance = mock_rs_cls.return_value
        mock_rs_instance.generate_income_statement.return_value = {
            'source': 'accounting_system',
            'generated_at': '2024-07-01T09:00:00+00:00',
            'period': {'start': date(2024, 7, 1), 'end': date(2024, 7, 31)},
            'income': {},
            'expenses': {},
            'totals': {
                'gross_income': Decimal('0.00'),
                'total_expenses': Decimal('0.00'),
                'net_income': Decimal('0.00'),
            },
        }

        # Loan/Repayment return non-zero amounts (cash-flow supplementary context)
        mock_loan_qs = _mock_queryset()
        mock_loan_qs.aggregate.return_value = {'principal': Decimal('200000.00')}
        mock_loan_cls = MagicMock()
        mock_loan_cls.objects.filter.return_value = mock_loan_qs

        mock_rep_qs = _mock_queryset()
        mock_rep_qs.aggregate.return_value = {'t': Decimal('150000.00')}
        mock_rep_cls = MagicMock()
        mock_rep_cls.objects.all.return_value = mock_rep_qs

        with patch('reports.financial_reports_service.ReportService', mock_rs_cls), \
             patch('reports.financial_reports_service.timezone') as mock_tz, \
             patch('loans.models.Loan', mock_loan_cls), \
             patch('loans.models.Repayment', mock_rep_cls):
            mock_tz.now.return_value.isoformat.return_value = '2024-07-01T09:00:00+00:00'
            from reports.financial_reports_service import get_profit_and_loss
            result = get_profit_and_loss(
                start_date=date(2024, 7, 1),
                end_date=date(2024, 7, 31),
            )

        # cash_flow_supplementary may reflect Loan/Repayment figures (informational)
        self.assertIn('cash_flow_supplementary', result)

        # But income totals must remain from GL (zero in this case)
        self.assertEqual(result['totals']['gross_income'], Decimal('0.00'))
        self.assertEqual(result['totals']['net_income'], Decimal('0.00'))


if __name__ == '__main__':
    unittest.main()
