<?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\Domain;

use InvalidArgumentException;

final class SprintWeek
{
    /**
     * Ordered list of German two-letter day labels mapped to bit positions.
     * The order is load-bearing: `array_search` gives the bit index.
     */
    public const DAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];

    public const MASK_ALL = 31; // 0b11111

    public function __construct(
        public readonly int    $id,
        public readonly int    $sprintId,
        public readonly int    $sortOrder,
        public readonly int    $isoWeek,
        public readonly string $startDate,
        public readonly float  $maxWorkingDays,
        public readonly int    $activeDaysMask,
    ) {
    }

    public function toAuditSnapshot(): array
    {
        return [
            'id'                => $this->id,
            'sprint_id'         => $this->sprintId,
            'sort_order'        => $this->sortOrder,
            'iso_week'          => $this->isoWeek,
            'start_date'        => $this->startDate,
            'max_working_days'  => $this->maxWorkingDays,
            'active_days_mask'  => $this->activeDaysMask,
        ];
    }

    /** @return list<string> e.g. ['Mo', 'Di', 'Do'] in canonical Mo→Fr order. */
    public function activeDays(): array
    {
        return self::maskToDays($this->activeDaysMask);
    }

    public function hasDay(string $twoLetter): bool
    {
        $bit = array_search($twoLetter, self::DAY_LABELS, true);
        if ($bit === false) {
            return false;
        }
        return (($this->activeDaysMask >> $bit) & 1) === 1;
    }

    /** Convenience for building updated copies in tests. */
    public function withMask(int $mask): self
    {
        return new self(
            id:             $this->id,
            sprintId:       $this->sprintId,
            sortOrder:      $this->sortOrder,
            isoWeek:        $this->isoWeek,
            startDate:      $this->startDate,
            maxWorkingDays: (float) self::popcount($mask),
            activeDaysMask: $mask & self::MASK_ALL,
        );
    }

    // -------------------------------------------------------------------
    // Pure helpers — shared with the controller.
    // -------------------------------------------------------------------

    public static function popcount(int $mask): int
    {
        $mask &= self::MASK_ALL;
        $n = 0;
        for ($i = 0; $i < 5; $i++) {
            if ((($mask >> $i) & 1) === 1) {
                $n++;
            }
        }
        return $n;
    }

    /** @return list<string> */
    public static function maskToDays(int $mask): array
    {
        $out = [];
        foreach (self::DAY_LABELS as $i => $label) {
            if ((($mask >> $i) & 1) === 1) {
                $out[] = $label;
            }
        }
        return $out;
    }

    /**
     * @param iterable<mixed> $days any iterable of strings
     * @throws InvalidArgumentException on unknown label or duplicate
     */
    public static function daysToMask(iterable $days): int
    {
        $mask = 0;
        $seen = [];
        foreach ($days as $raw) {
            if (!is_string($raw)) {
                throw new InvalidArgumentException('active_days entries must be strings');
            }
            $bit = array_search($raw, self::DAY_LABELS, true);
            if ($bit === false) {
                throw new InvalidArgumentException("Unknown weekday label: {$raw}");
            }
            if (isset($seen[$bit])) {
                throw new InvalidArgumentException("Duplicate weekday: {$raw}");
            }
            $seen[$bit] = true;
            $mask |= (1 << $bit);
        }
        return $mask;
    }
}
