<?php

namespace FluentCrm\App\Modules\MCP\Tools;

use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\CampaignUrlMetric;
use FluentCrm\App\Models\Subject;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;

/**
 * Campaign-centric MCP tools.
 *
 * Read tools (Phase 2): listCampaigns, getCampaign.
 * Write tools (Phase 3): upsertCampaign, changeCampaignStatus.
 *
 * Reads query the Campaign model directly (matching CampaignController's read
 * paths) and shape the result through MCPHelper formatters.
 */
class CampaignTools
{
    // -----------------------------------------------------------------
    // Read: list-campaigns
    // -----------------------------------------------------------------

    public static function listCampaigns($params)
    {
        $params = (array) $params;

        $pagination = MCPHelper::paginationFromInput($params);

        $search = sanitize_text_field((string) ($params['search'] ?? ''));
        $statuses = (array) ($params['statuses'] ?? []);
        $statuses = array_values(array_filter(array_map('sanitize_key', $statuses)));

        // All fc_campaigns columns. The framework rewrite made orderBy() throw
        // LogicException on column names that don't match ^[a-zA-Z0-9_\.]+$
        // — empty strings, "id ASC", "DROP TABLE", etc. — so an unguarded
        // sort_by would 500 the tool. Schema is stable (migration only adds
        // indexes), so hardcoding the column list avoids a per-request
        // SHOW COLUMNS without restricting agents to the input_schema enum.
        $allowedSortBy = [
            'id', 'parent_id', 'type', 'title', 'available_urls', 'slug',
            'status', 'template_id', 'email_subject', 'email_pre_header',
            'email_body', 'recipients_count', 'delay', 'utm_status',
            'utm_source', 'utm_medium', 'utm_campaign', 'utm_term',
            'utm_content', 'design_template', 'scheduled_at', 'settings',
            'created_by', 'created_at', 'updated_at',
        ];
        $sortBy = sanitize_key((string) ($params['sort_by'] ?? 'created_at'));
        if (!in_array($sortBy, $allowedSortBy, true)) {
            $sortBy = 'created_at';
        }
        $sortType = strtoupper(sanitize_text_field((string) ($params['sort_type'] ?? 'DESC')));
        $sortType = in_array($sortType, ['ASC', 'DESC'], true) ? $sortType : 'DESC';
        // Stats add several aggregate queries per campaign. Keep title/status
        // scans cheap by default; callers that need engagement data opt in.
        $includeStats = array_key_exists('include_stats', $params)
            ? (bool) $params['include_stats']
            : false;
        // One-off sends (created by send-email-to-contact) live in
        // fc_campaigns with type='custom_email_campaign'. The Campaign
        // model's global scope hides them by default; let agents opt in
        // when they want a unified "what did I send recently" view.
        $includeOneOffs = !empty($params['include_one_offs']);

        if ($includeOneOffs) {
            $query = Campaign::withoutGlobalScope('type')
                ->whereIn('type', ['campaign', 'custom_email_campaign'])
                ->orderBy($sortBy, $sortType);
        } else {
            $query = Campaign::query()->orderBy($sortBy, $sortType);
        }

        if ($search !== '') {
            global $wpdb;
            $query->where('title', 'LIKE', '%' . $wpdb->esc_like($search) . '%');
        }

        if (!empty($statuses)) {
            $query->whereIn('status', $statuses);
        }

        $paginated = $query->paginate();

        $items = [];
        foreach ($paginated->items() as $campaign) {
            $items[] = MCPHelper::formatCampaignSummary($campaign, $includeStats);
        }

        return [
            'items'    => $items,
            'total'    => (int) $paginated->total(),
            'page'     => (int) $paginated->currentPage(),
            'per_page' => (int) $paginated->perPage(),
            'pages'    => (int) $paginated->lastPage(),
        ];
    }

    // -----------------------------------------------------------------
    // Read: get-campaign
    // -----------------------------------------------------------------

    public static function getCampaign($params)
    {
        $params = (array) $params;
        $campaignId = (int) ($params['campaign_id'] ?? 0);

        if (!$campaignId) {
            return MCPHelper::error('invalid_param', __('campaign_id is required', 'fluent-crm'));
        }

        // Bypass the type global scope so one-off sends (created by
        // send-email-to-contact) are reachable too. Without this, an id
        // returned by list-campaigns(include_one_offs=true) couldn't be
        // fetched here — operator-test report 2026-05-07 #4.
        $campaign = Campaign::withoutGlobalScope('type')->find($campaignId);
        if (!$campaign) {
            return MCPHelper::error('not_found', __('Campaign not found', 'fluent-crm'), ['campaign_id' => $campaignId]);
        }

        // Resolve body_format before the type branch so one-off emails honor
        // it too (the early return used to skip it). text|html|both — default
        // both for backward compatibility. Default resolved before the enum
        // test so an omitted key doesn't warn (see ContactTools::getContact).
        $bodyFormat = $params['body_format'] ?? 'both';
        if (!in_array($bodyFormat, ['text', 'html', 'both'], true)) {
            $bodyFormat = 'both';
        }

        if ($campaign->type === 'custom_email_campaign') {
            return self::formatOneOffEmail($campaign, $bodyFormat);
        }

        $defaultIncludes = ['stats'];
        // array_key_exists: omitted => defaults, [] => none, explicit => those.
        if (array_key_exists('include', $params) && is_array($params['include'])) {
            $include = array_values(array_intersect($params['include'], ['stats', 'subjects', 'link_report', 'recipients_estimate']));
        } else {
            $include = $defaultIncludes;
        }

        $body = (string) $campaign->email_body;
        $bodyShape = MCPHelper::dualBodyShape($body, $bodyFormat);

        $settings = is_array($campaign->settings) ? $campaign->settings : (array) $campaign->settings;

        $recipients = self::recipientsForOutput($settings, 'subscribers');
        $recipientsExcluded = self::recipientsForOutput($settings, 'excludedSubscribers');
        $recipients = [
            'lists'         => $recipients['lists'],
            'tags'          => $recipients['tags'],
            'exclude_lists' => $recipientsExcluded['lists'],
            'exclude_tags'  => $recipientsExcluded['tags'],
        ];

        $sentStatuses = ['archived', 'working', 'paused'];
        $sentAt = in_array($campaign->status, $sentStatuses, true)
            ? MCPHelper::toIso8601($campaign->updated_at)
            : null;

        // Merge $bodyShape in the middle so an unrequested body key is omitted
        // entirely (not emitted as null) when body_format narrows the output.
        $data = array_merge([
            'id'                => (int) $campaign->id,
            'title'             => $campaign->title,
            'email_subject'     => $campaign->email_subject,
            'email_pre_header'  => $campaign->email_pre_header,
            'status'            => $campaign->status,
            'design_template'   => $campaign->design_template,
        ], $bodyShape, [
            'settings'          => self::settingsForOutput($settings),
            'recipients'        => $recipients,
            'scheduled_at'      => MCPHelper::toIso8601($campaign->scheduled_at),
            'scheduled_at_resolved' => MCPHelper::formatScheduledAtDual($campaign->scheduled_at),
            'sent_at'           => $sentAt,
            'created_at'        => MCPHelper::toIso8601($campaign->created_at),
        ]);

        if (in_array('stats', $include, true)) {
            $data['stats'] = MCPHelper::campaignStatsCompact($campaign);
            $data['stats']['revenue'] = self::campaignRevenue($campaign);
        }

        if (in_array('subjects', $include, true)) {
            $data['subjects'] = self::campaignSubjects($campaign);
        }

        if (in_array('link_report', $include, true)) {
            $data['link_report'] = self::campaignLinkReport($campaign);
        }

        if (in_array('recipients_estimate', $include, true)) {
            $data['recipients_estimate'] = self::campaignRecipientsEstimate($campaign);
        }

        return $data;
    }

    /**
     * Render a one-off send (`type=custom_email_campaign`) for get-campaign.
     * One-offs don't have a marketing lifecycle — the row's `status` column is
     * written once at creation ('published') and never advances, so it says
     * nothing about delivery. The real status lives on the single
     * fc_campaign_emails row, so surface it as `one_off_status` along with the
     * recipient. Operator-test report 2026-05-07 #4.
     *
     * Note: rows created before the status fix carry 'draft'. Nothing here
     * reads $campaign->status, so those keep rendering correctly.
     */
    private static function formatOneOffEmail($campaign, $bodyFormat = 'both')
    {
        $body      = (string) $campaign->email_body;
        $bodyShape = MCPHelper::dualBodyShape($body, $bodyFormat);

        // The one and only campaign-email row for this send.
        $email = \FluentCrm\App\Models\CampaignEmail::withoutGlobalScope('type')
            ->where('campaign_id', $campaign->id)
            ->orderBy('id', 'DESC')
            ->first();

        $oneOffStatus = $email ? (string) $email->status : 'unknown';
        $sentAt       = $email && in_array($email->status, ['sent', 'opened', 'clicked'], true)
            ? MCPHelper::toIso8601($email->updated_at)
            : null;

        // The recipient block is contact PII and this tool is gated on
        // `fcrm_read_emails` alone, so an email-only manager must not read
        // identities out of it — same boundary render-email-preview enforces by
        // demanding both caps. Skip the lookup entirely when unauthorized (one
        // less query) and still confirm that a recipient exists. PR #2026
        // review finding 2.
        $canReadContacts = \FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_read_contacts');

        $recipient = null;
        if ($email && $email->subscriber_id) {
            if (!$canReadContacts) {
                $recipient = ['note' => __('Recipient hidden — requires contact read permission.', 'fluent-crm')];
            } else {
                $sub = \FluentCrm\App\Models\Subscriber::find($email->subscriber_id);
                if ($sub) {
                    $recipient = [
                        'id'    => (int) $sub->id,
                        'email' => $sub->email,
                        'full_name' => trim((string) $sub->full_name),
                    ];
                }
            }
        }

        $settings = is_array($campaign->settings) ? $campaign->settings : (array) $campaign->settings;

        return array_merge([
            'id'                => (int) $campaign->id,
            'kind'              => 'one_off_email',
            'title'             => MCPHelper::oneOffTitleForOutput($campaign->title),
            'email_subject'     => $campaign->email_subject,
            'email_pre_header'  => $campaign->email_pre_header,
            'one_off_status'    => $oneOffStatus,
            'design_template'   => $campaign->design_template,
        ], $bodyShape, [
            'settings'          => self::settingsForOutput($settings),
            'recipient'         => $recipient,
            'sent_at'           => $sentAt,
            'created_at'        => MCPHelper::toIso8601($campaign->created_at),
            'note'              => __('This is a one-off send (not a marketing campaign). It supports change-campaign-status action=delete only — schedule/pause/resume/duplicate are not applicable.', 'fluent-crm'),
        ]);
    }

    /**
     * Reverse the flat [{list, tag}] storage back into the agent-friendly
     * {lists:[{id,title}], tags:[{id,title}]} shape for output. Items with
     * 'all' on either side are treated as wildcards (omitted from the
     * matching collection). Distinct ids only.
     */
    private static function recipientsForOutput($settings, $key)
    {
        $items = $settings[$key] ?? [];
        if (!is_array($items)) {
            return ['lists' => [], 'tags' => []];
        }

        $listIds = [];
        $tagIds  = [];

        foreach ($items as $item) {
            if (!is_array($item)) {
                continue;
            }
            $listId = $item['list'] ?? null;
            $tagId  = $item['tag'] ?? null;
            if ($listId !== null && $listId !== '' && $listId !== 'all' && is_numeric($listId)) {
                $listIds[(int) $listId] = true;
            }
            if ($tagId !== null && $tagId !== '' && $tagId !== 'all' && is_numeric($tagId)) {
                $tagIds[(int) $tagId] = true;
            }
        }

        $listsOut = [];
        if ($listIds) {
            foreach (\FluentCrm\App\Models\Lists::whereIn('id', array_keys($listIds))->get() as $list) {
                $listsOut[] = ['id' => (int) $list->id, 'title' => $list->title];
            }
        }
        $tagsOut = [];
        if ($tagIds) {
            foreach (\FluentCrm\App\Models\Tag::whereIn('id', array_keys($tagIds))->get() as $tag) {
                $tagsOut[] = ['id' => (int) $tag->id, 'title' => $tag->title];
            }
        }

        return ['lists' => $listsOut, 'tags' => $tagsOut];
    }

    private static function settingsForOutput($settings)
    {
        return [
            'mailer_settings'  => $settings['mailer_settings'] ?? new \stdClass(),
            'is_transactional' => $settings['is_transactional'] ?? 'no',
            'click_tracker'    => $settings['click_tracker'] ?? 'yes',
            'open_tracker'     => $settings['open_tracker'] ?? 'yes',
        ];
    }

    /**
     * Revenue meta is shaped like ['orderIds' => [1,2,3], 'usd' => 12450,
     * 'eur' => 600] — currency keys are interleaved with the
     * 'orderIds' tracking array. Iterating naïvely and grabbing the first
     * key produced `currency: "orderIds"` and a wrong amount (round-4
     * review P1 #7). Skip the meta key, sum numeric values across all
     * currency entries, and surface the order count.
     */
    private static function campaignRevenue($campaign)
    {
        $revenue = fluentcrm_get_campaign_meta($campaign->id, '_campaign_revenue', true);
        if (!is_array($revenue)) {
            return ['total' => 0, 'currency' => '', 'orders_count' => 0, 'by_currency' => []];
        }

        $orderCount = isset($revenue['orderIds']) && is_array($revenue['orderIds'])
            ? count($revenue['orderIds'])
            : 0;

        $byCurrency = [];
        foreach ($revenue as $key => $value) {
            if ($key === 'orderIds' || !is_numeric($value)) {
                continue;
            }
            $byCurrency[strtoupper((string) $key)] = (float) $value;
        }

        // Pick a primary currency for the headline number (first one set
        // wins; multi-currency installs surface the rest in by_currency).
        $primaryCurrency = '';
        $primaryTotal    = 0.0;
        if ($byCurrency) {
            $primaryCurrency = (string) array_key_first($byCurrency);
            $primaryTotal    = (float) $byCurrency[$primaryCurrency];
        }

        return [
            'total'        => $primaryTotal,
            'currency'     => $primaryCurrency,
            'orders_count' => $orderCount,
            'by_currency'  => $byCurrency,
        ];
    }

    /**
     * A/B subjects live in fc_meta keyed by object_id (NOT campaign_id —
     * that column doesn't exist on fc_meta). The Subject model's global
     * scope handles the object_type filter.
     *
     * Storage shape (per Campaign::syncSubjects): `key` is a stable
     * identifier, `value` is the subject string itself. Operator-test
     * report 2026-05-07 #7 — the read path previously assumed `value`
     * was a serialized {email_subject, weight} array (which it isn't),
     * so the response always came back empty even after a successful
     * upsert.
     */
    private static function campaignSubjects($campaign)
    {
        $subjects = Subject::where('object_id', $campaign->id)->get();
        $out = [];
        foreach ($subjects as $subject) {
            $out[] = [
                'id'      => (int) $subject->id,
                'key'     => (string) $subject->key,
                'value'   => (string) $subject->value,
            ];
        }
        return $out;
    }

    /**
     * Link report — delegate to CampaignUrlMetric::getLinksReport, which
     * joins fc_campaign_url_metrics → fc_url_stores correctly (the metrics
     * table only stores url_id; the URL string lives in fc_url_stores).
     */
    private static function campaignLinkReport($campaign)
    {
        $metric = new CampaignUrlMetric();
        $links  = method_exists($metric, 'getLinksReport') ? $metric->getLinksReport($campaign) : [];

        $formatted = [];
        foreach ((array) $links as $link) {
            $formatted[] = [
                'url'          => $link['url'] ?? '',
                'total_clicks' => (int) ($link['total'] ?? 0),
            ];
        }

        return [
            'links'        => $formatted,
            'click_status' => method_exists($campaign, 'getClickTrackingStatus') ? $campaign->getClickTrackingStatus(false) : 'yes',
            'open_status'  => method_exists($campaign, 'getOpenTrackingStatus') ? $campaign->getOpenTrackingStatus(false) : 'yes',
        ];
    }

    /**
     * Find the first available title shaped like "Title (2)", "Title (3)", …
     * given a base title that already exists. Mirrors what CampaignController
     * ::ensureUniqueDefaultTitle does for the "Untitled" placeholder, but
     * for any agent-supplied title.
     */
    private static function nextAvailableTitle($baseTitle)
    {
        $count = 2;
        while ($count < 1000) {
            $candidate = $baseTitle . ' (' . $count . ')';
            if (!Campaign::where('title', $candidate)->exists()) {
                return $candidate;
            }
            $count++;
        }
        // Astronomical fallback — append a timestamp to guarantee uniqueness.
        return $baseTitle . ' (' . time() . ')';
    }

    /**
     * Estimate recipients for a campaign using its stored segment settings.
     *
     * Routes through the same `Campaign::getSubscriberIdsCountBySegmentSettings`
     * helper as `upsert-campaign.estimated_recipients` and the underlying
     * estimator behind `estimate-dynamic-segment` (via ContactsQuery's filter
     * translation) — so all three callers converge on the same number for the
     * same filter (review bug #4).
     */
    private static function campaignRecipientsEstimate($campaign)
    {
        if (!in_array($campaign->status, ['draft', 'scheduled', 'pending-scheduled'], true)) {
            return null;
        }

        $settings = is_array($campaign->settings) ? $campaign->settings : (array) maybe_unserialize($campaign->settings);

        $start = microtime(true);
        $count = self::estimateRecipientsFromSettings($settings);
        $execMs = (int) round((microtime(true) - $start) * 1000);

        return [
            'count'             => $count !== null ? (int) $count : 0,
            'execution_time_ms' => $execMs,
        ];
    }

    // -----------------------------------------------------------------
    // Write: upsert-campaign
    // -----------------------------------------------------------------

    public static function upsertCampaign($params)
    {
        $params = (array) $params;
        $campaignId = isset($params['campaign_id']) ? (int) $params['campaign_id'] : 0;

        $isNew = !$campaignId;
        $campaign = null;

        if (!$isNew) {
            $campaign = Campaign::find($campaignId);
            if (!$campaign) {
                return MCPHelper::error('not_found', __('Campaign not found', 'fluent-crm'), ['campaign_id' => $campaignId]);
            }
            $editableStatuses = ['draft', 'scheduled', 'pending-scheduled'];
            if (!in_array($campaign->status, $editableStatuses, true)) {
                return MCPHelper::error('not_supported', __('Cannot edit campaign in current status', 'fluent-crm'), [
                    'status'           => $campaign->status,
                    'allowed_statuses' => $editableStatuses,
                ]);
            }
        }

        // Title is required for create.
        $title = isset($params['title']) ? sanitize_text_field((string) $params['title']) : '';
        if ($isNew && $title === '') {
            return MCPHelper::error('invalid_param', __('title is required when creating a campaign', 'fluent-crm'));
        }

        // Pre-flight validation. Must run before Campaign::create() so a
        // bad recipients shape / design_template / unsupported keys never
        // leaves an orphaned draft row behind (operator-test report
        // 2026-05-07 #2).
        $validation = self::validateUpsertInput($params);
        if (is_wp_error($validation)) {
            return $validation;
        }

        // TRC-006: validate + sanitize the settings closed shapes BEFORE the
        // draft is created, then merge the sanitized value (not raw params)
        // below. A late settings failure must not leave an orphan draft.
        $sanitizedSettings = MCPHelper::sanitizeSettingsShape($params['settings'] ?? null);
        if (is_wp_error($sanitizedSettings)) {
            return $sanitizedSettings;
        }
        $params['settings'] = $sanitizedSettings;

        $titleConflictWarning = null;
        if ($isNew) {
            // Title uniqueness: by default we auto-suffix on conflict so
            // an agent retrying after a transient error doesn't get stuck.
            // Round 2 #10: when we suffix, surface a warning so the agent
            // can tell the user "I created a copy named X". Strict mode
            // (if_exists='error') keeps the hard-error behavior.
            $ifExists = isset($params['if_exists']) ? sanitize_key((string) $params['if_exists']) : 'auto_suffix';
            $originalTitle = $title;
            if ($title !== '' && Campaign::where('title', $title)->exists()) {
                if ($ifExists === 'error') {
                    return MCPHelper::error('invalid_param', __('A campaign with that title already exists', 'fluent-crm'), [
                        'title' => $title,
                    ]);
                }
                $existingId = (int) Campaign::where('title', $title)->value('id');
                $title = self::nextAvailableTitle($title);
                $titleConflictWarning = sprintf(
                    /* translators: 1: requested title, 2: existing campaign id, 3: actual title used */
                    __('A campaign with title "%1$s" already exists (id %2$d). Created this one as "%3$s" instead. Pass if_exists="error" to make conflicts a hard failure.', 'fluent-crm'),
                    $originalTitle,
                    $existingId,
                    $title
                );
            }
            $campaign = Campaign::create(['title' => $title]);
            do_action('fluent_crm/campaign_created', $campaign);

            // If we auto-suffixed, sync the new title back into $params so
            // the passthru loop below doesn't overwrite the suffix with the
            // original (review B4 round 3). Without this, the warning was
            // lying — it said "Created as X (2)" but the saved title was
            // still "X" because passthru clobbered it.
            if ($titleConflictWarning) {
                $params['title'] = $title;
            }
        }

        // Build the update payload.
        $updateData = [];
        $passthru = ['title', 'email_subject', 'email_pre_header', 'email_body', 'design_template'];
        foreach ($passthru as $field) {
            if (array_key_exists($field, $params) && $params[$field] !== null) {
                $updateData[$field] = $params[$field];
            }
        }

        // design_template was already validated by validateUpsertInput()
        // above; sanitize_key here is just to normalize for storage.
        if (isset($updateData['design_template']) && $updateData['design_template'] !== '') {
            $updateData['design_template'] = sanitize_key((string) $updateData['design_template']);
        }

        // Shared allowlist with send-email-to-contact — unknown utm keys were
        // already rejected in validateUpsertInput(); this no longer loops
        // arbitrary keys into utm_<anything> columns (TRC-006 gate 4).
        if (!empty($params['utm']) && is_array($params['utm'])) {
            $utm = $params['utm'];
            if (array_key_exists('status', $utm)) {
                $updateData['utm_status'] = !empty($utm['status']) ? 1 : 0;
            }
            foreach (MCPHelper::utmAllowedKeys() as $key) {
                if ($key === 'status') {
                    continue;
                }
                if (isset($utm[$key])) {
                    $updateData['utm_' . $key] = sanitize_text_field((string) $utm[$key]);
                }
            }
        }

        // Settings — merge into existing.
        $settings = $campaign->settings ?: [];
        if (is_string($settings)) {
            $settings = (array) maybe_unserialize($settings);
        }

        if (!empty($params['settings']) && is_array($params['settings'])) {
            $settings = array_replace_recursive((array) $settings, $params['settings']);
        }

        // Recipients — universal filter shape lands in `settings.subscribers`.
        // Shape was already validated by validateUpsertInput(). Campaigns
        // can ONLY persist tags + lists, so anything else would silently
        // disappear (round-4 review P0 #1 — 3.5x audience inflation).
        if (!empty($params['recipients']) && is_array($params['recipients'])) {
            $settings['subscribers'] = self::filterToCampaignSegment($params['recipients']);
        }
        if (!empty($params['exclude_recipients']) && is_array($params['exclude_recipients'])) {
            $settings['excludedSubscribers'] = self::filterToCampaignSegment($params['exclude_recipients']);
        }

        // If the agent passed a top-level design_template, propagate it into
        // settings.template_config so the two stay in sync. Only fall the
        // other direction (template_config -> top-level) when the agent did
        // NOT specify design_template — otherwise the boot's default
        // template_config (set to 'simple' on this install) would clobber
        // the agent's choice (round 2 #29).
        if (isset($updateData['design_template']) && $updateData['design_template'] !== '') {
            if (!isset($settings['template_config']) || !is_array($settings['template_config'])) {
                $settings['template_config'] = [];
            }
            $settings['template_config']['design_template'] = $updateData['design_template'];
        } elseif (!empty($settings['template_config']['design_template'])) {
            $updateData['design_template'] = $settings['template_config']['design_template'];
        }

        $updateData['settings'] = $settings;
        $updateData = \FluentCrm\App\Services\Sanitize::campaign($updateData);

        $campaign->fill($updateData)->save();

        // Subjects (A/B): syncSubjects requires {key, value}; the agent
        // can pass {value} alone (key auto-generated). Items missing a
        // value are dropped by syncSubjects, so prepare a clean payload
        // here. Operator-test report 2026-05-07 #7.
        if (!empty($params['subjects']) && is_array($params['subjects']) && method_exists($campaign, 'syncSubjects')) {
            $prepared = [];
            foreach (array_values($params['subjects']) as $i => $s) {
                if (!is_array($s) || empty($s['value'])) {
                    continue;
                }
                $value = sanitize_text_field((string) $s['value']);
                $key   = !empty($s['key']) ? sanitize_text_field((string) $s['key']) : substr(md5($value . '|' . $i), 0, 16);
                $item  = ['key' => $key, 'value' => $value];
                if (!empty($s['id'])) {
                    $item['id'] = (int) $s['id'];
                }
                $prepared[] = $item;
            }
            if ($prepared) {
                $campaign->syncSubjects($prepared);
            }
        }

        if (!empty($params['label_ids']) && is_array($params['label_ids']) && method_exists($campaign, 'attachLabels')) {
            $campaign->attachLabels(array_map('intval', $params['label_ids']));
        }

        do_action('fluent_crm/campaign_data_updated', $campaign, $params);

        // Estimate recipients if a segment was provided so the agent sees the
        // count in the same call.
        $estimated = null;
        $warnings = [];
        if (!empty($settings['subscribers']) || !empty($settings['excludedSubscribers'])) {
            $estimated = self::estimateRecipientsFromSettings($settings);
        }

        if (empty($settings['excludedSubscribers'])) {
            $warnings[] = __('No exclude_recipients set — campaign may include unsubscribed segments', 'fluent-crm');
        }
        if (empty($params['email_pre_header']) && empty($campaign->email_pre_header)) {
            $warnings[] = __('email_pre_header not set — better deliverability with a preheader', 'fluent-crm');
        }
        if ($titleConflictWarning) {
            $warnings[] = $titleConflictWarning;
        }

        $campaign = Campaign::find($campaign->id);

        return [
            'ok'                   => true,
            'action'               => $isNew ? 'created' : 'updated',
            'campaign'             => self::getCampaign(['campaign_id' => (int) $campaign->id, 'include' => ['stats']]),
            'estimated_recipients' => $estimated,
            'warnings'             => $warnings,
        ];
    }

    /**
     * Pre-flight validation for upsert-campaign — must run BEFORE any
     * Campaign row is created so a bad payload never leaves an orphan
     * draft (operator-test report 2026-05-07 #2). Centralizes:
     *
     *   - design_template enum (free includes only, no visual_builder)
     *   - recipients shape (lists/tags/sending_filter only)
     *   - exclude_recipients shape (same constraint)
     *
     * @return true|\WP_Error
     */
    private static function validateUpsertInput($params)
    {
        if (isset($params['design_template']) && $params['design_template'] !== null && $params['design_template'] !== '') {
            $designTemplate = sanitize_key((string) $params['design_template']);
            $allowed = array_keys(\FluentCrm\App\Modules\MCP\Tools\ContextTools::allowedDesignTemplates());
            if (!in_array($designTemplate, $allowed, true)) {
                return MCPHelper::error('invalid_param', __('design_template not allowed via MCP', 'fluent-crm'), [
                    'design_template' => $designTemplate,
                    'allowed'         => $allowed,
                ]);
            }
        }

        // A present-but-non-object recipients/exclude would slip past the
        // is_array guards below and be silently ignored — hard-error instead
        // (direct REST callers bypass the adapter type check).
        foreach (['recipients', 'exclude_recipients'] as $rk) {
            if (array_key_exists($rk, $params) && $params[$rk] !== null && !is_array($params[$rk])) {
                return MCPHelper::invalidTypeError($rk, 'an object');
            }
        }

        if (!empty($params['recipients']) && is_array($params['recipients'])) {
            $reject = self::rejectUnsupportedRecipientKeys($params['recipients'], 'recipients');
            if (is_wp_error($reject)) {
                return $reject;
            }
        }

        if (!empty($params['exclude_recipients']) && is_array($params['exclude_recipients'])) {
            $reject = self::rejectUnsupportedRecipientKeys($params['exclude_recipients'], 'exclude_recipients');
            if (is_wp_error($reject)) {
                return $reject;
            }
        }

        // The `settings` passthrough is deliberately extensible and is merged
        // with array_replace_recursive, so audience-targeting keys smuggled
        // through it would bypass EVERY recipient check above — the rejection
        // of unsupported `recipients` keys, and filterToCampaignSegment()'s
        // id-resolution. Verified: settings={sending_filter:'dynamic_segment',
        // dynamic_segment:{id:99}} persisted verbatim and flipped the campaign's
        // targeting mode to a segment MCP never validated; settings.subscribers
        // writes the recipient list raw when no `recipients` param is present.
        // Same blast radius as the round-4 P0 audience-inflation bug, so these
        // keys are refused wherever they appear.
        if (!empty($params['settings']) && is_array($params['settings'])) {
            $targeting = ['subscribers', 'excludedSubscribers', 'sending_filter', 'dynamic_segment', 'advanced_filters'];
            $smuggled  = array_values(array_intersect(array_keys($params['settings']), $targeting));
            if ($smuggled) {
                return MCPHelper::error('invalid_param', sprintf(
                    /* translators: %s: comma-separated rejected setting keys */
                    __('settings may not carry audience-targeting keys: %s. Recipients are set through the `recipients` / `exclude_recipients` parameters (tags + lists only) so they can be validated; writing them through settings would skip that entirely. To target a status/engagement/contact-type segment, apply a temporary tag via apply-segments-to-contacts (dry_run first), use that tag in recipients, then remove it after the send.', 'fluent-crm'),
                    implode(', ', $smuggled)
                ), [
                    'rejected_setting_keys' => $smuggled,
                    'use_instead'           => ['recipients', 'exclude_recipients'],
                ]);
            }
        }

        // TRC-006: reject unknown/mistyped utm keys before any write. settings
        // are validated + sanitized in upsertCampaign so the sanitized value is
        // what gets merged (not the raw params).
        $utmError = MCPHelper::validateUtm($params);
        if (is_wp_error($utmError)) {
            return $utmError;
        }

        return true;
    }

    /**
     * Campaigns can target lists + tags only. The `recipients` object on
     * upsert-campaign accepts the universal filter shape but anything
     * other than {tags, lists, sending_filter} is silently dropped on
     * persistence — and previously that drop didn't even produce a
     * warning. Hard-error now so the agent can take the documented
     * workaround (apply a temporary tag, target by tag, remove after
     * send).
     *
     * Round-4 review P0 #1 — single most damaging bug, 3.5x audience
     * inflation in one test.
     *
     * `sending_filter` is NOT in the supported set either: filterToCampaignSegment()
     * only reads tags + lists, so accepting it would have been the same silent
     * drop this method exists to prevent. Campaign targeting through MCP is
     * list/tag only by design.
     *
     * @param array  $recipients
     * @param string $paramName Either 'recipients' or 'exclude_recipients'
     * @return true|\WP_Error
     */
    private static function rejectUnsupportedRecipientKeys($recipients, $paramName)
    {
        $supported = ['lists', 'tags'];
        $unsupported = array_values(array_diff(array_keys($recipients), $supported));
        if (!empty($unsupported)) {
            return MCPHelper::error('invalid_param', sprintf(
                /* translators: 1: parameter name, 2: comma-separated unsupported keys */
                __('%1$s only persists lists + tags on a campaign — these keys would be silently dropped: %2$s. To target a status/engagement/contact-type segment, apply a temporary tag via apply-segments-to-contacts (use dry_run first), use that tag in recipients, then remove it after the send completes.', 'fluent-crm'),
                $paramName,
                implode(', ', $unsupported)
            ), [
                'unsupported_keys' => $unsupported,
                'supported_keys'   => $supported,
                // Step 4 is deliberately gated on the SEND finishing, not on
                // scheduling. change-campaign-status(action=schedule) only
                // records the intent — recipients are materialized later by the
                // cron processor, so detaching the tag right after scheduling
                // shrinks the audience before it has been computed and the
                // campaign goes out to a fraction of the intended list.
                'workaround'       => '1) apply-segments-to-contacts(filter={...}, add_tags=["temp-X"], dry_run=true); 2) re-run without dry_run; 3) upsert-campaign(recipients={tags:["temp-X"]}); 4) change-campaign-status(action=schedule), then WAIT until get-campaign reports status=archived (recipients are materialized by the cron processor after scheduling — removing the tag before that shrinks the audience mid-send); 5) apply-segments-to-contacts(filter={tags:["temp-X"]}, remove_tags=["temp-X"]); 6) manage-tag(action=delete, tag_id=X)',
            ]);
        }
        return true;
    }

    /**
     * Translate the universal filter shape to the flat
     * [{list, tag}] array that Campaign settings.subscribers expects
     * (see Campaign::getSubscribeIdsByListModel — its loop reads
     * `$item['list']` and `$item['tag']` directly).
     *
     * Semantics match ContactsQuery:
     *   - tags only        → [{list:'all', tag:N}, ...]            (tag IN [...])
     *   - lists only       → [{list:N, tag:'all'}, ...]            (list IN [...])
     *   - lists AND tags   → cross product so every (list, tag) pair lands
     *                        in queryGroups, ANDed within a pair, ORed across
     *
     * Storage shape: array of {list, tag} pairs at the top level
     * (NOT a nested {lists:[], tags:[]} object — that was the round-1 bug).
     */
    private static function filterToCampaignSegment($filter)
    {
        $tagIds = !empty($filter['tags'])
            ? MCPHelper::resolveTagIds((array) $filter['tags'])['ids']
            : [];
        $listIds = !empty($filter['lists'])
            ? MCPHelper::resolveListIds((array) $filter['lists'])['ids']
            : [];

        $items = [];

        if (!$tagIds && !$listIds) {
            return $items;
        }

        if ($tagIds && $listIds) {
            foreach ($listIds as $listId) {
                foreach ($tagIds as $tagId) {
                    $items[] = ['list' => (string) $listId, 'tag' => (string) $tagId];
                }
            }
        } elseif ($tagIds) {
            foreach ($tagIds as $tagId) {
                $items[] = ['list' => 'all', 'tag' => (string) $tagId];
            }
        } else {
            foreach ($listIds as $listId) {
                $items[] = ['list' => (string) $listId, 'tag' => 'all'];
            }
        }

        return $items;
    }

    private static function estimateRecipientsFromSettings($settings)
    {
        try {
            $count = (new Campaign())->getSubscriberIdsCountBySegmentSettings([
                'subscribers'         => $settings['subscribers'] ?? [],
                'excludedSubscribers' => $settings['excludedSubscribers'] ?? [],
                'sending_filter'      => $settings['sending_filter'] ?? 'list_tag',
                'dynamic_segment'     => $settings['dynamic_segment'] ?? null,
                'advanced_filters'    => $settings['advanced_filters'] ?? [],
            ]);
            return (int) $count;
        } catch (\Throwable $e) {
            return null;
        }
    }

    // -----------------------------------------------------------------
    // Write: change-campaign-status
    // -----------------------------------------------------------------

    public static function changeCampaignStatus($params)
    {
        $params = (array) $params;
        $campaignId = (int) ($params['campaign_id'] ?? 0);
        $action = sanitize_key((string) ($params['action'] ?? ''));

        if (!$campaignId) {
            return MCPHelper::error('invalid_param', __('campaign_id is required', 'fluent-crm'));
        }
        if (!in_array($action, ['schedule', 'unschedule', 'pause', 'resume', 'duplicate', 'delete'], true)) {
            return MCPHelper::error('invalid_param', __('Invalid action', 'fluent-crm'));
        }

        // Bypass the type scope so one-off sends are reachable. Allowed
        // actions on one-offs are restricted below — operator-test
        // report 2026-05-07 #4.
        $campaign = Campaign::withoutGlobalScope('type')->find($campaignId);
        if (!$campaign) {
            return MCPHelper::error('not_found', __('Campaign not found', 'fluent-crm'), ['campaign_id' => $campaignId]);
        }

        if ($campaign->type === 'custom_email_campaign' && $action !== 'delete') {
            return MCPHelper::error('not_supported', __('One-off email sends only support action=delete. They do not have a marketing-campaign lifecycle (schedule/pause/resume/duplicate).', 'fluent-crm'), [
                'campaign_id'      => $campaignId,
                'campaign_type'    => 'one_off',
                'allowed_actions'  => ['delete'],
            ]);
        }

        $previousStatus = $campaign->status;

        switch ($action) {
            case 'schedule':
                return self::actionSchedule($campaign, $params, $previousStatus);
            case 'unschedule':
                return self::actionUnschedule($campaign, $previousStatus);
            case 'pause':
                return self::actionPause($campaign, $previousStatus);
            case 'resume':
                return self::actionResume($campaign, $previousStatus);
            case 'duplicate':
                return self::actionDuplicate($campaign, $params);
            case 'delete':
                return self::actionDelete($campaign);
        }

        return MCPHelper::error('invalid_param', __('Unhandled action', 'fluent-crm'));
    }

    private static function actionSchedule($campaign, $params, $previousStatus)
    {
        if ($campaign->status !== 'draft') {
            return MCPHelper::error('not_supported', __('Only draft campaigns can be scheduled', 'fluent-crm'), [
                'status' => $campaign->status,
            ]);
        }

        $sendingType = $params['sending_type'] ?? ($params['scheduled_at'] ?? null ? 'schedule' : 'instant');

        if ($sendingType === 'instant') {
            $scheduleAt = null;
        } elseif ($sendingType === 'range_schedule') {
            $range = $params['schedule_range'] ?? [];
            if (!is_array($range) || count($range) !== 2) {
                return MCPHelper::error('invalid_param', __('schedule_range must be [start, end]', 'fluent-crm'));
            }
            // Each end gets the same tz-aware parse as a single scheduled_at.
            $rangeStart = MCPHelper::validateScheduledAt(sanitize_text_field($range[0]));
            if (is_wp_error($rangeStart)) {
                return $rangeStart;
            }
            $rangeEnd = MCPHelper::validateScheduledAt(sanitize_text_field($range[1]));
            if (is_wp_error($rangeEnd)) {
                return $rangeEnd;
            }
            if ($rangeEnd->getTimestamp() <= $rangeStart->getTimestamp()) {
                return MCPHelper::error('invalid_param', __('schedule_range end must be after the start time', 'fluent-crm'), [
                    'start' => $rangeStart->format('Y-m-d H:i:s'),
                    'end'   => $rangeEnd->format('Y-m-d H:i:s'),
                ]);
            }
            $scheduleAt = [
                $rangeStart->format('Y-m-d H:i:s'),
                $rangeEnd->format('Y-m-d H:i:s'),
            ];
        } else {
            $rawInput = sanitize_text_field((string) ($params['scheduled_at'] ?? ''));
            if (!$rawInput) {
                return MCPHelper::error('invalid_param', __('scheduled_at is required for sending_type=schedule', 'fluent-crm'));
            }
            $dt = MCPHelper::validateScheduledAt($rawInput);
            if (is_wp_error($dt)) {
                return $dt;
            }
            // Store as a site-tz mysql string so MySQL has no ambiguity and
            // the read path doesn't have to guess which tz the row is in
            // (operator-test report 2026-05-07 #3).
            $scheduleAt = $dt->format('Y-m-d H:i:s');
        }

        // Auto-commit recipients if not yet set so we can read recipients_count.
        if (!$campaign->recipients_count) {
            $settings = is_array($campaign->settings) ? $campaign->settings : (array) maybe_unserialize($campaign->settings);
            if (!empty($settings['subscribers']) || !empty($settings['excludedSubscribers'])) {
                $count = (new Campaign())->getSubscriberIdsCountBySegmentSettings($settings);
                $campaign->recipients_count = (int) $count;
                $campaign->save();
            }
        }

        if (!$campaign->recipients_count) {
            return MCPHelper::error('validation_failed', __('No recipients found for this campaign', 'fluent-crm'));
        }

        // Now apply the same transition the controller does.
        $settings = is_array($campaign->settings) ? $campaign->settings : (array) maybe_unserialize($campaign->settings);

        if ($scheduleAt === null) {
            $settings['sending_type'] = 'instant';
            $update = [
                'status'           => 'processing',
                'updated_at'       => fluentCrmTimestamp(),
                'scheduled_at'     => fluentCrmTimestamp(),
                'recipients_count' => 0,
                'settings'         => $settings,
            ];
        } elseif (is_array($scheduleAt)) {
            $settings['sending_type']  = 'range_schedule';
            $settings['schedule_range'] = [strtotime($scheduleAt[0]), strtotime($scheduleAt[1])];
            $update = [
                'status'           => 'pending-scheduled',
                'updated_at'       => fluentCrmTimestamp(),
                'scheduled_at'     => $scheduleAt[0],
                'recipients_count' => 0,
                'settings'         => $settings,
            ];
        } else {
            $settings['sending_type'] = 'schedule';
            $update = [
                'status'           => 'pending-scheduled',
                'updated_at'       => fluentCrmTimestamp(),
                'scheduled_at'     => $scheduleAt,
                'recipients_count' => 0,
                'settings'         => $settings,
            ];
        }

        // Only seed trackers from site defaults when the campaign hasn't
        // explicitly set them already (review #31). The previous
        // unconditional override clobbered an agent's
        // settings.click_tracker='yes' to the site default 'anonymous'.
        if (!isset($update['settings']['click_tracker']) || $update['settings']['click_tracker'] === '') {
            $update['settings']['click_tracker'] = fluentcrmTrackClicking();
        }
        if (!isset($update['settings']['open_tracker']) || $update['settings']['open_tracker'] === '') {
            $update['settings']['open_tracker'] = fluentcrmTrackEmailOpen();
        }
        $update['settings'] = maybe_serialize($update['settings']);

        $updated = Campaign::where('id', $campaign->id)->where('status', 'draft')->update($update);
        if (!$updated) {
            return MCPHelper::error('failed', __('Could not transition campaign — status changed concurrently', 'fluent-crm'));
        }

        // Wipe pre-processed emails only after the draft-status transition wins.
        \FluentCrm\App\Models\CampaignEmail::where('campaign_id', $campaign->id)->delete();
        fluentcrm_update_campaign_meta($campaign->id, '_recipient_processed', 0);
        fluentcrm_update_campaign_meta($campaign->id, '_last_recipient_id', 0);

        $campaign = Campaign::find($campaign->id);
        fluentcrm_update_campaign_meta($campaign->id, '_campaign_sent_by', get_current_user_id());

        if ($scheduleAt) {
            do_action('fluent_crm/campaign_scheduled', $campaign, $campaign->scheduled_at);
        } else {
            do_action('fluent_crm/campaign_set_send_now', $campaign);
        }

        return [
            'ok'                       => true,
            'action'                   => 'schedule',
            'campaign'                 => self::getCampaign(['campaign_id' => (int) $campaign->id]),
            'previous_status'          => $previousStatus,
            'current_status'           => $campaign->status,
            'scheduled_at'             => MCPHelper::toIso8601($campaign->scheduled_at),
            'scheduled_at_resolved'    => MCPHelper::formatScheduledAtDual($campaign->scheduled_at),
        ];
    }

    private static function actionUnschedule($campaign, $previousStatus)
    {
        if (!in_array($campaign->status, ['scheduled', 'pending-scheduled', 'processing'], true)) {
            return MCPHelper::error('not_supported', __('Campaign is not in a schedulable state', 'fluent-crm'), [
                'status' => $campaign->status,
            ]);
        }

        $campaign->status = 'draft';
        // Clear the stale schedule timestamp so UIs (and get-campaign) don't
        // misreport "scheduled for ..." after the campaign has been
        // un-scheduled (review #32).
        $campaign->scheduled_at = null;
        $campaign->save();

        \FluentCrm\App\Models\CampaignEmail::where('campaign_id', $campaign->id)->delete();
        \FluentCrm\App\Models\CampaignEmail::withoutGlobalScope('type')
            ->where('campaign_id', $campaign->id)
            ->whereIn('status', ['scheduled', 'scheduling'])
            ->delete();

        return [
            'ok'              => true,
            'action'          => 'unschedule',
            'campaign'        => self::getCampaign(['campaign_id' => (int) $campaign->id]),
            'previous_status' => $previousStatus,
            'current_status'  => $campaign->status,
        ];
    }

    private static function actionPause($campaign, $previousStatus)
    {
        if ($campaign->status !== 'working') {
            return MCPHelper::error('not_supported', __('Only working campaigns can be paused', 'fluent-crm'), [
                'status' => $campaign->status,
            ]);
        }
        $campaign->status = 'paused';
        $campaign->save();
        \FluentCrm\App\Models\CampaignEmail::where('campaign_id', $campaign->id)
            ->whereIn('status', ['scheduled', 'pending', 'scheduling'])
            ->update(['status' => 'paused']);

        return [
            'ok'              => true,
            'action'          => 'pause',
            'campaign'        => self::getCampaign(['campaign_id' => (int) $campaign->id]),
            'previous_status' => $previousStatus,
            'current_status'  => 'paused',
        ];
    }

    private static function actionResume($campaign, $previousStatus)
    {
        if ($campaign->status !== 'paused') {
            return MCPHelper::error('not_supported', __('Only paused campaigns can be resumed', 'fluent-crm'), [
                'status' => $campaign->status,
            ]);
        }
        $campaign->status = 'working';
        $campaign->save();
        \FluentCrm\App\Models\CampaignEmail::where('campaign_id', $campaign->id)
            ->where('status', 'paused')
            ->update([
                'status'       => 'scheduled',
                'scheduled_at' => current_time('mysql'),
            ]);

        return [
            'ok'              => true,
            'action'          => 'resume',
            'campaign'        => self::getCampaign(['campaign_id' => (int) $campaign->id]),
            'previous_status' => $previousStatus,
            'current_status'  => 'working',
        ];
    }

    private static function actionDuplicate($campaign, $params)
    {
        $newTitle = isset($params['new_title']) && $params['new_title'] !== ''
            ? sanitize_text_field((string) $params['new_title'])
            : __('[Duplicate] ', 'fluent-crm') . $campaign->title;

        $newCampaign = [
            'title'            => $newTitle,
            'slug'             => $campaign->slug . '-' . time(),
            'email_body'       => $campaign->email_body,
            'status'           => 'draft',
            'template_id'      => $campaign->template_id,
            'email_subject'    => $campaign->email_subject,
            'email_pre_header' => $campaign->email_pre_header,
            'utm_status'       => $campaign->utm_status,
            'utm_source'       => $campaign->utm_source,
            'utm_medium'       => $campaign->utm_medium,
            'utm_campaign'     => $campaign->utm_campaign,
            'utm_term'         => $campaign->utm_term,
            'utm_content'      => $campaign->utm_content,
            'design_template'  => $campaign->design_template,
            'created_by'       => get_current_user_id(),
            'settings'         => $campaign->settings,
        ];

        $copy = Campaign::create($newCampaign);

        if (method_exists($campaign, 'getFormattedLabels')) {
            $labelIds = $campaign->getFormattedLabels()->pluck('id')->toArray();
            if ($labelIds && method_exists($copy, 'attachLabels')) {
                $copy->attachLabels($labelIds);
            }
        }
        if (method_exists($copy, 'duplicateSubjects')) {
            $copy->duplicateSubjects($campaign);
        }

        do_action('fluent_crm/campaign_duplicated', $copy, $campaign);

        return [
            'ok'                   => true,
            'action'               => 'duplicate',
            'campaign'             => self::getCampaign(['campaign_id' => (int) $copy->id]),
            'duplicated_from_id'   => (int) $campaign->id,
        ];
    }

    private static function actionDelete($campaign)
    {
        if (!\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_email_delete')) {
            return MCPHelper::error('forbidden', __('Deleting campaigns requires fcrm_manage_email_delete', 'fluent-crm'));
        }

        $campaignId = (int) $campaign->id;
        if (method_exists($campaign, 'deleteCampaignData')) {
            $campaign->deleteCampaignData();
        }
        $campaign->delete();
        do_action('fluent_crm/campaign_deleted', $campaignId);

        return [
            'ok'         => true,
            'action'     => 'delete',
            'deleted_id' => $campaignId,
        ];
    }
}
