<?php

namespace Ignite\Core\Entities;

use Illuminate\Database\Eloquent\Relations\BelongsTo;
use LogicException;

/**
 * Durable state for one asynchronous CSV or XLSX report download.
 *
 * The entity owns lifecycle transitions so controllers, services, and queue jobs
 * cannot accidentally resurrect cancelled work or complete a failed download.
 *
 * @property int $id
 * @property int $user_id
 * @property string $report_type
 * @property array $parameters
 * @property string $format
 * @property string|null $file_path
 * @property string $status
 * @property int $progress
 * @property int $row_count
 * @property int $retry_count
 * @property string|null $error_message
 * @property \Illuminate\Support\Carbon|null $queued_at
 * @property \Illuminate\Support\Carbon|null $started_at
 * @property \Illuminate\Support\Carbon|null $completed_at
 * @property \Illuminate\Support\Carbon|null $failed_at
 * @property \Illuminate\Support\Carbon|null $cancelled_at
 * @property \Illuminate\Support\Carbon|null $expires_at
 * @property-read User $user
 */
class ReportDownload extends Base
{
    public const STATUS_PENDING = 'PENDING';
    public const STATUS_QUEUED = 'QUEUED';
    public const STATUS_IN_PROGRESS = 'IN_PROGRESS';
    public const STATUS_COMPLETE = 'COMPLETE';
    public const STATUS_FAILED = 'FAILED';
    public const STATUS_CANCELLED = 'CANCELLED';
    public const STATUS_EXPIRED = 'EXPIRED';

    /** @var string */
    protected $table = 'core_report_download';

    /** @var array<string, string> */
    protected $casts = [
        'parameters' => 'array',
        'progress' => 'integer',
        'row_count' => 'integer',
        'retry_count' => 'integer',
    ];

    /** @var array<int, string> */
    protected $dates = [
        'queued_at',
        'started_at',
        'completed_at',
        'failed_at',
        'cancelled_at',
        'expires_at',
        'created_at',
        'updated_at',
    ];

    /**
     * User who requested the download.
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id', 'user_id');
    }

    /**
     * Mark a newly created or retried request ready for Horizon.
     *
     * @throws LogicException
     */
    public function markQueued(): void
    {
        $this->assertStatus([self::STATUS_PENDING]);
        $this->forceFill([
            'status' => self::STATUS_QUEUED,
            'queued_at' => now(),
        ])->save();
    }

    /**
     * Claim queued work for a worker.
     *
     * @throws LogicException
     */
    public function markInProgress(): void
    {
        $this->assertStatus([self::STATUS_QUEUED]);
        $this->forceFill([
            'status' => self::STATUS_IN_PROGRESS,
            'started_at' => now(),
            'progress' => 0,
        ])->save();
    }

    /**
     * Persist measured generation progress without claiming completion early.
     *
     * @param int $downloadedRows
     * @param int $expectedRows
     * @throws LogicException
     */
    public function updateProgress(int $downloadedRows, int $expectedRows): void
    {
        $this->assertStatus([self::STATUS_IN_PROGRESS]);
        $percentage = $expectedRows > 0
            ? min(99, (int) floor(($downloadedRows / $expectedRows) * 100))
            : 0;

        $this->forceFill([
            'row_count' => max(0, $downloadedRows),
            'progress' => $percentage,
        ])->save();
    }

    /**
     * Publish final file metadata and complete the lifecycle.
     *
     * @param string $path
     * @param int $rowCount
     * @throws LogicException
     */
    public function markComplete(string $path, int $rowCount): void
    {
        $this->assertStatus([self::STATUS_IN_PROGRESS]);
        $this->forceFill([
            'status' => self::STATUS_COMPLETE,
            'file_path' => $path,
            'row_count' => max(0, $rowCount),
            'progress' => 100,
            'completed_at' => now(),
            'expires_at' => now()->addDays((int) config('core.report_download.retention_days', 7)),
            'error_message' => null,
            'failed_at' => null,
        ])->save();
    }

    /**
     * Record final queue failure unless the user cancelled the work first.
     */
    public function markFailed(string $message): void
    {
        if ($this->status === self::STATUS_CANCELLED) {
            return;
        }

        $this->assertStatus([
            self::STATUS_PENDING,
            self::STATUS_QUEUED,
            self::STATUS_IN_PROGRESS,
        ]);
        $this->forceFill([
            'status' => self::STATUS_FAILED,
            'error_message' => $message,
            'failed_at' => now(),
        ])->save();
    }

    /**
     * Return a transiently failed attempt to the queueable state.
     *
     * Laravel will deliver the same job again according to its retry policy. The
     * final failed() hook, rather than an individual attempt, owns FAILED status.
     */
    public function releaseForQueueRetry(): void
    {
        if ($this->status !== self::STATUS_IN_PROGRESS) {
            return;
        }

        $this->forceFill([
            'status' => self::STATUS_QUEUED,
            'started_at' => null,
        ])->save();
    }

    /**
     * Cooperatively cancel pending, queued, or currently running work.
     */
    public function cancel(): void
    {
        $this->assertStatus([
            self::STATUS_PENDING,
            self::STATUS_QUEUED,
            self::STATUS_IN_PROGRESS,
        ]);
        $this->forceFill([
            'status' => self::STATUS_CANCELLED,
            'cancelled_at' => now(),
        ])->save();
    }

    /**
     * Reset the same failed record before dispatching its replacement job.
     *
     * The previous completed file is intentionally not deleted here. The service
     * replaces it atomically only after a retry has produced a complete new file.
     */
    public function prepareRetry(): void
    {
        $this->assertStatus([self::STATUS_FAILED]);
        $this->forceFill([
            'status' => self::STATUS_PENDING,
            'progress' => 0,
            'row_count' => 0,
            'retry_count' => $this->retry_count + 1,
            'error_message' => null,
            'queued_at' => null,
            'started_at' => null,
            'completed_at' => null,
            'failed_at' => null,
            'cancelled_at' => null,
            'expires_at' => null,
        ])->save();
    }

    /**
     * Mark published file metadata as expired after a later cleanup process removes it.
     *
     * File cleanup is intentionally outside this entity because storage changes must
     * succeed before the durable record is advanced to EXPIRED.
     */
    public function markExpired(): void
    {
        $this->assertStatus([self::STATUS_COMPLETE]);
        $this->forceFill([
            'status' => self::STATUS_EXPIRED,
            'file_path' => null,
        ])->save();
    }

    /**
     * @param array<int, string> $allowed
     * @throws LogicException
     */
    protected function assertStatus(array $allowed): void
    {
        if (! in_array($this->status, $allowed, true)) {
            throw new LogicException(sprintf(
                'Cannot transition report download %s from %s.',
                $this->getKey() ?: 'new',
                $this->status
            ));
        }
    }
}
