<?php
/**
 * Task Repository
 *
 * Manages persisting and retrieving tasks from markdown files.
 *
 * PHP version 8.2+
 *
 * @category Repository
 * @package  IronKanban\Repository
 * @author   Keith Solomon <keith@keithsolomon.net>
 * @license  Unlicense https://unlicense.org
 * @link     https://git.keithsolomon.net/keith/IronKanban
 */

declare(strict_types=1);

namespace IronKanban\Repository;

use IronKanban\Domain\Task;
use IronKanban\Markdown\FrontMatterParser;
use IronKanban\Support\FileLock;
use IronKanban\Support\IdGenerator;
use IronKanban\Support\Paths;
use IronKanban\Support\TaskMapper;
use RuntimeException;

/**
 * TaskRepository manages task persistence and retrieval
 *
 * @category Repository
 * @package  IronKanban\Repository
 * @author   Keith Solomon <keith@keithsolomon.net>
 * @license  Unlicense https://unlicense.org
 * @link     https://git.keithsolomon.net/keith/IronKanban
 */
class TaskRepository {
    /**
     * Constructor for TaskRepository
     *
     * @param Paths             $paths             The paths helper
     * @param FrontMatterParser $frontMatterParser The front matter parser
     * @param TaskMapper        $taskMapper        The task mapper
     * @param IdGenerator       $idGenerator       The ID generator
     */
    public function __construct(
        private readonly Paths $paths,
        private readonly FrontMatterParser $frontMatterParser,
        private readonly TaskMapper $taskMapper,
        private readonly IdGenerator $idGenerator
    ) {
    }

    /**
     * Get all tasks for a project
     *
     * @param string $projectId The project identifier
     *
     * @return Task[] Array of tasks
     */
    public function getAll(string $projectId): array {
        $tasksPath = $this->paths->getTasksPath($projectId);
        $files = glob($tasksPath . DIRECTORY_SEPARATOR . '*.md');

        if ($files === false) {
            return [];
        }

        $tasks = [];

        foreach ($files as $filePath) {
            if (!is_file($filePath)) {
                continue;
            }

            $fileName = basename($filePath);
            $document = $this->frontMatterParser->parseFile($filePath);

            $tasks[] = $this->taskMapper->fromDocument(
                $projectId,
                $fileName,
                $document
            );
        }

        usort(
            $tasks, function (Task $a, Task $b): int {
                if ($a->column === $b->column) {
                    return $a->order <=> $b->order;
                }

                return strcmp($a->column, $b->column);
            }
        );

        return $tasks;
    }

    /**
     * Get a specific task by ID
     *
     * @param string $projectId The project identifier
     * @param string $taskId    The task identifier
     *
     * @return Task The task
     *
     * @throws RuntimeException If task not found
     */
    public function get(string $projectId, string $taskId): Task {
        foreach ($this->getAll($projectId) as $task) {
            if ($task->id === $taskId) {
                return $task;
            }
        }

        throw new RuntimeException("Task not found: {$taskId}");
    }

    /**
     * Save a task
     *
     * @param Task $task The task to save
     *
     * @return void
     */
    public function save(Task $task): void {
        $this->paths->ensureTasksPath($task->projectId);

        $filePath = $this->paths->getTaskFilePath($task->projectId, $task->fileName);
        $lockPath = $filePath . '.lock';

        FileLock::run(
            $lockPath,
            function () use ($task, $filePath): void {
                $document = $this->taskMapper->toDocument($task);
                $contents = $this->frontMatterParser->dump($document);

                $this->_atomicWrite($filePath, $contents);
            }
        );
    }

    /**
     * Create a new task
     *
     * @param array $data The task data
     *
     * @return Task The created task
     *
     * @throws RuntimeException If required fields are missing
     */
    public function create(array $data): Task {
        $projectId = $this->_requireString($data, 'projectId');
        $title     = $this->_requireString($data, 'title');
        $body      = (string) ($data['body'] ?? '');
        $column    = $this->taskMapper->normalizeColumn(
            (string) ($data['column'] ?? 'backlog')
        );
        $priority  = (string) ($data['priority'] ?? 'normal');
        $completed = (bool) ($data['completed'] ?? false);
        $isActive  = (bool) ($data['isActive'] ?? true);
        $order     = isset($data['order']) ? (int) $data['order'] : 100;
        $now       = gmdate('c');

        $taskId   = $this->idGenerator->generateTaskId();
        $fileName = $this->idGenerator->generateTaskFileName($taskId);

        $meta = [
            'id'         => $taskId,
            'type'       => 'task',
            'title'      => $title,
            'project_id' => $projectId,
            'column'     => $column,
            'order'      => $order,
            'completed'  => $completed,
            'priority'   => $priority,
            'is_active'  => $isActive,
            'created'    => $now,
            'updated'    => $now,
            'tags'       => [],
        ];

        if (array_key_exists('status', $data)) {
            $meta['status'] = $data['status'];
        }

        if (array_key_exists('statusNote', $data)) {
            $meta['status_note'] = $data['statusNote'];
        }

        if (array_key_exists('section', $data)) {
            $meta['section'] = $data['section'];
        }

        $task = new Task(
            id: $taskId,
            title: $title,
            projectId: $projectId,
            column: $column,
            order: $order,
            completed: $completed,
            priority: $priority,
            isActive: $isActive,
            body: $body,
            fileName: $fileName,
            meta: $meta
        );

        $this->save($task);

        return $task;
    }

    /**
     * Delete a task
     *
     * @param string $projectId The project identifier
     * @param string $taskId    The task identifier
     *
     * @return void
     *
     * @throws RuntimeException If task not found
     */
    public function delete(string $projectId, string $taskId): void {
        $task = $this->get($projectId, $taskId);
        $filePath = $this->paths->getTaskFilePath($projectId, $task->fileName);
        $lockPath = $filePath . '.lock';

        FileLock::run(
            $lockPath,
            function () use ($filePath): void {
                if (!is_file($filePath)) {
                    throw new RuntimeException("Task file not found: {$filePath}");
                }

                if (!unlink($filePath)) {
                    throw new RuntimeException(
                        "Unable to delete task file: {$filePath}"
                    );
                }
            }
        );
    }

    /**
     * Map a markdown file to a task
     *
     * @param string $projectId The project identifier
     * @param string $filePath  The file path to the task
     *
     * @return Task The mapped task
     */
    private function _mapFileToTask(string $projectId, string $filePath): Task {
        $document = $this->frontMatterParser->parseFile($filePath);
        $meta = $document->meta;

        $id = (string) ($meta['id'] ?? pathinfo($filePath, PATHINFO_FILENAME));
        $title = (string) ($meta['title'] ?? $id);

        $column = isset($meta['column'])
        ? $this->_normalizeColumn((string) $meta['column'])
        : $this->_mapLegacySectionToColumn((string) ($meta['section'] ?? 'backlog'));

        $order = isset($meta['order']) ? (int) $meta['order'] : 100;
        $completed = (bool) ($meta['completed'] ?? false);
        $priority = (string) ($meta['priority'] ?? 'normal');
        $isActive = (bool) ($meta['is_active'] ?? true);

        return new Task(
            id: $id,
            title: $title,
            projectId: (string) ($meta['project_id'] ?? $projectId),
            column: $column,
            order: $order,
            completed: $completed,
            priority: $priority,
            isActive: $isActive,
            body: $document->body,
            meta: $meta
        );
    }

    /**
     * Map a task to a front matter document
     *
     * @param Task $task The task to map
     *
     * @return FrontMatterDocument The mapped document
     */
    private function _mapTaskToDocument(Task $task): FrontMatterDocument {
        $meta = $task->meta;

        $meta['id'] = $task->id;
        $meta['type'] = 'task';
        $meta['title'] = $task->title;
        $meta['project_id'] = $task->projectId;
        $meta['column'] = $task->column;
        $meta['order'] = $task->order;
        $meta['completed'] = $task->completed;
        $meta['priority'] = $task->priority;
        $meta['is_active'] = $task->isActive;

        if (!isset($meta['created']) || (string) $meta['created'] === '') {
            $meta['created'] = gmdate('c');
        }

        $meta['updated'] = gmdate('c');

        return new FrontMatterDocument($meta, $task->body);
    }

    /**
     * Atomically write contents to a file
     *
     * @param string $filePath The file path
     * @param string $contents The contents to write
     *
     * @return void
     *
     * @throws RuntimeException If write operation fails
     */
    private function _atomicWrite(string $filePath, string $contents): void {
        $directory = dirname($filePath);
        $tempPath = tempnam($directory, 'ikb_');

        if ($tempPath === false) {
            throw new RuntimeException(
                "Unable to create temp file in: {$directory}"
            );
        }

        try {
            $bytesWritten = file_put_contents($tempPath, $contents);

            if ($bytesWritten === false) {
                throw new RuntimeException(
                    "Unable to write temp file: {$tempPath}"
                );
            }

            if (!rename($tempPath, $filePath)) {
                throw new RuntimeException(
                    "Unable to move temp file into place: {$filePath}"
                );
            }
        } finally {
            if (is_file($tempPath)) {
                @unlink($tempPath);
            }
        }
    }

    /**
     * Generate a unique task ID
     *
     * @return string The generated task ID
     */
    private function _generateTaskId(): string {
        $randID = substr(bin2hex(random_bytes(3)), 0, 6);
        $taskID = 'task-' . gmdate('Ymd-His') . '-' . $randID;

        return $taskID;
    }

    /**
     * Require a string field from data array
     *
     * @param array  $data The data array
     * @param string $key  The field key
     *
     * @return string The string value
     *
     * @throws RuntimeException If field is missing or empty
     */
    private function _requireString(array $data, string $key): string {
        $value = trim((string) ($data[$key] ?? ''));

        if ($value === '') {
            throw new RuntimeException("Missing required field: {$key}");
        }

        return $value;
    }

    /**
     * Normalize a column name
     *
     * @param string $column The column name to normalize
     *
     * @return string The normalized column name
     */
    private function _normalizeColumn(string $column): string {
        $column = trim(strtolower($column));

        if ($column === '') {
            return 'backlog';
        }

        $column = preg_replace('/[^a-z0-9]+/', '-', $column) ?? 'backlog';
        $column = trim($column, '-');

        return $column !== '' ? $column : 'backlog';
    }

    /**
     * Map legacy section to column name
     *
     * @param string $section The legacy section name
     *
     * @return string The modern column name
     */
    private function _mapLegacySectionToColumn(string $section): string {
        return $this->_normalizeColumn($section);
    }
}
