<?php

namespace App\Campaigns;

use App\Agent\Workspace;
use App\Models\Asset;
use App\Models\Brief;
use App\Models\Campaign;

/**
 * The order a campaign gets built in, and what is left.
 *
 * Options made each answer a click, but the agent still waited to be asked what
 * came next, so the user had to drive. This is the missing half: a fixed order,
 * derived from what the campaign already holds rather than from anything the
 * model remembers, so "next" is the same answer every turn.
 *
 * Order follows the launch flow on SERP-1833: account and assets, then the
 * campaign, then the ad set, then creative, then verification. Within that,
 * things that unlock other things come first: a Page and pixel cannot be listed
 * until an ad account is chosen, and the objective decides whether a pixel is
 * needed at all.
 */
class LaunchFlow
{
    /**
     * The shared questions asked before any platform is chosen.
     *
     * These belong to the brief, not to a campaign: the page being advertised,
     * the money and the dates are the same answer however many platforms it
     * runs on. Asking them once is the whole point of the brief, and asking
     * them first means everything after can be a recommendation drawn from the
     * page rather than a blank question.
     *
     * @var list<array{key: string, label: string, tool: string, offers: bool, ask?: string}>
     */
    private const BRIEF_SETUP = [
        ['key' => 'landing_url', 'label' => 'landing page', 'tool' => 'analyze_landing_page', 'offers' => false,
            'ask' => 'What are you promoting? Paste the URL of the page the ads should send people to.'],
        ['key' => 'budget', 'label' => 'budget', 'tool' => 'set_campaign_budget', 'offers' => true,
            'ask' => 'How should the budget work, and how much?'],
        ['key' => 'schedule', 'label' => 'end date', 'tool' => 'set_schedule', 'offers' => true,
            'ask' => 'When should it stop?'],
    ];

    /**
     * The shared questions asked last, once every platform is configured.
     *
     * Creative and copy are reused across platforms, so they are the brief's
     * too. They come last because writing the copy is what pairs each ad with a
     * creative, and there is nothing to pair until the campaign exists.
     *
     * @var list<array{key: string, label: string, tool: string, offers: bool, ask?: string}>
     */
    private const BRIEF_CREATIVE = [
        ['key' => 'creatives', 'label' => 'creatives', 'tool' => 'import_creatives', 'offers' => true,
            'ask' => 'Which creatives should be used?'],
        // Written, not offered as a choice. This asked "I can write the ad copy
        // now, or you can give me the headline and body text yourself. Which
        // would you prefer?", which is a question with an obvious answer that
        // costs a turn: the buyer came here so it would write the copy.
        //
        // offers=true was tried and cannot work: it makes the controller call
        // write_ad_copy with no arguments to put options on screen, and that
        // tool requires ads, so the step stepped in with an error and nothing
        // to click. What to emphasise is asked by import_creatives instead,
        // which has a chooser and runs first.
        ['key' => 'copy', 'label' => 'ad copy', 'tool' => 'write_ad_copy', 'offers' => false,
            'ask' => 'Writing the ad copy now.'],
    ];

    /**
     * Every step for this campaign, shared and platform, in the order asked.
     *
     * The platform's own steps sit in the middle: after the brief has settled
     * what is being advertised and for how much, and before the creative that
     * every platform shares. A second platform therefore only ever repeats the
     * middle, which is five or six questions rather than fifteen.
     *
     * @return list<array{key: string, label: string, tool: string, offers: bool, ask?: string}>
     */
    public function steps(Campaign $campaign): array
    {
        return [
            ...self::BRIEF_SETUP,
            ...$this->platforms->for($campaign)->steps(),
            ...self::BRIEF_CREATIVE,
        ];
    }

    public function __construct(
        private readonly Platforms\Platforms $platforms,
        private readonly Workspace $workspace,
    ) {}

    /**
     * The question to ask before a campaign exists at all.
     *
     * The rest of the flow reads a campaign, so until one exists there is no
     * flow and the model was left to open the conversation however it liked.
     * What it did was ask which platform to advertise on, which is the one
     * question the buyer has least reason to care about first.
     *
     * So the opening question is the landing page, answerable before anything
     * has been created, and the thing every later suggestion is drawn from.
     * Only that one: the rest of the brief is worth asking once there is a
     * platform to size it against.
     *
     * @return array{key: string, label: string, tool: string, offers: bool, ask?: string}|null
     */
    public function beforeCampaign(?Brief $brief): ?array
    {
        if ($brief !== null && filled($brief->landing_url)) {
            return null;
        }

        return collect(self::BRIEF_SETUP)->firstWhere('key', 'landing_url');
    }

    /**
     * The next thing that needs answering, or null when the campaign is whole.
     *
     * @return array{key: string, label: string, tool: string, offers: bool, ask?: string}|null
     */
    public function next(Campaign $campaign): ?array
    {
        foreach ($this->steps($campaign) as $step) {
            if (! $this->done($campaign, $step['key'])) {
                return $step;
            }
        }

        return null;
    }

    /**
     * What is still outstanding on the other campaigns of the same brief.
     *
     * Everything else here reports on the campaign in focus, which is right for
     * asking the next question and wrong for saying whether the work is done.
     * A conversation advertising on two platforms holds two campaigns, and the
     * buyer is looking at one of them.
     *
     * Seen on dev on 23 Sep: a Meta and Google thread where every "write the ad
     * copy" landed on Google, because Google was in focus. Meta finished with no
     * ads at all and the chat said "Everything is ready. Shall I publish this
     * campaign?" - true of what was on screen, and not of the campaign sitting
     * behind it.
     *
     * Keyed by campaign name, because that is what the buyer sees on the tabs.
     *
     * @return array<string, string> campaign name to the step it is waiting on
     */
    public function outstandingElsewhere(Campaign $focus): array
    {
        $brief = $focus->brief;

        if ($brief === null) {
            return [];
        }

        $waiting = [];

        foreach ($brief->campaigns()->where('id', '!=', $focus->id)->get() as $sibling) {
            // A campaign already on the platform is not waiting for anything.
            if ($sibling->isPublished()) {
                continue;
            }

            if ($step = $this->next($sibling)) {
                $waiting[$sibling->name] = $step['label'];
            }
        }

        return $waiting;
    }

    /**
     * Every step, with whether it is settled, for showing progress.
     *
     * @return list<array{key: string, label: string, done: bool}>
     */
    public function progress(Campaign $campaign): array
    {
        return array_map(fn (array $step): array => [
            'key' => $step['key'],
            'label' => $step['label'],
            'done' => $this->done($campaign, $step['key']),
        ], array_values(array_filter(
            $this->steps($campaign),
            fn (array $step): bool => $this->applies($campaign, $step['key']),
        )));
    }

    /**
     * Whether a step applies at all.
     *
     * A traffic campaign needs no pixel, so asking for one is not a step that
     * is outstanding, it is a step that does not exist. A date is the same: a
     * campaign that runs until someone stops it needs no end date, and only a
     * lifetime budget makes one compulsory, because it is the total to spend by
     * then.
     */
    private function applies(Campaign $campaign, string $key): bool
    {
        return match ($key) {
            // Asked of the platform, not of a list of platform names kept here.
            //
            // This read $campaign->platform === 'meta', which happens to be the
            // same answer today, and PublishGate already asks requiresPage().
            // The two agreeing by coincidence is the problem: the moment a
            // platform is told it needs a Page, the gate blocks on a missing
            // one while the flow never asks for it, and the campaign cannot
            // publish and cannot be fixed. LinkedIn is a live candidate for
            // exactly that, since every sponsored post needs an organization.
            'page' => $this->platforms->for($campaign)->requiresPage(),
            'special_ad_categories' => $this->platforms->for($campaign)->specialAdCategories() !== [],
            'pixel' => filled($campaign->objective)
                && $this->platforms->for($campaign)->requiresPixel($campaign->objective),
            // Only where a pixel event is what the ad set optimises toward, and
            // only once the pixel it reads the events from has been chosen. A
            // traffic campaign promotes the Page and Meta rejects an event sent
            // against it.
            'conversion_event' => filled($campaign->objective)
                && filled($campaign->pixel_id)
                && $this->platforms->for($campaign)->conversionEvent($campaign->objective) !== null,
            'schedule' => $campaign->usesLifetimeBudget(),
            // Only an Instagram placement makes an Instagram account relevant.
            'instagram' => in_array('instagram', (array) ($campaign->placements ?? []), true),
            // Only where the platform sells more than one kind of campaign.
            'campaign_type' => $this->platforms->for($campaign)->campaignTypes() !== [],
            // A platform whose ads are text has nothing to attach a creative to.
            // Asking a Google Search campaign for an image is a step it can
            // never finish, and it sits in front of the ad copy that would.
            'creatives' => $this->platforms->for($campaign)->requiresCreativeAsset($campaign),
            default => true,
        };
    }

    private function done(Campaign $campaign, string $key): bool
    {
        if (! $this->applies($campaign, $key)) {
            return true;
        }

        return match ($key) {
            'ad_account' => filled($campaign->ad_account_id),
            'campaign_type' => filled($campaign->campaign_type),
            'objective' => filled($campaign->objective),
            // Null means nobody has answered. An empty list would be an answer,
            // which is why the column is nullable.
            'special_ad_categories' => $campaign->special_ad_categories !== null,
            'landing_url' => filled($campaign->landing_url),
            // An empty string is the answer "no tracking". Null is nobody
            // having been asked, which is how every ad so far went out untagged.
            'utm' => $campaign->utm !== null,
            'page' => filled($campaign->page_id),
            'pixel' => filled($campaign->pixel_id),
            'conversion_event' => filled($campaign->conversion_event),
            'budget' => $campaign->budget > 0,
            'bidding' => filled($campaign->bid_strategy),
            // A campaign that runs until it is paused has no end date, so the
            // date alone cannot say whether this was asked. Provenance can: the
            // tool records a source even when the answer was "none". Without
            // this, choosing no end date left the step outstanding and the
            // controller re-offered the dates on every later turn.
            'schedule' => $campaign->ends_at !== null
                || array_key_exists('ends_at', $this->workspace->sourcesFor($campaign)),
            // Countries always count. Placements only count where the platform
            // models them: Google declares none, so requiring one would leave
            // this step outstanding forever and the flow would never move past
            // targeting. See SetTargeting::supportedPlacements().
            'targeting' => filled($campaign->locations['countries'] ?? null)
                && (filled($campaign->placements)
                    || $this->platforms->for($campaign)->placements() === []),
            // Empty string is "asked, and there is none", which the tool writes
            // when the account has no Instagram presence. Without that this step
            // could never be finished by an advertiser who simply has no handle.
            'instagram' => $campaign->instagram_account_id !== null,
            'identity' => filled(data_get($campaign->meta, 'tiktok_identity_id')),
            // What this step asks for is a creative to advertise with, which
            // lands in the conversation rather than on the campaign. Reading it
            // off the ads instead meant it could never be settled: no ad row
            // exists until the copy is written, one step later, so the flow
            // asked for creatives forever and never reached the copy that would
            // have finished it.
            //
            // An adopted campaign arrives in a conversation holding nothing, so
            // ads that already carry their creatives count too.
            'creatives' => $this->workspace->artifacts(Asset::class)->isNotEmpty()
                || ($campaign->ads()->exists() && ! $campaign->ads()->whereNull('asset_id')->exists()),
            'copy' => $campaign->ads()->exists(),
            default => true,
        };
    }
}
