HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/be.naniguide.com/app/Http/Controllers/Refactor/CampaignController.php
<?php

namespace App\Http\Controllers\Refactor;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log as LaravelLog;
use App\Http\Controllers\Controller;
use App\Library\Cache\AppCache;
use App\Jobs\RefreshCacheableJob;
use App\Library\StringHelper;
use Illuminate\Support\Str;
use App\Model\Campaign;
use App\Model\AbTest;
use App\Model\AbTestVariant;
use App\Model\Customer;
use App\Model\Email;
use App\Model\IpLocation;
use App\Model\MailList;
use App\Model\Subscriber;
use App\Model\Timeline;
use App\Model\TrackingLog;
use App\Model\BounceLog;
use App\Model\OpenLog;
use App\Model\ClickLog;
use App\Model\UnsubscribeLog;
use App\Model\FeedbackLog;
use App\Model\JobMonitor;
use App\Events\CampaignUpdated;
use App\Model\Setting;
use App\Services\Refactor\CampaignService;
use App\Services\AssetService;
use App\Services\PersonalizationTagCatalog;
use App\Services\TemplateService;
use App\Dto\Refactor\CampaignRecipientsDto;
use App\Dto\Refactor\CampaignSetupDto;
use Carbon\Carbon;
use DB;
use Gate;

class CampaignController extends Controller
{
    /**
     * Campaigns index listing page.
     */
    public function index(Request $request)
    {
        if (Gate::denies('list', Campaign::class)) {
            return $this->notAuthorized();
        }

        return view('refactor.pages.campaigns.index', [
            'campaigns' => $request->user()->customer->local()->campaigns(),
        ]);
    }

    /**
     * Campaigns AJAX listing partial.
     */
    public function listing(Request $request)
    {
        if (Gate::denies('list', Campaign::class)) {
            return $this->notAuthorized();
        }

        if (!$request->query('page') && $request->route('page')) {
            \Illuminate\Pagination\Paginator::currentPageResolver(
                fn () => (int) $request->route('page')
            );
        }

        $campaigns = $request->user()->customer->local()->campaigns()
            ->search($request->keyword)
            ->filter($request);

        if ($request->status) {
            $campaigns = $campaigns->byStatus($request->status);
        }

        $campaigns = $campaigns
            ->orderBy($request->sort_order ?: 'created_at', $request->sort_direction ?: 'desc')
            ->paginate($request->per_page ?: \App\Model\MailList::$itemsPerPage);

        return view('refactor.pages.campaigns._list', ['campaigns' => $campaigns]);
    }

    /**
     * Return campaign last_error as JSON (for debug popover).
     */
    public function lastError(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('read', $campaign)) {
            return response()->json(['error' => 'Unauthorized'], 403);
        }

        $payload = json_decode($campaign->getRawOriginal('last_error') ?? 'null', true);
        $lastError = is_array($payload)
            ? trim(($payload['message'] ?? '').((($payload['backtrace'] ?? '') !== '' ? "\n\n".$payload['backtrace'] : '')))
            : null;

        return response()->json([
            'last_error' => $lastError,
        ]);
    }

    public function updateStatistics(Request $request, $uid)
    {
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $entry = $request->input('entry'); // null = refresh entire manifest
        if ($entry === null) {
            RefreshCacheableJob::dispatchAll($campaign);
        } else {
            RefreshCacheableJob::dispatch($campaign, $entry);
        }

        return response()->json([
            'message' => trans('refactor/campaigns.cache.refresh_dispatched'),
        ], 202);
    }

    /**
     * Campaign overview/report page.
     */
    public function overview(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        // All metrics come from the cache manifest. Nothing recomputes on
        // page load — refresh is user-driven via the freshness-strip button
        // (see docs/cache/README.md "No auto warm-on-miss"). Cold cache =
        // zeros + "never" timestamp; the UI's empty-state branches handle it.
        $scope = AppCache::for($campaign);

        $subscriberCount    = intval($scope->read('SubscriberCount', 0));
        $deliveredCount     = intval($scope->read('DeliveredCount', 0));
        $failedCount        = intval($scope->read('FailedDeliveredCount', 0));
        $openCount          = intval($scope->read('UniqOpenCount', 0));
        $openRate           = floatval($scope->read('UniqOpenRate', 0));
        $clickRate          = floatval($scope->read('ClickedRate', 0));
        $deliveredRate      = floatval($scope->read('DeliveredRate', 0));
        $notOpenRate        = floatval($scope->read('NotOpenRate', 0));
        $clickCount         = intval($scope->read('ClickCount', 0));
        $uniqueClickCount   = intval($scope->read('UniqueClickCount', 0));
        $bounceCount        = intval($scope->read('BounceCount', 0));
        $unsubscribeCount   = intval($scope->read('UnsubscribeCount', 0));
        $feedbackCount      = intval($scope->read('FeedbackCount', 0));
        $bounceRate         = floatval($scope->read('BounceRate', 0));
        $unsubscribeRate    = floatval($scope->read('UnsubscribeRate', 0));
        $feedbackRate       = floatval($scope->read('FeedbackRate', 0));
        $abuseFeedbackCount = intval($scope->read('AbuseFeedbackCount', 0));

        $topBlocks = $scope->read('OverviewTopBlocks', [
            'top_links' => collect(), 'top_open_countries' => collect(), 'top_click_countries' => collect(),
            'top_active_subscribers' => collect(), 'top_locations' => collect(),
            'last_open_at' => null, 'last_click_at' => null,
        ]);
        $topLinks             = $topBlocks['top_links'] ?? collect();
        $topOpenCountries     = $topBlocks['top_open_countries'] ?? collect();
        $topClickCountries    = $topBlocks['top_click_countries'] ?? collect();
        $topActiveSubscribers = $topBlocks['top_active_subscribers'] ?? collect();
        $topLocations         = $topBlocks['top_locations'] ?? collect();
        $lastOpenAt           = $topBlocks['last_open_at'] ?? null;   // Carbon|null
        $lastClickAt          = $topBlocks['last_click_at'] ?? null;

        $campaignComputedAt = $campaign->getOverviewComputedAt();
        // True when OverviewTopBlocks has never been computed — empty arrays
        // came from the read() default, not from "campaign actually has no
        // opens/clicks". Surfaces the per-card refresh CTA in the blade.
        $topBlocksMissed = $scope->lastUpdatedAt('OverviewTopBlocks') === null;

        return view('refactor.pages.campaigns.overview', compact(
            'campaign',
            'subscriberCount', 'deliveredCount', 'failedCount',
            'openCount', 'openRate', 'clickRate', 'deliveredRate', 'notOpenRate',
            'clickCount', 'uniqueClickCount', 'bounceCount', 'unsubscribeCount',
            'feedbackCount', 'bounceRate', 'unsubscribeRate', 'feedbackRate', 'abuseFeedbackCount',
            'topLinks', 'topOpenCountries', 'topClickCountries',
            'topActiveSubscribers', 'topLocations', 'lastOpenAt', 'lastClickAt',
            'campaignComputedAt', 'topBlocksMissed'
        ));
    }

    /**
     * Copy/replicate a campaign.
     * GET  → returns popup form HTML
     * POST → copies campaign, returns JSON
     */
    public function copy(Request $request)
    {
        $campaign = Campaign::findByUid($request->copy_campaign_uid);

        if (Gate::denies('copy', $campaign)) {
            if ($request->ajax()) {
                return response()->json(['message' => 'Not authorized'], 403);
            }
            return $this->notAuthorized();
        }

        if ($request->isMethod('post')) {
            $validator = \Validator::make($request->all(), [
                'name' => 'required',
            ]);

            if ($validator->fails()) {
                return response()->json([
                    'status' => 'error',
                    'errors' => $validator->errors(),
                ], 422);
            }

            $campaign->copy($request->name);

            return response()->json([
                'status' => 'success',
                'message' => trans('refactor/campaigns.copy.success'),
            ]);
        }

        return view('refactor.pages.campaigns.copy', [
            'campaign' => $campaign,
        ]);
    }

    /**
     * Links page.
     */
    public function links(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $links = $campaign->clickLogs()
            ->select('click_logs.url', DB::raw('count(*) AS clickCount'), DB::raw(sprintf('max(%s) AS lastClick', table('click_logs.created_at'))))
            ->groupBy('click_logs.url')
            ->orderBy('clickCount', 'desc')
            ->get();

        $totalClicks = $links->sum('clickCount');
        $uniqueClickCount = $campaign->uniqueClickCount();
        $clickRate = floatval(AppCache::for($campaign)->read('ClickedRate', 0));

        return view('refactor.pages.campaigns.links', compact('campaign', 'links', 'totalClicks', 'uniqueClickCount', 'clickRate'));
    }

    /**
     * Open map page — rich geographic analysis.
     */
    public function openMap(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        try {
            $locations = $campaign->openLocations()->get()->map(function ($loc) {
                return [
                    'lat'      => $loc->latitude,
                    'lng'      => $loc->longitude,
                    'ip'       => $loc->ip_address ?? '',
                    'email'    => $loc->email ?? 'Unknown',
                    'sub_id'   => $loc->subscriber_id,
                    'sub_uid'  => $loc->subscriber_uid,
                    'list_uid' => $loc->list_uid,
                    'time'     => $loc->open_at ? \Carbon\Carbon::parse($loc->open_at)->diffForHumans() : '',
                    'area'     => $loc->name(),
                ];
            })->filter(fn($l) => $l['lat'] && $l['lng'])->values();
            $locations = $this->enrichWithNames($locations, $campaign);
        } catch (\Exception $e) {
            $locations = collect();
        }

        try {
            $topOpenCountries = $campaign->topOpenCountries(10)->get();
            $topClickCountries = $campaign->topClickCountries(10)->get();
            $topLocations = $campaign->topLocations(15)->get();
            $uniqueCountries = $topOpenCountries->count();
            $uniqueLocations = $topLocations->count();
            $totalOpens = intval(AppCache::for($campaign)->read('UniqOpenCount', 0));
        } catch (\Exception $e) {
            $topOpenCountries = collect();
            $topClickCountries = collect();
            $topLocations = collect();
            $uniqueCountries = 0;
            $uniqueLocations = 0;
            $totalOpens = 0;
        }

        return view('refactor.pages.campaigns.open_map', compact(
            'campaign', 'locations',
            'topOpenCountries', 'topClickCountries', 'topLocations',
            'uniqueCountries', 'uniqueLocations', 'totalOpens'
        ));
    }

    /**
     * Batch-resolve FIRST_NAME / LAST_NAME for the subscribers referenced in a
     * map's marker collection. Single SQL hit keyed by subscriber id; on no
     * defaultMailList or missing name fields, returns the original collection
     * untouched (markers still render with email-only). Adds `first_name`,
     * `last_name`, `full_name`, `initials` keys per row.
     */
    private function enrichWithNames($locations, Campaign $campaign)
    {
        $list = $campaign->defaultMailList;
        $firstCol = $list ? optional($list->getFields->firstWhere('tag', 'FIRST_NAME'))->custom_field_name : null;
        $lastCol  = $list ? optional($list->getFields->firstWhere('tag', 'LAST_NAME'))->custom_field_name  : null;

        $subIds = $locations->pluck('sub_id')->filter()->unique()->values()->all();
        $names = [];
        if (!empty($subIds) && ($firstCol || $lastCol)) {
            $cols = array_filter(['id', $firstCol, $lastCol]);
            $names = DB::table('subscribers')->whereIn('id', $subIds)->select($cols)->get()
                ->keyBy('id')->toArray();
        }

        $isDemo = isSiteDemo();
        $pool = $isDemo ? \App\Model\Subscriber::demoAvatarPool() : [];

        return $locations->map(function ($loc) use ($names, $firstCol, $lastCol, $isDemo, $pool) {
            $row = $loc['sub_id'] && isset($names[$loc['sub_id']]) ? $names[$loc['sub_id']] : null;
            $first = ($row && $firstCol) ? trim((string) ($row->{$firstCol} ?? '')) : '';
            $last  = ($row && $lastCol)  ? trim((string) ($row->{$lastCol}  ?? '')) : '';
            $full  = trim("{$first} {$last}");
            $initials = ($first || $last)
                ? strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1))
                : strtoupper(mb_substr($loc['email'] ?: '?', 0, 1));
            $loc['first_name'] = $first;
            $loc['last_name']  = $last;
            $loc['full_name']  = $full;
            $loc['initials']   = $initials;
            $loc['avatar_url'] = null;
            if ($isDemo && !empty($pool) && $loc['sub_id']) {
                $idx = abs(crc32((string) $loc['sub_id'])) % count($pool);
                $loc['avatar_url'] = route('user.avatar', ['uid' => $pool[$idx]]);
            }
            return $loc;
        });
    }

    /**
     * Click map page — same shape as openMap but built from click_logs.
     */
    public function clickMap(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $locations = $campaign->clickLocations()->get()->map(function ($loc) {
            return [
                'lat'      => $loc->latitude,
                'lng'      => $loc->longitude,
                'ip'       => $loc->ip_address ?? '',
                'email'    => $loc->email ?? 'Unknown',
                'sub_id'   => $loc->subscriber_id,
                'sub_uid'  => $loc->subscriber_uid,
                'list_uid' => $loc->list_uid,
                'time'     => $loc->click_at ? \Carbon\Carbon::parse($loc->click_at)->diffForHumans() : '',
                'area'     => $loc->name(),
                'url'      => $loc->url ?? '',
            ];
        })->filter(fn($l) => $l['lat'] && $l['lng'])->values();
        $locations = $this->enrichWithNames($locations, $campaign);

        $topClickCountries = $campaign->topClickCountries(10)->get();
        $topOpenCountries  = $campaign->topOpenCountries(10)->get();
        $uniqueCountries   = $topClickCountries->count();
        $totalClicks       = intval(AppCache::for($campaign)->read('ClickCount', 0));

        return view('refactor.pages.campaigns.click_map', compact(
            'campaign', 'locations', 'topClickCountries', 'topOpenCountries', 'uniqueCountries', 'totalClicks'
        ));
    }

    /**
     * Subscribers page.
     */
    public function subscribers(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $list = $campaign->defaultMailList;
        $columns = $request->user()->getSetting('subscribers_columns') ?? [];
        $fields = $list ? $list->getFields->where('tag', '!=', 'EMAIL') : collect();

        $subscriberCount = intval(AppCache::for($campaign)->read('SubscriberCount', 0));
        $deliveredCount = intval(AppCache::for($campaign)->read('DeliveredCount', 0));
        $failedCount = intval(AppCache::for($campaign)->read('FailedDeliveredCount', 0));
        $pendingCount = $campaign->subscribersToSend()->count();
        $skippedCount = max(0, $subscriberCount - $deliveredCount - $failedCount - $pendingCount);

        return view('refactor.pages.campaigns.subscribers', compact(
            'campaign', 'list', 'columns', 'fields',
            'subscriberCount', 'deliveredCount', 'failedCount', 'pendingCount', 'skippedCount'
        ));
    }

    /**
     * Subscribers AJAX listing.
     */
    public function subscribersListing(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('read', $campaign)) {
            return;
        }

        $filterMode = $request->tracking_status ?: 'all';
        $sort = match ($request->sort_order) {
            'recent_sent' => 'recent_sent',
            'recent_activity', '', null => 'recent_activity',
            default => throw new \LogicException("Unknown subscribers sort_order: {$request->sort_order}"),
        };

        // Pending / Skipped use dedicated methods — no tracking_logs join
        if ($filterMode === 'pending') {
            $subscribers = $campaign->subscribersToSend()->addSelect('subscribers.*');

            if ($request->keyword) {
                $subscribers = $subscribers->where('subscribers.email', 'like', '%' . $request->keyword . '%');
            }

            $subscribers = $subscribers->paginate($request->per_page ?: 25);
        } elseif ($filterMode === 'skipped') {
            $subscribers = $campaign->subscribersSkipped()->addSelect('subscribers.*');

            if ($request->keyword) {
                $subscribers = $subscribers->where('subscribers.email', 'like', '%' . $request->keyword . '%');
            }

            $subscribers = $subscribers->paginate($request->per_page ?: 25);
        } else {
            // Default: delivery report with tracking_logs join
            $subscribers = $campaign->getDeliveryReport()
                ->addSelect('subscribers.*')
                ->addSelect('bounce_logs.raw AS bounced_message')
                ->addSelect('feedback_logs.feedback_type AS feedback_message')
                ->addSelect('tracking_logs.error AS failed_message')
                ->addSelect('tracking_logs.created_at AS sent_at');

            if ($request->open == 'yes') {
                $openByEmails = $campaign->openLogs()->join('subscribers', 'tracking_logs.subscriber_id', '=', 'subscribers.id')->groupBy('subscribers.email')->select('subscribers.email');
                $subscribers = $subscribers->joinSub($openByEmails, 'OpenedByEmails', fn($j) => $j->on('subscribers.email', '=', 'OpenedByEmails.email'));
            } elseif ($request->open == 'not_opened') {
                $openByEmails = $campaign->openLogs()->join('subscribers', 'tracking_logs.subscriber_id', '=', 'subscribers.id')->groupBy('subscribers.email')->select('subscribers.email');
                $subscribers = $subscribers->leftJoinSub($openByEmails, 'OpenedByEmails', fn($j) => $j->on('subscribers.email', '=', 'OpenedByEmails.email'))->whereNull('OpenedByEmails.email');
            }

            if ($request->click == 'clicked') {
                $clickByEmails = $campaign->clickLogs()->join('subscribers', 'tracking_logs.subscriber_id', '=', 'subscribers.id')->groupBy('subscribers.email')->select('subscribers.email');
                $subscribers = $subscribers->joinSub($clickByEmails, 'ClickedByEmails', fn($j) => $j->on('subscribers.email', '=', 'ClickedByEmails.email'));
            } elseif ($request->click == 'not_clicked') {
                $clickByEmails = $campaign->clickLogs()->join('subscribers', 'tracking_logs.subscriber_id', '=', 'subscribers.id')->groupBy('subscribers.email')->select('subscribers.email');
                $subscribers = $subscribers->leftJoinSub($clickByEmails, 'ClickedByEmails', fn($j) => $j->on('subscribers.email', '=', 'ClickedByEmails.email'))->whereNull('ClickedByEmails.email');
            }

            if ($filterMode !== 'all') {
                $subscribers = $subscribers->where('tracking_logs.status', $filterMode);
            }

            if ($request->keyword) {
                $subscribers = $subscribers->where('subscribers.email', 'like', '%' . $request->keyword . '%');
            }

            // Sort: recent_activity (last click → fallback last open → fallback sent),
            // or recent_sent (sent timestamp).
            if ($sort === 'recent_activity') {
                $clickAgg = \DB::table('click_logs')
                    ->select('message_id', \DB::raw('MAX(created_at) AS last_click_at'))
                    ->whereNotNull('message_id')
                    ->groupBy('message_id');
                $openAgg = \DB::table('open_logs')
                    ->select('message_id', \DB::raw('MAX(created_at) AS last_open_at'))
                    ->whereNotNull('message_id')
                    ->groupBy('message_id');
                $subscribers = $subscribers
                    ->leftJoinSub($clickAgg, 'sub_last_click', fn($j) => $j->on('sub_last_click.message_id', '=', 'tracking_logs.message_id'))
                    ->leftJoinSub($openAgg, 'sub_last_open', fn($j) => $j->on('sub_last_open.message_id', '=', 'tracking_logs.message_id'))
                    ->orderByRaw('COALESCE(sub_last_click.last_click_at, sub_last_open.last_open_at, tracking_logs.created_at) DESC');
            } else {
                $subscribers = $subscribers->orderBy('tracking_logs.created_at', 'desc');
            }

            $subscribers = $subscribers->paginate($request->per_page ?: 25);
        }

        $list = $campaign->defaultMailList;
        $columns = $request->user()->getSetting('subscribers_columns') ?? [];
        // Apply column picker selection on the AJAX listing call. The picker
        // ships a single CSV value (columns_csv) because McList collapses
        // array filters into comma-joined strings, breaking Laravel's
        // `?columns[]=...` parsing. Fall back to the saved setting if no
        // explicit selection is in the request.
        $selectedColumns = is_array($request->columns)
            ? $request->columns
            : ($request->columns_csv !== null ? array_filter(explode(',', (string) $request->columns_csv)) : $columns);
        $fields = $list ? $list->getFields->whereIn('uid', $selectedColumns)->where('tag', '!=', 'EMAIL') : collect();

        // Resolve FIRST_NAME / LAST_NAME columns once (avoid N+1 in view).
        $firstNameCol = null;
        $lastNameCol = null;
        if ($list) {
            $firstNameField = $list->getFields->firstWhere('tag', 'FIRST_NAME');
            $lastNameField = $list->getFields->firstWhere('tag', 'LAST_NAME');
            $firstNameCol = $firstNameField ? $firstNameField->custom_field_name : null;
            $lastNameCol = $lastNameField ? $lastNameField->custom_field_name : null;
        }

        return view('refactor.pages.campaigns._subscribers_list', compact(
            'campaign', 'list', 'subscribers', 'fields', 'columns', 'filterMode',
            'firstNameCol', 'lastNameCol'
        ));
    }

    // ================================================================
    // Sending Logs — all 6 pages follow the same pattern
    // ================================================================

    private function loadCampaign(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('read', $campaign)) {
            abort(403);
        }
        return $campaign;
    }

    public function trackingLog(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        return view('refactor.pages.campaigns.logs._layout', [
            'campaign' => $campaign,
            'activeTab' => 'tracking_log',
            'logTitle' => 'Delivery log',
            'logDesc' => 'Track the delivery status of every email sent in this campaign.',
            'logIcon' => 'local_shipping',
            'listingUrl' => route('refactor.campaigns.tracking_log_listing', $campaign->uid),
        ]);
    }

    public function trackingLogListing(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        $items = TrackingLog::search($request, $campaign)->paginate($request->per_page ?: 25);
        return view('refactor.pages.campaigns.logs._tracking_list', compact('campaign', 'items'));
    }

    public function bounceLog(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        return view('refactor.pages.campaigns.logs._layout', [
            'campaign' => $campaign,
            'activeTab' => 'bounce_log',
            'logTitle' => 'Bounce log',
            'logDesc' => 'Emails that bounced back — hard or soft bounces from recipient servers.',
            'logIcon' => 'replay',
            'listingUrl' => route('refactor.campaigns.bounce_log_listing', $campaign->uid),
        ]);
    }

    public function bounceLogListing(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        $items = BounceLog::search($request, $campaign)->paginate($request->per_page ?: 25);
        return view('refactor.pages.campaigns.logs._bounce_list', compact('campaign', 'items'));
    }

    public function feedbackLog(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        return view('refactor.pages.campaigns.logs._layout', [
            'campaign' => $campaign,
            'activeTab' => 'feedback_log',
            'logTitle' => 'Feedback log',
            'logDesc' => 'Spam complaints and feedback received from recipient mail servers.',
            'logIcon' => 'feedback',
            'listingUrl' => route('refactor.campaigns.feedback_log_listing', $campaign->uid),
        ]);
    }

    public function feedbackLogListing(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        $items = FeedbackLog::search($request, $campaign)->paginate($request->per_page ?: 25);
        return view('refactor.pages.campaigns.logs._feedback_list', compact('campaign', 'items'));
    }

    public function openLog(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        return view('refactor.pages.campaigns.logs._layout', [
            'campaign' => $campaign,
            'activeTab' => 'open_log',
            'logTitle' => 'Open log',
            'logDesc' => 'Every time a subscriber opens your email, it is recorded here.',
            'logIcon' => 'visibility',
            'listingUrl' => route('refactor.campaigns.open_log_listing', $campaign->uid),
        ]);
    }

    public function openLogListing(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        $items = OpenLog::search($request, $campaign)->paginate($request->per_page ?: 25);
        return view('refactor.pages.campaigns.logs._open_list', compact('campaign', 'items'));
    }

    public function clickLog(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        return view('refactor.pages.campaigns.logs._layout', [
            'campaign' => $campaign,
            'activeTab' => 'click_log',
            'logTitle' => 'Click log',
            'logDesc' => 'Every link click by your subscribers is tracked and listed here.',
            'logIcon' => 'ads_click',
            'listingUrl' => route('refactor.campaigns.click_log_listing', $campaign->uid),
        ]);
    }

    public function clickLogListing(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        $items = ClickLog::search($request, $campaign)->paginate($request->per_page ?: 25);
        return view('refactor.pages.campaigns.logs._click_list', compact('campaign', 'items'));
    }

    public function unsubscribeLog(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        return view('refactor.pages.campaigns.logs._layout', [
            'campaign' => $campaign,
            'activeTab' => 'unsubscribe_log',
            'logTitle' => 'Unsubscribe log',
            'logDesc' => 'Subscribers who opted out after receiving this campaign.',
            'logIcon' => 'unsubscribe',
            'listingUrl' => route('refactor.campaigns.unsubscribe_log_listing', $campaign->uid),
        ]);
    }

    public function unsubscribeLogListing(Request $request)
    {
        $campaign = $this->loadCampaign($request);
        $items = UnsubscribeLog::search($request, $campaign)->paginate($request->per_page ?: 25);
        return view('refactor.pages.campaigns.logs._unsubscribe_list', compact('campaign', 'items'));
    }

    // ================================================================
    // Campaign Creation Wizard
    // ================================================================

    /**
     * Step 0: Select campaign type (regular or plain-text).
     */
    public function selectType(Request $request)
    {
        // Tầng 1: ownership + RBAC
        if (Gate::denies('create', Campaign::class)) {
            return $this->noMoreItem();
        }
        // Tầng 2: plan quota
        $customer = $request->user()->customer->local();
        if (! \App\Services\Plans\Gates\QuotaGate::allows($customer, \App\Services\Plans\Quotas\QuotaKey::MAX_CAMPAIGNS)) {
            return $this->noMoreItem();
        }

        return view('refactor.pages.campaigns.wizard.select_type');
    }

    /**
     * Create campaign and redirect to recipients step.
     */
    public function create(Request $request)
    {
        if (Gate::denies('create', Campaign::class)) {
            return $this->noMoreItem();
        }
        $customer = $request->user()->customer->local();
        if (! \App\Services\Plans\Gates\QuotaGate::allows($customer, \App\Services\Plans\Quotas\QuotaKey::MAX_CAMPAIGNS)) {
            return $this->noMoreItem();
        }

        if ($request->type === 'ab-test') {
            $campaign = $request->user()->customer->local()->newDefaultCampaign();
            $campaign->saveFromArray(['type' => 'regular']);

            AbTest::forceCreate([
                'uid'             => Str::random(20),
                'campaign_id'     => $campaign->id,
                'winner_criteria' => AbTest::CRITERIA_OPEN_RATE,
                'test_percentage' => 100,
                'split_strategy'  => AbTest::STRATEGY_EXACT,
            ]);

            return redirect()->route('refactor.campaigns.ab_test_setup', $campaign->uid);
        }

        $service = new CampaignService();
        $campaign = $service->create($request->user()->customer, $request->type ?? 'regular');

        return redirect()->route('refactor.campaigns.recipients', $campaign->uid);
    }

    /**
     * Step 1: Recipients — select lists and segments.
     */
    public function recipients(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        $customer = $request->user()->customer;
        $service = new CampaignService();

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $data = !empty($request->old()) ? $request->old() : $request->all();
        $rules = $service->recipientsRules($campaign, $data);
        $service->fillRecipients($campaign, $data);

        if ($request->isMethod('post')) {
            $this->validate($request, $rules);
            $dto = CampaignRecipientsDto::fromRequest($request->all());
            $service->saveRecipients($campaign, $dto);

            return redirect()->route('refactor.campaigns.setup', $campaign->uid);
        }

        // Cronjob warning
        $cronjobWarning = null;
        if (empty(AppCache::for($customer->local())->read('MailListSelectOptions'))) {
            $lastExec = Carbon::createFromTimestamp(Setting::get('cronjob_last_execution') ?? 0);
            $cronjobWarning = $lastExec->timezone($customer->getTimezone());
        }

        return view('refactor.pages.campaigns.wizard.recipients', [
            'campaign' => $campaign,
            'rules' => $rules,
            'cronjobWarning' => $cronjobWarning,
            'step' => 1,
        ]);
    }

    /**
     * AJAX: Get segment <option> tags for a given list.
     */
    public function segmentOptions(Request $request)
    {
        $service = new CampaignService();
        $segments = $service->getSegments($request->list_uid ?? '');

        $html = '';
        foreach ($segments as $seg) {
            $html .= '<option value="' . e($seg->uid) . '">' . e($seg->name) . '</option>';
        }

        return response($html)->header('Content-Type', 'text/html');
    }

    /**
     * AJAX: Get list-segment form row.
     */
    public function listSegmentForm(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        return view('refactor.pages.campaigns.wizard._list_segment_row', [
            'campaign' => $campaign,
            'index' => $request->index ?? 0,
        ]);
    }

    /**
     * Step 2: Setup — name, subject, from, tracking.
     */
    public function setup(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        $customer = $request->user()->customer;
        $service = new CampaignService();

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $campaign->from_name = !empty($campaign->from_name) ? $campaign->from_name : ($campaign->defaultMailList->from_name ?? '');
        $campaign->from_email = !empty($campaign->from_email) ? $campaign->from_email : ($campaign->defaultMailList->from_email ?? '');

        if ($request->old()) {
            $campaign->fillAttributes($request->old());
        }

        if ($request->isMethod('post')) {
            $this->validate($request, $service->setupRules($campaign, $request));
            $dto = CampaignSetupDto::fromRequest($request->all());
            $service->saveSetup($campaign, $dto, $customer);

            return redirect()->route('refactor.campaigns.template', $campaign->uid);
        }

        $allowUnverified = $customer->allowUnverifiedFromEmailAddress();

        $trackingDomains = \App\Model\TrackingDomain::where('customer_id', $customer->id)
            ->where('status', \App\Model\TrackingDomain::STATUS_VERIFIED)
            ->orderBy('name')
            ->get();

        // Gate the tracking-domain picker on the plan quota, not on the row
        // count: a customer with quota=0 (or no quota at all) shouldn't see the
        // picker even if they previously had domains. `limit()` returns false
        // for "not granted" and 0 for "granted but zero allocation" — both hide.
        $tdLimit = app(\App\Services\Plans\Quotas\QuotasService::class)
            ->limit($customer, \App\Services\Plans\Quotas\QuotaKey::MAX_TRACKING_DOMAINS);
        $canUseTrackingDomain = $tdLimit !== false && $tdLimit !== 0;

        return view('refactor.pages.campaigns.wizard.setup', [
            'campaign' => $campaign,
            'rules' => $service->setupRules($campaign),
            'step' => 2,
            'identityDropboxUrl' => route('refactor.campaigns.identity_dropbox'),
            'allowUnverified' => $allowUnverified,
            'autoSuggestTags' => app(PersonalizationTagCatalog::class)->campaignTags($campaign->defaultMailList),
            'trackingDomains' => $trackingDomains,
            'canUseTrackingDomain' => $canUseTrackingDomain,
        ]);
    }

    /**
     * AJAX endpoint — verified identity suggestions for from_email / reply_to fields.
     */
    public function identityDropbox(Request $request)
    {
        $customer = $request->user()->customer;
        $droplist = app(\App\SendingServers\Identities\IdentityCatalog::class)
            ->droplistForCustomer($customer->local(), strtolower(trim($request->keyword)));

        return response()->json($droplist);
    }

    /**
     * Step 3: Template — select/edit email template.
     */
    public function template(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        // Plain text → show textarea
        if ($campaign->type === 'plain-text') {
            if ($request->isMethod('post')) {
                $this->validate($request, ['plain' => 'required']);
                $campaign->plain = $request->plain;
                $campaign->save();
                return redirect()->route('refactor.campaigns.schedule', $campaign->uid);
            }
        }

        return view('refactor.pages.campaigns.wizard.template', [
            'campaign' => $campaign,
            'step' => 3,
            'autoSuggestTags' => app(PersonalizationTagCatalog::class)->campaignTags($campaign->defaultMailList),
        ]);
    }

    /**
     * Template gallery — AJAX paginated template thumbnails.
     */
    public function templateGallery(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        $customer = $request->user()->customer;

        if ($request->from === 'mine') {
            $templates = $customer->customerEmailTemplates();
        } else {
            $templates = \App\Model\SystemEmailTemplate::query();
        }

        if ($request->category_uid) {
            $templates = $templates->categoryUid($request->category_uid);
        }

        // Base/Extended sub-filter (system tab only — customer "mine" templates aren't classified)
        if ($request->from !== 'mine' && in_array($request->tab, ['base', 'extended'], true)) {
            $categoryName = $request->tab === 'base' ? 'Base' : 'Extended';
            $category = \App\Model\TemplateCategory::whereName($categoryName)->first();
            if ($category) {
                $templates = $templates->categoryUid($category->uid);
            }
        }

        $templates = $templates->orderBy('created_at', 'desc')->paginate(12);
        $categories = \App\Model\TemplateCategory::all();

        return view('refactor.pages.campaigns.wizard._template_gallery', compact('campaign', 'templates', 'categories'));
    }

    /**
     * Choose a template — AJAX POST.
     */
    public function templateChoose(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        // Try user template first, then system template
        $template = \App\Model\Template::findByUid($request->template_uid);
        if (!$template) {
            $sysTemplate = \App\Model\SystemEmailTemplate::findByUid($request->template_uid);
            if ($sysTemplate) {
                $template = $sysTemplate->template;
            }
        }

        if (!$template) {
            if ($request->ajax()) {
                return response()->json(['status' => 'error', 'message' => trans('refactor/campaigns.error.template_not_found')], 404);
            }

            return redirect()->route('refactor.campaigns.template', $campaign->uid);
        }

        TemplateService::for($campaign->email)->setTemplate($template, $campaign->name);
        $campaign->email->updatePlainFromHtml();
        $campaign->unsetRelation('email');
        $campaign->updateLinks();

        if ($request->ajax()) {
            return response()->json([
                'status' => 'success',
                'message' => trans('refactor/campaigns.error.template_selected'),
                'redirect' => route('refactor.campaigns.template', $campaign->uid),
            ]);
        }

        return redirect()->route('refactor.campaigns.template', $campaign->uid);
    }

    /**
     * Paste raw HTML as template content. GET renders textarea; POST saves.
     */
    public function customHtml(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        if ($request->isMethod('post')) {
            $this->validate($request, ['html' => 'required|string']);

            if ($campaign->isCustomHtml()) {
                $campaign->template->updateContent(\App\Model\Template::DEFAULT_BUILDER_JSON, $request->html);
            } else {
                TemplateService::for($campaign->email)->setCustomHtml($request->html, $campaign->name);
                $campaign->email->updatePlainFromHtml();
                $campaign->unsetRelation('email');
                $campaign->updateLinks();
            }

            if ($request->ajax()) {
                return response()->json([
                    'status' => 'success',
                    'message' => trans('refactor/campaigns.custom_html.saved'),
                    'redirect' => route('refactor.campaigns.template', $campaign->uid),
                ]);
            }

            return redirect()->route('refactor.campaigns.template', $campaign->uid);
        }

        return view('refactor.pages.campaigns.wizard.custom_html', [
            'campaign' => $campaign,
            'step' => 3,
            'currentHtml' => $campaign->isCustomHtml() ? ($campaign->template->content ?? '') : '',
        ]);
    }

    /**
     * Step 4: Schedule — send now or later.
     */
    public function schedule(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        $currentTimezone = $campaign->customer->getTimezone();
        $service = new CampaignService();

        if ($campaign->step() < 3) {
            return redirect()->route('refactor.campaigns.template', $campaign->uid);
        }

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        if ($request->isMethod('post')) {
            $service->saveSchedule(
                $campaign,
                $request->send_now === 'yes',
                $request->delivery_date,
                $request->delivery_time,
                $currentTimezone
            );

            return redirect()->route('refactor.campaigns.confirm', $campaign->uid);
        }

        $runAt = is_null($campaign->run_at) ? Carbon::now($currentTimezone) : $campaign->run_at;
        $runAt->timezone($currentTimezone);

        return view('refactor.pages.campaigns.wizard.schedule', [
            'campaign' => $campaign,
            'delivery_date' => $runAt->format('Y-m-d'),
            'delivery_time' => $runAt->format('H:i'),
            'step' => 4,
        ]);
    }

    /**
     * Step 5: Confirm & Launch.
     */
    public function confirm(Request $request)
    {
        $customer = $request->user()->customer;
        $campaign = Campaign::findByUid($request->uid);
        $service = new CampaignService();

        if ($campaign->step() < 4) {
            return redirect()->route('refactor.campaigns.schedule', $campaign->uid);
        }

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $score = $service->getSpamScore($campaign);

        if ($request->isMethod('post') && $campaign->step() >= 5) {
            // Running campaign: changes already saved via wizard steps, just redirect to list
            if (in_array($campaign->status, [Campaign::STATUS_QUEUED, Campaign::STATUS_SENDING])) {
                return redirect()->route('refactor.campaigns.index');
            }

            $error = $service->launch($campaign, $customer);
            if ($error) {
                return redirect()->back()->with('alert-error', $error);
            }

            return redirect()->route('refactor.campaigns.overview', $campaign->uid);
        }

        return view('refactor.pages.campaigns.wizard.confirm', [
            'campaign' => $campaign,
            'score' => $score,
            'step' => 5,
            'subscriberCount' => $campaign->subscribersCount(),
        ]);
    }

    // ================================================================
    // A/B Test Wizard
    // ================================================================

    public function abTestSetup(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest    = $campaign->abTest()->with('variants.email.template')->firstOrFail();
        $lists     = $request->user()->customer->mailLists()->orderBy('name')->get();
        $activeTab = $request->input('tab', 'config');

        return view('refactor.pages.campaigns.ab_test.setup', compact('campaign', 'abTest', 'lists', 'activeTab'));
    }

    public function abTestSaveConfig(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest = $campaign->abTest()->firstOrFail();

        $request->validate([
            'name'            => 'required|string|max:255',
            'mail_list_id'    => 'required|integer',
            'test_percentage' => 'required|integer|min:1|max:100',
            'split_strategy'  => 'required|in:exact,hash',
        ]);

        $campaign->name = $request->name;
        $campaign->save();

        $campaign->listsSegments()->delete();
        $campaign->addListSegment(MailList::findOrFail($request->mail_list_id), null);
        $campaign->default_mail_list_id = $request->mail_list_id;
        $campaign->save();

        $abTest->test_percentage = $request->test_percentage;
        $abTest->split_strategy  = $request->split_strategy;
        $abTest->save();

        return redirect()->route('refactor.campaigns.ab_test_setup', ['uid' => $request->uid, 'tab' => 'variants'])
            ->with('success', trans('refactor/campaigns.ab.config_saved'));
    }

    public function abTestSaveWinnerRules(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest = $campaign->abTest()->firstOrFail();

        $request->validate([
            'winner_criteria'   => 'required|in:open_rate,click_rate,manual',
            'winner_wait_hours' => 'nullable|integer|min:1|max:720',
        ]);

        $abTest->winner_criteria   = $request->winner_criteria;
        $abTest->winner_wait_hours = $request->winner_criteria === AbTest::CRITERIA_MANUAL
            ? null
            : (int) $request->winner_wait_hours;
        $abTest->save();

        return redirect()->route('refactor.campaigns.ab_test_confirm', ['uid' => $request->uid])
            ->with('success', trans('refactor/campaigns.ab.winner_saved'));
    }

    public function abTestStoreVariant(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest = $campaign->abTest()->firstOrFail();

        $request->validate([
            'name'             => 'required|string|max:255',
            'subject'          => 'required|string|max:500',
            'split_percentage' => 'required|integer|min:1|max:100',
        ]);

        $variantUid = $request->variant_uid;

        if ($variantUid) {
            $variant = AbTestVariant::where('uid', $variantUid)
                ->where('ab_test_id', $abTest->id)
                ->firstOrFail();
            $variant->name             = $request->name;
            $variant->split_percentage = $request->split_percentage;
            $variant->save();
            $variant->email()->update(['subject' => $request->subject]);
        } else {
            $baseEmail = $campaign->email;
            $varEmail  = $baseEmail ? $baseEmail->copy() : Email::newDefault();

            if (!$baseEmail) {
                $varEmail->customer_id = $campaign->customer_id;
                $varEmail->type        = Email::TYPE_REGULAR;
            }

            // Ensure sender fields are always set (inherit from campaign if missing)
            $varEmail->from_email = $varEmail->from_email ?: $campaign->from_email;
            $varEmail->from_name  = $varEmail->from_name ?: $campaign->from_name;
            $varEmail->reply_to   = $varEmail->reply_to ?: ($campaign->reply_to ?? $campaign->from_email);
            $varEmail->subject    = $request->subject;
            $varEmail->save();

            $newVariant = AbTestVariant::forceCreate([
                'uid'              => Str::random(20),
                'ab_test_id'       => $abTest->id,
                'name'             => $request->name,
                'email_id'         => $varEmail->id,
                'split_percentage' => $request->split_percentage,
                'is_control'       => $abTest->variants()->count() === 0,
            ]);
        }

        $openVariantUid = isset($newVariant) ? $newVariant->uid : ($variant->uid ?? null);

        return redirect()->route('refactor.campaigns.ab_test_setup', ['uid' => $request->uid, 'tab' => 'variants', 'open_variant' => $openVariantUid])
            ->with('success', trans('refactor/campaigns.ab.variant_saved'));
    }

    /**
     * F4.5 — Atomically apply AI-generated subject variants to the A/B test.
     *
     * Body shape: { variants: [{ name, subject, split_percentage }, ...] }
     *
     * Mirrors abTestStoreVariant validation per element but wraps every row in
     * one DB transaction so partial failures roll back. Auto-balances split when
     * the caller omits or zeroes the percentages (e.g. JS sent placeholders).
     *
     * Returns JSON for the chatbox-style modal:
     *   200 { status: 'success', created: N, redirect: '<variants tab url>' }
     *   422 { status: 'error', error_type: 'validation', errors: {...} }
     *   403 { status: 'error', error_type: 'authz' }
     */
    public function abTestAiApplyVariants(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('update', $campaign)) {
            return response()->json([
                'status' => 'error',
                'error_type' => 'authz',
                'message' => trans('messages.not_authorized') ?: 'Not authorized',
            ], 403);
        }

        $abTest = $campaign->abTest()->firstOrFail();

        $validator = \Validator::make($request->all(), [
            'variants' => 'required|array|min:1|max:5',
            'variants.*.name' => 'required|string|max:255',
            'variants.*.subject' => 'required|string|max:500',
            'variants.*.split_percentage' => 'nullable|integer|min:0|max:100',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'status' => 'error',
                'error_type' => 'validation',
                'errors' => $validator->errors()->toArray(),
            ], 422);
        }

        $variants = (array) $request->input('variants', []);
        $count = count($variants);

        // Auto-balance split when caller omitted percentages (JS may send 0s as a hint).
        $providedSplit = array_sum(array_map(fn ($v) => (int) ($v['split_percentage'] ?? 0), $variants));
        if ($providedSplit !== 100 || $providedSplit === 0) {
            $base = intdiv(100, $count);
            $remainder = 100 - ($base * $count);
            foreach ($variants as $i => $_) {
                $variants[$i]['split_percentage'] = $base + ($i === $count - 1 ? $remainder : 0);
            }
        }

        $created = [];
        try {
            DB::transaction(function () use ($abTest, $campaign, $variants, &$created) {
                $existingCount = $abTest->variants()->count();
                foreach ($variants as $i => $payload) {
                    $baseEmail = $campaign->email;
                    $varEmail = $baseEmail ? $baseEmail->copy() : Email::newDefault();

                    if (!$baseEmail) {
                        $varEmail->customer_id = $campaign->customer_id;
                        $varEmail->type = Email::TYPE_REGULAR;
                    }

                    $varEmail->from_email = $varEmail->from_email ?: $campaign->from_email;
                    $varEmail->from_name = $varEmail->from_name ?: $campaign->from_name;
                    $varEmail->reply_to = $varEmail->reply_to ?: ($campaign->reply_to ?? $campaign->from_email);
                    $varEmail->subject = (string) $payload['subject'];
                    $varEmail->save();

                    $variant = AbTestVariant::forceCreate([
                        'uid' => Str::random(20),
                        'ab_test_id' => $abTest->id,
                        'name' => (string) $payload['name'],
                        'email_id' => $varEmail->id,
                        'split_percentage' => (int) $payload['split_percentage'],
                        'is_control' => $existingCount === 0 && $i === 0,
                    ]);

                    $created[] = $variant->uid;
                }
            });
        } catch (\Throwable $e) {
            LaravelLog::warning('F4.5 abTestAiApplyVariants — transaction failed', [
                'campaign_uid' => $campaign->uid,
                'error' => $e->getMessage(),
            ]);

            return response()->json([
                'status' => 'error',
                'error_type' => 'apply_failed',
                'message' => trans('refactor/ai_subject_ab.errors.apply_failed'),
            ], 500);
        }

        return response()->json([
            'status' => 'success',
            'created' => count($created),
            'variant_uids' => $created,
            'redirect' => route('refactor.campaigns.ab_test_setup', ['uid' => $campaign->uid, 'tab' => 'variants']),
        ]);
    }

    public function abTestDeleteVariant(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest  = $campaign->abTest()->firstOrFail();
        $variant = AbTestVariant::where('uid', $request->variantUid)
            ->where('ab_test_id', $abTest->id)
            ->firstOrFail();

        if ($variant->email) {
            $variant->email->deleteAndCleanup();
        }
        $variant->delete();

        return redirect()->route('refactor.campaigns.ab_test_setup', ['uid' => $request->uid, 'tab' => 'variants'])
            ->with('success', trans('refactor/campaigns.ab.variant_deleted'));
    }

    /**
     * Variant settings side panel — returns HTML content.
     */
    public function abTestVariantPanel(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return response('Not authorized', 403);
        }

        $abTest  = $campaign->abTest()->with('variants.email.template')->firstOrFail();
        $variant = AbTestVariant::where('uid', $request->variantUid)
            ->where('ab_test_id', $abTest->id)
            ->with('email.template')
            ->firstOrFail();

        $customer = $request->user()->customer;

        return view('refactor.pages.campaigns.ab_test._variant_panel', [
            'campaign' => $campaign,
            'abTest' => $abTest,
            'variant' => $variant,
            'identityDropboxUrl' => route('refactor.campaigns.identity_dropbox'),
            'allowUnverified' => $customer->allowUnverifiedFromEmailAddress(),
        ]);
    }

    /**
     * Update variant settings — AJAX POST, returns JSON.
     */
    public function abTestUpdateVariant(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return response()->json(['status' => 'error', 'message' => 'Not authorized'], 403);
        }

        $abTest  = $campaign->abTest()->firstOrFail();
        $variant = AbTestVariant::where('uid', $request->variantUid)
            ->where('ab_test_id', $abTest->id)
            ->firstOrFail();

        $request->validate([
            'name'             => 'required|string|max:255',
            'subject'          => 'required|string|max:500',
            'from_name'        => 'required|string|max:255',
            'from_email'       => 'required|email|max:255',
            'reply_to'         => 'required|email|max:255',
            'split_percentage' => 'required|integer|min:1|max:100',
        ]);

        $variant->name             = $request->name;
        $variant->split_percentage = $request->split_percentage;
        $variant->save();

        if ($variant->email) {
            $variant->email->update([
                'subject'    => $request->subject,
                'from_name'  => $request->from_name,
                'from_email' => $request->from_email,
                'reply_to'   => $request->reply_to,
            ]);
        }

        return response()->json([
            'status'  => 'success',
            'message' => trans('refactor/campaigns.ab.variant_updated'),
        ]);
    }

    /**
     * Template gallery for a variant — AJAX GET.
     */
    public function abTestVariantTemplateGallery(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        $customer = $request->user()->customer;

        if ($request->from === 'mine') {
            $templates = $customer->customerEmailTemplates();
        } else {
            $templates = \App\Model\SystemEmailTemplate::query();
        }

        if ($request->category_uid) {
            $templates = $templates->categoryUid($request->category_uid);
        }

        $templates = $templates->orderBy('created_at', 'desc')->paginate(12);
        $context = 'ab-variant';

        return view('refactor.pages.campaigns.wizard._template_gallery', compact('campaign', 'templates', 'context'));
    }

    /**
     * Select template for a variant — AJAX POST, returns JSON.
     */
    public function abTestVariantSelectTemplate(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return response()->json(['status' => 'error', 'message' => 'Not authorized'], 403);
        }

        $abTest  = $campaign->abTest()->firstOrFail();
        $variant = AbTestVariant::where('uid', $request->variantUid)
            ->where('ab_test_id', $abTest->id)
            ->firstOrFail();

        $template = \App\Model\Template::findByUid($request->template_uid);
        if (!$template) {
            $sysTemplate = \App\Model\SystemEmailTemplate::findByUid($request->template_uid);
            if ($sysTemplate) {
                $template = $sysTemplate->template;
            }
        }

        if (!$template) {
            return response()->json(['status' => 'error', 'message' => 'Template not found'], 404);
        }

        $email = $variant->email;
        if (!$email) {
            return response()->json(['status' => 'error', 'message' => 'Variant has no email'], 400);
        }

        $email->setTemplate($template, $variant->name);

        $thumbUrl = $email->getThumbUrl();

        return response()->json([
            'status'    => 'success',
            'message'   => trans('refactor/campaigns.ab.template_selected'),
            'thumb_url' => $thumbUrl,
            'template_name' => $template->name,
        ]);
    }

    /**
     * Preview a variant's email template — renders raw HTML.
     */
    public function abTestVariantPreview(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('preview', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest  = $campaign->abTest()->firstOrFail();
        $variant = AbTestVariant::where('uid', $request->variantUid)
            ->where('ab_test_id', $abTest->id)
            ->with('email.template')
            ->firstOrFail();

        $template = optional($variant->email)->template;

        if (!$template) {
            return response(trans('refactor/campaigns.ab.no_template'), 404);
        }

        return response($template->content)
            ->header('Content-Type', 'text/html');
    }

    /**
     * A/B Test Report overview page.
     */
    public function abTestReport(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest = $campaign->abTest()->with('variants.email', 'winnerVariant')->firstOrFail();

        // Gather per-variant stats
        $variantStats = [];
        foreach ($abTest->variants as $variant) {
            $assignmentCount = $variant->assignments()->count();

            // Delivery stats from tracking logs
            $deliveredCount = TrackingLog::where('ab_test_variant_id', $variant->id)
                ->where('status', 'sent')->count();
            $failedCount = TrackingLog::where('ab_test_variant_id', $variant->id)
                ->where('status', 'failed')->count();

            // Opens (open_logs has no subscriber_id — join via tracking_logs)
            $openCount = DB::table('open_logs')
                ->join('tracking_logs', 'tracking_logs.message_id', '=', 'open_logs.message_id')
                ->where('tracking_logs.ab_test_variant_id', $variant->id)
                ->distinct('tracking_logs.subscriber_id')
                ->count('tracking_logs.subscriber_id');

            // Clicks
            $clickCount = DB::table('click_logs')
                ->join('tracking_logs', 'tracking_logs.message_id', '=', 'click_logs.message_id')
                ->where('tracking_logs.ab_test_variant_id', $variant->id)
                ->distinct('tracking_logs.subscriber_id')
                ->count('tracking_logs.subscriber_id');

            // Bounces
            $bounceCount = DB::table('bounce_logs')
                ->join('tracking_logs', 'tracking_logs.message_id', '=', 'bounce_logs.message_id')
                ->where('tracking_logs.ab_test_variant_id', $variant->id)
                ->count();

            // Unsubscribes
            $unsubCount = DB::table('unsubscribe_logs')
                ->join('tracking_logs', 'tracking_logs.message_id', '=', 'unsubscribe_logs.message_id')
                ->where('tracking_logs.ab_test_variant_id', $variant->id)
                ->distinct('unsubscribe_logs.subscriber_id')
                ->count('unsubscribe_logs.subscriber_id');

            $openRate  = $deliveredCount > 0 ? round($openCount / $deliveredCount * 100, 2) : 0;
            $clickRate = $deliveredCount > 0 ? round($clickCount / $deliveredCount * 100, 2) : 0;
            $bounceRate = ($deliveredCount + $failedCount) > 0 ? round($bounceCount / ($deliveredCount + $failedCount) * 100, 2) : 0;
            $unsubRate = $deliveredCount > 0 ? round($unsubCount / $deliveredCount * 100, 2) : 0;

            $variantStats[] = [
                'variant'        => $variant,
                'assignments'    => $assignmentCount,
                'delivered'      => $deliveredCount,
                'failed'         => $failedCount,
                'opens'          => $openCount,
                'clicks'         => $clickCount,
                'bounces'        => $bounceCount,
                'unsubscribes'   => $unsubCount,
                'open_rate'      => $openRate,
                'click_rate'     => $clickRate,
                'bounce_rate'    => $bounceRate,
                'unsub_rate'     => $unsubRate,
            ];
        }

        // Statistical confidence (z-test for two proportions)
        $confidence = null;
        if (count($variantStats) === 2) {
            $metric = $abTest->winner_criteria === AbTest::CRITERIA_CLICK_RATE ? 'click_rate' : 'open_rate';
            $n1 = max($variantStats[0]['delivered'], 1);
            $n2 = max($variantStats[1]['delivered'], 1);
            $p1 = $variantStats[0][$metric] / 100;
            $p2 = $variantStats[1][$metric] / 100;
            $pPool = ($p1 * $n1 + $p2 * $n2) / ($n1 + $n2);
            $se = sqrt($pPool * (1 - $pPool) * (1/$n1 + 1/$n2));

            if ($se > 0) {
                $z = abs($p1 - $p2) / $se;
                // Approximate p-value from z-score (two-tailed)
                $pValue = 2 * (1 - $this->normalCdf($z));
                $confidenceLevel = round((1 - $pValue) * 100, 1);
                $isSignificant = $pValue < 0.05;
                $lift = $p2 > 0 ? round(($p1 - $p2) / $p2 * 100, 1) : 0;
                if ($variantStats[0][$metric] < $variantStats[1][$metric]) {
                    $lift = $p1 > 0 ? round(($p2 - $p1) / $p1 * 100, 1) : 0;
                }

                $confidence = [
                    'z_score'      => round($z, 3),
                    'p_value'      => round($pValue, 4),
                    'confidence'   => $confidenceLevel,
                    'significant'  => $isSignificant,
                    'lift'         => $lift,
                    'metric'       => $metric,
                    'sample_sizes' => [$n1, $n2],
                ];
            }
        }

        // Overall campaign stats
        $subscriberCount = intval(AppCache::for($campaign)->read('SubscriberCount', 0));

        // Aggregate totals across all variants — the view's `@if ($hasData)`
        // section + funnel + agg-stats row read these. Without them the
        // template renders the "no data yet" placeholder even when variants
        // have full engagement data.
        $totalDelivered = array_sum(array_column($variantStats, 'delivered'));
        $totalFailed    = array_sum(array_column($variantStats, 'failed'));
        $totalOpens     = array_sum(array_column($variantStats, 'opens'));
        $totalClicks    = array_sum(array_column($variantStats, 'clicks'));
        $totalBounces   = array_sum(array_column($variantStats, 'bounces'));
        $totalUnsubs    = array_sum(array_column($variantStats, 'unsubscribes'));
        $avgOpenRate    = $totalDelivered > 0 ? round($totalOpens  / $totalDelivered * 100, 2) : 0;
        $avgClickRate   = $totalDelivered > 0 ? round($totalClicks / $totalDelivered * 100, 2) : 0;
        $hasData        = $totalDelivered > 0 || $totalFailed > 0;

        return view('refactor.pages.campaigns.ab_report.index', compact(
            'campaign', 'abTest', 'variantStats', 'confidence', 'subscriberCount',
            'hasData', 'totalDelivered', 'totalFailed', 'totalOpens', 'totalClicks',
            'totalBounces', 'totalUnsubs', 'avgOpenRate', 'avgClickRate'
        ));
    }

    /**
     * A/B Report chart data — AJAX GET, returns JSON.
     */
    public function abTestReportData(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('read', $campaign)) {
            return response()->json([], 403);
        }

        $abTest = $campaign->abTest()->with('variants')->firstOrFail();
        $period = $request->input('period', '7d');

        $hours = match ($period) {
            '24h' => 24,
            '3d'  => 72,
            '7d'  => 168,
            '30d' => 720,
            default => 168,
        };

        $since = Carbon::now()->subHours($hours);
        $interval = $hours <= 24 ? 1 : ($hours <= 72 ? 3 : ($hours <= 168 ? 6 : 24));
        $metric = $request->input('metric', 'opens');

        $datasets = [];
        foreach ($abTest->variants as $variant) {
            $table = $metric === 'clicks' ? 'click_logs' : 'open_logs';

            $data = DB::table($table)
                ->join('tracking_logs', 'tracking_logs.message_id', '=', $table . '.message_id')
                ->where('tracking_logs.ab_test_variant_id', $variant->id)
                ->where($table . '.created_at', '>=', $since)
                ->select(DB::raw("DATE_FORMAT({$table}.created_at, '%Y-%m-%d %H:00:00') AS period"), DB::raw('COUNT(*) AS cnt'))
                ->groupBy('period')
                ->orderBy('period')
                ->get()
                ->pluck('cnt', 'period')
                ->toArray();

            $datasets[] = [
                'label' => $variant->name,
                'data'  => $data,
            ];
        }

        return response()->json([
            'datasets' => $datasets,
            'metric'   => $metric,
            'period'   => $period,
        ]);
    }

    /**
     * Manually choose A/B test winner — POST.
     */
    public function abTestChooseWinner(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('chooseWinner', $campaign)) {
            return response()->json(['status' => 'error', 'message' => 'Not authorized'], 403);
        }

        $abTest = $campaign->abTest()->firstOrFail();
        $variant = AbTestVariant::where('uid', $request->variant_uid)
            ->where('ab_test_id', $abTest->id)
            ->firstOrFail();

        $abTest->declareWinner($variant);

        return response()->json([
            'status'  => 'success',
            'message' => trans('refactor/campaigns.ab.winner_chosen', ['name' => $variant->name]),
        ]);
    }

    /**
     * Individual variant report — deep-dive with existing campaign report tabs.
     */
    public function abTestVariantReport(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest  = $campaign->abTest()->with('variants.email', 'winnerVariant')->firstOrFail();
        $variant = AbTestVariant::where('uid', $request->variantUid)
            ->where('ab_test_id', $abTest->id)
            ->with('email')
            ->firstOrFail();

        // Gather stats for this variant only
        $deliveredCount = TrackingLog::where('ab_test_variant_id', $variant->id)
            ->where('status', 'sent')->count();

        $openCount = DB::table('open_logs')
            ->join('tracking_logs', 'tracking_logs.message_id', '=', 'open_logs.message_id')
            ->where('tracking_logs.ab_test_variant_id', $variant->id)
            ->distinct('tracking_logs.subscriber_id')
            ->count('tracking_logs.subscriber_id');

        $clickCount = DB::table('click_logs')
            ->join('tracking_logs', 'tracking_logs.message_id', '=', 'click_logs.message_id')
            ->where('tracking_logs.ab_test_variant_id', $variant->id)
            ->distinct('tracking_logs.subscriber_id')
            ->count('tracking_logs.subscriber_id');

        $bounceCount = DB::table('bounce_logs')
            ->join('tracking_logs', 'tracking_logs.message_id', '=', 'bounce_logs.message_id')
            ->where('tracking_logs.ab_test_variant_id', $variant->id)
            ->count();

        $unsubCount = DB::table('unsubscribe_logs')
            ->join('tracking_logs', 'tracking_logs.message_id', '=', 'unsubscribe_logs.message_id')
            ->where('tracking_logs.ab_test_variant_id', $variant->id)
            ->count();

        $openRate  = $deliveredCount > 0 ? round($openCount / $deliveredCount * 100, 2) : 0;
        $clickRate = $deliveredCount > 0 ? round($clickCount / $deliveredCount * 100, 2) : 0;

        // Top links for this variant
        $topLinks = DB::table('click_logs')
            ->join('tracking_logs', 'tracking_logs.message_id', '=', 'click_logs.message_id')
            ->where('tracking_logs.ab_test_variant_id', $variant->id)
            ->select('click_logs.url', DB::raw('COUNT(*) AS click_count'))
            ->groupBy('click_logs.url')
            ->orderByDesc('click_count')
            ->limit(10)
            ->get();

        return view('refactor.pages.campaigns.ab_report.variant', compact(
            'campaign', 'abTest', 'variant',
            'deliveredCount', 'openCount', 'clickCount', 'bounceCount', 'unsubCount',
            'openRate', 'clickRate', 'topLinks'
        ));
    }

    /**
     * Standard normal CDF approximation (Abramowitz & Stegun).
     */
    private function normalCdf(float $x): float
    {
        $a1 =  0.254829592;
        $a2 = -0.284496736;
        $a3 =  1.421413741;
        $a4 = -1.453152027;
        $a5 =  1.061405429;
        $p  =  0.3275911;

        $sign = $x < 0 ? -1 : 1;
        $x = abs($x) / sqrt(2);
        $t = 1.0 / (1.0 + $p * $x);
        $y = 1.0 - ((((($a5 * $t + $a4) * $t) + $a3) * $t + $a2) * $t + $a1) * $t * exp(-$x * $x);

        return 0.5 * (1.0 + $sign * $y);
    }

    /**
     * A/B Test confirm — preflight review before sending.
     */
    public function abTestConfirm(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest = $campaign->abTest()->with('variants.email.template', 'winnerVariant')->firstOrFail();
        $list = $campaign->defaultMailList;
        $subscriberCount = $list ? AppCache::for($list)->read('SubscriberCount', 0) : 0;

        // Readiness checks
        $checks = [];
        $checks['has_list'] = $list !== null;
        $checks['has_variants'] = $abTest->variants->count() >= 2;
        $checks['split_ok'] = $abTest->variants->sum('split_percentage') === 100;
        $checks['all_templates'] = $abTest->variants->every(fn($v) => $v->email && $v->email->template);
        $checks['all_subjects'] = $abTest->variants->every(fn($v) => $v->email && !empty($v->email->subject));
        $checks['all_sender_fields'] = $abTest->variants->every(fn($v) => $v->email && !empty($v->email->from_email) && !empty($v->email->from_name));
        $allReady = !in_array(false, $checks, true);

        return view('refactor.pages.campaigns.ab_test.confirm', compact(
            'campaign', 'abTest', 'list', 'subscriberCount', 'checks', 'allReady'
        ));
    }

    /**
     * A/B Test send — start the campaign.
     */
    public function abTestSend(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest = $campaign->abTest()->with('variants.email')->firstOrFail();

        // Validate readiness
        $list = $campaign->defaultMailList;
        if (!$list || $abTest->variants->count() < 2 || $abTest->variants->sum('split_percentage') !== 100) {
            return redirect()->route('refactor.campaigns.ab_test_setup', ['uid' => $campaign->uid, 'tab' => 'variants'])
                ->with('error', trans('refactor/campaigns.ab.not_ready'));
        }

        $customer = $request->user()->customer;

        if ($policyError = $campaign->validateLaunchPolicy($customer)) {
            return redirect()->route('refactor.campaigns.ab_test_setup', ['uid' => $campaign->uid, 'tab' => 'variants'])
                ->with('error', $policyError);
        }

        // Schedule or send now
        if ($request->send_type === 'schedule' && $request->schedule_date) {
            $currentTimezone = $customer->getTimezone();
            $runAt = \Carbon\Carbon::parse($request->schedule_date, $currentTimezone)->timezone(config('app.timezone'));
            $campaign->run_at = $runAt;
            $campaign->setScheduled();
        } else {
            // Queue for immediate sending
            $campaign->run_at = null;
            $campaign->save();
            $queue = $customer->custom_queue_name ?: ACM_QUEUE_TYPE_BATCH;
            $campaign->execute(false, $queue);
            (new \App\ActivityLog\Activities\CampaignStarted($campaign, $request->user()))->record();
        }

        return redirect()->route('refactor.campaigns.ab_test_report', $campaign->uid)
            ->with('success', $request->schedule_date
                ? trans('refactor/campaigns.ab.scheduled_success')
                : trans('refactor/campaigns.ab.sending_success'));
    }

    // ================================================================
    // Template Extras — Attachments, Preheader, Test Email
    // ================================================================

    /**
     * Upload attachment (AJAX POST with file).
     */
    public function uploadAttachment(Request $request, AssetService $assetService)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return response()->json(['status' => 'error', 'message' => 'Not authorized'], 403);
        }

        $this->validate($request, ['file' => 'required|file|max:25600']); // 25MB max

        $file = $request->file('file');
        $attachment = $assetService->uploadAttachment($campaign, $file);

        return response()->json([
            'status' => 'success',
            'message' => trans('refactor/campaigns.flash.attachment_uploaded'),
            'attachment' => [
                'uid' => $attachment->uid,
                'name' => $attachment->original_name,
                'size' => $attachment->size,
                'mime_type' => $attachment->mime_type,
            ]
        ]);
    }

    /**
     * Remove attachment (AJAX POST).
     */
    public function removeAttachment(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return response()->json(['status' => 'error'], 403);
        }

        $attachment = $campaign->attachments()->where('uid', $request->attachment_uid)->first();
        if ($attachment) {
            $attachment->remove();
        }

        return response()->json(['status' => 'success', 'message' => trans('refactor/campaigns.flash.attachment_removed')]);
    }

    /**
     * Download attachment.
     */
    public function downloadAttachment(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $attachment = $campaign->attachments()->where('uid', $request->attachment_uid)->first();
        if (!$attachment) {
            abort(404, 'Attachment not found');
        }

        $content = $attachment->getFileContent();

        return response($content, 200, [
            'Content-Type' => $attachment->mime_type,
            'Content-Disposition' => 'attachment; filename="' . $attachment->original_name . '"',
        ]);
    }

    /**
     * Preheader — set inline from the template wizard step. The standalone
     * popup view was never created; UI uses the inline #cr-preheader-input
     * + #cr-save-preheader on the template page (POSTs here). The GET path
     * just returns the current value as JSON for any caller that needs it.
     */
    public function preheader(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        if ($request->isMethod('post')) {
            $campaign->setPreheader($request->preheader);
            return response()->json(['status' => 'success', 'message' => trans('refactor/campaigns.flash.preheader_saved')]);
        }

        return response()->json(['preheader' => $campaign->preheader ?? '']);
    }

    /**
     * Send test email — popup form.
     */
    public function sendTestEmail(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('send_test_email', $campaign)) {
            return $this->notAuthorized();
        }

        if ($request->isMethod('post')) {
            $this->validate($request, ['email' => 'required|email']);

            try {
                $sending = $campaign->sendTestEmail($request->email);

                if (($sending['status'] ?? 'error') !== 'success') {
                    return response()->json([
                        'status' => 'error',
                        'message' => $sending['message'] ?? trans('refactor/common.error.generic'),
                    ], 422);
                }

                return response()->json([
                    'status' => 'success',
                    'message' => $sending['message'] ?? trans('refactor/campaigns.flash.test_sent', ['email' => $request->email]),
                ]);
            } catch (\Exception $e) {
                return response()->json([
                    'status' => 'error',
                    'message' => $e->getMessage(),
                ], 422);
            }
        }

        return view('refactor.pages.campaigns.wizard._send_test', compact('campaign'));
    }

    /**
     * Webhooks list.
     */
    public function webhooks(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $webhooks = $campaign->campaignWebhooks;

        return view('refactor.pages.campaigns.wizard._webhooks', compact('campaign', 'webhooks'));
    }

    /**
     * Add webhook — popup form.
     */
    public function webhooksAdd(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        if ($request->isMethod('post')) {
            $this->validate($request, [
                'type' => 'required|in:open,click',
                'endpoint' => 'required|url',
            ]);

            $webhook = new \App\Model\CampaignWebhook();
            $webhook->campaign_id = $campaign->id;
            $webhook->type = $request->type;
            $webhook->endpoint = $request->endpoint;
            $webhook->save();

            return response()->json(['status' => 'success', 'message' => trans('refactor/campaigns.flash.webhook_added')]);
        }

        return view('refactor.pages.campaigns.wizard._webhook_form', compact('campaign'));
    }

    /**
     * Delete webhook.
     */
    public function webhooksDelete(Request $request)
    {
        $campaign = Campaign::findByUid($request->uid);
        if (Gate::denies('update', $campaign)) {
            return response()->json(['status' => 'error'], 403);
        }

        $webhook = $campaign->campaignWebhooks()->where('uid', $request->webhook_uid)->first();
        if ($webhook) {
            $webhook->delete();
        }

        return response()->json(['status' => 'success', 'message' => trans('refactor/campaigns.flash.webhook_deleted')]);
    }

    public function trackingLogDownload(Request $request, $uid)
    {
        $campaign = \App\Model\Campaign::findByUid($uid);

        if (!$campaign || Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $logtype = $request->input('logtype');
        $job = new \App\Jobs\ExportCampaignLog($campaign, $logtype);
        $monitor = $campaign->dispatchWithMonitor($job);

        return view('refactor.pages.campaigns.download_tracking_log', [
            'campaign' => $campaign,
            'job' => $monitor,
        ]);
    }

    public function pause(Request $request)
    {
        $campaigns = Campaign::whereIn(
            'uid',
            is_array($request->uids) ? $request->uids : explode(',', $request->uids)
        );

        foreach ($campaigns->get() as $campaign) {
            if (\Gate::allows('pause', $campaign)) {
                $campaign->pause();
                $campaign->logger()->warning('Campaign was manually paused');
            }
        }

        return response()->json([
            'status' => 'success',
            'message' => trans('messages.campaigns.paused'),
        ]);
    }

    public function resume(Request $request)
    {
        $uids = is_array($request->uids) ? $request->uids : explode(',', $request->uids);
        $campaigns = Campaign::whereIn('uid', $uids)->get();

        if (config('custom.japan') && !\App\Model\Setting::get('license')) {
            return response()->json([
                'status' => 'error',
                'message' => trans('messages.license.required'),
            ], 400);
        }

        foreach ($campaigns as $campaign) {
            if (\Gate::allows('resume', $campaign)) {
                if ($request->input('queue') == ACM_QUEUE_TYPE_HIGH) {
                    $campaign->resume(ACM_QUEUE_TYPE_HIGH);
                } else {
                    $campaign->resume(ACM_QUEUE_TYPE_BATCH);
                }
            }
        }

        return response()->json([
            'status' => 'success',
            'message' => trans('messages.campaigns.restarted'),
        ]);
    }

    public function deleteCampaigns(Request $request)
    {
        $uids = is_array($request->uids) ? $request->uids : explode(',', $request->uids);
        $campaigns = Campaign::whereIn('uid', $uids);

        foreach ($campaigns->get() as $campaign) {
            if (\Gate::allows('delete', $campaign)) {
                $campaign->deleteAndCleanup();
            }
        }

        return response()->json([
            'status' => 'success',
            'message' => trans('messages.campaigns.deleted'),
        ]);
    }

    public function archive(Request $request)
    {
        $uids = is_array($request->uids) ? $request->uids : explode(',', $request->uids);
        $campaigns = Campaign::whereIn('uid', $uids);

        foreach ($campaigns->get() as $campaign) {
            if (\Gate::allows('archive', $campaign)) {
                $campaign->logger()->info('Dispatch ArchiveCampaign job');
                \App\Jobs\ArchiveCampaign::dispatch($campaign);
            }
        }

        return response()->json([
            'status' => 'success',
            'message' => trans('messages.campaign.archive_job_dispatched'),
        ]);
    }

    public function resend(Request $request, $uid)
    {
        $campaign = Campaign::findByUid($uid);

        if (\Gate::denies('resend', $campaign)) {
            if ($request->isMethod('post')) {
                return response()->json([
                    'status' => 'error',
                    'message' => trans('messages.not_authorized_message'),
                ], 403);
            }
            return $this->notAuthorized();
        }

        if ($request->isMethod('post')) {
            // Japan + not license
            if (config('custom.japan') && !\App\Model\Setting::get('license')) {
                return response()->json([
                    'status' => 'error',
                    'message' => trans('messages.license.required'),
                ], 400);
            }

            $campaign->resend($request->option);

            return response()->json([
                'status' => 'success',
                'message' => trans('messages.campaign.resent'),
            ]);
        }

        return view('refactor.pages.campaigns.resend', [
            'campaign' => $campaign,
        ]);
    }

    public function preview(Request $request, $uid)
    {
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('preview', $campaign)) {
            return $this->notAuthorized();
        }

        return view('refactor.pages.campaigns.preview', [
            'campaign'    => $campaign,
            'hasContent'  => $campaign->step() >= 3,
        ]);
    }

    public function previewContent(Request $request, $uid, $subscriber_id = null)
    {
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('preview', $campaign)) {
            return $this->notAuthorized();
        }

        if ($campaign->step() < 3) {
            return response($this->emptyPreviewHtml());
        }

        $subscriber = $subscriber_id ? \App\Model\Subscriber::find($subscriber_id) : null;

        return response($campaign->getHtmlContent($subscriber));
    }

    private function emptyPreviewHtml(): string
    {
        $msg = e(trans('messages.campaign_no_content_to_preview'));
        return <<<HTML
<!doctype html>
<html><head><meta charset="utf-8"></head>
<body style="margin:0;display:flex;align-items:center;justify-content:center;height:100vh;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif;color:#6b7280;font-size:14px;background:#fff">
    {$msg}
</body></html>
HTML;
    }

    public function previewAs(Request $request, $uid)
    {
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        return view('refactor.pages.campaigns.previewAs', [
            'campaign' => $campaign,
        ]);
    }

    public function previewAsList(Request $request, $uid)
    {
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('read', $campaign)) {
            return;
        }

        $subscribers = $campaign->subscribers()
            ->search($request->keyword)->paginate($request->per_page);

        return view('refactor.pages.campaigns.previewAsList', [
            'subscribers' => $subscribers,
            'campaign' => $campaign,
        ]);
    }

    public function previewAsContent(Request $request, $uid, $subscriber_id)
    {
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('read', $campaign)) {
            return response('Unauthorized', 403);
        }

        $subscriber = \App\Model\Subscriber::find($subscriber_id);

        if (!$subscriber) {
            return response('Subscriber not found', 404);
        }

        $msgId = $request->has('message_id') && $request->message_id !== '' ? $request->message_id : null;

        try {
            $server = $campaign->pickSendingServer();
        } catch (\Exception $e) {
            $server = null;
        }

        $html = $campaign->getHtmlContent($subscriber, $msgId, $server, false);

        // Debug: ensure tracking pixel is present
        \Log::info('PreviewAsContent', [
            'campaign_uid' => $uid,
            'subscriber_id' => $subscriber_id,
            'message_id' => $msgId,
            'track_open' => $campaign->track_open,
            'has_pixel' => str_contains($html, 'tracking pixel'),
        ]);

        return response($html);
    }

    public function previewAsDownload(Request $request, $uid, $subscriber_id)
    {
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('read', $campaign)) {
            return response('Unauthorized', 403);
        }

        $subscriber = \App\Model\Subscriber::find($subscriber_id);

        if (!$subscriber) {
            return response('Subscriber not found', 404);
        }

        $server = $campaign->pickSendingServer();

        list($message, $msgId) = $campaign->prepareEmail($subscriber, $server);

        $filename = 'email-' . $subscriber->email . '-' . date('Y-m-d-His') . '.eml';

        return response($message->toString(), 200, [
            'Content-Type' => 'message/rfc822',
            'Content-Disposition' => 'attachment; filename="' . $filename . '"',
        ]);
    }

    public function chart24h(Request $request, $uid)
    {
        $currentTimezone = $request->user()->customer->getTimezone();
        $nowInUserTimezone = Carbon::now($currentTimezone);
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $result = [
            'columns' => [],
            'opened' => [],
            'clicked' => [],
        ];

        if ($request->period == '24h') {
            $hours = [];

            for ($i = 23; $i >= 0; --$i) {
                $time = $nowInUserTimezone->copy()->subHours($i);
                $result['columns'][] = $time->format('h') . ':00 ' . $time->format('A');
                $hours[] = $time->format('H');
            }

            $openData24h = $campaign->openUniqHours($nowInUserTimezone->copy()->subHours(24), $nowInUserTimezone);
            $clickData24h = $campaign->clickHours($nowInUserTimezone->copy()->subHours(24), $nowInUserTimezone);

            foreach ($hours as $hour) {
                $result['opened'][] = isset($openData24h[$hour]) ? count($openData24h[$hour]) : 0;
                $result['clicked'][] = isset($clickData24h[$hour]) ? count($clickData24h[$hour]) : 0;
            }
        } elseif ($request->period == '3_days') {
            $days = [];

            for ($i = 2; $i >= 0; --$i) {
                $time = $nowInUserTimezone->copy()->subDays($i);
                $result['columns'][] = $time->format('m-d');
                $days[] = $time->format('Y-m-d');
            }

            $openData = $campaign->openUniqDays($nowInUserTimezone->copy()->subDays(3), $nowInUserTimezone->endOfDay());
            $clickData = $campaign->clickDays($nowInUserTimezone->copy()->subDays(3), $nowInUserTimezone->endOfDay());

            foreach ($days as $day) {
                $result['opened'][] = isset($openData[$day]) ? count($openData[$day]) : 0;
                $result['clicked'][] = isset($clickData[$day]) ? count($clickData[$day]) : 0;
            }
        } elseif ($request->period == '7_days') {
            $days = [];

            for ($i = 6; $i >= 0; --$i) {
                $time = $nowInUserTimezone->copy()->subDays($i);
                $result['columns'][] = $time->format('m-d');
                $days[] = $time->format('Y-m-d');
            }

            $openData = $campaign->openUniqDays($nowInUserTimezone->copy()->subDays(7), $nowInUserTimezone->endOfDay());
            $clickData = $campaign->clickDays($nowInUserTimezone->copy()->subDays(7), $nowInUserTimezone->endOfDay());

            foreach ($days as $day) {
                $result['opened'][] = isset($openData[$day]) ? count($openData[$day]) : 0;
                $result['clicked'][] = isset($clickData[$day]) ? count($clickData[$day]) : 0;
            }
        } elseif ($request->period == 'last_month') {
            $days = [];

            for ($i = $nowInUserTimezone->copy()->subMonths(1)->diff(Carbon::now())->days - 1; $i >= 0; --$i) {
                $time = $nowInUserTimezone->copy()->subDays($i);
                $result['columns'][] = $time->format('m-d');
                $days[] = $time->format('Y-m-d');
            }

            $openData = $campaign->openUniqDays($nowInUserTimezone->copy()->subMonths(1), $nowInUserTimezone->endOfDay());
            $clickData = $campaign->clickDays($nowInUserTimezone->copy()->subMonths(1), $nowInUserTimezone->endOfDay());

            foreach ($days as $day) {
                $result['opened'][] = isset($openData[$day]) ? count($openData[$day]) : 0;
                $result['clicked'][] = isset($clickData[$day]) ? count($clickData[$day]) : 0;
            }
        }

        return response()->json($result);
    }

    public function chartCountry(Request $request, $uid)
    {
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $result = [
            'data' => [],
        ];

        $total = $campaign->uniqueOpenCount();
        $count = 0;
        foreach ($campaign->topOpenCountries()->get() as $location) {
            $countryName = !empty($location->country_name) ? $location->country_name : trans('messages.unknown');
            $result['data'][] = ['value' => $location->aggregate, 'name' => $countryName];
            $count += $location->aggregate;
        }

        if ($total > $count) {
            $result['data'][] = ['value' => $total - $count, 'name' => trans('messages.others')];
        }

        usort($result['data'], function ($a, $b) {
            return strcmp($a['value'], $b['value']);
        });
        $result['data'] = array_reverse($result['data']);

        return response()->json($result);
    }

    public function chartClickCountry(Request $request, $uid)
    {
        $campaign = Campaign::findByUid($uid);

        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        $result = [
            'data' => [],
        ];

        $total = $campaign->clickCount();
        $count = 0;
        foreach ($campaign->topClickCountries()->get() as $location) {
            $result['data'][] = ['value' => $location->aggregate, 'name' => $location->country_name];
            $count += $location->aggregate;
        }

        if ($total > $count) {
            $result['data'][] = ['value' => $total - $count, 'name' => trans('messages.others')];
        }

        usort($result['data'], function ($a, $b) {
            return strcmp($a['value'], $b['value']);
        });
        $result['data'] = array_reverse($result['data']);

        return response()->json($result);
    }

    public function templateEdit(Request $request, $uid)
    {
        if ($request->isMethod('post')) {
            $campaign = Campaign::findByUid($uid);

            if (Gate::denies('update', $campaign)) {
                return $this->notAuthorized();
            }

            $validator = \Validator::make($request->all(), [
                'content' => 'required',
                'json' => 'required',
            ]);

            if ($validator->fails()) {
                return response()->json([
                    'message' => $validator->errors()->first(),
                ], 400);
            }

            if (\App\Services\Plans\Gates\EntitlementGate::allows($request->user()->customer, \App\Services\Plans\Entitlements\EntitlementKey::HAS_UNSUBSCRIBE_URL_REQUIRED) && Setting::isYes('campaign.enforce_unsubscribe_url_check')) {
                if (!\App\Library\StringHelper::containsUnsubscribeTag($request->content)) {
                    return response()->json([
                        'message' => trans('messages.template.validation.unsubscribe_url_required'),
                    ], 400);
                }
            }

            $campaign->setTemplateContent($request->content, $request->json);
            $campaign->updatePlainFromHtml();

            return response()->json([
                'status' => 'success',
            ]);
        }

        $campaign = Campaign::findByUid($uid);
        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        // Template must exist before entering the builder.
        // Campaigns without a template should select one first.
        $template = $campaign->getEmailTemplate();
        if (!$template) {
            return redirect()->route('refactor.campaigns.template', ['uid' => $campaign->uid]);
        }

        return view('refactor.pages.campaigns.builder', [
            'campaign'  => $campaign,
            'template'  => $template,
            'list'      => $campaign->defaultMailList,
            'templates' => $request->user()->customer->getBuilderTemplates(),
        ]);
    }

    public function abTestVariantTemplateEdit(Request $request, $uid, $variant_uid)
    {
        if ($request->isMethod('post')) {
            $campaign = Campaign::findByUid($uid);

            if (Gate::denies('update', $campaign)) {
                return $this->notAuthorized();
            }

            $abTest  = $campaign->abTest()->firstOrFail();
            $variant = AbTestVariant::where('uid', $variant_uid)
                ->where('ab_test_id', $abTest->id)
                ->with('email.template')
                ->firstOrFail();

            $validator = \Validator::make($request->all(), [
                'content' => 'required',
                'json' => 'required',
            ]);

            if ($validator->fails()) {
                return response()->json([
                    'message' => $validator->errors()->first(),
                ], 400);
            }

            $variant->email->setTemplateContent($request->content, $request->json);
            $campaign->updateLinks();

            return response()->json([
                'status' => 'success',
            ]);
        }

        $campaign = Campaign::findByUid($uid);
        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        $abTest  = $campaign->abTest()->firstOrFail();
        $variant = AbTestVariant::where('uid', $variant_uid)
            ->where('ab_test_id', $abTest->id)
            ->with('email.template')
            ->firstOrFail();

        $variantEmail = $variant->email;
        $template     = $variantEmail->template;

        // Copy from campaign email if variant has no template
        if (!$template && $campaign->email && $campaign->email->template) {
            $variantEmail->setTemplate($campaign->email->template, $variant->name);
            $variantEmail->refresh();
            $template = $variantEmail->template;
        }

        // No template → redirect to template selection, don't create blank
        if (!$template) {
            return redirect()->route('refactor.campaigns.template', ['uid' => $campaign->uid]);
        }

        return view('refactor.pages.campaigns.ab_test.variant_builder', [
            'campaign'  => $campaign,
            'variant'   => $variant,
            'template'  => $template,
            'templates' => $request->user()->customer->getBuilderTemplates(),
        ]);
    }

    public function trackingLogExportProgress(Request $request, $uid)
    {
        $job = JobMonitor::findByUid($uid);

        $progress = $job->getJsonData();
        $progress['status'] = $job->status;
        $progress['error'] = $job->error;
        $progress['download'] = route('refactor.campaigns.download_export', ['uid' => $job->uid]);

        return response()->json($progress);
    }

    public function downloadExport(Request $request, $uid)
    {
        $job = JobMonitor::findByUid($uid);
        $path = $job->getJsonData()['path'];

        return response()->download($path)->deleteFileAfterSend(true);
    }

    // =========================================================================
    // Public tracking endpoints — hit by deployed emails (pixels, links, etc.)
    // Routes: openTrackingUrl / clickTrackingUrl / unsubscribe.form /
    //         unsubscribe.oneclick / webViewerUrl / webViewerPreviewUrl /
    //         campaign_message
    // =========================================================================

    /**
     * Tracking pixel — registers an open event.
     */
    public function open(Request $request)
    {
        try {
            $ipAddress = $request->ip();
            $userAgent = array_key_exists('HTTP_USER_AGENT', $_SERVER) ? $_SERVER['HTTP_USER_AGENT'] : null;
            $messageId = StringHelper::base64UrlDecode($request->message_id);

            $customerUid = StringHelper::extractCustomerUidFromMessageId($messageId);
            $customer = Customer::findByUid($customerUid);
            if (!is_null($customer)) {
                $customer->setUserDbConnection();
            }

            $openLog = OpenLog::createFromMessageId($messageId, $ipAddress, $userAgent);

            if ($openLog->trackingLog && $openLog->trackingLog->campaign) {
                $openLog->trackingLog->campaign->queueOpenCallbacks($openLog);
                Timeline::recordOpenedCampaignEmail($openLog->trackingLog->subscriber, $openLog->trackingLog->campaign);
            }
        } catch (\Exception $ex) {
            // swallow — never break the pixel response
        }

        return response()->file(public_path('images/transparent.gif'));
    }

    /**
     * Click-through redirector — logs click then redirects to target URL.
     */
    public function click(Request $request)
    {
        if (ClickLog::isUrlBlacklisted($request->url)) {
            return response('SendMails has detected that this URL is malicious and has dropped it.', 403)
                ->header('Content-Type', 'text/plain');
        }

        list($url, $log) = ClickLog::createFromRequest($request);

        if ($log && $log->trackingLog && $log->trackingLog->campaign) {
            $log->trackingLog->campaign->queueClickCallbacks($log);
            Timeline::recordClickedCampaignEmail($log->trackingLog->subscriber, $log->trackingLog->campaign, $url);
        }

        return redirect()->away($url);
    }

    /**
     * Confirmation-flow unsubscribe (`unsubscribe.form`).
     *
     * GET renders the confirmation page (no DB write — bot-scan-safe).
     * POST (form submit) performs the actual unsubscribe.
     *
     * The `signed` middleware guarantees (subscriber, message_id) was produced
     * by this instance: an attacker enumerating /c/{1..N}/... gets 403.
     */
    public function unsubscribeForm(Request $request)
    {
        $messageId = StringHelper::base64UrlDecode($request->message_id);
        $this->switchToOwningCustomerDb($messageId);

        $subscriber = Subscriber::find($request->subscriber);
        if (is_null($subscriber)) {
            return view('somethingWentWrong', ['message' => trans('subscriber.invalid')]);
        }

        if ($subscriber->isUnsubscribed()) {
            return view('public.unsubscribe.already', ['subscriber' => $subscriber]);
        }

        if ($request->isMethod('GET')) {
            return view('public.unsubscribe.form', [
                'subscriber' => $subscriber,
                'submitUrl'  => $request->fullUrl(),
            ]);
        }

        // POST — form submit
        $this->writeUnsubscribe($subscriber, $messageId, $request);
        return view('public.unsubscribe.success', ['subscriber' => $subscriber]);
    }

    /**
     * RFC 8058 one-click unsubscribe (`unsubscribe.oneclick`).
     *
     * POST-only. Body MUST be exactly `List-Unsubscribe=One-Click`. Server
     * unsubscribes immediately and returns 200 with empty body. Used by Gmail
     * and Apple Mail native unsubscribe buttons. CSRF disabled on this route
     * (mail clients have no session/token); the signed URL replaces CSRF as
     * the integrity check.
     */
    public function unsubscribeOneClick(Request $request)
    {
        if ($request->input('List-Unsubscribe') !== 'One-Click') {
            abort(400, 'Invalid one-click unsubscribe body');
        }

        $messageId = StringHelper::base64UrlDecode($request->message_id);
        $this->switchToOwningCustomerDb($messageId);

        $subscriber = Subscriber::find($request->subscriber);
        if (is_null($subscriber)) {
            abort(404);
        }

        if ($subscriber->isUnsubscribed()) {
            return response('', 200); // RFC 8058: idempotent success
        }

        $this->writeUnsubscribe($subscriber, $messageId, $request);
        return response('', 200);
    }

    /**
     * Common unsubscribe write path used by all three routes (legacy, form,
     * one-click). Validates message_id against tracking_logs and falls back to
     * the subscriber's most recent tracking_log when the URL-supplied value is
     * missing or unknown (covers internal bugs + tracking_log retention).
     */
    private function writeUnsubscribe(Subscriber $subscriber, ?string $messageId, Request $request): void
    {
        $effectiveMessageId = $this->resolveAttributableMessageId($subscriber, $messageId);

        $trackingInfo = [
            'message_id' => $effectiveMessageId,
            'user_agent' => $request->userAgent(),
        ];

        $location = IpLocation::add($request->ip());
        if (!is_null($location)) {
            $trackingInfo['ip_address'] = $location->ip_address;
        }

        $subscriber->unsubscribe($trackingInfo);
    }

    /**
     * Return a message_id that is guaranteed to JOIN against tracking_logs, so
     * campaign-level unsubscribe-log reports always attribute correctly.
     *
     * Priority:
     *   1. The URL-supplied message_id, if it matches a tracking_log for this
     *      subscriber (normal case: real user clicked a real link).
     *   2. The subscriber's most recent tracking_log message_id (fallback when
     *      the URL value is junk, e.g. bot scanners truncated the path).
     *   3. NULL (subscriber with zero tracking_logs — synthetic / test data).
     */
    private function resolveAttributableMessageId(Subscriber $subscriber, ?string $messageId): ?string
    {
        if (!empty($messageId)) {
            $exists = TrackingLog::where('message_id', $messageId)
                ->where('subscriber_id', $subscriber->id)
                ->exists();
            if ($exists) {
                return $messageId;
            }
            LaravelLog::warning('Unsubscribe URL message_id not matching tracking_logs', [
                'subscriber_id'     => $subscriber->id,
                'message_id_in_url' => $messageId,
            ]);
        }

        return $subscriber->trackingLogs()
            ->orderByDesc('id')
            ->limit(1)
            ->value('message_id');
    }

    /**
     * Switch DB connection to the customer that owns the message — extracted
     * from message_id by the StringHelper. Used in all three unsubscribe entry
     * points (legacy + form + one-click) where the request is unauthenticated
     * and the customer scope must be inferred from URL.
     */
    private function switchToOwningCustomerDb(?string $messageId): void
    {
        if (empty($messageId)) {
            return;
        }
        $customerUid = StringHelper::extractCustomerUidFromMessageId($messageId);
        if (empty($customerUid)) {
            return;
        }
        $customer = Customer::findByUid($customerUid);
        if (!is_null($customer)) {
            $customer->setUserDbConnection();
        }
    }

    /**
     * Web view of a sent email (link from header "view in browser").
     */
    public function webView(Request $request)
    {
        $messageId = StringHelper::base64UrlDecode($request->message_id);

        $customerUid = StringHelper::extractCustomerUidFromMessageId($messageId);
        $customer = Customer::findByUid($customerUid);
        if (!is_null($customer)) {
            $customer->setUserDbConnection();
        }

        $tracking_log = TrackingLog::where('message_id', '=', $messageId)->first();

        try {
            if (!$tracking_log) {
                throw new \Exception(trans('messages.web_view_can_not_find_tracking_log_with_message_id'));
            }

            $subscriber = $tracking_log->subscriber;
            $campaign = $tracking_log->campaign;

            if (!$campaign || !$subscriber) {
                throw new \Exception(trans('messages.web_view_can_not_find_campaign_or_subscriber'));
            }

            return view('public.campaigns.web_view', [
                'campaign' => $campaign,
                'subscriber' => $subscriber,
                'message_id' => $messageId,
            ]);
        } catch (\Exception $e) {
            return view('somethingWentWrong', ['message' => trans('messages.the_email_no_longer_exists')]);
        }
    }

    /**
     * Web view preview — used before a campaign is sent (preview-as-subscriber).
     */
    public function webViewPreview(Request $request)
    {
        $subscriber = Subscriber::find($request->subscriber_id);
        $campaign = Campaign::findByUid($request->campaign_uid);

        if (is_null($subscriber) || is_null($campaign)) {
            throw new \Exception('Invalid subscriber or campaign UID');
        }

        return view('public.campaigns.web_view', [
            'campaign' => $campaign,
            'subscriber' => $subscriber,
            'message_id' => null,
        ]);
    }

    /**
     * Campaign test / sample-link notification page.
     */
    public function notification(Request $request)
    {
        $message = StringHelper::base64UrlDecode($request->message);
        return response($message, 200)->header('Content-Type', 'text/plain');
    }

    // =========================================================================
    // Legacy entry points — route `campaigns/{uid}` and `campaigns/{uid}/edit`.
    // Redirect bookmarks/external links into the refactor flow.
    // =========================================================================

    /**
     * Show a campaign (redirects to the refactor overview or edit wizard).
     */
    public function show($id)
    {
        $campaign = Campaign::findByUid($id);

        if (Gate::denies('read', $campaign)) {
            return $this->notAuthorized();
        }

        // Trigger CampaignUpdate event so campaign cache info is refreshed immediately
        event(new CampaignUpdated($campaign));

        if ($campaign->status == 'new') {
            return redirect()->action([static::class, 'edit'], ['uid' => $campaign->uid]);
        }

        return redirect()->route('refactor.campaigns.overview', ['uid' => $campaign->uid]);
    }

    /**
     * Resume editing a campaign — picks the right refactor wizard step based on state.
     */
    public function edit($id)
    {
        $campaign = Campaign::findByUid($id);

        if (Gate::denies('update', $campaign)) {
            return $this->notAuthorized();
        }

        if (!$campaign->default_mail_list_id) {
            return redirect()->route('refactor.campaigns.recipients', ['uid' => $campaign->uid]);
        }
        if (!$campaign->hasSetupFields()) {
            return redirect()->route('refactor.campaigns.setup', ['uid' => $campaign->uid]);
        }
        if (!$campaign->hasTemplate()) {
            return redirect()->route('refactor.campaigns.template', ['uid' => $campaign->uid]);
        }
        return redirect()->route('refactor.campaigns.confirm', ['uid' => $campaign->uid]);
    }

}