"""
Unit tests for the profit_and_loss and financial_statements views.

Task 6.5 — Verifies:
  (a) When Account.objects.exists() is True, both views set
      context['source'] == 'accounting_system' and populate context['generated_at'].
  (b) A FinancialStatementImbalanceError raised inside financial_statements results
      in a messages.warning to the user rather than a 500 error.

Requirements: 5.3, 12.1, 12.3

Implementation notes
--------------------
Both views use @login_required + @staff_required decorators.  Rather than spin up
a full test database (which has pre-existing migration conflicts in this project),
we call the view functions DIRECTLY after bypassing the decorators, using
Django's RequestFactory + mock message storage.  This is identical to the pattern
used in the existing service-layer tests (test_financial_reports_service.py).

staff_required checks request.user.role in
  ['admin', 'team_leader', 'loan_officer', 'secretary', 'auditor'].

We mock:
  - Branch.objects  → returns empty iterable (no real DB query)
  - Account.objects → controls the "if Account.objects.exists()" branch
  - AccountingService.calculate_account_balance → returns Decimal('0.00')
"""

import os
import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'branch_system.settings')
django.setup()

import unittest
from decimal import Decimal
from unittest.mock import patch, MagicMock, call

from django.test import RequestFactory
from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.messages import constants as msg_constants

from accounting.exceptions import FinancialStatementImbalanceError
import reports.financial_reports_views as views_module


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

def _mock_account_queryset(exists=True):
    """Return a minimal mock queryset whose .exists() returns the given value."""
    qs = MagicMock()
    qs.exists.return_value = exists
    qs.filter.return_value = qs
    qs.exclude.return_value = qs
    qs.order_by.return_value = qs
    qs.__iter__ = MagicMock(return_value=iter([]))
    return qs


def _make_request(path='/reports/financial/profit-loss/'):
    """Build a GET request with a mock staff user and messages storage."""
    factory = RequestFactory()
    request = factory.get(path)

    # Mock authenticated staff user (role='admin' passes staff_required)
    mock_user = MagicMock()
    mock_user.is_authenticated = True
    mock_user.role = 'admin'
    request.user = mock_user

    # Attach messages framework storage so messages.warning() works
    setattr(request, 'session', 'session')
    messages_storage = FallbackStorage(request)
    setattr(request, '_messages', messages_storage)

    return request


def _get_messages_from_request(request):
    """Return the list of messages stored on the request."""
    storage = getattr(request, '_messages', None)
    if storage is None:
        return []
    return list(storage)


# ---------------------------------------------------------------------------
# Tests for profit_and_loss view
# ---------------------------------------------------------------------------

class ProfitAndLossViewTests(unittest.TestCase):
    """
    Tests for the profit_and_loss view.
    Requirements: 12.1, 12.3
    """

    def _call_view(self, account_exists=True):
        """
        Call the profit_and_loss view function directly (no decorator bypass needed
        as we supply a properly-mocked authenticated staff user).
        AccountingService.calculate_account_balance returns Decimal('0.00').
        """
        request = _make_request('/reports/financial/profit-loss/')
        mock_account_qs = _mock_account_queryset(exists=account_exists)

        with patch('reports.financial_reports_views.Branch') as mock_branch, \
             patch('accounting.models.Account') as mock_account_cls, \
             patch('accounting.services.accounting_service.AccountingService'
                   '.calculate_account_balance', return_value=Decimal('0.00')), \
             patch('reports.financial_reports_views.render',
                   side_effect=lambda req, tmpl, ctx: (tmpl, ctx)) as mock_render:

            mock_branch.objects.filter.return_value.order_by.return_value = []
            mock_account_cls.objects = mock_account_qs

            result = views_module.profit_and_loss(request)

        # result is (template_name, context) from our render side_effect
        return result, request

    def test_source_is_accounting_system_when_accounts_exist(self):
        """
        Requirement 12.1 / 12.3: When Account.objects.exists() is True the view
        must set context['source'] == 'accounting_system'.
        """
        (tmpl, ctx), _req = self._call_view(account_exists=True)
        self.assertEqual(ctx['source'], 'accounting_system')

    def test_generated_at_is_populated_when_accounts_exist(self):
        """
        Requirement 12.1: context['generated_at'] must be a non-empty string
        when the accounting-system path is taken.
        """
        (tmpl, ctx), _req = self._call_view(account_exists=True)
        generated_at = ctx.get('generated_at')
        self.assertIsNotNone(generated_at)
        self.assertNotEqual(generated_at, '')

    def test_view_does_not_crash_when_no_accounts(self):
        """
        Requirement 2.3: When Account.objects.exists() is False the view must
        not raise an exception — it falls through to the default source value.
        """
        try:
            (tmpl, ctx), _req = self._call_view(account_exists=False)
        except Exception as exc:
            self.fail(f"profit_and_loss raised an unexpected exception: {exc}")

    def test_template_rendered(self):
        """The view must render the profit_and_loss template."""
        (tmpl, ctx), _req = self._call_view(account_exists=True)
        self.assertIn('profit_and_loss', tmpl)


# ---------------------------------------------------------------------------
# Tests for financial_statements view
# ---------------------------------------------------------------------------

class FinancialStatementsViewTests(unittest.TestCase):
    """
    Tests for the financial_statements view.
    Requirements: 5.3, 12.1, 12.3
    """

    def _call_view_normal(self):
        """
        Call financial_statements with accounts existing and balances returning 0.
        No exception is raised — normal happy path.
        """
        request = _make_request('/reports/financial/statements/')
        mock_account_qs = _mock_account_queryset(exists=True)

        with patch('reports.financial_reports_views.Branch') as mock_branch, \
             patch('accounting.models.Account') as mock_account_cls, \
             patch('accounting.services.accounting_service.AccountingService'
                   '.calculate_account_balance', return_value=Decimal('0.00')), \
             patch('reports.financial_reports_views.render',
                   side_effect=lambda req, tmpl, ctx: (tmpl, ctx)):

            mock_branch.objects.filter.return_value.order_by.return_value = []
            mock_account_cls.objects = mock_account_qs

            result = views_module.financial_statements(request)

        return result, request

    def _call_view_with_imbalance(self, difference=Decimal('500.00')):
        """
        Call financial_statements where the outer try-block raises
        FinancialStatementImbalanceError so the except-branch is exercised.

        The view wraps each individual calculate_account_balance call in its
        own try/except Exception block, so we cannot trigger the outer handler
        from there.  Instead we raise from AccountingService() instantiation
        (which is NOT wrapped in an inner except), so the error propagates to
        the outer except FinancialStatementImbalanceError handler.
        """
        request = _make_request('/reports/financial/statements/')
        mock_account_qs = _mock_account_queryset(exists=True)

        def _raise_imbalance(*args, **kwargs):
            raise FinancialStatementImbalanceError(difference)

        with patch('reports.financial_reports_views.Branch') as mock_branch, \
             patch('accounting.models.Account') as mock_account_cls, \
             patch('accounting.services.accounting_service.AccountingService',
                   side_effect=_raise_imbalance), \
             patch('reports.financial_reports_views.render',
                   side_effect=lambda req, tmpl, ctx: (tmpl, ctx)):

            mock_branch.objects.filter.return_value.order_by.return_value = []
            mock_account_cls.objects = mock_account_qs

            result = views_module.financial_statements(request)

        return result, request

    # -- source / generated_at tests -----------------------------------------

    def test_source_is_accounting_system_normal(self):
        """
        Requirement 12.1 / 12.3: context['source'] must always be
        'accounting_system' in the financial_statements view.
        """
        (tmpl, ctx), _req = self._call_view_normal()
        self.assertEqual(ctx['source'], 'accounting_system')

    def test_generated_at_is_populated_normal(self):
        """
        Requirement 12.1: context['generated_at'] must be a non-empty string.
        """
        (tmpl, ctx), _req = self._call_view_normal()
        generated_at = ctx.get('generated_at')
        self.assertIsNotNone(generated_at)
        self.assertNotEqual(generated_at, '')

    # -- FinancialStatementImbalanceError handling tests ----------------------

    def test_imbalance_error_does_not_propagate(self):
        """
        Requirement 5.3: FinancialStatementImbalanceError must NOT propagate as
        an unhandled exception from the view.
        """
        try:
            self._call_view_with_imbalance()
        except FinancialStatementImbalanceError:
            self.fail(
                "FinancialStatementImbalanceError propagated out of the view "
                "— it should be caught and converted to messages.warning."
            )
        except Exception as exc:
            self.fail(f"Unexpected exception escaped from view: {exc}")

    def test_imbalance_error_produces_warning_message(self):
        """
        Requirement 5.3: When FinancialStatementImbalanceError is caught the view
        must issue a messages.warning. The warning text must mention the difference.
        """
        difference = Decimal('500.00')
        (tmpl, ctx), request = self._call_view_with_imbalance(difference=difference)

        msgs = _get_messages_from_request(request)
        warning_msgs = [m for m in msgs if m.level == msg_constants.WARNING]

        self.assertGreater(
            len(warning_msgs), 0,
            "Expected at least one messages.WARNING but found none."
        )
        self.assertIn(str(difference), warning_msgs[0].message)

    def test_imbalance_error_is_warning_not_error(self):
        """
        Requirement 5.3: The message for an imbalance must be at WARNING level,
        not ERROR level.
        """
        (tmpl, ctx), request = self._call_view_with_imbalance()
        msgs = _get_messages_from_request(request)

        error_msgs = [m for m in msgs if m.level == msg_constants.ERROR]
        warning_msgs = [m for m in msgs if m.level == msg_constants.WARNING]

        self.assertEqual(
            len(error_msgs), 0,
            "messages.ERROR must not be used for an imbalance; use messages.WARNING."
        )
        self.assertGreater(len(warning_msgs), 0,
                           "Expected messages.WARNING for imbalance but found none.")

    def test_source_still_accounting_system_after_imbalance(self):
        """
        Requirement 12.1: Even when FinancialStatementImbalanceError is caught,
        context['source'] must remain 'accounting_system'.
        """
        (tmpl, ctx), _req = self._call_view_with_imbalance()
        self.assertEqual(ctx['source'], 'accounting_system')

    def test_template_still_rendered_after_imbalance(self):
        """
        After catching FinancialStatementImbalanceError the view must still
        render the financial_statements template (not a redirect or 500).
        """
        (tmpl, ctx), _req = self._call_view_with_imbalance()
        self.assertIn('financial_statements', tmpl)


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