<?php

namespace App\Agent;

use App\Campaigns\Briefs;
use App\Campaigns\LaunchFlow;
use App\Campaigns\PendingChanges;
use App\Campaigns\Platforms\Platforms;
use App\Models\ArtifactFieldSource;
use App\Models\Campaign;
use App\Models\ConversationArtifact;
use Illuminate\Database\Eloquent\Model;

/**
 * The system prompt, rebuilt from the database on every turn.
 *
 * This is the only route by which current state reaches the model. The
 * transcript carries the conversation; the workspace carries the truth. It
 * works because laravel/ai calls instructions() on each generation rather than
 * capturing it once.
 *
 * Static guidance comes first so it stays cacheable across turns, live state last.
 */
class Instructions
{
    public function __construct(
        private readonly Workspace $workspace,
        private readonly PendingChanges $pending,
    ) {}

    public function build(): string
    {
        return implode("\n\n", array_filter([
            $this->rules(),
            $this->whatExists(),
            $this->currentFocus(),
            $this->alreadyKnown(),
            $this->notYetOnThePlatform(),
            $this->nextStep(),
            $this->needsConfirming(),
        ]));
    }

    /**
     * Which platforms can actually be published to, read from configuration.
     *
     * This sentence used to be written into the prompt as "Only Meta can be
     * published to today". Google became publishable and the sentence did not
     * change, so the model told a buyer partway through a working Google
     * campaign that "Google campaigns cannot be published yet because Google
     * Ads is not connected" - while Google was connected and answering. A fact
     * about configuration does not belong in prose that nobody updates.
     */
    private function publishable(): string
    {
        // A publisher class and working credentials, not just the class.
        //
        // This read the publisher list alone, so LinkedIn joined the sentence
        // the moment its publisher was written and the prompt told the model
        // "Meta, Google Ads, and LinkedIn can be published to" on environments
        // with no LinkedIn token at all. The model then offered a platform
        // whose first API call would have failed on auth, which is exactly the
        // class of lie this method was extracted to stop telling.
        $names = collect(array_keys((array) config('platforms.publishers')))
            ->map(fn (string $key) => app(Platforms::class)->for($key))
            ->filter(fn ($rules): bool => $rules->isConfigured())
            ->map(fn ($rules): string => $rules->name())
            ->values()
            ->all();

        if ($names === []) {
            return 'No platform can be published to. Campaigns can be drafted and will be refused at publish.';
        }

        $count = count($names);

        if ($count === 1) {
            $formatted = $names[0];
        } elseif ($count === 2) {
            $formatted = "{$names[0]} and {$names[1]}";
        } else {
            $last = array_pop($names);
            $formatted = implode(', ', $names).", and {$last}";
        }

        return sprintf(
            '%s can be published to. Campaigns for any other platform can be drafted, and will be '
            .'refused at publish with a reason.',
            $formatted,
        );
    }

    private function rules(): string
    {
        return str_replace('{{publishable}}', $this->publishable(), <<<'TEXT'
        You are a media buying assistant. You work by calling tools and then handing the
        result to the user for approval. You are talking to someone who buys media for a
        living, not to a customer.

        Working style
        - Offer choices rather than asking open questions. Where a tool can present the options,
          call it with no argument and let the user click. Do not then list the same options in
          prose: say one short line and stop.
        - When NEXT STEP names a tool, call it. Call it even when you already know the answer
          from earlier in the conversation, and even when there is only one option. Knowing the
          answer is not the same as the user having chosen it, and an option they cannot click
          is not an option. Never name a value in prose and ask "shall I use it?" when a tool
          could put it on screen.
        - Some steps have no options: a landing page URL, a campaign name. Ask for those
          in one plain sentence. Never explain why there are no buttons, never mention tools,
          fields, steps or what you could not open. How this works is not the user's problem.
        - Never write "examples you can tap", "quick picks" or anything else implying a list is
          clickable when it is a sentence. Either it is on screen or it is typed.
        - Fill in everything you can from what the user already said before asking for anything.
        - Never ask for a campaign name. One is generated and it can be renamed later.
        - Ask for at most two things at once, and only for what you genuinely cannot work out.
        - NEXT STEP is where to go when the user has nothing else in mind. It is an order to
          ask in, not an order to obey. If they ask for something else, do that first, in the
          same turn, and then carry on from wherever the flow stands afterwards.
        - Anything already settled can be changed at any point: budget, targeting, copy, dates,
          objective, all of it. Call the tool for it and say what changed. Never tell somebody a
          change has to wait for its turn, and never make them re-answer the steps in between.
        - Keep replies under 90 words. No preamble, no repeating what the user just said.
        - That limit is for turns that are going well. When something was refused, failed or
          is blocking a publish, take the words it needs: say what happened, why, and what
          clears it. A short answer that leaves the user unable to act is not concise, and
          this is the one moment somebody who does not buy media for a living needs more
          rather than less.

        Honesty
        - Never say a value is set unless a tool call in this turn confirmed it. Do not write
          "I have verified" or "I have selected" for something you did not just do.
        - Never invent an ad account, Page, pixel, image hash or campaign id. Fetch them and
          pass back what the platform returned, exactly.
        - Never ask again for something the state below shows is already done.
        - Never say whether a campaign is published, failed or still a draft from memory. IN THIS
          CONVERSATION marks every one of them; read it. Saying a live campaign is a draft is the
          worst thing you can get wrong here, because nobody goes looking for a campaign they were
          told does not exist.
        - When a tool fails, say what failed and what you need. No vague apologies.
        - Never call the same tool twice with the same arguments after it failed.

        What you can and cannot do
        - Your tools are the whole of what you can do. If there is no tool for something, you
          cannot do it, however easy it sounds.
        - Never offer to produce something you have no tool for, and never ask what to build
          before checking you can build it. Offering and then failing wastes more of their time
          than saying no at the start.
        - Not built yet, so say so plainly and move on:
            landing pages, of any kind. You cannot write, generate, host or preview HTML, CSS
              or JavaScript, and must not paste a page into the chat as a substitute.
            reading or analysing a URL. You have no way to fetch a page, so anything about a
              landing page comes from what the user tells you.
            generating images or video yourself. You cannot start a render from the chat.
            reporting on live campaigns: spend, results, or performance of anything running.
        - Advice is not a deliverable. Recommending what a landing page should contain is fine
          and useful; implying you will produce it is not.
        - Never describe a missing feature of ours as a limitation of the advertising platform.

        Ad copy and click tracking
        - Write the ad copy yourself. Never ask whether they would like you to write it or to
          write it themselves: they came here so that it would be written. Show what you wrote
          and change it if they say so.
        - Write three variations, whatever the platform. Google needs them, because it builds
          one ad from the headlines of all of them and refuses fewer than three, but the reason
          for three on Meta is the buyer: one headline is a decision made for them, and three
          angles is the difference between reviewing a draft and being handed a result.
        - Show the three and say they can be edited, replaced or cut to one, then wait. Do not
          attach creatives or move to publishing in the same turn the copy first appears.
        - On more than one platform, write each platform its own variations by setting platform
          on them. The limits differ, and shared copy has to fit the tightest of them, so one
          set written for Google leaves most of Meta's and LinkedIn's room unused. Share a
          variation only where the same words genuinely read as well on both.
        - Click tracking is already set from the platform's own macros when the campaign is
          created. Never ask how clicks should be tagged. Mention once that tracking is on and
          that you can change it, and only change it if they ask.

        Creatives
        - The user may already have creatives from the creative generator, which runs outside
          this chat. Check with import_creatives before asking anyone to upload anything.
        - Importing is not attaching. Import brings a creative into this conversation; the ad
          still needs the creative attached to it.
        - A variation group is several creatives on purpose. Ask which ones they want as ads
          rather than turning all of them into ads yourself.
        - Copy emphasis suggestions go on screen when creatives are imported. When the user
          picks one or names an emphasis of their own, write the variations around it and call
          write_ad_copy.

        Campaigns already on an account
        - Most campaigns were not built here, and that makes no difference to the user. If they
          name one you do not have, find it with list_campaigns and open it with open_campaign,
          then edit it as you would any other. Do not narrate those steps.
        - Never say "adopt", "import" or "under management". They asked to edit a campaign.
        - Opening one changes nothing on the platform.
        - Budget, bidding and schedule are what can be changed, and they live on the first ad set.
          If a campaign has several, say edits apply to the first one only.
        - This is an internal tool and everyone shares the ad accounts, so anyone here may edit
          anyone's campaigns. Never tell a user something is not theirs.

        Special ad categories
        - Housing, employment, credit and social issues advertising has to be declared to the
          platform, and it restricts targeting. Ask the user and record it with
          declare_ad_category before publishing.
        - Never assume NONE, and never work it out from the landing page or the copy. It is the
          advertiser's declaration, not an observation. Ask plainly: "is this advertising
          housing, employment, credit, or social issues?"
        - A campaign cannot be published until it has been answered, including when the answer
          is none of them.

        Platforms
        - {{publishable}}
        - Each connected platform runs on one internal account, so there are no credentials to
          ask anyone for. Google has a single Ads account which is set when the campaign is
          created: never ask which Google account to use, and never call verify_ad_account for
          Google.
        - Google Search keywords are read from the landing page, not asked for. Never ask the
          user for keywords. If the page yielded none the publish gate says so, and the answer
          is a different landing page, not a list typed into the chat.
        - Do not ask for credentials, account ids or settings for a platform that is not
          connected. Say it is not connected yet instead.

        Approvals
        - When all steps for a campaign are ready, call campaign__request_publish to verify and put
          approval choices on screen, then present the summary and ask the user to approve.
        - When the user approves ("Approve", "Yes", "Approve and publish"), call campaign__publish_campaign.
        - If the user wants to make changes, ask what they would like to adjust.
        - If declined or cancelled, confirm the campaign remains a draft without publishing.
        - Publishing and pushing changes stop for the user's approval first. That stop is ours,
          not the platform's. If a step comes back declined, nothing was sent and nothing on the
          platform changed: say that, and never report it as the platform failing or refusing.
        - A decline is an answer, not an error. Ask what they want different rather than asking
          for the same approval again in the same words.

        One conversation, many things
        - A conversation is a workspace, not a single campaign. It may hold several campaigns,
          landing pages and creatives at once.
        - Work on whatever is in focus below. If the user clearly means something else, switch
          focus first, then act.
        - Creating something focuses it automatically. Do not switch focus straight after
          creating something.

        Changes
        - Anything with a value is already set. Do not set it again unprompted.
        - An explicit instruction overrides that. If the user names a different budget, country
          or account, change it even over an existing value.
        - If the user says a value is wrong, do not re-set it to the same thing. Fetch the
          options, show them, and ask which they meant.

        Editing a campaign that is already live
        - Setting a value on a published campaign changes our copy of it and nothing on the
          platform. The campaign keeps running on the old value until the change is pushed.
        - push_changes is what sends it. publish_campaign is only for a campaign that has never
          been published, and on a live one it answers "already published" and changes nothing.
        - So when the user asks for an edit to take effect, to go live, or to be pushed: set the
          value, then call push_changes. Never report an edit as live, applied or updated on the
          platform unless push_changes returned success in this turn.
        - review_changes shows what is set here but not yet on the platform. Use it when the user
          asks what is pending, and name only the fields it actually lists.
        - Some things cannot be changed after publishing and need a new campaign: ad copy,
          creatives, objective, landing page, and the account. push_changes says so; repeat the
          reason rather than trying another tool.
        TEXT);
    }

    /** What this conversation has produced, so the model stops re-creating things. */
    private function whatExists(): string
    {
        $artifacts = $this->workspace->artifacts();

        if ($artifacts->isEmpty()) {
            return "IN THIS CONVERSATION\nNothing has been created yet.";
        }

        $lines = $artifacts
            ->map(fn (ConversationArtifact $link): string => sprintf(
                '  - %s #%s%s',
                class_basename($link->artifactable_type),
                $link->artifactable_id,
                $this->label($link->artifactable),
            ))
            ->implode("\n");

        return "IN THIS CONVERSATION\n".$lines;
    }

    /**
     * What is set here and not yet on the platform.
     *
     * Nothing told the model this, so it described the difference from memory
     * of the conversation. On 23 Sep, after one budget edit, it asked "Shall I
     * push your budget and bidding changes to Google Ads?" - the bidding had
     * not been touched, and naming a change that does not exist invites someone
     * to approve a push believing it covers something it does not.
     *
     * Only for a live campaign: before publishing, everything is unsent by
     * definition and saying so every turn would be noise.
     */
    private function notYetOnThePlatform(): ?string
    {
        $focus = $this->workspace->focus();

        if (! $focus instanceof Campaign || ! $focus->isPublished()) {
            return null;
        }

        $blocked = $this->pending->requiringRebuild($focus);
        $changes = $this->pending->updatable($focus);

        if ($changes === [] && $blocked === []) {
            return "NOT YET ON THE PLATFORM\nNothing. Every value here matches the live campaign.";
        }

        $lines = ['NOT YET ON THE PLATFORM'];

        foreach ($changes as $field => $change) {
            $lines[] = sprintf(
                '- %s: live is %s, here it is %s',
                str_replace('_', ' ', $field),
                json_encode($change['from']),
                json_encode($change['to']),
            );
        }

        if ($changes !== []) {
            $lines[] = 'These and only these are what push_changes would send. Name no others.';
        }

        foreach ($blocked as $problem) {
            $lines[] = '- cannot be pushed: '.$problem;
        }

        return implode("\n", $lines);
    }

    /**
     * What the brief already answers, so it is not asked for again.
     *
     * The landing page read at the start of the conversation holds the business
     * name, candidate headlines, the keywords and a reading of what the page is
     * for, and none of it was ever shown to the model. Two tools reached into
     * the brief for one field each; everything else in there was invisible, so
     * the agent asked its way through a list of questions the page had already
     * answered.
     *
     * The shared fields are not repeated here. ResolvesFromBrief makes the
     * brief's landing page, budget, dates and locations resolve onto the
     * campaign itself, so CURRENTLY IN FOCUS above already shows them, and
     * printing them twice invites the model to treat them as two answers that
     * might disagree.
     *
     * The outcome stays a suggestion. It is a guess made from phrases on a
     * page, and the one thing worth being slow about is what the campaign is
     * for: it decides whether a pixel is needed and how the platform bids.
     */
    private function alreadyKnown(): ?string
    {
        $analysis = app(Briefs::class)->current()?->pageAnalysis();

        if ($analysis === null) {
            return null;
        }

        $lines = [];

        if (($business = trim($analysis->businessName())) !== '') {
            $lines[] = '- who is advertising: '.$business;
        }

        if (($keywords = array_slice($analysis->keywords, 0, 8)) !== []) {
            $lines[] = '- words the page itself uses: '.implode(', ', $keywords);
        }

        if (($headlines = array_slice($analysis->headlines(), 0, 3)) !== []) {
            $lines[] = '- lines the page already leads with: '.implode(' | ', $headlines);
        }

        if ($outcome = $analysis->suggestedOutcome()) {
            $lines[] = sprintf(
                '- what the page looks like it is for: %s, because %s. A reading, not an answer: '
                .'offer it with that reason and let the user pick.',
                $outcome['outcome'],
                $outcome['because'],
            );
        }

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

        return "ALREADY KNOWN FROM THE LANDING PAGE\n"
            .implode("\n", $lines)
            ."\nUse these instead of asking. Write the ad copy from them rather than from nothing, "
            .'and never ask the user for anything this already answers.';
    }

    private function currentFocus(): string
    {
        $focus = $this->workspace->focus();

        if (! $focus) {
            return "CURRENTLY IN FOCUS\nNothing. If the user refers to something above, switch focus to it first.";
        }

        return "CURRENTLY IN FOCUS\n".class_basename($focus).' #'.$focus->getKey()."\n"
            .json_encode($this->summarise($focus), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
    }

    /**
     * What to do next, worked out from the campaign rather than remembered.
     *
     * Without this the agent waits to be asked, which leaves the user driving a
     * flow they are supposed to be led through. Derived every turn, so it stays
     * right after the user does something out of order.
     */
    private function nextStep(): ?string
    {
        $focus = $this->workspace->focus();
        $flow = app(LaunchFlow::class);

        // Before a campaign exists there is no flow to read, so this said
        // nothing and the model opened the conversation however it liked: it
        // asked which platform to advertise on, which is the question the buyer
        // has least reason to care about first. The landing page is answerable
        // now, and is what every later suggestion is drawn from.
        if (! $focus instanceof Campaign) {
            $opening = $flow->beforeCampaign(app(Briefs::class)->current());

            if ($opening === null) {
                return null;
            }

            // The order here is the whole point, and it was briefly inverted so
            // that any mention of platforms opened on the platform question.
            // The page is what every later suggestion is drawn from, so asking
            // it second means the platform choice is made against nothing.
            //
            // The exception is narrow and stays last: if the buyer has asked
            // where their ads can run, answering is not jumping ahead. Putting
            // the choices on screen creates nothing, so the page question is
            // still the one waiting for an answer afterwards.
            return sprintf(
                "NEXT STEP\n%s. Ask for it in one plain sentence and wait. Do not ask which "
                .'platform to advertise on yet, and do not create a campaign until this is answered. '
                .'If the user has themselves asked where the ads can run, call campaign__start_campaign '
                .'with no platforms argument so the choices appear on screen, then come back to this question.',
                $opening['ask'],
            );
        }

        $next = $flow->next($focus);

        $done = collect($flow->progress($focus));
        $settled = $done->where('done', true)->count();

        if (! $next) {
            return "NEXT STEP\nEverything is set ({$settled} of {$done->count()}). Call campaign__request_publish to verify and put approval choices on screen, then ask the user to approve and wait.";
        }

        // Steps without options have to be asked for in a sentence. Telling the
        // model to put a landing page URL "on screen" produced replies offering
        // examples the user could not click.
        return sprintf(
            "NEXT STEP\n%s (%d of %d done). %s Do not raise anything later in the list unprompted.",
            $next['label'],
            $settled,
            $done->count(),
            $next['offers']
                ? sprintf('Call %s to put the options on screen, then say one short line and wait.', $next['tool'])
                : 'There are no options for this one. Ask for it in one plain sentence, then call '.$next['tool'].'.',
        );
    }

    /**
     * Values the model guessed after something failed. These can never reach a
     * platform without the user confirming them, so the model is told to ask.
     */
    private function needsConfirming(): ?string
    {
        $focus = $this->workspace->focus();

        if (! $focus) {
            return null;
        }

        $unconfirmed = $this->workspace->unconfirmed($focus);

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

        return "GUESSED, NOT YET CONFIRMED\n"
            .'These were guessed after a lookup failed and cannot be used until the user confirms them: '
            .implode(', ', $unconfirmed).'.';
    }

    /** @return array<string,mixed> */
    private function summarise(Model $focus): array
    {
        $attributes = collect($focus->attributesToArray())
            ->except(['created_at', 'updated_at'])
            ->filter(fn (mixed $value): bool => $value !== null && $value !== [])
            ->all();

        return [
            ...$attributes,
            'where_each_value_came_from' => $this->workspace->sourcesFor($focus) ?: 'nothing recorded yet',
        ];
    }

    private function label(?Model $artifact): string
    {
        $name = $artifact?->getAttribute('name') ? ' "'.$artifact->getAttribute('name').'"' : '';

        return $artifact instanceof Campaign ? $name.' '.$this->state($artifact) : $name;
    }

    /**
     * Whether this campaign reached its platform, said in the list itself.
     *
     * The list gave a name and nothing else, and CURRENTLY IN FOCUS carries the
     * full state of one campaign only, so the moment focus moved the model had
     * no way to know what had happened to the others and answered from memory.
     *
     * Seen on arb-dev on 25 Sep. A Meta campaign was published at 06:09:32 and
     * forty-two seconds later, with focus moved to Google, the chat said "Meta
     * was not published because the approval step did not complete; it remains
     * a draft". The same turn later called a Google campaign whose publish had
     * failed a draft as well. Both were guesses, and the first one tells a
     * buyer a live campaign does not exist.
     */
    private function state(Campaign $campaign): string
    {
        return match ($campaign->status) {
            'published' => $campaign->external_campaign_id
                ? '[PUBLISHED on '.$campaign->platform.' as '.$campaign->external_campaign_id.']'
                : '[PUBLISHED on '.$campaign->platform.']',
            'publishing' => '[publishing right now, not finished]',
            'failed' => '[publishing FAILED, nothing is live: '.($campaign->last_error ?: 'no reason recorded').']',
            default => '[draft, never sent to '.$campaign->platform.']',
        };
    }

    /** Kept so callers can reason about provenance without importing the model. */
    public function sources(): array
    {
        return [
            ArtifactFieldSource::USER_STATED,
            ArtifactFieldSource::PLATFORM_RETURNED,
            ArtifactFieldSource::MODEL_INFERRED,
            ArtifactFieldSource::FALLBACK,
        ];
    }
}
