<?php

namespace Tests\Unit\Services\Publishing;

use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost;
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\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Mockery;
use Tests\TestCase;

class ArticlePublishingServiceTest extends TestCase
{
    use RefreshDatabase;

    protected ArticlePublishingService $service;

    protected LogSaver $logSaver;

    protected function setUp(): void
    {
        parent::setUp();
        $this->logSaver = Mockery::mock(LogSaver::class);
        $this->logSaver->shouldReceive('info')->zeroOrMoreTimes();
        $this->logSaver->shouldReceive('warning')->zeroOrMoreTimes();
        $this->logSaver->shouldReceive('error')->zeroOrMoreTimes();
        $this->logSaver->shouldReceive('debug')->zeroOrMoreTimes();
        $this->service = new ArticlePublishingService($this->logSaver);
    }

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

    /**
     * @return array{RouteArticle, PlatformChannel, PlatformAccount, Article}
     */
    private function createRouteArticleWithAccount(): array
    {
        $feed = Feed::factory()->create();
        $platformInstance = PlatformInstance::factory()->create();
        $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->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)->approved()->create([
            'article_id' => $article->id,
        ]);

        return [$routeArticle, $channel, $account, $article];
    }

    public function test_publish_route_article_returns_null_when_no_active_account(): void
    {
        $feed = Feed::factory()->create();
        $channel = PlatformChannel::factory()->create();

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

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

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

        $result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']);

        $this->assertTrue($result->failed());
        $this->assertDatabaseCount('article_publications', 0);
    }

    public function test_publish_route_article_successfully_publishes(): void
    {
        [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount();

        $publisherDouble = Mockery::mock(LemmyPublisher::class);
        $publisherDouble->shouldReceive('publishToChannel')
            ->once()
            ->andReturn(['post_view' => ['post' => ['id' => 123]]]);

        $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
        $service->shouldAllowMockingProtectedMethods();
        $service->shouldReceive('makePublisher')->andReturn($publisherDouble);

        $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);

        $this->assertTrue($result->succeeded());
        $this->assertDatabaseHas('article_publications', [
            'article_id' => $article->id,
            'platform_channel_id' => $channel->id,
            'post_id' => 123,
            'published_by' => $account->username,
        ]);
    }

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

        $remoteCalls = 0;

        // A competing listener committed its publication while this one was
        // between its duplicate check and its own insert. The second attempt
        // must notice and skip — the unique index cannot retract a remote post.
        $publisherDouble = Mockery::mock(LemmyPublisher::class);
        $publisherDouble->shouldReceive('publishToChannel')
            ->andReturnUsing(function () use (&$remoteCalls, $article, $channel) {
                $remoteCalls++;

                ArticlePublication::create([
                    'article_id' => $article->id,
                    'post_id' => 999,
                    'platform_channel_id' => $channel->id,
                    'published_by' => 'other-worker',
                    'published_at' => now(),
                    'platform' => $channel->platformInstance->platform->value,
                    'publication_data' => [],
                ]);

                return ['post_view' => ['post' => ['id' => 900 + $remoteCalls]]];
            });

        $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
        $service->shouldAllowMockingProtectedMethods();
        $service->shouldReceive('makePublisher')->andReturn($publisherDouble);

        $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);
        $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);

        $this->assertSame(1, $remoteCalls, 'The remote must be called once, not once per racing listener.');
        $this->assertSame(1, ArticlePublication::where('article_id', $article->id)
            ->where('platform_channel_id', $channel->id)
            ->count());
    }

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

        // Another worker holds the lock, so block() gives up and throws. Faked
        // rather than genuinely contended, so the test does not sit out the wait.
        $lock = Mockery::mock(Lock::class);
        $lock->shouldReceive('block')->once()->andThrow(new LockTimeoutException);
        Cache::shouldReceive('lock')
            ->with("publish:{$article->id}:{$channel->id}", 180)
            ->andReturn($lock);

        $publisherDouble = Mockery::mock(LemmyPublisher::class);
        $publisherDouble->shouldNotReceive('publishToChannel');

        $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
        $service->shouldAllowMockingProtectedMethods();
        $service->shouldReceive('makePublisher')->andReturn($publisherDouble);

        // Must decline rather than throw: a LockTimeoutException would reach the
        // caller's catch block and be recorded as a publish failure.
        $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);

        $this->assertTrue($result->wasSkipped());
        $this->assertDatabaseCount('article_publications', 0);
    }

    public function test_publish_route_article_handles_publishing_failure_gracefully(): void
    {
        [$routeArticle] = $this->createRouteArticleWithAccount();

        $publisherDouble = Mockery::mock(LemmyPublisher::class);
        $publisherDouble->shouldReceive('publishToChannel')
            ->once()
            ->andThrow(new Exception('network error'));

        $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
        $service->shouldAllowMockingProtectedMethods();
        $service->shouldReceive('makePublisher')->andReturn($publisherDouble);

        $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']);

        $this->assertTrue($result->failed());
        $this->assertDatabaseCount('article_publications', 0);
    }

    public function test_publish_skips_duplicate_when_url_already_posted_to_channel(): void
    {
        [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount();

        // Simulate the URL already being posted to this channel
        PlatformChannelPost::storePost(
            $channel,
            '999',
            $article->url,
            'Different Title',
        );

        $publisherDouble = Mockery::mock(LemmyPublisher::class);
        $publisherDouble->shouldNotReceive('publishToChannel');

        $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
        $service->shouldAllowMockingProtectedMethods();
        $service->shouldReceive('makePublisher')->andReturn($publisherDouble);

        $result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']);

        $this->assertTrue($result->wasSkipped());
        $this->assertDatabaseCount('article_publications', 0);
    }

    public function test_publish_skips_duplicate_when_title_already_posted_to_channel(): void
    {
        [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount();

        // Simulate the same title already posted with a different URL
        PlatformChannelPost::storePost(
            $channel,
            '888',
            'https://example.com/different-url',
            'Breaking News',
        );

        $publisherDouble = Mockery::mock(LemmyPublisher::class);
        $publisherDouble->shouldNotReceive('publishToChannel');

        $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
        $service->shouldAllowMockingProtectedMethods();
        $service->shouldReceive('makePublisher')->andReturn($publisherDouble);

        $result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']);

        $this->assertTrue($result->wasSkipped());
        $this->assertDatabaseCount('article_publications', 0);
    }

    public function test_publish_proceeds_when_no_duplicate_exists(): void
    {
        [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount();

        // Existing post in the channel has a completely different URL and title
        PlatformChannelPost::storePost(
            $channel,
            '777',
            'https://example.com/other-article',
            'Totally Different Title',
        );

        $publisherDouble = Mockery::mock(LemmyPublisher::class);
        $publisherDouble->shouldReceive('publishToChannel')
            ->once()
            ->andReturn(['post_view' => ['post' => ['id' => 456]]]);

        $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
        $service->shouldAllowMockingProtectedMethods();
        $service->shouldReceive('makePublisher')->andReturn($publisherDouble);

        $result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']);

        $this->assertTrue($result->succeeded());
        $this->assertDatabaseHas('article_publications', [
            'article_id' => $article->id,
            'post_id' => 456,
        ]);
    }
}
