<?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\Services\Import;

use App\Domain\Import\ImportResult;
use App\Domain\Import\ParsedSheet;
use App\Domain\SprintWeek;
use App\Domain\TaskAssignment;
use App\Domain\User;
use App\Http\Request;
use App\Repositories\SprintRepository;
use App\Repositories\SprintWeekRepository;
use App\Repositories\SprintWorkerDayRepository;
use App\Repositories\SprintWorkerRepository;
use App\Repositories\TaskAssignmentRepository;
use App\Repositories\TaskRepository;
use App\Repositories\WorkerRepository;
use App\Services\AuditLogger;
use InvalidArgumentException;
use PDO;
use RuntimeException;
use Throwable;

/**
 * Commit a ParsedSheet into the database.
 *
 * Modes:
 *   - target='new'                   → create a fresh sprint with $sprintName
 *   - target='existing', $sprintId   → import into an empty existing sprint
 *                                      (no weeks, no workers, no tasks).
 *
 * Workers are matched by case-insensitive name; missing ones are auto-created
 * (audit row each). Task owners that don't resolve to any worker are recorded
 * in ImportResult::$missingOwners and the task is created with no owner.
 *
 * The whole commit runs in one transaction; on any failure the caller sees
 * the underlying exception and nothing is persisted.
 */
final class SprintImporter
{
    public function __construct(
        private readonly PDO                       $pdo,
        private readonly SprintRepository          $sprints,
        private readonly SprintWeekRepository      $weeks,
        private readonly SprintWorkerRepository    $sprintWorkers,
        private readonly SprintWorkerDayRepository $days,
        private readonly TaskRepository            $tasks,
        private readonly TaskAssignmentRepository  $assignments,
        private readonly WorkerRepository          $workers,
        private readonly AuditLogger               $audit,
    ) {
    }

    /**
     * @param 'new'|'existing' $target
     */
    public function commit(
        ParsedSheet $sheet,
        string $sprintName,
        string $startDate,
        string $endDate,
        string $target,
        ?int $existingSprintId,
        Request $req,
        ?User $actor,
    ): ImportResult {
        if ($target !== 'new' && $target !== 'existing') {
            throw new InvalidArgumentException('target must be "new" or "existing"');
        }
        if ($target === 'existing' && $existingSprintId === null) {
            throw new InvalidArgumentException('existingSprintId required when target="existing"');
        }
        if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate) ||
            !preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
            throw new InvalidArgumentException('startDate/endDate must be Y-m-d');
        }
        if ($sprintName === '') {
            throw new InvalidArgumentException('sprintName is required');
        }

        $this->pdo->beginTransaction();
        try {
            // --- 1. Resolve target sprint --------------------------------------
            if ($target === 'new') {
                $sprint = $this->sprints->create(
                    name:            $sprintName,
                    startDate:       $startDate,
                    endDate:         $endDate,
                    reserveFraction: $sheet->reserveFraction,
                );
                $this->audit->recordForRequest(
                    action:     'CREATE',
                    entityType: 'sprint',
                    entityId:   $sprint->id,
                    before:     null,
                    after:      $sprint->toAuditSnapshot(),
                    req:        $req,
                    actor:      $actor,
                );
            } else {
                $sprint = $this->sprints->find((int) $existingSprintId);
                if ($sprint === null) {
                    throw new RuntimeException("Sprint {$existingSprintId} not found");
                }
                if (!$this->isSprintEmpty($sprint->id)) {
                    throw new RuntimeException(
                        "Sprint {$sprint->id} is not empty; refuse to merge."
                    );
                }
                // Apply the wizard-set name + dates + reserve fraction onto the
                // existing empty sprint (typical case: user picked a sprint shell
                // they prepared earlier).
                $diff = $this->sprints->update($sprint->id, [
                    'name'             => $sprintName,
                    'start_date'       => $startDate,
                    'end_date'         => $endDate,
                    'reserve_fraction' => $sheet->reserveFraction,
                ]);
                $this->audit->recordForRequest(
                    action:     'UPDATE',
                    entityType: 'sprint',
                    entityId:   $sprint->id,
                    before:     $diff['before']->toAuditSnapshot(),
                    after:      $diff['after']->toAuditSnapshot(),
                    req:        $req,
                    actor:      $actor,
                );
                $sprint = $diff['after'];
            }
            // Tag the sprint creation/merge as the importer's anchor row.
            $this->audit->recordForRequest(
                action:     'IMPORTED_FROM_XLSX',
                entityType: 'sprint',
                entityId:   $sprint->id,
                before:     null,
                after:      [
                    'sheet_name'         => $sheet->sheetName,
                    'week_count'         => count($sheet->weeks),
                    'worker_count'       => count($sheet->workers),
                    'task_count'         => count($sheet->tasks),
                ],
                req:        $req,
                actor:      $actor,
            );

            // --- 2. Materialise weeks ------------------------------------------
            $weekIds = []; // sortOrder => sprint_week_id
            foreach ($sheet->weeks as $pw) {
                $mask = self::maxDaysToMask($pw->maxWorkingDays);
                $weekStart = $pw->inferredStartDate ?? $startDate;
                $stmt = $this->pdo->prepare(
                    'INSERT INTO sprint_weeks
                     (sprint_id, sort_order, iso_week, start_date, max_working_days, active_days_mask)
                     VALUES (?, ?, ?, ?, ?, ?)'
                );
                $stmt->execute([
                    $sprint->id,
                    $pw->sortOrder,
                    $pw->kw,
                    $weekStart,
                    (float) SprintWeek::popcount($mask),
                    $mask,
                ]);
                $wid = (int) $this->pdo->lastInsertId();
                $weekIds[$pw->sortOrder] = $wid;
                $this->audit->recordForRequest(
                    action:     'CREATE',
                    entityType: 'sprint_week',
                    entityId:   $wid,
                    before:     null,
                    after:      [
                        'id'               => $wid,
                        'sprint_id'        => $sprint->id,
                        'sort_order'       => $pw->sortOrder,
                        'iso_week'         => $pw->kw,
                        'start_date'       => $weekStart,
                        'max_working_days' => (float) SprintWeek::popcount($mask),
                        'active_days_mask' => $mask,
                    ],
                    req:        $req,
                    actor:      $actor,
                );
            }

            // --- 3. Resolve / create workers + sprint_workers + per-week days ---
            $workerByFold = $this->buildWorkerLookup();
            $createdWorkers = [];
            $sprintWorkerIdByFold = [];
            foreach ($sheet->workers as $pw) {
                $fold = self::fold($pw->name);
                if (!isset($workerByFold[$fold])) {
                    $w = $this->workers->create($pw->name, true, 0.0);
                    $workerByFold[$fold] = $w;
                    $createdWorkers[] = $w->name;
                    $this->audit->recordForRequest(
                        action:     'CREATE',
                        entityType: 'worker',
                        entityId:   $w->id,
                        before:     null,
                        after:      $w->toAuditSnapshot(),
                        req:        $req,
                        actor:      $actor,
                    );
                }
                $worker = $workerByFold[$fold];

                $sw = $this->sprintWorkers->add($sprint->id, $worker->id, $pw->rtb);
                $sprintWorkerIdByFold[$fold] = $sw->id;
                $this->audit->recordForRequest(
                    action:     'CREATE',
                    entityType: 'sprint_worker',
                    entityId:   $sw->id,
                    before:     null,
                    after:      $sw->toAuditSnapshot(),
                    req:        $req,
                    actor:      $actor,
                );

                foreach ($pw->daysPerWeek as $sortOrder => $days) {
                    if ($days <= 0.0 || !isset($weekIds[$sortOrder])) {
                        continue;
                    }
                    $r = $this->days->upsert($sw->id, $weekIds[$sortOrder], (float) $days);
                    if ($r['action'] === 'CREATE' && $r['after'] !== null) {
                        $this->audit->recordForRequest(
                            action:     'CREATE',
                            entityType: 'sprint_worker_day',
                            entityId:   $r['after']->id,
                            before:     null,
                            after:      $r['after']->toAuditSnapshot(),
                            req:        $req,
                            actor:      $actor,
                        );
                    }
                }
            }

            // --- 4. Tasks + assignments ----------------------------------------
            $missingOwners  = [];
            $assignmentCells = 0;
            $statusCells     = 0;
            foreach ($sheet->tasks as $pt) {
                $ownerId = null;
                if ($pt->ownerName !== null) {
                    $ofold = self::fold($pt->ownerName);
                    if (isset($workerByFold[$ofold])) {
                        $ownerId = $workerByFold[$ofold]->id;
                    } else {
                        $missingOwners[] = $pt->ownerName;
                    }
                }

                $task = $this->tasks->create(
                    sprintId:      $sprint->id,
                    title:         $pt->title,
                    ownerWorkerId: $ownerId,
                    priority:      $pt->priority,
                );
                $this->audit->recordForRequest(
                    action:     'CREATE',
                    entityType: 'task',
                    entityId:   $task->id,
                    before:     null,
                    after:      $task->toAuditSnapshot(),
                    req:        $req,
                    actor:      $actor,
                );

                foreach ($pt->assignments as $pa) {
                    $afold = self::fold($pa->workerName);
                    if (!isset($sprintWorkerIdByFold[$afold])) {
                        // Cell points at a worker not on this sprint — drop with a warning.
                        // (Should not happen for correct workbooks, but be defensive.)
                        continue;
                    }
                    $swId = $sprintWorkerIdByFold[$afold];

                    if ($pa->days > 0.0) {
                        $r = $this->assignments->upsert($task->id, $swId, $pa->days);
                        if ($r['action'] === 'CREATE' && $r['after'] !== null) {
                            $assignmentCells++;
                            $this->audit->recordForRequest(
                                action:     'CREATE',
                                entityType: 'task_assignment',
                                entityId:   $r['after']->id,
                                before:     null,
                                after:      $r['after']->toAuditSnapshot(),
                                req:        $req,
                                actor:      $actor,
                            );
                        }
                    }

                    if ($pa->status !== TaskAssignment::STATUS_ZUGEWIESEN) {
                        $r = $this->assignments->upsertStatus($task->id, $swId, $pa->status);
                        if ($r['action'] !== 'NOOP' && $r['after'] !== null) {
                            $statusCells++;
                            $this->audit->recordForRequest(
                                action:     $r['action'] === 'CREATE' ? 'CREATE' : 'UPDATE',
                                entityType: 'task_assignment',
                                entityId:   $r['after']->id,
                                before:     $r['before']?->toAuditSnapshot(),
                                after:      $r['after']->toAuditSnapshot(),
                                req:        $req,
                                actor:      $actor,
                            );
                        }
                    }
                }
            }

            $this->pdo->commit();

            return new ImportResult(
                sprintId:             $sprint->id,
                sprintName:           $sprint->name,
                weekCount:            count($sheet->weeks),
                workerCount:          count($sheet->workers),
                taskCount:            count($sheet->tasks),
                assignmentCellCount:  $assignmentCells,
                statusCellCount:      $statusCells,
                createdWorkers:       $createdWorkers,
                missingOwners:        array_values(array_unique($missingOwners)),
            );
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }
    }

    private function isSprintEmpty(int $sprintId): bool
    {
        $rows = function (string $sql, array $args): int {
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute($args);
            return (int) $stmt->fetchColumn();
        };
        $w = $rows('SELECT COUNT(*) FROM sprint_weeks   WHERE sprint_id = ?', [$sprintId]);
        $sw = $rows('SELECT COUNT(*) FROM sprint_workers WHERE sprint_id = ?', [$sprintId]);
        $t = $rows('SELECT COUNT(*) FROM tasks          WHERE sprint_id = ?', [$sprintId]);
        return $w === 0 && $sw === 0 && $t === 0;
    }

    /** @return array<string, \App\Domain\Worker> case-folded name → Worker */
    private function buildWorkerLookup(): array
    {
        $out = [];
        foreach ($this->workers->all() as $w) {
            $out[self::fold($w->name)] = $w;
        }
        return $out;
    }

    /** Case-fold + collapse internal whitespace for robust name matching. */
    public static function fold(string $name): string
    {
        $s = trim($name);
        $s = preg_replace('/\s+/u', ' ', $s) ?? $s;
        return mb_strtolower($s);
    }

    /**
     * Map an integer max-working-days into a 5-bit Mo..Fr active_days_mask.
     * Defaults to "first N weekdays set" — equivalent to (1 << N) - 1, capped
     * to 31 (full Mo..Fr). The user can refine the mask afterwards in Sprint
     * Settings.
     */
    public static function maxDaysToMask(int $maxDays): int
    {
        if ($maxDays <= 0) {
            return 0;
        }
        if ($maxDays >= 5) {
            return SprintWeek::MASK_ALL;
        }
        return (1 << $maxDays) - 1;
    }
}
