<?php

namespace App\Agent\Tools\Campaign;

use App\Agent\Choices;
use App\Agent\Tools\Campaign\Concerns\ActsOnFocusedCampaign;
use App\Agent\Workspace;
use App\Campaigns\Briefs;
use App\Models\ArtifactFieldSource;
use App\Models\Campaign;
use Carbon\Carbon;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
use Throwable;

/** When the campaign in focus runs. */
class SetSchedule implements Tool
{
    use ActsOnFocusedCampaign;

    public function __construct(
        private readonly Workspace $workspace,
        private readonly Choices $choices,
        private readonly Briefs $briefs,
    ) {}

    public function description(): Stringable|string
    {
        return 'Set the start and end dates of the campaign in focus. Omit the end date to '
            .'run continuously. Dates are ISO 8601, for example 2026-10-01T09:00:00Z. Call '
            .'with no argument to offer end dates for the user to pick. '
            // The start had no stated default and no way to say "no date", so a
            // user answering "none" was asked again, four times in one
            // conversation on 25 Sep. Both are spelled out here because the
            // model reads this before it reads anything else.
            .'The start is optional: pass starts_at as null, or leave it out, and the campaign '
            .'starts as soon as it is approved. If the user answers none, no date, now, or as '
            .'soon as possible, pass null rather than asking again. '
            // "start oct 1" was answered with "Which year, start time, and time
            // zone should I use for October 1?" on arb-dev on 25 Sep: three
            // questions back for one date that had only one sensible reading.
            .'A date given without a year, a time or a time zone is complete. Pass it through '
            .'as written: the next occurrence of that date is assumed, at the start of the day, '
            .'in the ad account\'s time zone. Never ask which year, what time, or which time zone.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'starts_at' => $schema->string()->description(
                'When the campaign should start. Null means as soon as it is approved.'
            ),
            'ends_at' => $schema->string()->description(
                'When it should stop. Omit or pass null to run continuously.'
            ),
        ];
    }

    /**
     * End dates as lengths rather than dates.
     *
     * A buyer setting a lifetime budget is thinking "two weeks", not
     * "2026-09-25T00:00:00Z", and typing a date is the step most likely to be
     * got wrong. The real date is worked out here so the ad account's timezone
     * is never the user's problem.
     */
    private function offerEndDates(Campaign $campaign): void
    {
        $from = $campaign->starts_at ?: now();

        $lifetime = $campaign->usesLifetimeBudget();

        // Named where the name carries a reason, plain where it does not.
        //
        // Every length was "14 days", which tells a media buyer everything and
        // a first-time advertiser nothing: the question is not how many days
        // but what each length is for. Two of them have a well understood
        // purpose and now say it. The rest stay as lengths rather than being
        // given invented names, and 7, 60 and 90 stay on the list because
        // naming three options is not a reason to take the others away.
        $named = [
            14 => ['label' => '14-day test', 'why' => 'Long enough to learn what works before committing more.'],
            30 => ['label' => '30-day flight', 'why' => 'A full month, which is how most committed runs are bought.'],
        ];

        $options = collect([7, 14, 30, 60, 90])
            ->map(function (int $days) use ($from, $named, $lifetime): array {
                $ends = $from->copy()->addDays($days);

                return [
                    'value' => $ends->toIso8601String(),
                    'label' => $named[$days]['label'] ?? $days.' days',
                    'hint' => trim('Ends '.$ends->format('j M Y').'. '.($named[$days]['why'] ?? '')),
                    // Suggested only where there has to be an end date. With a
                    // daily budget the honest default is not committing to one.
                    'badge' => $lifetime && $days === 14 ? 'Suggested' : null,
                    'sends' => 'Run it for '.$days.' days',
                ];
            })->all();

        // Running until it is stopped is a real answer, and without it on screen
        // there was nothing to click that meant "no end date": the five lengths
        // were the whole list, so the step could not be finished except by
        // typing. Not offered against a lifetime budget, which Meta refuses
        // without an end time, and the publish gate refuses first.
        //
        // First and suggested when it is available, because a campaign with a
        // daily budget has no reason to carry an end date somebody invented to
        // clear the step, and an end date is the harder of the two to undo.
        if (! $lifetime) {
            array_unshift($options, [
                'value' => '',
                'label' => 'Continuous',
                'hint' => 'Runs until you pause it. No end date.',
                'badge' => 'Suggested',
                'sends' => 'Run it with no end date',
            ]);
        }

        $this->choices->offer(
            field: 'ends_at',
            question: $lifetime
                ? 'How long should it run? A lifetime budget is the total to spend by then.'
                : 'How long should it run?',
            options: $options,
        );
    }

    /**
     * The answers that mean "no date", which Carbon reads as a failure.
     *
     * "When should the campaign start?" was asked four times in one
     * conversation on 25 Sep because the user kept answering "none" and the
     * model kept passing it through, and Carbon::parse('none') throws, so the
     * reply was "[none] is not a date I can read" and the question came back.
     * The step could not be finished by answering it correctly.
     *
     * For a start date these all mean the same thing as leaving it out, which
     * is that it begins once approved. For an end date they mean it runs until
     * somebody stops it. Both are already stored as null, so the words only
     * need to reach the same place a cleared value does.
     *
     * Deliberately a short list of unambiguous answers rather than anything
     * clever. A date this does not recognise is still refused by name, because
     * guessing at "next quarter" is how a campaign starts on the wrong day.
     */
    /**
     * A date with the parts nobody should have to be asked for.
     *
     * Carbon reads "oct 1" as the first of October in the current year, which
     * is a date in the past for most of the year, and a campaign cannot start
     * in the past. The next occurrence is the only reading that makes sense of
     * a bare day and month, so a date that has already gone rolls forward a
     * year.
     *
     * Only when the year was not given. "1 October 2025" is a real answer, even
     * though it is behind us, and correcting it to 2026 would silently move a
     * campaign a year from where it was put; the publish gate refuses a past
     * start with a reason instead.
     */
    private function readDate(string $answer, string $field): Carbon
    {
        $date = Carbon::parse($answer);

        // A day with no year means the next one. Carbon reads "oct 1" as this
        // year's, which is behind us for most of the year, and no platform
        // takes a campaign starting in the past.
        //
        // Compared against the start of today, not against now. isPast() was
        // true for anything earlier in the current day, so "today" and
        // "midnight" parse to 00:00 and "9am" and "noon" to a time already
        // gone, and every one of them was pushed a full year forward: "start
        // today" booked a campaign for the same date in 2027. Raised on the PR;
        // none of my tests used a same-day answer.
        //
        // Only when the year was not typed. "1 October 2025" is a real answer
        // even though it has gone, and moving it to 2026 would shift a campaign
        // a year without saying so; the publish gate refuses a past start with
        // a reason instead.
        if (preg_match('/\d{4}/', $answer) !== 1 && $date->lt(now()->startOfDay())) {
            $date = $date->addYear();
        }

        // A day with no time is a whole day, not the moment the turn happened.
        //
        // Carbon fills the missing time from the clock, so "start 1 October"
        // asked at 14:37 produced a campaign starting at 14:37 on the 1st, and
        // an end date would have stopped it at 14:37 too. A start runs from the
        // beginning of its day and an end runs to the end of its day, which is
        // what "from the 1st until the 20th" means to the person who said it.
        if (preg_match('/\d{1,2}:\d{2}|\d\s*[ap]\.?m\.?/i', $answer) !== 1) {
            $date = $field === 'ends_at' ? $date->endOfDay() : $date->startOfDay();
        }

        return $date;
    }

    private function meansNoDate(string $answer): bool
    {
        return in_array(strtolower(trim($answer)), [
            'none', 'no', 'no date', 'not set', 'n/a', 'na', 'null',
            'now', 'asap', 'immediately', 'right away', 'as soon as possible',
            'continuous', 'continuously', 'ongoing', 'indefinite', 'indefinitely',
        ], true);
    }

    /**
     * Record the dates on the brief so no later platform has to ask.
     *
     * Only a real date is worth sharing. A brief holding null for both says
     * nothing a later platform can act on, and cannot be told from a brief
     * nobody has answered, so that case falls through to the question.
     *
     * Never overwritten: the brief keeps what was decided first.
     */
    private function rememberSchedule(Campaign $campaign): void
    {
        $brief = $this->briefs->current();

        if ($brief === null || $brief->starts_at || $brief->ends_at) {
            return;
        }

        if ($campaign->starts_at === null && $campaign->ends_at === null) {
            return;
        }

        $brief->update(['starts_at' => $campaign->starts_at, 'ends_at' => $campaign->ends_at]);
    }

    /**
     * Put the brief's dates on this campaign, if it has none of its own.
     *
     * Null when there is nothing to copy, or when this campaign has already
     * been asked: a recorded source means somebody answered, including when
     * they answered "no end date", and that answer outranks the brief's.
     *
     * @return array<string, string|null>|null what was applied
     */
    private function applyBriefSchedule(Campaign $campaign): ?array
    {
        $brief = $this->briefs->current();

        if ($brief === null || (! $brief->starts_at && ! $brief->ends_at)) {
            return null;
        }

        $sources = $this->workspace->sourcesFor($campaign);

        if ($campaign->starts_at || $campaign->ends_at
            || array_key_exists('starts_at', $sources) || array_key_exists('ends_at', $sources)) {
            return null;
        }

        // Only the fields the brief actually answered.
        //
        // Copying both and recording both marked the end date as answered when
        // the brief held only a start, so the step was complete and the end date
        // was never asked for. On a lifetime budget that is a deadlock: the gate
        // refuses a lifetime total with no end date, and no step was left to
        // supply one. The same trap as the LinkedIn pixel, built by hand.
        $applied = [];

        foreach (['starts_at', 'ends_at'] as $field) {
            if ($brief->{$field} === null) {
                continue;
            }

            $campaign->{$field} = $brief->{$field};
            $applied[$field] = $brief->{$field}->toIso8601String();
        }

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

        $campaign->save();

        foreach (array_keys($applied) as $field) {
            $this->workspace->recordSource($campaign, $field, ArtifactFieldSource::USER_STATED);
        }

        return ['campaign_id' => $campaign->id, ...$applied];
    }

    public function handle(Request $request): Stringable|string
    {
        if (! $campaign = $this->focused()) {
            return $this->noCampaign();
        }

        $arguments = $request->all();

        // Key presence, not blankness: passing ends_at as null is how a date is
        // cleared, and that must stay a clear rather than become a question.
        if (! array_key_exists('starts_at', $arguments) && ! array_key_exists('ends_at', $arguments)) {
            // Already answered for this brief, on another platform.
            //
            // Applied and recorded rather than left to the brief fallback,
            // which cannot serve dates: a null campaign date means both "never
            // asked" and "answered no end date", and blank() cannot tell them
            // apart, so a campaign set to run continuously would inherit an end
            // date nobody chose for it. Writing a real value here removes the
            // ambiguity instead of working around it.
            if ($applied = $this->applyBriefSchedule($campaign)) {
                return $this->json([
                    'ok' => true,
                    ...$applied,
                    'from_brief' => true,
                    'note' => 'The dates are the ones already set for this brief. Say so in one short line, '
                        .'mention they can change them, and move on.',
                ]);
            }

            $this->offerEndDates($campaign);

            return $this->json([
                'ok' => true,
                'note' => 'End dates are on screen. Say one short line and wait.',
            ]);
        }

        $changed = [];

        foreach (['starts_at', 'ends_at'] as $field) {
            if (! array_key_exists($field, $arguments)) {
                continue;
            }

            if (blank($arguments[$field])) {
                $campaign->{$field} = null;
                $changed[$field] = null;

                // Recorded even though the value is null, because "no end date"
                // is an answer. The flow reads this to tell it apart from never
                // having been asked, which is the only reason a campaign that
                // runs continuously can finish the schedule step.
                $this->workspace->recordSource($campaign, $field, ArtifactFieldSource::USER_STATED);

                continue;
            }

            if ($this->meansNoDate((string) $arguments[$field])) {
                $campaign->{$field} = null;
                $changed[$field] = null;
                $this->workspace->recordSource($campaign, $field, ArtifactFieldSource::USER_STATED);

                continue;
            }

            try {
                $campaign->{$field} = $this->readDate((string) $arguments[$field], $field);
            } catch (Throwable) {
                return $this->json(['ok' => false, 'error' => "[{$arguments[$field]}] is not a date I can read."]);
            }

            $changed[$field] = $campaign->{$field}->toIso8601String();
            $this->workspace->recordSource($campaign, $field, ArtifactFieldSource::USER_STATED);
        }

        if ($changed === []) {
            return $this->json(['ok' => false, 'error' => 'Pass a start date, an end date, or both.']);
        }

        if ($campaign->starts_at && $campaign->ends_at && $campaign->ends_at->lte($campaign->starts_at)) {
            return $this->json(['ok' => false, 'error' => 'The end date must be after the start date.']);
        }

        $campaign->save();

        $this->rememberSchedule($campaign);

        return $this->json(['ok' => true, 'campaign_id' => $campaign->id, ...$changed]);
    }
}
