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

use App\Domain\Sprint;
use App\Domain\SprintWeek;
use DateTimeImmutable;
use PDO;
use RuntimeException;

final class SprintRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    /**
     * List sprints with worker + task counts. Newest start first.
     *
     * @return list<array{sprint: Sprint, nWorkers: int, nTasks: int}>
     */
    public function allWithCounts(): array
    {
        $stmt = $this->pdo->query(
            'SELECT s.*,
                    (SELECT COUNT(*) FROM sprint_workers WHERE sprint_id = s.id) AS n_workers,
                    (SELECT COUNT(*) FROM tasks          WHERE sprint_id = s.id) AS n_tasks
             FROM sprints s
             ORDER BY s.start_date DESC, s.id DESC'
        );
        $out = [];
        foreach ($stmt as $row) {
            $out[] = [
                'sprint'   => self::hydrate($row),
                'nWorkers' => (int) $row['n_workers'],
                'nTasks'   => (int) $row['n_tasks'],
            ];
        }
        return $out;
    }

    public function find(int $id): ?Sprint
    {
        $stmt = $this->pdo->prepare('SELECT * FROM sprints WHERE id = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch();
        return is_array($row) ? self::hydrate($row) : null;
    }

    /**
     * Create a sprint row. Caller is responsible for the enclosing transaction
     * (e.g. to also audit the change and materialise week rows atomically).
     */
    public function create(
        string $name,
        string $startDate,
        string $endDate,
        float $reserveFraction,
    ): Sprint {
        $now = gmdate('Y-m-d\TH:i:s\Z');
        $stmt = $this->pdo->prepare(
            'INSERT INTO sprints (name, start_date, end_date, reserve_fraction, is_archived, created_at, updated_at)
             VALUES (?, ?, ?, ?, 0, ?, ?)'
        );
        $stmt->execute([$name, $startDate, $endDate, $reserveFraction, $now, $now]);

        $id = (int) $this->pdo->lastInsertId();
        $sprint = $this->find($id);
        if ($sprint === null) {
            throw new RuntimeException('Inserted sprint not found');
        }
        return $sprint;
    }

    /** Whitelisted updatable columns on `sprints` for admin edits. */
    private const UPDATABLE = ['name', 'start_date', 'end_date', 'reserve_fraction', 'is_archived'];

    /**
     * Apply the given field changes. Returns before/after for auditing.
     *
     * @param array<string,mixed> $changes
     * @return array{before: Sprint, after: Sprint}
     */
    public function update(int $id, array $changes): array
    {
        $before = $this->find($id);
        if ($before === null) {
            throw new RuntimeException("Sprint {$id} not found");
        }

        $changes = array_intersect_key($changes, array_flip(self::UPDATABLE));
        if ($changes === []) {
            return ['before' => $before, 'after' => $before];
        }

        $sets = [];
        $vals = [];
        foreach ($changes as $col => $v) {
            $sets[] = "{$col} = ?";
            $vals[] = match ($col) {
                'is_archived'       => ((bool) $v) ? 1 : 0,
                'reserve_fraction'  => (float) $v,
                default             => (string) $v,
            };
        }
        $sets[] = 'updated_at = ?';
        $vals[] = gmdate('Y-m-d\TH:i:s\Z');
        $vals[] = $id;

        $stmt = $this->pdo->prepare(
            'UPDATE sprints SET ' . implode(', ', $sets) . ' WHERE id = ?'
        );
        $stmt->execute($vals);

        $after = $this->find($id) ?? $before;
        return ['before' => $before, 'after' => $after];
    }

    /**
     * Delete a sprint. Does NOT read cascaded child rows; the controller is
     * responsible for auditing those (sprint_weeks / sprint_workers /
     * sprint_worker_days / tasks / task_assignments) BEFORE calling this
     * method, per spec §7. Returns the pre-deletion row for the parent
     * audit entry.
     */
    public function delete(int $id): ?Sprint
    {
        $before = $this->find($id);
        if ($before === null) {
            return null;
        }
        $this->pdo
            ->prepare('DELETE FROM sprints WHERE id = ?')
            ->execute([$id]);
        return $before;
    }

    /**
     * Materialise N week rows for a sprint.
     *
     * Each week's `active_days_mask` is seeded from the sprint's `[startDate,
     * endDate]` window: a Mo..Fr bit is set only when that calendar weekday
     * falls inside the window. Weeks fully inside the range get `MASK_ALL`;
     * the leading/trailing partial weeks get a trimmed mask. The user can
     * still toggle the per-week checkboxes afterwards in Sprint Settings.
     *
     * Returns the inserted rows so the caller can audit each CREATE.
     *
     * @return list<array{id:int, sort_order:int, iso_week:int, start_date:string, max_working_days:float, active_days_mask:int}>
     */
    public function materializeWeeks(int $sprintId, string $startDate, string $endDate, int $nWeeks): array
    {
        if ($nWeeks < 1) {
            return [];
        }

        $d0 = DateTimeImmutable::createFromFormat('Y-m-d', $startDate);
        $dN = DateTimeImmutable::createFromFormat('Y-m-d', $endDate);
        if ($d0 === false) {
            throw new RuntimeException("Invalid start_date: {$startDate}");
        }
        if ($dN === false) {
            throw new RuntimeException("Invalid end_date: {$endDate}");
        }

        $insert = $this->pdo->prepare(
            'INSERT INTO sprint_weeks
             (sprint_id, sort_order, iso_week, start_date, max_working_days, active_days_mask)
             VALUES (?, ?, ?, ?, ?, ?)'
        );

        $out = [];
        for ($i = 1; $i <= $nWeeks; $i++) {
            $weekStart = $d0->modify('+' . ($i - 1) . ' weeks');
            $iso       = (int) $weekStart->format('W');
            $ymd       = $weekStart->format('Y-m-d');
            $mask      = self::maskForWeek($weekStart, $d0, $dN);
            $maxDays   = (float) SprintWeek::popcount($mask);
            $insert->execute([$sprintId, $i, $iso, $ymd, $maxDays, $mask]);

            $out[] = [
                'id'               => (int) $this->pdo->lastInsertId(),
                'sort_order'       => $i,
                'iso_week'         => $iso,
                'start_date'       => $ymd,
                'max_working_days' => $maxDays,
                'active_days_mask' => $mask,
            ];
        }
        return $out;
    }

    /**
     * Build the Mo..Fr bitmask of weekdays whose calendar date falls inside
     * the sprint's [start, end] window. `weekStart` is this week's first
     * day (sprint_start + N×7 days, not necessarily a Monday).
     */
    private static function maskForWeek(
        DateTimeImmutable $weekStart,
        DateTimeImmutable $sprintStart,
        DateTimeImmutable $sprintEnd,
    ): int {
        $mask = 0;
        for ($d = 0; $d < 7; $d++) {
            $day = $weekStart->modify("+{$d} days");
            $dow = (int) $day->format('N'); // 1=Mon … 7=Sun
            if ($dow > 5) {
                continue;
            }
            if ($day < $sprintStart || $day > $sprintEnd) {
                continue;
            }
            $mask |= (1 << ($dow - 1));
        }
        return $mask;
    }

    /**
     * @param array<string,mixed> $row
     */
    private static function hydrate(array $row): Sprint
    {
        return new Sprint(
            id:              (int) $row['id'],
            name:            (string) $row['name'],
            startDate:       (string) $row['start_date'],
            endDate:         (string) $row['end_date'],
            reserveFraction: (float) $row['reserve_fraction'],
            isArchived:      ((int) $row['is_archived']) === 1,
            createdAt:       (string) $row['created_at'],
            updatedAt:       (string) $row['updated_at'],
        );
    }
}
