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

use App\Auth\SessionGuard;
use App\Domain\TaskAssignment;
use App\Http\Request;
use App\Http\Response;
use App\Repositories\AppSettingsRepository;
use App\Repositories\SprintRepository;
use App\Repositories\SprintWorkerDayRepository;
use App\Repositories\SprintWorkerRepository;
use App\Repositories\TaskAssignmentRepository;
use App\Repositories\TaskRepository;
use App\Repositories\UserRepository;
use App\Repositories\WorkerRepository;
use App\Services\AuditLogger;
use App\Services\CapacityCalculator;
use InvalidArgumentException;
use PDO;
use Throwable;

/**
 * Task CRUD + assignments.
 *
 * Tasks live per-sprint; assignments are per (task, sprint_worker) cell.
 * All endpoints are admin-only JSON. Every mutation writes per-row audit
 * entries inside the same transaction as the DB change.
 */
final class TaskController
{
    /**
     * R01-N24: see SprintController::MAX_BATCH_ITEMS. Same rationale, same
     * cap; reorder / assignments / status share the cell-style payload
     * shape and need the same defence-in-depth bound.
     */
    public const MAX_BATCH_ITEMS = 5000;

    public function __construct(
        private readonly PDO                       $pdo,
        private readonly UserRepository            $users,
        private readonly SprintRepository          $sprints,
        private readonly SprintWorkerRepository    $sprintWorkers,
        private readonly SprintWorkerDayRepository $days,
        private readonly TaskRepository            $tasks,
        private readonly TaskAssignmentRepository  $assignments,
        private readonly WorkerRepository          $workers,
        private readonly AuditLogger               $audit,
        private readonly AppSettingsRepository     $appSettings,
    ) {
    }

    /** POST /sprints/{id}/tasks — create a task (rows append at sort_order MAX+1). */
    public function create(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAdminJson($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        $sprintId = (int) $params['id'];
        $sprint   = $this->sprints->find($sprintId);
        if ($sprint === null) {
            return Response::err('not_found', 'Sprint not found', 404);
        }

        $body  = $req->json() ?? [];
        $title = isset($body['title']) && is_string($body['title']) ? trim($body['title']) : '';
        if ($title === '') {
            $title = '(Untitled task)';
        }

        $priority = isset($body['priority']) ? (int) $body['priority'] : 1;
        if ($priority !== 1 && $priority !== 2) {
            return Response::err('validation', 'priority must be 1 or 2', 422);
        }

        $ownerWorkerId = null;
        if (isset($body['owner_worker_id']) && $body['owner_worker_id'] !== null && $body['owner_worker_id'] !== '') {
            $ownerWorkerId = (int) $body['owner_worker_id'];
            if ($this->workers->find($ownerWorkerId) === null) {
                return Response::err('validation', 'Unknown owner_worker_id', 422);
            }
        }

        $this->pdo->beginTransaction();
        try {
            $task = $this->tasks->create($sprintId, $title, $ownerWorkerId, $priority);
            $this->audit->recordForRequest(
                'CREATE', 'task', $task->id,
                null, $task->toAuditSnapshot(),
                $req, $actor,
            );
            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::err('db_error', 'Could not create task', 500);
        }

        return Response::ok([
            'task'        => $task->toAuditSnapshot(),
            'assignments' => (object) [],  // new task, no assignments yet
        ]);
    }

    /** PATCH /tasks/{id} — edit title / owner / priority. */
    public function update(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAuthJson($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        $id = (int) $params['id'];
        $task = $this->tasks->find($id);
        if ($task === null) {
            return Response::err('not_found', 'Task not found', 404);
        }

        $body    = $req->json() ?? [];
        $changes = [];

        if (array_key_exists('title', $body)) {
            $title = is_string($body['title']) ? trim($body['title']) : '';
            if ($title === '') {
                return Response::err('validation', 'title cannot be empty', 422);
            }
            $changes['title'] = $title;
        }

        if (array_key_exists('priority', $body)) {
            $p = (int) $body['priority'];
            if ($p !== 1 && $p !== 2) {
                return Response::err('validation', 'priority must be 1 or 2', 422);
            }
            $changes['priority'] = $p;
        }

        if (array_key_exists('owner_worker_id', $body)) {
            $v = $body['owner_worker_id'];
            if ($v === null || $v === '') {
                $changes['owner_worker_id'] = null;
            } else {
                $ow = (int) $v;
                if ($this->workers->find($ow) === null) {
                    return Response::err('validation', 'Unknown owner_worker_id', 422);
                }
                $changes['owner_worker_id'] = $ow;
            }
        }

        if (array_key_exists('description', $body)) {
            $desc = is_string($body['description']) ? $body['description'] : '';
            if (strlen($desc) > 8000) {
                return Response::err('validation', 'description too long', 422);
            }
            $changes['description'] = $desc;
        }

        if (array_key_exists('url', $body)) {
            $u = is_string($body['url']) ? trim($body['url']) : '';
            if ($u !== '') {
                if (strlen($u) > 2048) {
                    return Response::err('validation', 'url too long', 422);
                }
                if (!preg_match('#^https?://#i', $u)) {
                    return Response::err('validation', 'url must start with http:// or https://', 422);
                }
            }
            $changes['url'] = $u;
        }

        if ($changes === []) {
            return Response::ok(['task' => $task->toAuditSnapshot()]);
        }

        $this->pdo->beginTransaction();
        try {
            $result = $this->tasks->update($id, $changes);
            $this->audit->recordForRequest(
                'UPDATE', 'task', $id,
                $result['before']->toAuditSnapshot(),
                $result['after']->toAuditSnapshot(),
                $req, $actor,
            );

            $responsePayload = [
                'task' => $result['after']->toAuditSnapshot(),
            ];

            // If priority changed, touched workers' Available depends on prio-1
            // commitments. Recompute capacity for every sprint worker so the
            // client can paint the updated summary in one go.
            if (array_key_exists('priority', $changes) && $changes['priority'] !== $result['before']->priority) {
                $responsePayload['per_worker'] = $this->computeCapacity($result['after']->sprintId);
            }

            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::err('db_error', 'Could not update task', 500);
        }

        return Response::ok($responsePayload);
    }

    /** DELETE /tasks/{id} — delete a task; audits each assignment before cascade. */
    public function delete(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAuthJson($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        $id = (int) $params['id'];
        $task = $this->tasks->find($id);
        if ($task === null) {
            return Response::err('not_found', 'Task not found', 404);
        }

        // Snapshot every assignment before the FK cascade wipes them.
        $assignments = $this->assignments->allForTask($id);

        $this->pdo->beginTransaction();
        try {
            foreach ($assignments as $a) {
                $this->audit->recordForRequest(
                    'DELETE', 'task_assignment', $a->id,
                    $a->toAuditSnapshot(), null,
                    $req, $actor,
                );
            }

            $this->tasks->delete($id);
            $this->audit->recordForRequest(
                'DELETE', 'task', $id,
                $task->toAuditSnapshot(), null,
                $req, $actor,
            );

            $responsePayload = ['removed_id' => $id];

            // If it was a prio-1 task, available changes on every worker that
            // had an assignment.
            if ($task->priority === 1 && $assignments !== []) {
                $responsePayload['per_worker'] = $this->computeCapacity($task->sprintId);
            }

            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::err('db_error', 'Could not delete task', 500);
        }

        return Response::ok($responsePayload);
    }

    /** POST /sprints/{id}/tasks/reorder — apply an ordering. */
    public function reorder(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAuthJson($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        $sprintId = (int) $params['id'];
        if ($this->sprints->find($sprintId) === null) {
            return Response::err('not_found', 'Sprint not found', 404);
        }

        $body = $req->json();
        if (!is_array($body) || !array_is_list($body)) {
            return Response::err('validation', 'body must be a list of {task_id, sort_order}', 422);
        }
        if (count($body) > self::MAX_BATCH_ITEMS) {
            return Response::err(
                'too_many_items',
                'reorder list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap',
                413,
            );
        }

        $ordering  = [];
        $seenOrder = [];
        foreach ($body as $row) {
            if (!is_array($row) || !isset($row['task_id'], $row['sort_order'])) {
                return Response::err('validation', 'each entry needs task_id and sort_order', 422);
            }
            $tid   = (int) $row['task_id'];
            $order = (int) $row['sort_order'];
            if ($tid <= 0 || $order < 1) {
                return Response::err('validation', 'ids/orders must be positive', 422);
            }
            if (isset($seenOrder[$order])) {
                return Response::err('validation', 'duplicate sort_order', 422);
            }
            $seenOrder[$order] = true;
            $ordering[] = ['task_id' => $tid, 'sort_order' => $order];
        }

        $this->pdo->beginTransaction();
        try {
            $diffs = $this->tasks->reorder($sprintId, $ordering);
            foreach ($diffs as $d) {
                $this->audit->recordForRequest(
                    'UPDATE', 'task', $d['after']->id,
                    $d['before']->toAuditSnapshot(),
                    $d['after']->toAuditSnapshot(),
                    $req, $actor,
                );
            }
            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::err('db_error', 'Could not reorder', 500);
        }

        return Response::ok(['moved' => count($diffs)]);
    }

    /** PATCH /tasks/{id}/assignments — batch upsert of task_assignments. */
    public function updateAssignments(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAdminJson($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        $taskId = (int) $params['id'];
        $task = $this->tasks->find($taskId);
        if ($task === null) {
            return Response::err('not_found', 'Task not found', 404);
        }

        $body = $req->json();
        if (!is_array($body) || !array_is_list($body)) {
            return Response::err('validation', 'body must be a list of {sprint_worker_id, days}', 422);
        }
        if ($body === []) {
            return Response::ok(['applied' => 0, 'noop' => 0]);
        }
        if (count($body) > self::MAX_BATCH_ITEMS) {
            return Response::err(
                'too_many_items',
                'assignment list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap',
                413,
            );
        }

        // Cross-check sprint worker IDs belong to the task's sprint.
        $validSw = [];
        foreach ($this->sprintWorkers->allForSprint($task->sprintId) as $sw) {
            $validSw[$sw->id] = true;
        }

        $cells = [];
        foreach ($body as $i => $row) {
            if (!is_array($row) || !isset($row['sprint_worker_id'], $row['days'])) {
                return Response::err('validation', "cell[{$i}] needs sprint_worker_id, days", 422);
            }
            $swId  = (int) $row['sprint_worker_id'];
            $daysN = $row['days'];
            if (!is_numeric($daysN)) {
                return Response::err('validation', "cell[{$i}] days must be numeric", 422);
            }
            $days = (float) $daysN;
            if (!isset($validSw[$swId])) {
                return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
            }
            if ($days < 0) {
                return Response::err('validation', "cell[{$i}] days cannot be negative", 422);
            }
            // Assignments step by 0.5 but have no hard upper bound per spec §3.
            $doubled = $days * 2;
            if (abs($doubled - round($doubled)) > 1e-9) {
                return Response::err('validation', "cell[{$i}] days must step by 0.5", 422);
            }
            $cells[] = ['sw_id' => $swId, 'days' => $days];
        }

        $applied = 0;
        $noop    = 0;

        $this->pdo->beginTransaction();
        try {
            foreach ($cells as $c) {
                $result = $this->assignments->upsert($taskId, $c['sw_id'], $c['days']);
                if ($result['action'] === 'NOOP') {
                    $noop++;
                    continue;
                }
                $applied++;
                $this->audit->recordForRequest(
                    action:     $result['action'],
                    entityType: 'task_assignment',
                    entityId:   $result['after']?->id ?? $result['before']?->id,
                    before:     $result['before']?->toAuditSnapshot(),
                    after:      $result['after']?->toAuditSnapshot(),
                    req:        $req,
                    actor:      $actor,
                );
            }
            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::err('db_error', 'Could not save assignments', 500);
        }

        $data = [
            'applied' => $applied,
            'noop'    => $noop,
            'task_id' => $taskId,
        ];

        if ($applied > 0 && $task->priority === 1) {
            $data['per_worker'] = $this->computeCapacity($task->sprintId);
        }

        return Response::ok($data);
    }

    /**
     * PATCH /tasks/{id}/assignments/status — set per-cell workflow status.
     *
     * Open to any signed-in user (Phase 18 — first non-admin write surface),
     * gated by the global app_settings.task_status_enabled flag. CSRF still
     * required. Days are NOT modified by this endpoint.
     */
    public function updateAssignmentsStatus(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAuthJson($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }
        if (!$this->appSettings->getBool('task_status_enabled', false)) {
            return Response::err('feature_disabled', 'Task status colors are disabled', 403);
        }

        $taskId = (int) $params['id'];
        $task = $this->tasks->find($taskId);
        if ($task === null) {
            return Response::err('not_found', 'Task not found', 404);
        }

        $body = $req->json();
        if (!is_array($body) || !array_is_list($body)) {
            return Response::err('validation', 'body must be a list of {sprint_worker_id, status}', 422);
        }
        if ($body === []) {
            return Response::ok(['applied' => 0, 'noop' => 0]);
        }
        if (count($body) > self::MAX_BATCH_ITEMS) {
            return Response::err(
                'too_many_items',
                'status list exceeds ' . self::MAX_BATCH_ITEMS . '-item cap',
                413,
            );
        }

        $validSw = [];
        foreach ($this->sprintWorkers->allForSprint($task->sprintId) as $sw) {
            $validSw[$sw->id] = true;
        }

        $cells = [];
        foreach ($body as $i => $row) {
            if (!is_array($row) || !isset($row['sprint_worker_id'], $row['status'])) {
                return Response::err('validation', "cell[{$i}] needs sprint_worker_id, status", 422);
            }
            $swId   = (int) $row['sprint_worker_id'];
            $status = is_string($row['status']) ? $row['status'] : '';
            if (!isset($validSw[$swId])) {
                return Response::err('validation', "cell[{$i}] sprint_worker {$swId} not in sprint", 422);
            }
            if (!TaskAssignment::isValidStatus($status)) {
                return Response::err('validation', "cell[{$i}] invalid status", 422);
            }
            $cells[] = ['sw_id' => $swId, 'status' => $status];
        }

        $applied = 0;
        $noop    = 0;

        $this->pdo->beginTransaction();
        try {
            foreach ($cells as $c) {
                try {
                    $result = $this->assignments->upsertStatus(
                        $taskId, $c['sw_id'], $c['status'],
                    );
                } catch (InvalidArgumentException) {
                    // Already validated above, but guard the repo invariant.
                    $this->pdo->rollBack();
                    return Response::err('validation', 'invalid status', 422);
                }
                if ($result['action'] === 'NOOP') {
                    $noop++;
                    continue;
                }
                $applied++;
                $this->audit->recordForRequest(
                    action:     $result['action'],
                    entityType: 'task_assignment',
                    entityId:   $result['after']?->id ?? $result['before']?->id,
                    before:     $result['before']?->toAuditSnapshot(),
                    after:      $result['after']?->toAuditSnapshot(),
                    req:        $req,
                    actor:      $actor,
                );
            }
            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::err('db_error', 'Could not save statuses', 500);
        }

        return Response::ok([
            'applied' => $applied,
            'noop'    => $noop,
            'task_id' => $taskId,
        ]);
    }

    /**
     * POST /tasks/{id}/move — reassign a task to another sprint.
     *
     * All task_assignments are dropped (audited per-cell before the wipe);
     * task lands at the bottom of the destination sprint's list. Capacity is
     * affected on both sides for prio-1 tasks, but the client just reloads
     * the page after a successful move so we don't need to send fresh
     * per-worker numbers in the response.
     */
    public function moveToSprint(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAuthJson($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        $id   = (int) $params['id'];
        $task = $this->tasks->find($id);
        if ($task === null) {
            return Response::err('not_found', 'Task not found', 404);
        }

        $body         = $req->json() ?? [];
        $destSprintId = isset($body['sprint_id']) ? (int) $body['sprint_id'] : 0;
        if ($destSprintId <= 0) {
            return Response::err('validation', 'sprint_id required', 422);
        }
        if ($destSprintId === $task->sprintId) {
            return Response::err('validation', 'task already in this sprint', 422);
        }
        if ($this->sprints->find($destSprintId) === null) {
            return Response::err('not_found', 'Destination sprint not found', 404);
        }

        $oldAssignments = $this->assignments->allForTask($id);

        $this->pdo->beginTransaction();
        try {
            // Audit each assignment before they vanish.
            foreach ($oldAssignments as $a) {
                $this->audit->recordForRequest(
                    'DELETE', 'task_assignment', $a->id,
                    $a->toAuditSnapshot(), null,
                    $req, $actor,
                );
            }

            $result = $this->tasks->moveToSprint($id, $destSprintId);
            $this->audit->recordForRequest(
                'UPDATE', 'task', $id,
                $result['before']->toAuditSnapshot(),
                $result['after']->toAuditSnapshot(),
                $req, $actor,
            );

            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::err('db_error', 'Could not move task', 500);
        }

        return Response::ok([
            'task'       => $result['after']->toAuditSnapshot(),
            'per_worker' => $this->computeCapacity($result['before']->sprintId),
        ]);
    }

    /**
     * POST /tasks/{id}/copy — clone a task into another sprint.
     *
     * Carries title / owner / priority / description / url; assignments are
     * NOT carried (fresh slate per the design call). The new task records
     * `linked_task_id = source.id`; the bidirectional UI link is rendered
     * by the sprint view via TaskRepository::linkedSummariesForTasks.
     */
    public function copyToSprint(Request $req, array $params): Response
    {
        $actor = SessionGuard::requireAuthJson($req, $this->users);
        if ($actor instanceof Response) {
            return $actor;
        }

        $id   = (int) $params['id'];
        $task = $this->tasks->find($id);
        if ($task === null) {
            return Response::err('not_found', 'Task not found', 404);
        }

        $body         = $req->json() ?? [];
        $destSprintId = isset($body['sprint_id']) ? (int) $body['sprint_id'] : 0;
        if ($destSprintId <= 0) {
            return Response::err('validation', 'sprint_id required', 422);
        }
        if ($this->sprints->find($destSprintId) === null) {
            return Response::err('not_found', 'Destination sprint not found', 404);
        }

        $this->pdo->beginTransaction();
        try {
            $copy = $this->tasks->create(
                sprintId:      $destSprintId,
                title:         $task->title,
                ownerWorkerId: $task->ownerWorkerId,
                priority:      $task->priority,
                description:   $task->description,
                url:           $task->url,
                linkedTaskId:  $task->id,
            );
            $this->audit->recordForRequest(
                'CREATE', 'task', $copy->id,
                null, $copy->toAuditSnapshot(),
                $req, $actor,
            );
            $this->pdo->commit();
        } catch (Throwable) {
            $this->pdo->rollBack();
            return Response::err('db_error', 'Could not copy task', 500);
        }

        return Response::ok([
            'task' => $copy->toAuditSnapshot(),
        ]);
    }

    /**
     * Full per-worker capacity recompute for a sprint. Used to keep the
     * client-side capacity strip in sync when changes cascade across rows.
     *
     * @return array<string, array{ressourcen:float, after_reserves:float, committed_prio1:float, available:float}>
     */
    private function computeCapacity(int $sprintId): array
    {
        $sprint = $this->sprints->find($sprintId);
        if ($sprint === null) {
            return [];
        }
        $dayGrid   = $this->days->grid($sprintId);
        $committed = $this->assignments->committedPrio1BySprint($sprintId);

        $out = [];
        foreach ($this->sprintWorkers->allForSprint($sprintId) as $sw) {
            $ressourcen = array_sum($dayGrid[$sw->id] ?? []);
            $out[(string) $sw->id] = CapacityCalculator::forWorker(
                $ressourcen,
                $sprint->reserveFraction,
                $committed[$sw->id] ?? 0.0,
            );
        }
        return $out;
    }
}
