<?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\Task;
use PDO;
use RuntimeException;

final class TaskRepository
{
    /** Whitelisted updatable columns. */
    private const UPDATABLE = [
        'title',
        'owner_worker_id',
        'priority',
        'description',
        'url',
        'linked_task_id',
    ];

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

    /** @return list<Task> ordered by sort_order ASC */
    public function allForSprint(int $sprintId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM tasks WHERE sprint_id = ? ORDER BY sort_order ASC'
        );
        $stmt->execute([$sprintId]);
        $out = [];
        foreach ($stmt as $row) {
            $out[] = self::hydrate($row);
        }
        return $out;
    }

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

    public function create(
        int $sprintId,
        string $title,
        ?int $ownerWorkerId,
        int $priority,
        string $description = '',
        string $url = '',
        ?int $linkedTaskId = null,
    ): Task {
        $now = gmdate('Y-m-d\TH:i:s\Z');
        $maxStmt = $this->pdo->prepare(
            'SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE sprint_id = ?'
        );
        $maxStmt->execute([$sprintId]);
        $max = (int) $maxStmt->fetchColumn();
        $stmt = $this->pdo->prepare(
            'INSERT INTO tasks
                (sprint_id, title, owner_worker_id, priority, sort_order,
                 created_at, updated_at, description, url, linked_task_id)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
        );
        $stmt->execute([
            $sprintId, $title, $ownerWorkerId, $priority, $max + 1,
            $now, $now, $description, $url, $linkedTaskId,
        ]);
        $id = (int) $this->pdo->lastInsertId();
        $task = $this->find($id);
        if ($task === null) {
            throw new RuntimeException('Inserted task not found');
        }
        return $task;
    }

    /**
     * Move a task to a different sprint. Caller is responsible for auditing
     * task_assignment deletes BEFORE this method (cascade rule, spec §7) and
     * for auditing the task UPDATE after. The task lands at the bottom of the
     * destination sprint's task list.
     *
     * @return array{before: Task, after: Task}
     */
    public function moveToSprint(int $taskId, int $destSprintId): array
    {
        $before = $this->find($taskId);
        if ($before === null) {
            throw new RuntimeException("Task {$taskId} not found");
        }

        // Wipe assignments first — cells from one sprint don't belong to another.
        $this->pdo->prepare('DELETE FROM task_assignments WHERE task_id = ?')
            ->execute([$taskId]);

        $maxStmt = $this->pdo->prepare(
            'SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE sprint_id = ?'
        );
        $maxStmt->execute([$destSprintId]);
        $maxOrder = (int) $maxStmt->fetchColumn();

        $stmt = $this->pdo->prepare(
            'UPDATE tasks
             SET sprint_id = ?, sort_order = ?, updated_at = ?
             WHERE id = ?'
        );
        $stmt->execute([$destSprintId, $maxOrder + 1, gmdate('Y-m-d\TH:i:s\Z'), $taskId]);

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

    /**
     * Resolve linked-task summaries for a list of tasks in one sprint, in both
     * directions: each task's source (when this row was a copy) and each
     * task's copies (rows that point back to it). Used by the sprint view to
     * render bidirectional "linked" chips.
     *
     * @param list<Task> $tasks
     * @return array<int, list<array{id:int, title:string, sprint_id:int, sprint_name:string, direction:string}>>
     */
    public function linkedSummariesForTasks(array $tasks): array
    {
        if ($tasks === []) {
            return [];
        }

        $ids        = [];
        $forwardIds = [];
        foreach ($tasks as $t) {
            $ids[] = $t->id;
            if ($t->linkedTaskId !== null) {
                $forwardIds[] = $t->linkedTaskId;
            }
        }

        $lookupIds = array_values(array_unique(array_merge($ids, $forwardIds)));
        $byId = [];
        if ($lookupIds !== []) {
            $place = implode(',', array_fill(0, count($lookupIds), '?'));
            $stmt  = $this->pdo->prepare(
                'SELECT t.id, t.title, t.sprint_id, t.linked_task_id, s.name AS sprint_name
                 FROM tasks t
                 LEFT JOIN sprints s ON s.id = t.sprint_id
                 WHERE t.id IN (' . $place . ')'
            );
            $stmt->execute($lookupIds);
            foreach ($stmt as $row) {
                $byId[(int) $row['id']] = [
                    'id'             => (int) $row['id'],
                    'title'          => (string) $row['title'],
                    'sprint_id'      => (int) $row['sprint_id'],
                    'sprint_name'    => (string) ($row['sprint_name'] ?? ''),
                    'linked_task_id' => isset($row['linked_task_id']) && $row['linked_task_id'] !== null
                        ? (int) $row['linked_task_id']
                        : null,
                ];
            }
        }

        // Reverse: every row whose linked_task_id is one of our task ids.
        $reverseByTarget = [];
        if ($ids !== []) {
            $place = implode(',', array_fill(0, count($ids), '?'));
            $stmt  = $this->pdo->prepare(
                'SELECT t.id, t.title, t.sprint_id, t.linked_task_id, s.name AS sprint_name
                 FROM tasks t
                 LEFT JOIN sprints s ON s.id = t.sprint_id
                 WHERE t.linked_task_id IN (' . $place . ')'
            );
            $stmt->execute($ids);
            foreach ($stmt as $row) {
                $tgt = (int) $row['linked_task_id'];
                $reverseByTarget[$tgt][] = [
                    'id'          => (int) $row['id'],
                    'title'       => (string) $row['title'],
                    'sprint_id'   => (int) $row['sprint_id'],
                    'sprint_name' => (string) ($row['sprint_name'] ?? ''),
                    'direction'   => 'copy',
                ];
            }
        }

        $out = [];
        foreach ($tasks as $t) {
            $entries = [];
            if ($t->linkedTaskId !== null && isset($byId[$t->linkedTaskId])) {
                $src = $byId[$t->linkedTaskId];
                $entries[] = [
                    'id'          => $src['id'],
                    'title'       => $src['title'],
                    'sprint_id'   => $src['sprint_id'],
                    'sprint_name' => $src['sprint_name'],
                    'direction'   => 'source',
                ];
            }
            foreach ($reverseByTarget[$t->id] ?? [] as $rev) {
                $entries[] = $rev;
            }
            if ($entries !== []) {
                $out[$t->id] = $entries;
            }
        }
        return $out;
    }

    /**
     * @param array<string,mixed> $changes
     * @return array{before: Task, after: Task}
     */
    public function update(int $id, array $changes): array
    {
        $before = $this->find($id);
        if ($before === null) {
            throw new RuntimeException("Task {$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) {
                'title'           => (string) $v,
                'owner_worker_id' => $v === null ? null : (int) $v,
                'priority'        => (int) $v,
                'description'     => (string) ($v ?? ''),
                'url'             => (string) ($v ?? ''),
                'linked_task_id'  => $v === null ? null : (int) $v,
                default           => $v,
            };
        }
        $sets[] = 'updated_at = ?';
        $vals[] = gmdate('Y-m-d\TH:i:s\Z');
        $vals[] = $id;

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

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

    /**
     * Delete a task. Does NOT read cascaded assignment rows; the controller
     * is responsible for auditing those BEFORE calling this method.
     * Returns the pre-deletion row for auditing.
     */
    public function delete(int $id): ?Task
    {
        $before = $this->find($id);
        if ($before === null) {
            return null;
        }
        $this->pdo
            ->prepare('DELETE FROM tasks WHERE id = ?')
            ->execute([$id]);
        return $before;
    }

    /**
     * Apply an ordering of tasks within a sprint. Same two-phase negate-then-
     * apply pattern as SprintWorkerRepository::reorder.
     *
     * @param list<array{task_id:int, sort_order:int}> $ordering
     * @return list<array{before: Task, after: Task}>
     */
    public function reorder(int $sprintId, array $ordering): array
    {
        if ($ordering === []) {
            return [];
        }

        $current = [];
        foreach ($this->allForSprint($sprintId) as $t) {
            $current[$t->id] = $t;
        }

        $stage = $this->pdo->prepare(
            'UPDATE tasks SET sort_order = -? WHERE id = ? AND sprint_id = ?'
        );
        foreach ($ordering as $row) {
            $stage->execute([$row['sort_order'], $row['task_id'], $sprintId]);
        }

        $apply = $this->pdo->prepare(
            'UPDATE tasks SET sort_order = ?, updated_at = ? WHERE id = ? AND sprint_id = ?'
        );
        $now = gmdate('Y-m-d\TH:i:s\Z');
        foreach ($ordering as $row) {
            $apply->execute([$row['sort_order'], $now, $row['task_id'], $sprintId]);
        }

        $out = [];
        foreach ($ordering as $row) {
            $tid = (int) $row['task_id'];
            $before = $current[$tid] ?? null;
            if ($before === null) {
                continue;
            }
            if ($before->sortOrder === (int) $row['sort_order']) {
                continue;
            }
            $after = $this->find($tid);
            if ($after !== null) {
                $out[] = ['before' => $before, 'after' => $after];
            }
        }
        return $out;
    }

    /**
     * @param array<string,mixed> $row
     */
    private static function hydrate(array $row): Task
    {
        return new Task(
            id:            (int)    $row['id'],
            sprintId:      (int)    $row['sprint_id'],
            title:         (string) $row['title'],
            ownerWorkerId: isset($row['owner_worker_id']) && $row['owner_worker_id'] !== null
                ? (int) $row['owner_worker_id']
                : null,
            priority:      (int)    $row['priority'],
            sortOrder:     (int)    $row['sort_order'],
            createdAt:     (string) $row['created_at'],
            updatedAt:     (string) $row['updated_at'],
            description:   (string) ($row['description'] ?? ''),
            url:           (string) ($row['url'] ?? ''),
            linkedTaskId:  isset($row['linked_task_id']) && $row['linked_task_id'] !== null
                ? (int) $row['linked_task_id']
                : null,
        );
    }
}
