<?php

namespace App\Agent\Tools\Campaign;

use App\Agent\Workspace;
use App\Campaigns\Briefs;
use App\Campaigns\Platforms\Platforms;
use App\DTOs\LandingPageAnalysis;
use App\Models\ArtifactFieldSource;
use App\Models\Campaign;
use App\Services\LandingPageAnalyzer;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use RuntimeException;
use Stringable;

/**
 * Read the page being advertised, before anything else is decided.
 *
 * The first thing a buyer knows is what they are promoting, and it is the
 * highest-information answer in the whole flow: the objective, the ad copy, the
 * keywords and even which platforms suit can all be proposed from it. Asking
 * for an ad account first, as the flow used to, is asking the user to do
 * administration before they have said what they want.
 *
 * Deliberately does not require a campaign in focus. This runs at the point
 * where there may not be one yet. If there is, the landing URL is recorded on
 * it so the flow does not ask again.
 *
 * Everything returned is a suggestion. Nothing here is written to the campaign
 * except the URL the user themselves supplied.
 */
class AnalyzeLandingPage implements Tool
{
    public function __construct(
        private readonly Workspace $workspace,
        private readonly LandingPageAnalyzer $analyzer,
        private readonly Briefs $briefs,
        private readonly Platforms $platforms,
    ) {}

    public function description(): Stringable|string
    {
        return 'Read the landing page the user wants to advertise and report what it says: '
            .'title, description, headings, and suggested campaign name, ad copy and keywords. '
            .'Call this first, as soon as the user gives a URL, before asking for an ad account, '
            .'an objective, or anything else. Present the suggestions for the user to confirm or '
            .'change. Never state a fact about the page that is not in the result.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'url' => $schema->string()
                ->description('The public URL of the page the ads should send traffic to.'),
        ];
    }

    /**
     * Rename any campaign here still holding a generated name.
     *
     * @see handle() for why they end up with one
     */
    private function renamePlaceholders(LandingPageAnalysis $analysis): void
    {
        $fromPage = trim($analysis->campaignName());

        if ($fromPage === '') {
            return;
        }

        foreach ($this->workspace->artifacts(Campaign::class) as $link) {
            $campaign = $link->artifactable;

            if (! $campaign instanceof Campaign || $campaign->status !== 'draft') {
                continue;
            }

            // A name the buyer typed is never touched. A generated one is, and
            // so is one this method wrote earlier: if the landing page changes,
            // a name taken from the old page is staler than no name at all.
            //
            // Raised on the PR, which spotted that the provenance was never
            // moved off FALLBACK and so this could rename repeatedly. It can,
            // and that part is deliberate; the defect was the provenance, which
            // told the panel a name derived from the page was still an
            // unanswered placeholder.
            if (! in_array(
                $this->workspace->sourcesFor($campaign)['name'] ?? null,
                [ArtifactFieldSource::FALLBACK, ArtifactFieldSource::MODEL_INFERRED],
                true,
            )) {
                continue;
            }

            // The platform is appended so several campaigns from one brief are
            // told apart in an account list, the same as nameFor() does.
            $campaign->update([
                'name' => $fromPage.' - '.$this->platforms->for($campaign)->name(),
            ]);

            $this->workspace->recordSource($campaign, 'name', ArtifactFieldSource::MODEL_INFERRED);
        }
    }

    public function handle(Request $request): Stringable|string
    {
        $url = trim((string) ($request->all()['url'] ?? ''));

        if ($url === '') {
            return $this->json([
                'ok' => false,
                'error' => 'Ask the user which page the ads should send people to, then call this again with the URL.',
            ]);
        }

        try {
            $analysis = $this->analyzer->analyze($url);
        } catch (RuntimeException $exception) {
            // The reason is the useful part: a private address and a 404 need
            // different things from the user, and both are worth saying plainly.
            //
            // The instruction beside it is what stops the loop. Returning only
            // the reason left the agent with nothing to do but ask for the URL
            // again, and on dev on 23 Sep it asked three times in a row for a
            // URL that had been given three times, word for word, until the
            // user gave up and said so. A page we cannot read is not the same
            // as a page the user got wrong: it may be geo-blocked, behind a
            // WAF, or refusing our user agent, and in every one of those cases
            // the URL they typed is the right one and the campaign can go on
            // without us having read it.
            return $this->json([
                'ok' => false,
                'error' => $exception->getMessage(),
                'url_as_given' => $url,
                'next' => 'Tell the user plainly that this page could not be read and why. Do not ask '
                    .'for the URL again if they have already given one: they answered, and repeating '
                    .'the question reads as not listening. Offer the two real choices instead, which '
                    .'are to correct the address or to carry on with it as it is, and say that the '
                    .'only thing lost by carrying on is our suggestions for the copy. If they choose '
                    .'to carry on, set the landing page with set_campaign_basics and continue.',
            ]);
        }

        // Kept on the brief so every later step can suggest from the page
        // without fetching it again, and so a second platform inherits it
        // rather than asking for the page a second time.
        //
        // remember() rather than current(): on the first message of a new chat
        // the conversation does not exist yet, so there is no brief to write
        // to. It holds the analysis and writes it the moment there is one.
        $this->briefs->remember($analysis);

        // The URL is the user's own words, so it is user-stated. Everything
        // else the page implies stays a suggestion until someone picks it.
        $campaign = $this->workspace->focus();

        if ($campaign instanceof Campaign) {
            $this->briefs->attach($campaign);
            $campaign->update(['landing_url' => $analysis->url]);
            $this->workspace->recordSource($campaign, 'landing_url', ArtifactFieldSource::USER_STATED);
        }

        // A campaign created before the page was read is still carrying the
        // placeholder, so give it the page's name now.
        //
        // nameFor() reads the page analysis, but on a first message the
        // campaigns exist before the URL does: "create a campaign across
        // multiple platforms", platforms picked, and only then the page. So
        // every one of them was named "Meta campaign 25 Sep 05:48" and stayed
        // that way. On arb-dev on 25 Sep one of those published to Meta under
        // that name while the chat had shown "A Platform with Autonomous
        // Intelligence to Achieve Growth", and it could not be found in Ads
        // Manager by the only name the buyer had been given.
        //
        // Only where provenance says the name was not the buyer's: generated
        // because nobody said otherwise, or taken from a page by this method
        // before. A name the user typed is never touched. One taken from an
        // earlier page is, because a name describing a page no longer being
        // advertised is worse than no name.
        $this->renamePlaceholders($analysis);

        $suggestion = $analysis->suggestedOutcome();

        return $this->json(array_filter([
            'ok' => true,
            ...$analysis->toArray(),
            // Said plainly so the model repeats the reason rather than
            // presenting a guess as a finding.
            'looks_like' => $suggestion
                ? $suggestion['outcome'].', because '.$suggestion['because']
                : null,
            'note' => 'Say in one or two lines what the page is selling, then offer the next step. '
                .'Treat the suggestions as proposals to confirm, not as settled values.',
        ], fn (mixed $value): bool => $value !== null));
    }

    /** @param  array<string,mixed>  $payload */
    private function json(array $payload): string
    {
        return json_encode($payload, JSON_UNESCAPED_SLASHES);
    }
}
