"""
Property-based tests for validation and security
Tests Properties 31, 32, 33, 36, 37, 38
Requirements: 9.11, 9.12, 10.1, 10.2, 10.8, 10.9, 10.10
"""
import pytest
from hypothesis import given, strategies as st, settings, assume
from decimal import Decimal
from datetime import date, timedelta
from django.test import TestCase, RequestFactory
from django.contrib.auth import get_user_model
from django.utils import timezone
from loans.models import Loan, LoanApplication, LoanProduct
from loans.validators import LoanEditValidator, PenaltyValidator, ReportFilterValidator
from loans.sanitizers import InputSanitizer
from users.models import CustomUser
import json

User = get_user_model()


@pytest.mark.django_db
class TestValidationSecurityProperties(TestCase):
    """
    Property-based tests for validation and security
    """
    
    def setUp(self):
        """Set up test data"""
        self.factory = RequestFactory()
        
        # Create test user
        self.admin_user = CustomUser.objects.create_user(
            username='admin',
            email='admin@test.com',
            password='testpass123',
            phone_number='+254700000001',
            role='admin'
        )
        
        # Create loan product
        self.loan_product = LoanProduct.objects.create(
            name='Test Product',
            product_type='boost',
            description='Test product',
            min_amount=Decimal('1000.00'),
            max_amount=Decimal('100000.00'),
            interest_rate=Decimal('10.00'),
            processing_fee=Decimal('5.00'),
            duration_months=1,
            min_duration=7,
            max_duration=90
        )
        
        # Create borrower
        self.borrower = CustomUser.objects.create_user(
            username='borrower',
            email='borrower@test.com',
            password='testpass123',
            phone_number='+254700000002',
            role='borrower'
        )
        
        # Create loan application
        self.application = LoanApplication.objects.create(
            borrower=self.borrower,
            loan_product=self.loan_product,
            requested_amount=Decimal('10000.00'),
            status='approved'
        )
        
        # Create loan
        self.loan = Loan.objects.create(
            loan_number='TEST-001',
            borrower=self.borrower,
            application=self.application,
            principal_amount=Decimal('10000.00'),
            interest_amount=Decimal('1000.00'),
            processing_fee=Decimal('500.00'),
            total_amount=Decimal('11500.00'),
            disbursement_date=timezone.now(),
            due_date=timezone.now() + timedelta(days=30),
            status='active'
        )
    
    @given(
        principal=st.decimals(min_value=1000, max_value=100000, places=2),
        interest=st.decimals(min_value=0, max_value=50000, places=2),
        processing_fee=st.decimals(min_value=0, max_value=5000, places=2)
    )
    @settings(max_examples=50, deadline=None)
    def test_property_31_cross_view_amount_consistency(self, principal, interest, processing_fee):
        """
        Property 31: Cross-View Amount Consistency
        
        For any loan, the amounts (principal, interest, processing fee, total, amount paid, 
        outstanding) should be identical across all views (loan detail, reports, dashboards).
        
        Validates: Requirements 9.11, 9.12
        """
        # Create a loan with the generated amounts
        loan = Loan.objects.create(
            loan_number=f'TEST-{timezone.now().timestamp()}',
            borrower=self.borrower,
            application=self.application,
            principal_amount=principal,
            interest_amount=interest,
            processing_fee=processing_fee,
            total_amount=principal + interest + processing_fee,
            disbursement_date=timezone.now(),
            due_date=timezone.now() + timedelta(days=30),
            status='active'
        )
        
        # Fetch the loan from database (simulating different views)
        loan_from_db = Loan.objects.get(pk=loan.pk)
        
        # Verify amounts are consistent
        assert loan_from_db.principal_amount == principal
        assert loan_from_db.interest_amount == interest
        assert loan_from_db.processing_fee == processing_fee
        assert loan_from_db.total_amount == principal + interest + processing_fee
        
        # Clean up
        loan.delete()
    
    @given(
        loan_data=st.fixed_dictionaries({
            'loan_number': st.text(min_size=1, max_size=20, alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd'))),
            'principal_amount': st.decimals(min_value=1000, max_value=100000, places=2),
            'interest_amount': st.decimals(min_value=0, max_value=50000, places=2),
            'status': st.sampled_from(['active', 'paid', 'defaulted'])
        })
    )
    @settings(max_examples=50, deadline=None)
    def test_property_32_json_serialization_round_trip(self, loan_data):
        """
        Property 32: JSON Serialization Round-Trip
        
        For any valid loan object, serializing to JSON then deserializing should produce 
        an equivalent loan object with the same field values.
        
        Validates: Requirements 10.1
        """
        # Serialize to JSON
        json_data = json.dumps({
            'loan_number': loan_data['loan_number'],
            'principal_amount': str(loan_data['principal_amount']),
            'interest_amount': str(loan_data['interest_amount']),
            'status': loan_data['status']
        })
        
        # Deserialize from JSON
        deserialized = json.loads(json_data)
        
        # Verify round-trip consistency
        assert deserialized['loan_number'] == loan_data['loan_number']
        assert Decimal(deserialized['principal_amount']) == loan_data['principal_amount']
        assert Decimal(deserialized['interest_amount']) == loan_data['interest_amount']
        assert deserialized['status'] == loan_data['status']
    
    @given(
        period=st.sampled_from(['today', 'this_week', 'this_month', 'total']),
        status=st.sampled_from(['active', 'paid', 'defaulted', '']),
        min_amount=st.one_of(st.none(), st.decimals(min_value=0, max_value=50000, places=2)),
        max_amount=st.one_of(st.none(), st.decimals(min_value=0, max_value=100000, places=2))
    )
    @settings(max_examples=50, deadline=None)
    def test_property_33_query_parameter_parsing(self, period, status, min_amount, max_amount):
        """
        Property 33: Query Parameter Parsing
        
        For any valid query string with filter parameters, the system should correctly 
        parse all parameters into their expected types and values.
        
        Validates: Requirements 10.2
        """
        # Skip invalid combinations
        if min_amount is not None and max_amount is not None and max_amount < min_amount:
            assume(False)
        
        # Build filter data
        filter_data = {
            'period': period,
            'status': status,
            'min_amount': str(min_amount) if min_amount is not None else '',
            'max_amount': str(max_amount) if max_amount is not None else ''
        }
        
        # Parse using validator
        try:
            validated = ReportFilterValidator.validate_report_filters(filter_data)
            
            # Verify parsing
            assert validated['period'] == period
            if status:
                assert validated.get('status') == status or validated.get('status') is None
            if min_amount is not None:
                assert validated.get('min_amount') == min_amount
            if max_amount is not None:
                assert validated.get('max_amount') == max_amount
        except Exception:
            # Some combinations may be invalid, which is expected
            pass
    
    @given(
        principal=st.decimals(min_value=-1000, max_value=100000, places=2),
        interest=st.decimals(min_value=-1000, max_value=50000, places=2),
        processing_fee=st.decimals(min_value=-1000, max_value=5000, places=2)
    )
    @settings(max_examples=50, deadline=None)
    def test_property_36_input_validation(self, principal, interest, processing_fee):
        """
        Property 36: Input Validation
        
        For any report request, all input parameters should be validated before processing, 
        and invalid parameters should be rejected with descriptive error messages.
        
        Validates: Requirements 10.8
        """
        # Test amount validation
        try:
            validated_principal = LoanEditValidator.validate_amount(principal, "Principal")
            # If validation succeeds, amount should be positive
            assert validated_principal >= 0
        except Exception as e:
            # If validation fails, should have descriptive error message
            assert len(str(e)) > 0
            assert any(word in str(e).lower() for word in ['invalid', 'negative', 'required', 'greater'])
        
        try:
            validated_interest = LoanEditValidator.validate_amount(interest, "Interest")
            assert validated_interest >= 0
        except Exception as e:
            assert len(str(e)) > 0
        
        try:
            validated_fee = LoanEditValidator.validate_amount(processing_fee, "Processing Fee")
            assert validated_fee >= 0
        except Exception as e:
            assert len(str(e)) > 0
    
    @given(
        text_input=st.text(min_size=0, max_size=200)
    )
    @settings(max_examples=50, deadline=None)
    def test_property_37_input_sanitization(self, text_input):
        """
        Property 37: Input Sanitization
        
        For any user input (filter parameters, loan edits, penalty data), the system should 
        sanitize the input to prevent SQL injection, XSS, and other injection attacks.
        
        Validates: Requirements 10.9
        """
        # Sanitize the input
        sanitized = InputSanitizer.sanitize_string(text_input)
        
        # Verify no SQL injection patterns
        sql_patterns = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'UNION', '--', ';']
        for pattern in sql_patterns:
            # Sanitized output should not contain dangerous SQL keywords in executable form
            if pattern in text_input.upper():
                # If input contained SQL keywords, they should be escaped or removed
                assert pattern not in sanitized.upper() or '<' in sanitized or '&' in sanitized
        
        # Verify no XSS patterns
        xss_patterns = ['<script', 'javascript:', 'onerror=', 'onclick=']
        for pattern in xss_patterns:
            if pattern in text_input.lower():
                # XSS patterns should be escaped or removed
                assert pattern not in sanitized.lower() or '&lt;' in sanitized or '&' in sanitized
    
    @given(
        name=st.text(min_size=1, max_size=100, alphabet=st.characters(
            whitelist_categories=('Lu', 'Ll', 'Pd', 'Zs'),
            whitelist_characters="'-."
        )),
        loan_number=st.text(min_size=1, max_size=50, alphabet=st.characters(
            whitelist_categories=('Lu', 'Ll', 'Nd'),
            whitelist_characters='-'
        ))
    )
    @settings(max_examples=50, deadline=None)
    def test_property_38_special_character_handling(self, name, loan_number):
        """
        Property 38: Special Character Handling
        
        For any data containing special characters (borrower names, loan numbers), exports 
        should correctly encode and display these characters without corruption.
        
        Validates: Requirements 10.10
        """
        # Sanitize name (should preserve valid special characters)
        sanitized_name = InputSanitizer.sanitize_name(name)
        
        # Verify special characters in names are preserved
        # Names can contain letters, spaces, hyphens, apostrophes, periods
        for char in sanitized_name:
            assert char.isalpha() or char in [' ', '-', "'", '.']
        
        # Sanitize loan number (should preserve alphanumeric and hyphens)
        sanitized_loan_number = InputSanitizer.sanitize_loan_number(loan_number)
        
        # Verify loan number format
        for char in sanitized_loan_number:
            assert char.isalnum() or char == '-'
        
        # Verify no data corruption (length should be reasonable)
        assert len(sanitized_name) <= 100
        assert len(sanitized_loan_number) <= 50

