<?php

namespace Tests\Feature;

use App\Actions\FetchArticleDataAction;
use App\Actions\PublishRouteArticleAction;
use App\Enums\PublishStatusEnum;
use App\Events\RouteArticleApproved;
use App\Listeners\PublishApprovedArticleListener;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver;
use App\Services\Notification\NotificationService;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Mockery;
use Tests\TestCase;

/**
 * Reproduces #123: one article reaching a Lemmy community twice.
 *
 * These drive the real listener and the real publishing service, faking only
 * the Lemmy boundary, so the guard and the lock are actually exercised.
 */
class DuplicatePublishTest extends TestCase
{
    use RefreshDatabase;

    /** @var array{RouteArticle, PlatformChannel, Article} */
    private array $fixture;

    private int $remoteCalls = 0;

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

        $feed = Feed::factory()->create();
        $instance = PlatformInstance::factory()->create();
        $channel = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]);
        $account = PlatformAccount::factory()->create();

        /** @var Route $route */
        $route = Route::factory()->active()->create([
            'feed_id' => $feed->id,
            'platform_channel_id' => $channel->id,
        ]);

        $channel->platformAccounts()->attach($account->id, ['is_active' => true, 'priority' => 50]);

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

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

        $this->fixture = [$routeArticle, $channel, $article];
    }

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

    /**
     * Real publishing service with only the Lemmy call faked, counting how many
     * posts would actually be created remotely.
     */
    private function makeListener(): PublishApprovedArticleListener
    {
        $publisher = Mockery::mock(LemmyPublisher::class);
        $publisher->shouldReceive('publishToChannel')
            ->andReturnUsing(function () {
                $this->remoteCalls++;

                return ['post_view' => ['post' => ['id' => 2000000 + $this->remoteCalls]]];
            });

        $service = Mockery::mock(
            ArticlePublishingService::class,
            [app(LogSaver::class)]
        )->makePartial();
        $service->shouldAllowMockingProtectedMethods();
        $service->shouldReceive('makePublisher')->andReturn($publisher);

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

        return new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
    }

    public function test_clicking_approve_twice_dispatches_only_one_event(): void
    {
        Event::fake([RouteArticleApproved::class]);

        [$routeArticle] = $this->fixture;

        // The double-click: approve() must not dispatch a second time.
        $routeArticle->approve();
        $routeArticle->approve();

        Event::assertDispatchedTimes(RouteArticleApproved::class, 1);
    }

    public function test_clicking_approve_twice_creates_only_one_remote_post(): void
    {
        [$routeArticle, $channel, $article] = $this->fixture;

        $listener = $this->makeListener();

        $routeArticle->approve();
        $listener->handle(new RouteArticleApproved($routeArticle->fresh()));

        $routeArticle->approve();
        $listener->handle(new RouteArticleApproved($routeArticle->fresh()));

        $this->assertSame(1, $this->remoteCalls, 'A second approval must not post to Lemmy again.');

        $this->assertSame(1, ArticlePublication::where('article_id', $article->id)
            ->where('platform_channel_id', $channel->id)
            ->count());
    }

    public function test_two_queued_listeners_create_only_one_remote_post(): void
    {
        [$routeArticle, $channel, $article] = $this->fixture;

        // Two listeners already in flight. Running them back to back would not
        // reproduce anything — the second would see the first's publication row
        // and stop. The real race interleaves: the second listener reaches its
        // duplicate check while the first is still inside its Lemmy call, before
        // any row exists. That window is what the lock has to close.
        $publisher = Mockery::mock(LemmyPublisher::class);
        $publisher->shouldReceive('publishToChannel')
            ->andReturnUsing(function () use ($routeArticle) {
                $this->remoteCalls++;

                if ($this->remoteCalls === 1) {
                    $this->makeListener()->handle(new RouteArticleApproved($routeArticle->fresh()));
                }

                return ['post_view' => ['post' => ['id' => 2000000 + $this->remoteCalls]]];
            });

        $service = Mockery::mock(
            ArticlePublishingService::class,
            [app(LogSaver::class)]
        )->makePartial();
        $service->shouldAllowMockingProtectedMethods();
        $service->shouldReceive('makePublisher')->andReturn($publisher);

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

        $listener = new PublishApprovedArticleListener(new PublishRouteArticleAction($fetcher, $service, new NotificationService));
        $listener->handle(new RouteArticleApproved($routeArticle));

        $this->assertSame(1, $this->remoteCalls, 'Two listeners must not both post to Lemmy.');

        $this->assertSame(1, ArticlePublication::where('article_id', $article->id)
            ->where('platform_channel_id', $channel->id)
            ->count());
    }

    /**
     * Pins the #123 mechanism. Before b2d504f, approve() had no isApproved()
     * guard, so two calls dispatched two events. Two stale instances of the same
     * row reproduce that: each still reads PENDING, so the guard cannot fire and
     * the pre-fix code path runs.
     */
    public function test_without_the_approved_guard_two_approvals_dispatch_two_events(): void
    {
        Event::fake([RouteArticleApproved::class]);

        [$routeArticle] = $this->fixture;

        $first = RouteArticle::find($routeArticle->id);
        $second = RouteArticle::find($routeArticle->id);

        $this->assertTrue($second->isPending(), 'Both instances must start pending for the race to be reproduced.');

        $first->approve();
        $second->approve();

        Event::assertDispatchedTimes(RouteArticleApproved::class, 2);
    }

    public function test_two_stale_approvals_still_create_only_one_remote_post(): void
    {
        [$routeArticle, $channel, $article] = $this->fixture;

        $first = RouteArticle::find($routeArticle->id);
        $second = RouteArticle::find($routeArticle->id);

        $first->approve();
        $second->approve();

        $listener = $this->makeListener();
        $listener->handle(new RouteArticleApproved($first));
        $listener->handle(new RouteArticleApproved($second));

        $this->assertSame(1, $this->remoteCalls, 'The lock must hold even when two events get through.');

        $this->assertSame(1, ArticlePublication::where('article_id', $article->id)
            ->where('platform_channel_id', $channel->id)
            ->count());
    }

    public function test_a_single_approval_publishes_exactly_once(): void
    {
        [$routeArticle, $channel, $article] = $this->fixture;

        $this->makeListener()->handle(new RouteArticleApproved($routeArticle));

        $this->assertSame(1, $this->remoteCalls);
        $this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->fresh()->publish_status);
        $this->assertSame(1, ArticlePublication::where('article_id', $article->id)
            ->where('platform_channel_id', $channel->id)
            ->count());
    }
}
