<?php

namespace Tests\Unit\Jobs;

use App\Actions\FetchArticleDataAction;
use App\Actions\PublishRouteArticleAction;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Enums\PublishStatusEnum;
use App\Exceptions\PublishException;
use App\Jobs\PublishNextArticleJob;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Models\Setting;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use App\Services\Publishing\PublishOutcome;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Mockery;
use Tests\TestCase;

class PublishNextArticleJobTest extends TestCase
{
    use RefreshDatabase;

    private NotificationService $notificationService;

    protected function setUp(): void
    {
        parent::setUp();

        // The daily cap counts from startOfDay, so tests placing publications a
        // couple of hours back land on the previous day when run after midnight.
        Carbon::setTestNow('2026-07-15 12:00:00');

        $this->notificationService = new NotificationService;
    }

    /**
     * @param  array<string, mixed>  $articleOverrides
     * @param  array<string, mixed>  $routeOverrides
     */
    private function createApprovedRouteArticle(array $articleOverrides = [], array $routeOverrides = []): RouteArticle
    {
        $feed = Feed::factory()->create();
        /** @var Route $route */
        $route = Route::factory()->active()->create(array_merge(['feed_id' => $feed->id], $routeOverrides));
        $article = Article::factory()->unvalidated()->create(array_merge(['feed_id' => $feed->id], $articleOverrides));

        /** @var RouteArticle $routeArticle */
        $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([
            'article_id' => $article->id,
        ]);

        return $routeArticle;
    }

    public function test_constructor_sets_correct_queue(): void
    {
        $job = new PublishNextArticleJob;

        $this->assertEquals('publishing', $job->queue);
    }

    public function test_job_implements_should_queue(): void
    {
        $job = new PublishNextArticleJob;

        $this->assertInstanceOf(ShouldQueue::class, $job);
    }

    public function test_job_implements_should_be_unique(): void
    {
        $job = new PublishNextArticleJob;

        $this->assertInstanceOf(ShouldBeUnique::class, $job);
    }

    public function test_job_has_unique_for_property(): void
    {
        $job = new PublishNextArticleJob;

        $this->assertEquals(300, $job->uniqueFor);
    }

    public function test_job_uses_queueable_trait(): void
    {
        $job = new PublishNextArticleJob;

        $this->assertContains(Queueable::class, class_uses($job));
    }

    public function test_handle_returns_early_when_no_approved_route_articles(): void
    {
        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_returns_early_when_no_unpublished_approved_route_articles(): void
    {
        $routeArticle = $this->createApprovedRouteArticle();

        // Mark the article as already published to this channel
        ArticlePublication::factory()->create([
            'article_id' => $routeArticle->article_id,
            'platform_channel_id' => $routeArticle->platform_channel_id,
        ]);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_skips_non_approved_route_articles(): void
    {
        $feed = Feed::factory()->create();
        /** @var Route $route */
        $route = Route::factory()->active()->create(['feed_id' => $feed->id]);
        $article = Article::factory()->create(['feed_id' => $feed->id]);

        RouteArticle::factory()->forRoute($route)->pending()->create(['article_id' => $article->id]);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_publishes_oldest_approved_route_article(): void
    {
        $feed = Feed::factory()->create();
        /** @var Route $route */
        $route = Route::factory()->active()->create(['feed_id' => $feed->id]);

        $olderArticle = Article::factory()->unvalidated()->create(['feed_id' => $feed->id]);
        $newerArticle = Article::factory()->unvalidated()->create(['feed_id' => $feed->id]);

        RouteArticle::factory()->forRoute($route)->approved()->create([
            'article_id' => $olderArticle->id,
            'created_at' => now()->subHours(2),
        ]);
        RouteArticle::factory()->forRoute($route)->approved()->create([
            'article_id' => $newerArticle->id,
            'created_at' => now()->subHour(),
        ]);

        $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->with(Mockery::on(fn ($article) => $article->id === $olderArticle->id))
            ->andReturn($extractedData);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->with(
                Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id),
                $extractedData
            )
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_throws_exception_on_publishing_failure(): void
    {
        $routeArticle = $this->createApprovedRouteArticle();
        $article = $routeArticle->article;

        $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];
        $publishException = new PublishException($article, null);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn($extractedData);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andThrow($publishException);

        $job = new PublishNextArticleJob;

        $this->expectException(PublishException::class);

        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
    }

    public function test_handle_skips_publishing_when_last_publication_within_interval(): void
    {
        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->create([
            'published_at' => now()->subMinutes(3),
        ]);
        Setting::setArticlePublishingInterval(10);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);

        $articleFetcherMock->shouldNotReceive('execute');
        $publishingServiceMock->shouldNotReceive('publishRouteArticle');

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_skips_publishing_when_daily_cap_reached(): void
    {
        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->count(3)->create(['published_at' => now()->subHours(2)]);
        Setting::setArticlePublishingInterval(0);
        Setting::setDailyPublishCap(3);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);

        $articleFetcherMock->shouldNotReceive('execute');
        $publishingServiceMock->shouldNotReceive('publishRouteArticle');

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_publishes_when_below_daily_cap(): void
    {
        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->count(2)->create(['published_at' => now()->subHours(2)]);
        Setting::setArticlePublishingInterval(0);
        Setting::setDailyPublishCap(3);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_publishes_when_daily_cap_is_zero(): void
    {
        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->count(50)->create(['published_at' => now()->subHours(2)]);
        Setting::setArticlePublishingInterval(0);
        Setting::setDailyPublishCap(0);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_daily_cap_counts_each_channel_publication_separately(): void
    {
        $this->createApprovedRouteArticle();

        $article = Article::factory()->create();
        ArticlePublication::factory()->count(3)->create([
            'article_id' => $article->id,
            'published_at' => now()->subHours(2),
        ]);
        Setting::setArticlePublishingInterval(0);
        Setting::setDailyPublishCap(3);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);

        $articleFetcherMock->shouldNotReceive('execute');
        $publishingServiceMock->shouldNotReceive('publishRouteArticle');

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_daily_cap_counts_from_midnight_not_a_rolling_window(): void
    {
        Carbon::setTestNow('2026-07-15 01:00:00');

        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->count(3)->create(['published_at' => Carbon::parse('2026-07-14 23:00:00')]);
        Setting::setArticlePublishingInterval(0);
        Setting::setDailyPublishCap(3);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_daily_cap_counts_a_publication_just_after_midnight(): void
    {
        Carbon::setTestNow('2026-07-15 01:00:00');

        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->count(3)->create(['published_at' => Carbon::parse('2026-07-15 00:30:00')]);
        Setting::setArticlePublishingInterval(0);
        Setting::setDailyPublishCap(3);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);

        $articleFetcherMock->shouldNotReceive('execute');
        $publishingServiceMock->shouldNotReceive('publishRouteArticle');

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_ignores_publications_from_previous_days(): void
    {
        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->count(5)->create(['published_at' => now()->subDay()]);
        Setting::setArticlePublishingInterval(0);
        Setting::setDailyPublishCap(3);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_publishes_when_last_publication_beyond_interval(): void
    {
        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->create([
            'published_at' => now()->subMinutes(15),
        ]);
        Setting::setArticlePublishingInterval(10);

        $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn($extractedData);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_publishes_when_interval_is_zero(): void
    {
        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->create([
            'published_at' => now(),
        ]);
        Setting::setArticlePublishingInterval(0);

        $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn($extractedData);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_publishes_when_last_publication_exactly_at_interval(): void
    {
        $this->createApprovedRouteArticle();

        ArticlePublication::factory()->create([
            'published_at' => now()->subMinutes(10),
        ]);
        Setting::setArticlePublishingInterval(10);

        $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn($extractedData);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_publishes_when_no_previous_publications_exist(): void
    {
        $this->createApprovedRouteArticle();

        Setting::setArticlePublishingInterval(10);

        $extractedData = ['title' => 'Test Article', 'description' => 'Test description'];

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn($extractedData);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertTrue(true);
    }

    public function test_handle_creates_warning_notification_when_no_publication_created(): void
    {
        $routeArticle = $this->createApprovedRouteArticle(['title' => 'No Route Article']);

        $extractedData = ['title' => 'No Route Article', 'description' => 'Test description'];

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn($extractedData);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andReturn(PublishOutcome::failure('No publication created'));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertDatabaseHas('notifications', [
            'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
            'severity' => NotificationSeverityEnum::WARNING->value,
            'notifiable_type' => $routeArticle->article->getMorphClass(),
            'notifiable_id' => $routeArticle->article_id,
        ]);

        $notification = Notification::first();
        $this->assertStringContainsString('No Route Article', $notification->title);
    }

    public function test_handle_creates_notification_on_publish_exception(): void
    {
        $routeArticle = $this->createApprovedRouteArticle(['title' => 'Failing Article']);
        $article = $routeArticle->article;

        $extractedData = ['title' => 'Failing Article', 'description' => 'Test description'];
        $publishException = new PublishException($article, null);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn($extractedData);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->andThrow($publishException);

        $job = new PublishNextArticleJob;

        try {
            $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
        } catch (PublishException) {
            // Expected
        }

        $this->assertDatabaseHas('notifications', [
            'type' => NotificationTypeEnum::PUBLISH_FAILED->value,
            'severity' => NotificationSeverityEnum::ERROR->value,
            'notifiable_type' => $article->getMorphClass(),
            'notifiable_id' => $article->id,
        ]);

        $notification = Notification::first();
        $this->assertStringContainsString('Failing Article', $notification->title);
    }

    public function test_handle_skips_route_articles_that_previously_failed(): void
    {
        $routeArticle = $this->createApprovedRouteArticle();
        $routeArticle->recordPublishFailed('couldnt_find_community');

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldNotReceive('execute');

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldNotReceive('publishRouteArticle');

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertSame(PublishStatusEnum::ERROR, $routeArticle->fresh()->publish_status);
    }

    public function test_handle_publishes_a_later_article_when_the_oldest_has_failed(): void
    {
        $blocked = $this->createApprovedRouteArticle(['title' => 'Blocked Article']);
        $blocked->update(['created_at' => now()->subDays(2)]);
        $blocked->recordPublishFailed('couldnt_find_community');

        $next = $this->createApprovedRouteArticle(['title' => 'Next Article']);
        $next->update(['created_at' => now()->subDay()]);

        $articleFetcherMock = Mockery::mock(FetchArticleDataAction::class);
        $articleFetcherMock->shouldReceive('execute')
            ->once()
            ->andReturn(['title' => 'Next Article', 'description' => 'Test description']);

        $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
        $publishingServiceMock->shouldReceive('publishRouteArticle')
            ->once()
            ->with(Mockery::on(fn ($ra) => $ra->id === $next->id), Mockery::any())
            ->andReturn(PublishOutcome::published($this->makePublication()));

        $job = new PublishNextArticleJob;
        $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));

        $this->assertSame(PublishStatusEnum::PUBLISHED, $next->fresh()->publish_status);
    }

    public function test_job_can_be_serialized(): void
    {
        $job = new PublishNextArticleJob;

        $serialized = serialize($job);
        $unserialized = unserialize($serialized);

        $this->assertInstanceOf(PublishNextArticleJob::class, $unserialized);
        $this->assertEquals($job->queue, $unserialized->queue);
        $this->assertEquals($job->uniqueFor, $unserialized->uniqueFor);
    }

    protected function tearDown(): void
    {
        Carbon::setTestNow();
        Mockery::close();
        parent::tearDown();
    }

    private function makePublication(): ArticlePublication
    {
        /** @var ArticlePublication $publication */
        $publication = ArticlePublication::factory()->make();

        return $publication;
    }
}
