<?php
/*
 * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
 * SPDX-License-Identifier: Apache-2.0
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the LICENSE file in the project root for the full license text.
 */

declare(strict_types=1);

namespace App\Tests\Domain;

use App\Domain\SprintWeek;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

/**
 * Phase 12: SprintWeek's weekday helpers. Pure logic — no DB.
 */
final class SprintWeekTest extends TestCase
{
    public function testPopcountCoversAllFiveBits(): void
    {
        $this->assertSame(0, SprintWeek::popcount(0));
        $this->assertSame(5, SprintWeek::popcount(31));
        $this->assertSame(1, SprintWeek::popcount(1));
        $this->assertSame(4, SprintWeek::popcount(15));
        // bits outside the 5-day range are clamped away
        $this->assertSame(5, SprintWeek::popcount(0xFFFF));
    }

    public function testMaskToDaysReturnsCanonicalOrder(): void
    {
        $this->assertSame(['Mo', 'Di', 'Mi', 'Do', 'Fr'], SprintWeek::maskToDays(31));
        $this->assertSame(['Mo', 'Fr'],                   SprintWeek::maskToDays(0b10001));
        $this->assertSame([],                             SprintWeek::maskToDays(0));
    }

    public function testDaysToMaskRoundTripsBothDirections(): void
    {
        foreach ([0, 1, 2, 7, 15, 17, 31] as $m) {
            $days = SprintWeek::maskToDays($m);
            $this->assertSame($m, SprintWeek::daysToMask($days), "round-trip for mask={$m}");
        }
    }

    public function testDaysToMaskRejectsUnknownLabel(): void
    {
        $this->expectException(InvalidArgumentException::class);
        SprintWeek::daysToMask(['Mo', 'Sa']); // Sa not in Mo–Fr set
    }

    public function testDaysToMaskRejectsDuplicates(): void
    {
        $this->expectException(InvalidArgumentException::class);
        SprintWeek::daysToMask(['Mo', 'Mo']);
    }

    public function testHasDayMatchesMaskBit(): void
    {
        $w = new SprintWeek(
            id: 1, sprintId: 1, sortOrder: 1, isoWeek: 1,
            startDate: '2026-01-05', maxWorkingDays: 3.0,
            activeDaysMask: 0b00111, // Mo Di Mi
        );
        $this->assertTrue($w->hasDay('Mo'));
        $this->assertTrue($w->hasDay('Mi'));
        $this->assertFalse($w->hasDay('Do'));
        $this->assertFalse($w->hasDay('Fr'));
        // Unknown label is a miss, not an error.
        $this->assertFalse($w->hasDay('Sa'));
    }

    public function testWithMaskRecomputesMaxWorkingDays(): void
    {
        $w = new SprintWeek(
            id: 1, sprintId: 1, sortOrder: 1, isoWeek: 1,
            startDate: '2026-01-05', maxWorkingDays: 5.0,
            activeDaysMask: 31,
        );
        $dropped = $w->withMask(0b01111); // Mo Di Mi Do
        $this->assertSame(15, $dropped->activeDaysMask);
        $this->assertSame(4.0, $dropped->maxWorkingDays);
        $this->assertSame(['Mo', 'Di', 'Mi', 'Do'], $dropped->activeDays());
        // Bits outside Mo–Fr are trimmed.
        $this->assertSame(31, $w->withMask(0xFF)->activeDaysMask);
    }

    public function testAuditSnapshotIncludesMask(): void
    {
        $w = new SprintWeek(
            id: 77, sprintId: 9, sortOrder: 2, isoWeek: 8,
            startDate: '2026-02-16', maxWorkingDays: 2.0,
            activeDaysMask: 0b00011,
        );
        $snap = $w->toAuditSnapshot();
        $this->assertSame(0b00011, $snap['active_days_mask']);
        $this->assertSame(2.0,     $snap['max_working_days']);
    }
}
