<?php

namespace App\Federation\Delivery;

use App\Application\Services\DomainBlockManager;
use App\Domain\Comments\Comment;
use App\Domain\Posts\Post;
use App\Domain\SocialGraph\Follow;
use App\Federation\Actors\Actor;
use App\Federation\Actors\RemoteActorResolver;
use App\Infrastructure\Security\LinkedData\LinkedDataSignature;
use App\Jobs\Federation\DeliverActivityJob;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Calcola le inbox remote di destinazione per un'attivita' in uscita e
 * accoda una {@see DeliverActivityJob} per ciascuna, deduplicate: piu'
 * follower sullo stesso server remoto condividono la stessa "sharedInbox",
 * quindi ricevono una sola richiesta HTTP invece di una per follower (come
 * raccomandato dalla specifica ActivityPub).
 *
 * Ogni consegna e' accodata con "afterCommit()": se il chiamante e' dentro
 * una transazione (come tutti i servizi applicativi di dominio), il job
 * parte solo dopo che la riga che lo ha originato e' visibile nel database,
 * evitando che un worker concorrente la trovi mancante.
 */
final class ActivityDelivery
{
    public function __construct(
        private readonly LinkedDataSignature $linkedDataSignatures,
    ) {}

    /**
     * Consegna un'attivita' a tutti i follower *remoti* accettati di un
     * Actor locale (usato per Create/Update/Delete di post pubblici).
     *
     * @param  array<string, mixed>  $activity
     */
    public function deliverToFollowers(Actor $localActor, array $activity): void
    {
        $this->dispatchToInboxes($this->remoteFollowerInboxes($localActor), $activity, $localActor);
    }

    /**
     * Consegna un'attivita' a un singolo Actor remoto (Follow, Accept,
     * Reject, Like, Undo mirati a un solo destinatario).
     *
     * @param  array<string, mixed>  $activity
     */
    public function deliverTo(Actor $signingActor, Actor $target, array $activity): void
    {
        if ($target->isLocal()) {
            return;
        }

        $target->loadMissing('endpoints');

        // Come il fan-out ai follower: preferisci sharedInbox quando presente
        // (Lemmy/Mastodon la usano spesso al posto dell'inbox personale).
        $inbox = $target->endpoints?->shared_inbox ?: $target->endpoints?->inbox;

        // Actor remoto in cache senza endpoint (fetch parziale / vecchio):
        // ritenta un refresh prima di abbandonare il Follow.
        if (blank($inbox)) {
            $refreshed = app(RemoteActorResolver::class)->refresh($target);

            if ($refreshed !== null) {
                $target = $refreshed;
                $inbox = $target->endpoints?->shared_inbox ?: $target->endpoints?->inbox;
            }
        }

        if (blank($inbox)) {
            Log::channel('single')->warning('federation.delivery_skipped', [
                'reason' => 'missing_inbox',
                'target_uri' => $target->uri,
                'activity_type' => $activity['type'] ?? null,
                'activity_id' => $activity['id'] ?? null,
            ]);

            return;
        }

        $this->dispatchToInboxes(collect([$inbox]), $activity, $signingActor);
    }

    /**
     * Consegna un "Announce" (o il suo "Undo") ai follower remoti di chi
     * condivide, e in piu', se distinto, direttamente all'autore originale
     * del post condiviso: cosi' viene notificato anche se non segue chi
     * condivide, prassi comune tra le implementazioni del Fediverso.
     *
     * @param  array<string, mixed>  $activity
     */
    public function deliverAnnounce(Actor $sharer, Actor $originalAuthor, array $activity): void
    {
        $inboxes = $this->remoteFollowerInboxes($sharer);

        if (! $originalAuthor->isLocal() && $originalAuthor->id !== $sharer->id) {
            $authorInbox = $originalAuthor->endpoints?->shared_inbox
                ?: $originalAuthor->endpoints?->inbox;

            if (filled($authorInbox)) {
                $inboxes = $inboxes->push($authorInbox)->unique()->values();
            }
        }

        $this->dispatchToInboxes($inboxes, $activity, $sharer);
    }

    /**
     * Consegna un "Create"/"Delete" di un post o commento locale secondo la
     * sua audience: per visibilita' pubblica/non elencata/solo-follower va
     * ai follower remoti dell'autore, mentre per i messaggi diretti va
     * soltanto agli Actor remoti esplicitamente menzionati. In entrambi i
     * casi consegna anche direttamente a eventuali destinatari aggiuntivi
     * (es. l'autore del post padre di un commento), se remoti e distinti
     * dall'autore, cosi' da notificarli anche se non lo seguono.
     *
     * @param  array<string, mixed>  $activity
     * @param  list<?Actor>  $extraDirectTargets
     */
    public function deliverContent(Post|Comment $object, array $activity, array $extraDirectTargets = []): void
    {
        $author = $object->actor;

        if (! $author->isLocal()) {
            return;
        }

        if ($object instanceof Comment) {
            $object->loadMissing('post.community.actor', 'mentions.actor');

            if ($object->post->isInPrivateCommunity()) {
                // Come i Create dei post: niente fan-out ai follower
                // dell'autore. Solo membri remoti del Group (+ destinatari
                // diretti, es. autore del post padre se remoto).
                $group = $object->post->community->actor;
                $this->dispatchToInboxes($this->remoteFollowerInboxes($group), $activity, $author);

                collect($extraDirectTargets)
                    ->filter(fn (?Actor $target) => $target !== null && ! $target->isLocal() && $target->id !== $author->id)
                    ->unique('id')
                    ->each(fn (Actor $target) => $this->deliverTo($author, $target, $activity));

                return;
            }
        }

        $visibility = $object instanceof Post
            ? $object->visibility
            : ($object->post->visibility ?? Post::VISIBILITY_PUBLIC);

        if ($visibility === Post::VISIBILITY_DIRECT) {
            $mentionedActors = $object->mentions->map(fn ($mention) => $mention->actor)->filter();

            collect($extraDirectTargets)
                ->filter()
                ->concat($mentionedActors)
                ->unique('id')
                ->each(fn (Actor $target) => $this->deliverTo($author, $target, $activity));

            return;
        }

        $this->deliverToFollowers($author, $activity);

        // FEP-1b12: il Create deve raggiungere l'inbox del Group remoto
        // (locale il ritrasmette il Group stesso con Announce).
        $remoteGroupMentions = $object->mentions
            ->map(fn ($mention) => $mention->actor)
            ->filter(fn (?Actor $target) => $target !== null && $target->isGroup() && ! $target->isLocal());

        collect($extraDirectTargets)
            ->concat($remoteGroupMentions)
            ->filter(fn (?Actor $target) => $target !== null && ! $target->isLocal() && $target->id !== $author->id)
            ->unique('id')
            ->each(fn (Actor $target) => $this->deliverTo($author, $target, $activity));
    }

    /**
     * @return Collection<int, string>
     */
    private function remoteFollowerInboxes(Actor $localActor): Collection
    {
        $followerActorIds = DB::table('follows')
            ->where('following_id', $localActor->id)
            ->where('status', Follow::STATUS_ACCEPTED)
            ->pluck('follower_id');

        if ($followerActorIds->isEmpty()) {
            return collect();
        }

        return Actor::query()
            ->whereIn('id', $followerActorIds)
            ->where('is_local', false)
            ->where('status', Actor::STATUS_ACTIVE)
            ->with('endpoints')
            ->get()
            ->map(fn (Actor $actor) => $actor->endpoints?->shared_inbox ?: $actor->endpoints?->inbox)
            ->filter()
            ->unique()
            ->values();
    }

    /**
     * @param  Collection<int, string>  $inboxUrls
     * @param  array<string, mixed>  $activity
     */
    private function dispatchToInboxes(Collection $inboxUrls, array $activity, Actor $signingActor): void
    {
        if (! $signingActor->isLocal() || $inboxUrls->isEmpty()) {
            return;
        }

        // LD Signature sul documento: i peer Mastodon possono inoltrare
        // l'attivita' verificando la prova indipendentemente dalla firma HTTP.
        $signingActor->loadMissing('key');

        if ($signingActor->key !== null && $signingActor->key->hasPrivateKey() && ! isset($activity['signature'])) {
            try {
                $activity = $this->linkedDataSignatures->sign($activity, $signingActor);
            } catch (Throwable $exception) {
                Log::channel('single')->warning('federation.ld_signature.sign_failed', [
                    'activity_id' => $activity['id'] ?? null,
                    'actor_id' => $signingActor->id,
                    'error' => $exception->getMessage(),
                ]);
            }
        }

        foreach ($inboxUrls as $inboxUrl) {
            if (app(DomainBlockManager::class)->isBlockedUrl($inboxUrl)) {
                Log::channel('single')->info('federation.delivery_skipped', [
                    'reason' => 'domain_blocked',
                    'inbox' => $inboxUrl,
                    'activity_type' => $activity['type'] ?? null,
                    'activity_id' => $activity['id'] ?? null,
                ]);

                continue;
            }

            DeliverActivityJob::dispatch($inboxUrl, $activity, $signingActor->id)->afterCommit();

            Log::channel('single')->info('federation.delivery_queued', [
                'inbox' => $inboxUrl,
                'activity_type' => $activity['type'] ?? null,
                'activity_id' => $activity['id'] ?? null,
                'signing_actor_id' => $signingActor->id,
            ]);
        }
    }
}
