<?php

declare(strict_types=1);

namespace OCA\Mitgliederverwaltung\Tests\Unit;

use OCA\Mitgliederverwaltung\Db\FeeRecord;
use OCA\Mitgliederverwaltung\Db\FeeRecordMapper;
use OCA\Mitgliederverwaltung\Db\FeeRule;
use OCA\Mitgliederverwaltung\Db\FeeRuleMapper;
use OCA\Mitgliederverwaltung\Db\Member;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCA\Mitgliederverwaltung\Service\AuditService;
use OCA\Mitgliederverwaltung\Service\FeeCalculationService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;

/**
 * Comprehensive unit tests for FeeCalculationService.
 *
 * Covers all 13 test cases from Issue #45:
 * family discounts, frozen rates, Erziehungsberechtigter exclusion,
 * manual overrides, mixed families, error handling, and batch processing.
 */
class FeeCalculationServiceTest extends TestCase {

    private FeeCalculationService $service;
    private FeeRuleMapper&MockObject $ruleMapper;
    private FeeRecordMapper&MockObject $recordMapper;
    private MemberMapper&MockObject $memberMapper;
    private AuditService&MockObject $auditService;
    private IUserSession&MockObject $userSession;
    private LoggerInterface&MockObject $logger;

    protected function setUp(): void {
        parent::setUp();

        $this->ruleMapper = $this->createMock(FeeRuleMapper::class);
        $this->recordMapper = $this->createMock(FeeRecordMapper::class);
        $this->memberMapper = $this->createMock(MemberMapper::class);
        $this->auditService = $this->createMock(AuditService::class);
        $this->userSession = $this->createMock(IUserSession::class);
        $this->logger = $this->createMock(LoggerInterface::class);

        // Default user session mock
        $user = $this->createMock(IUser::class);
        $user->method('getUID')->willReturn('testuser');
        $this->userSession->method('getUser')->willReturn($user);

        $this->service = new FeeCalculationService(
            $this->ruleMapper,
            $this->recordMapper,
            $this->memberMapper,
            $this->auditService,
            $this->userSession,
            $this->logger
        );
    }

    // ── Helper factories ────────────────────────────────────────────

    /**
     * Create a Member entity with the given properties.
     */
    private function createMember(
        int $id,
        string $rolle = 'mitglied',
        string $status = 'aktiv',
        ?int $familyId = null,
        string $geburtsdatum = '2010-01-01',
        ?string $frozenFeeRate = null
    ): Member {
        $member = new Member();
        $member->setId($id);
        $member->setVorname('Test');
        $member->setNachname('Member' . $id);
        $member->setGeburtsdatum($geburtsdatum);
        $member->setRolle($rolle);
        $member->setStatus($status);
        $member->setFamilyId($familyId);
        $member->setFrozenFeeRate($frozenFeeRate);
        $member->setEintritt('2020-01-01');
        $member->setCreatedAt('2020-01-01 00:00:00');
        $member->setUpdatedAt('2020-01-01 00:00:00');
        return $member;
    }

    /**
     * Create a FeeRule entity with the given properties.
     *
     * @param array<string, float> $familyRules e.g. ["1" => 100.00, "2" => 75.00, "3+" => 50.00]
     */
    private function createFeeRule(
        int $yearFrom = 2026,
        string $baseRate = '120.00',
        array $familyRules = [],
        string $inactiveRule = 'freeze_rate_at_exit'
    ): FeeRule {
        $rule = new FeeRule();
        $rule->setId(1);
        $rule->setYearFrom($yearFrom);
        $rule->setBaseRate($baseRate);
        $rule->setFamilyRulesJson(json_encode($familyRules));
        $rule->setInactiveRule($inactiveRule);
        $rule->setCreatedAt('2026-01-01 00:00:00');
        $rule->setCreatedBy('admin');
        return $rule;
    }

    /**
     * Create a FeeRecord entity.
     */
    private function createFeeRecord(
        int $id,
        int $memberId,
        int $year,
        string $amount = '100.00',
        bool $manuellAngepasst = false
    ): FeeRecord {
        $record = new FeeRecord();
        $record->setId($id);
        $record->setMemberId($memberId);
        $record->setYear($year);
        $record->setAmount($amount);
        $record->setManuellAngepasst($manuellAngepasst);
        $record->setPaid(false);
        $record->setCreatedAt('2026-01-01 00:00:00');
        $record->setUpdatedAt('2026-01-01 00:00:00');
        return $record;
    }

    // ── Test Case 1: Single member, no family → base rate ───────────

    public function testSingleMemberNoFamilyGetsBaseRate(): void {
        $member = $this->createMember(1, 'mitglied', 'aktiv', null);
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 75.00, '3+' => 50.00]);

        $fee = $this->service->calculateFee($member, [], $rule);

        $this->assertSame('120.00', $fee, 'Single member without family should pay base rate');
    }

    // ── Test Case 2: Single child in family → rule for 1 child ──────

    public function testSingleChildInFamilyGetsFirstChildRate(): void {
        $child = $this->createMember(1, 'mitglied', 'aktiv', 10, '2012-05-15');
        $parent = $this->createMember(2, 'erziehungsberechtigter', 'aktiv', 10, '1985-03-20');

        $familyMembers = [$child, $parent];
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 75.00, '3+' => 50.00]);

        $fee = $this->service->calculateFee($child, $familyMembers, $rule);

        $this->assertSame('100.00', $fee, 'Single child in family should get first-child rate');
    }

    // ── Test Case 3: Two children in family → correct per-child amounts ─

    public function testTwoChildrenInFamilyGetCorrectRates(): void {
        // Older child (born first) = position 1
        $child1 = $this->createMember(1, 'mitglied', 'aktiv', 10, '2010-03-01');
        // Younger child = position 2
        $child2 = $this->createMember(2, 'mitglied', 'aktiv', 10, '2013-07-15');
        $parent = $this->createMember(3, 'erziehungsberechtigter', 'aktiv', 10, '1982-01-01');

        $familyMembers = [$child1, $child2, $parent];
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 75.00, '3+' => 50.00]);

        $feeChild1 = $this->service->calculateFee($child1, $familyMembers, $rule);
        $feeChild2 = $this->service->calculateFee($child2, $familyMembers, $rule);

        $this->assertSame('100.00', $feeChild1, 'First child (oldest) should get position-1 rate');
        $this->assertSame('75.00', $feeChild2, 'Second child should get position-2 rate');
    }

    // ── Test Case 4: Three+ children → 3+ rule applied ─────────────

    public function testThreeOrMoreChildrenGetCatchAllRate(): void {
        $child1 = $this->createMember(1, 'mitglied', 'aktiv', 10, '2008-01-01');
        $child2 = $this->createMember(2, 'mitglied', 'aktiv', 10, '2010-06-15');
        $child3 = $this->createMember(3, 'mitglied', 'aktiv', 10, '2013-09-20');
        $child4 = $this->createMember(4, 'mitglied', 'aktiv', 10, '2016-02-10');
        $parent = $this->createMember(5, 'erziehungsberechtigter', 'aktiv', 10, '1980-01-01');

        $familyMembers = [$child1, $child2, $child3, $child4, $parent];
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 75.00, '3+' => 50.00]);

        $this->assertSame('100.00', $this->service->calculateFee($child1, $familyMembers, $rule));
        $this->assertSame('75.00', $this->service->calculateFee($child2, $familyMembers, $rule));
        $this->assertSame('50.00', $this->service->calculateFee($child3, $familyMembers, $rule),
            'Third child should get 3+ catch-all rate');
        $this->assertSame('50.00', $this->service->calculateFee($child4, $familyMembers, $rule),
            'Fourth child should also get 3+ catch-all rate');
    }

    // ── Test Case 5: Inactive member with frozen rate ───────────────

    public function testInactiveMemberWithFrozenRateUsesFrozenRate(): void {
        $member = $this->createMember(1, 'mitglied', 'inaktiv', null, '2010-01-01', '95.50');
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00]);

        $fee = $this->service->calculateFee($member, [], $rule);

        $this->assertSame('95.50', $fee, 'Inactive member with frozen rate should use that frozen rate');
    }

    // ── Test Case 6: Erziehungsberechtigter → excluded (fee = 0) ────

    public function testErziehungsberechtigterPaysFee(): void {
        $parent = $this->createMember(1, 'erziehungsberechtigter', 'aktiv', 10, '1980-01-01');
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00]);

        $fee = $this->service->calculateFee($parent, [], $rule);

        $this->assertSame('0.00', $fee, 'Erziehungsberechtigter should have zero fee');
    }

    // ── Test Case 7: Manual override skipped during batch ───────────

    public function testBatchCalculateSkipsManualOverrides(): void {
        $member = $this->createMember(1, 'mitglied', 'aktiv', null);
        $rule = $this->createFeeRule(2026, '120.00');

        $this->ruleMapper->method('findByYear')
            ->with(2026)
            ->willReturn($rule);

        $this->memberMapper->method('findAll')
            ->willReturn([$member]);

        // Existing record that was manually adjusted
        $existingRecord = $this->createFeeRecord(100, 1, 2026, '200.00', true);
        $this->recordMapper->method('findByMemberAndYear')
            ->with(1, 2026)
            ->willReturn($existingRecord);

        // Should NOT call update or insert for this member
        $this->recordMapper->expects($this->never())->method('update');
        $this->recordMapper->expects($this->never())->method('insert');

        $result = $this->service->batchCalculate(2026);

        $this->assertSame(0, $result['created']);
        $this->assertSame(0, $result['updated']);
        $this->assertSame(1, $result['skipped'], 'Manually adjusted record should be skipped');
    }

    // ── Test Case 8: Mixed family (active + inactive children) ──────

    public function testMixedFamilyOnlyActiveChildrenCountForDiscount(): void {
        // Active child 1 (oldest active)
        $activeChild1 = $this->createMember(1, 'mitglied', 'aktiv', 10, '2009-03-01');
        // Inactive child (should not count for positioning)
        $inactiveChild = $this->createMember(2, 'mitglied', 'inaktiv', 10, '2011-06-15');
        // Active child 2 (youngest active)
        $activeChild2 = $this->createMember(3, 'mitglied', 'aktiv', 10, '2014-09-20');
        $parent = $this->createMember(4, 'erziehungsberechtigter', 'aktiv', 10, '1980-01-01');

        $familyMembers = [$activeChild1, $inactiveChild, $activeChild2, $parent];
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 75.00, '3+' => 50.00]);

        // Active child 1 is position 1 among active children
        $this->assertSame('100.00', $this->service->calculateFee($activeChild1, $familyMembers, $rule),
            'First active child should be position 1');

        // Active child 2 is position 2 among active children (inactive child not counted)
        $this->assertSame('75.00', $this->service->calculateFee($activeChild2, $familyMembers, $rule),
            'Second active child should be position 2, inactive child excluded from count');
    }

    // ── Test Case 9: Member becomes inactive mid-year → rate frozen ─

    public function testMemberBecomesInactiveMidYearUsesFrozenRate(): void {
        // Member was active, became inactive, has frozen rate set at the time of deactivation
        $member = $this->createMember(1, 'mitglied', 'inaktiv', 10, '2010-01-01', '100.00');
        $member->setAustritt('2026-06-15');

        // Even though the family has other children, this member uses frozen rate
        $activeChild = $this->createMember(2, 'mitglied', 'aktiv', 10, '2012-05-01');
        $familyMembers = [$member, $activeChild];

        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 75.00]);

        $fee = $this->service->calculateFee($member, $familyMembers, $rule);

        $this->assertSame('100.00', $fee,
            'Member who became inactive mid-year should use frozen rate, not recalculate');
    }

    // ── Test Case 10: No fee rule for year → error handling ─────────

    public function testNoFeeRuleForYearThrowsException(): void {
        $this->ruleMapper->method('findByYear')
            ->with(2030)
            ->willThrowException(new DoesNotExistException('No rule found'));

        $this->expectException(DoesNotExistException::class);

        $this->service->batchCalculate(2030);
    }

    // ── Test Case 11: Rule change between years → correct rule applied ─

    public function testRuleChangeBetweenYearsAppliesCorrectRule(): void {
        $member = $this->createMember(1, 'mitglied', 'aktiv', null);

        // Rule for 2025: base rate 100
        $rule2025 = $this->createFeeRule(2025, '100.00');
        // Rule for 2026: base rate 120
        $rule2026 = $this->createFeeRule(2026, '120.00');

        // Test 2025 rule
        $fee2025 = $this->service->calculateFee($member, [], $rule2025);
        $this->assertSame('100.00', $fee2025, 'Should use 2025 base rate');

        // Test 2026 rule
        $fee2026 = $this->service->calculateFee($member, [], $rule2026);
        $this->assertSame('120.00', $fee2026, 'Should use 2026 base rate');
    }

    /**
     * Test that batchCalculate for different years retrieves different rules.
     */
    public function testBatchCalculateUsesCorrectRulePerYear(): void {
        $member = $this->createMember(1, 'mitglied', 'aktiv', null);

        $rule2025 = $this->createFeeRule(2025, '100.00');
        $rule2026 = $this->createFeeRule(2026, '120.00');

        $this->ruleMapper->method('findByYear')
            ->willReturnCallback(function (int $year) use ($rule2025, $rule2026) {
                return match ($year) {
                    2025 => $rule2025,
                    2026 => $rule2026,
                    default => throw new DoesNotExistException('No rule found'),
                };
            });

        $this->memberMapper->method('findAll')
            ->willReturn([$member]);

        $this->recordMapper->method('findByMemberAndYear')
            ->willThrowException(new DoesNotExistException('No record'));

        // Expect insert calls with correct amounts for each year
        $insertedRecords = [];
        $this->recordMapper->method('insert')
            ->willReturnCallback(function (FeeRecord $record) use (&$insertedRecords) {
                $insertedRecords[] = $record;
                return $record;
            });

        // Batch for 2025
        $this->service->batchCalculate(2025);
        $this->assertSame('100.00', $insertedRecords[0]->getAmount(),
            'Batch for 2025 should use the 2025 rule base rate');

        // Batch for 2026
        $this->service->batchCalculate(2026);
        $this->assertSame('120.00', $insertedRecords[1]->getAmount(),
            'Batch for 2026 should use the 2026 rule base rate');
    }

    // ── Test Case 12: Empty family (no children) → no fees generated ─

    public function testEmptyFamilyNoChildrenReturnsBaseRate(): void {
        // A member in a family where all other members are Erziehungsberechtigte
        $parent1 = $this->createMember(1, 'erziehungsberechtigter', 'aktiv', 10, '1980-01-01');
        $parent2 = $this->createMember(2, 'erziehungsberechtigter', 'aktiv', 10, '1982-06-15');

        $familyMembers = [$parent1, $parent2];
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 75.00]);

        // Parents should have zero fee
        $this->assertSame('0.00', $this->service->calculateFee($parent1, $familyMembers, $rule),
            'Erziehungsberechtigter in empty family (no children) should pay nothing');
        $this->assertSame('0.00', $this->service->calculateFee($parent2, $familyMembers, $rule),
            'Second Erziehungsberechtigter should also pay nothing');
    }

    /**
     * If a regular member is in a family with no active children, they get base rate.
     */
    public function testMemberInFamilyWithNoActiveChildrenGetsBaseRate(): void {
        // A "mitglied" who is the only one in the family but is inactive (no active children)
        $member = $this->createMember(1, 'mitglied', 'aktiv', 10, '2010-01-01');
        $inactiveChild = $this->createMember(2, 'mitglied', 'inaktiv', 10, '2012-05-01');
        $parent = $this->createMember(3, 'erziehungsberechtigter', 'aktiv', 10, '1980-01-01');

        // Only one active child (member id=1), inactive child doesn't count
        $familyMembers = [$member, $inactiveChild, $parent];
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 75.00]);

        $fee = $this->service->calculateFee($member, $familyMembers, $rule);
        $this->assertSame('100.00', $fee,
            'The only active child in the family should be position 1');
    }

    // ── Test Case 13: Member without family with override banking → base rate ─

    public function testMemberWithoutFamilyWithOverrideBankingGetsBaseRate(): void {
        $member = $this->createMember(1, 'mitglied', 'aktiv', null);
        // Simulate override banking details (separate from fee calculation)
        $member->setNotizen('IBAN: DE89370400440532013000');

        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 75.00, '3+' => 50.00]);

        $fee = $this->service->calculateFee($member, [], $rule);

        $this->assertSame('120.00', $fee,
            'Member without family pays base rate regardless of banking override');
    }

    // ── Additional edge case tests ──────────────────────────────────

    /**
     * Test batch calculate creates new records when none exist.
     */
    public function testBatchCalculateCreatesNewRecords(): void {
        $member1 = $this->createMember(1, 'mitglied', 'aktiv', null);
        $member2 = $this->createMember(2, 'mitglied', 'aktiv', null);
        $rule = $this->createFeeRule(2026, '120.00');

        $this->ruleMapper->method('findByYear')->with(2026)->willReturn($rule);
        $this->memberMapper->method('findAll')->willReturn([$member1, $member2]);

        // No existing records
        $this->recordMapper->method('findByMemberAndYear')
            ->willThrowException(new DoesNotExistException('Not found'));

        $this->recordMapper->expects($this->exactly(2))->method('insert');

        $result = $this->service->batchCalculate(2026);

        $this->assertSame(2, $result['created']);
        $this->assertSame(0, $result['updated']);
        $this->assertSame(0, $result['skipped']);
    }

    /**
     * Test batch calculate updates existing non-manual records.
     */
    public function testBatchCalculateUpdatesExistingNonManualRecords(): void {
        $member = $this->createMember(1, 'mitglied', 'aktiv', null);
        $rule = $this->createFeeRule(2026, '120.00');
        $existingRecord = $this->createFeeRecord(100, 1, 2026, '100.00', false);

        $this->ruleMapper->method('findByYear')->with(2026)->willReturn($rule);
        $this->memberMapper->method('findAll')->willReturn([$member]);
        $this->recordMapper->method('findByMemberAndYear')
            ->with(1, 2026)
            ->willReturn($existingRecord);

        $this->recordMapper->expects($this->once())->method('update');
        $this->recordMapper->expects($this->never())->method('insert');

        $result = $this->service->batchCalculate(2026);

        $this->assertSame(0, $result['created']);
        $this->assertSame(1, $result['updated']);
        $this->assertSame(0, $result['skipped']);
    }

    /**
     * Test batch calculate excludes Erziehungsberechtigte from processing.
     */
    public function testBatchCalculateExcludesErziehungsberechtigte(): void {
        $parent = $this->createMember(1, 'erziehungsberechtigter', 'aktiv', 10);
        $child = $this->createMember(2, 'mitglied', 'aktiv', 10, '2012-01-01');
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00]);

        $this->ruleMapper->method('findByYear')->with(2026)->willReturn($rule);
        $this->memberMapper->method('findAll')->willReturn([$parent, $child]);
        $this->recordMapper->method('findByMemberAndYear')
            ->willThrowException(new DoesNotExistException('Not found'));

        // Only one insert should happen (for the child, not the parent)
        $this->recordMapper->expects($this->once())->method('insert');

        $result = $this->service->batchCalculate(2026);

        $this->assertSame(1, $result['created']);
    }

    /**
     * Test batch calculate includes inactive members with frozen rates.
     */
    public function testBatchCalculateIncludesInactiveWithFrozenRate(): void {
        $activeMember = $this->createMember(1, 'mitglied', 'aktiv', null);
        $inactiveWithFrozen = $this->createMember(2, 'mitglied', 'inaktiv', null, '2010-01-01', '95.00');
        $inactiveWithoutFrozen = $this->createMember(3, 'mitglied', 'inaktiv', null);

        $rule = $this->createFeeRule(2026, '120.00');

        $this->ruleMapper->method('findByYear')->with(2026)->willReturn($rule);
        $this->memberMapper->method('findAll')
            ->willReturn([$activeMember, $inactiveWithFrozen, $inactiveWithoutFrozen]);
        $this->recordMapper->method('findByMemberAndYear')
            ->willThrowException(new DoesNotExistException('Not found'));

        $insertedRecords = [];
        $this->recordMapper->method('insert')
            ->willReturnCallback(function (FeeRecord $record) use (&$insertedRecords) {
                $insertedRecords[] = $record;
                return $record;
            });

        $result = $this->service->batchCalculate(2026);

        // Active member + inactive with frozen rate = 2 records created
        // Inactive without frozen rate is excluded
        $this->assertSame(2, $result['created'],
            'Should create records for active member and inactive member with frozen rate');

        // Verify frozen rate is used for inactive member
        $frozenRecord = array_filter($insertedRecords, fn($r) => $r->getMemberId() === 2);
        $frozenRecord = array_values($frozenRecord)[0];
        $this->assertSame('95.00', $frozenRecord->getAmount(),
            'Inactive member should use frozen rate');
    }

    /**
     * Test rule snapshot is stored in fee records during batch.
     */
    public function testBatchCalculateStoresRuleSnapshot(): void {
        $member = $this->createMember(1, 'mitglied', 'aktiv', null);
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00]);

        $this->ruleMapper->method('findByYear')->with(2026)->willReturn($rule);
        $this->memberMapper->method('findAll')->willReturn([$member]);
        $this->recordMapper->method('findByMemberAndYear')
            ->willThrowException(new DoesNotExistException('Not found'));

        $insertedRecord = null;
        $this->recordMapper->method('insert')
            ->willReturnCallback(function (FeeRecord $record) use (&$insertedRecord) {
                $insertedRecord = $record;
                return $record;
            });

        $this->service->batchCalculate(2026);

        $this->assertNotNull($insertedRecord);
        $snapshot = json_decode($insertedRecord->getRuleSnapshotJson(), true);
        $this->assertSame('120.00', $snapshot['baseRate'],
            'Rule snapshot should contain the base rate');
        $this->assertSame(2026, $snapshot['yearFrom'],
            'Rule snapshot should contain the year');
    }

    // ── Query methods ───────────────────────────────────────────────

    public function testGetAllRules(): void {
        $rule = $this->createFeeRule(2026, '120.00');
        $this->ruleMapper->method('findAll')->willReturn([$rule]);

        $result = $this->service->getAllRules();

        $this->assertCount(1, $result);
        $this->assertSame('120.00', $result[0]->getBaseRate());
    }

    public function testGetRulesForYear(): void {
        $rule = $this->createFeeRule(2026, '120.00');
        $this->ruleMapper->method('findByYear')->with(2026)->willReturn($rule);

        $result = $this->service->getRulesForYear(2026);

        $this->assertSame('120.00', $result->getBaseRate());
    }

    public function testGetFeeRecordsForYear(): void {
        $record = $this->createFeeRecord(1, 1, 2026, '100.00');
        $this->recordMapper->method('findByYear')->with(2026)->willReturn([$record]);

        $result = $this->service->getFeeRecordsForYear(2026);

        $this->assertCount(1, $result);
        $this->assertSame('100.00', $result[0]->getAmount());
    }

    public function testGetMemberFeeHistory(): void {
        $record1 = $this->createFeeRecord(1, 1, 2025, '100.00');
        $record2 = $this->createFeeRecord(2, 1, 2026, '120.00');
        $this->recordMapper->method('findByMemberId')->with(1)->willReturn([$record1, $record2]);

        $result = $this->service->getMemberFeeHistory(1);

        $this->assertCount(2, $result);
    }

    // ── Record management ───────────────────────────────────────────

    public function testMarkAsPaid(): void {
        $record = $this->createFeeRecord(1, 1, 2026, '120.00');
        $this->recordMapper->method('findById')->with(1)->willReturn($record);
        $this->recordMapper->method('update')->willReturnCallback(fn($r) => $r);

        $result = $this->service->markAsPaid(1, '2026-04-01');

        $this->assertTrue($result->getPaid());
        $this->assertSame('2026-04-01', $result->getPaymentDate());
    }

    public function testManualOverride(): void {
        $record = $this->createFeeRecord(1, 1, 2026, '120.00');
        $this->recordMapper->method('findById')->with(1)->willReturn($record);
        $this->recordMapper->method('update')->willReturnCallback(fn($r) => $r);

        $result = $this->service->manualOverride(1, '200.00', 'Sonderbeitrag');

        $this->assertSame('200.00', $result->getAmount());
        $this->assertTrue($result->getManuellAngepasst());
        $this->assertSame('Sonderbeitrag', $result->getNotes());
    }

    public function testManualOverrideWithoutNotes(): void {
        $record = $this->createFeeRecord(1, 1, 2026, '120.00');
        $record->setNotes('old notes');
        $this->recordMapper->method('findById')->with(1)->willReturn($record);
        $this->recordMapper->method('update')->willReturnCallback(fn($r) => $r);

        $result = $this->service->manualOverride(1, '200.00');

        $this->assertSame('200.00', $result->getAmount());
        $this->assertSame('old notes', $result->getNotes());
    }

    // ── Rule management ─────────────────────────────────────────────

    public function testCreateRule(): void {
        $this->ruleMapper->method('insert')->willReturnCallback(function ($r) {
            $ref = new \ReflectionProperty($r, 'id');
            $ref->setAccessible(true);
            $ref->setValue($r, 1);
            return $r;
        });

        $data = [
            'yearFrom' => 2027,
            'baseRate' => '150.00',
            'familyRules' => ['1' => 120.00, '2' => 90.00],
            'inactiveRule' => 'freeze_rate_at_exit',
        ];

        $result = $this->service->createRule($data);

        $this->assertSame(2027, $result->getYearFrom());
        $this->assertSame('150.00', $result->getBaseRate());
    }

    public function testCreateRuleWithFamilyRulesJson(): void {
        $this->ruleMapper->method('insert')->willReturnCallback(function ($r) {
            $ref = new \ReflectionProperty($r, 'id');
            $ref->setAccessible(true);
            $ref->setValue($r, 1);
            return $r;
        });

        $data = [
            'yearFrom' => 2027,
            'baseRate' => '150.00',
            'familyRulesJson' => '{"1": 100, "2": 75}',
        ];

        $result = $this->service->createRule($data);

        $this->assertSame('{"1": 100, "2": 75}', $result->getFamilyRulesJson());
    }

    public function testCreateRuleValidationMissingYearFrom(): void {
        $this->expectException(\OCA\Mitgliederverwaltung\Service\ValidationException::class);
        $this->expectExceptionMessage('yearFrom');

        $this->service->createRule(['baseRate' => '100.00']);
    }

    public function testCreateRuleValidationMissingBaseRate(): void {
        $this->expectException(\OCA\Mitgliederverwaltung\Service\ValidationException::class);
        $this->expectExceptionMessage('baseRate');

        $this->service->createRule(['yearFrom' => 2027]);
    }

    public function testCreateRuleValidationNegativeBaseRate(): void {
        $this->expectException(\OCA\Mitgliederverwaltung\Service\ValidationException::class);
        $this->expectExceptionMessage('negativ');

        $this->service->createRule(['yearFrom' => 2027, 'baseRate' => '-10.00']);
    }

    public function testUpdateRule(): void {
        $existingRule = $this->createFeeRule(2026, '120.00');
        $this->ruleMapper->method('findById')->with(1)->willReturn($existingRule);
        $this->ruleMapper->method('update')->willReturnCallback(fn($r) => $r);

        $result = $this->service->updateRule(1, [
            'yearFrom' => 2027,
            'baseRate' => '150.00',
            'inactiveRule' => 'no_fee',
        ]);

        $this->assertSame(2027, $result->getYearFrom());
        $this->assertSame('150.00', $result->getBaseRate());
    }

    public function testUpdateRuleWithFamilyRulesJson(): void {
        $existingRule = $this->createFeeRule(2026, '120.00');
        $this->ruleMapper->method('findById')->with(1)->willReturn($existingRule);
        $this->ruleMapper->method('update')->willReturnCallback(fn($r) => $r);

        $result = $this->service->updateRule(1, [
            'familyRulesJson' => '{"1": 100}',
        ]);

        $this->assertSame('{"1": 100}', $result->getFamilyRulesJson());
    }

    public function testUpdateRuleWithFamilyRulesArray(): void {
        $existingRule = $this->createFeeRule(2026, '120.00');
        $this->ruleMapper->method('findById')->with(1)->willReturn($existingRule);
        $this->ruleMapper->method('update')->willReturnCallback(fn($r) => $r);

        $result = $this->service->updateRule(1, [
            'familyRules' => ['1' => 100, '2' => 75],
        ]);

        $decoded = json_decode($result->getFamilyRulesJson(), true);
        $this->assertEquals(100, $decoded['1']);
    }

    // ── getCurrentUserId fallback ─────────────────────────────────

    public function testCreateRuleWithNoUserSession(): void {
        $this->userSession = $this->createMock(IUserSession::class);
        $this->userSession->method('getUser')->willReturn(null);

        $service = new FeeCalculationService(
            $this->ruleMapper,
            $this->recordMapper,
            $this->memberMapper,
            $this->auditService,
            $this->userSession,
            $this->logger
        );

        $this->ruleMapper->method('insert')->willReturnCallback(function ($r) {
            $ref = new \ReflectionProperty($r, 'id');
            $ref->setAccessible(true);
            $ref->setValue($r, 1);
            return $r;
        });

        $result = $service->createRule([
            'yearFrom' => 2027,
            'baseRate' => '100.00',
        ]);

        $this->assertSame('system', $result->getCreatedBy());
    }

    /**
     * Test family discount with explicit position rules (no catch-all).
     */
    public function testFamilyDiscountWithExplicitPositionRules(): void {
        $child1 = $this->createMember(1, 'mitglied', 'aktiv', 10, '2010-01-01');
        $child2 = $this->createMember(2, 'mitglied', 'aktiv', 10, '2012-06-01');
        $child3 = $this->createMember(3, 'mitglied', 'aktiv', 10, '2015-09-01');

        $familyMembers = [$child1, $child2, $child3];
        // Explicit rules for positions 1, 2, and 3 (no catch-all "3+")
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 80.00, '3' => 60.00]);

        $this->assertSame('100.00', $this->service->calculateFee($child1, $familyMembers, $rule));
        $this->assertSame('80.00', $this->service->calculateFee($child2, $familyMembers, $rule));
        $this->assertSame('60.00', $this->service->calculateFee($child3, $familyMembers, $rule));
    }

    /**
     * Test that a fourth child falls back to base rate when only explicit
     * rules for positions 1-3 exist (no catch-all).
     */
    public function testFourthChildFallsBackToBaseRateWithoutCatchAll(): void {
        $child1 = $this->createMember(1, 'mitglied', 'aktiv', 10, '2008-01-01');
        $child2 = $this->createMember(2, 'mitglied', 'aktiv', 10, '2010-01-01');
        $child3 = $this->createMember(3, 'mitglied', 'aktiv', 10, '2012-01-01');
        $child4 = $this->createMember(4, 'mitglied', 'aktiv', 10, '2015-01-01');

        $familyMembers = [$child1, $child2, $child3, $child4];
        // Only positions 1-3, no catch-all
        $rule = $this->createFeeRule(2026, '120.00', ['1' => 100.00, '2' => 80.00, '3' => 60.00]);

        $fee = $this->service->calculateFee($child4, $familyMembers, $rule);
        $this->assertSame('120.00', $fee,
            'Fourth child without a catch-all rule should fall back to base rate');
    }
}
