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/ai.naniguide.com/app/Http/Controllers/Refactor/AutomationController.php
<?php

namespace App\Http\Controllers\Refactor;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Model\Automation2;
use App\Model\MailList;
use App\Model\Segment;
use App\Services\TemplateService;
use Gate;

class AutomationController extends Controller
{
    public function index(Request $request)
    {
        if (Gate::denies('list', Automation2::class)) {
            return $this->notAuthorized();
        }

        $customer = $request->user()->customer;
        $total = $customer->local()->automation2s()->count();
        $active = $customer->local()->automation2s()->where('status', 'active')->count();
        $inactive = $customer->local()->automation2s()->where('status', 'inactive')->count();

        return view('refactor.pages.automations.index', [
            'customer' => $customer,
            'total' => $total,
            'active' => $active,
            'inactive' => $inactive,
        ]);
    }

    public function listing(Request $request)
    {
        if (Gate::denies('list', Automation2::class)) {
            return $this->notAuthorized();
        }

        $query = $request->user()->customer->local()->automation2s()
            ->search($request->keyword);

        // Status filter
        if (!empty($request->status)) {
            $query->where('status', $request->status);
        }

        $automations = $query
            ->orderBy($request->sort_order ?? 'created_at', $request->sort_direction ?? 'desc')
            ->paginate($request->per_page ?? 25);

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

    /**
     * Create automation wizard — single-page with step-by-step UX.
     */
    public function create(Request $request)
    {
        if (Gate::denies('create', new Automation2())) {
            return $this->noMoreItem();
        }

        $customer = $request->user()->customer;
        $lists = $customer->local()->lists()->orderBy('name')->get();

        // Available trigger types — sourced from the v2 TriggerKey enum
        // (legacy Automation2::getTriggerTypes() was dropped in Phase 2 cleanup;
        // the enum is the single source of truth for valid trigger keys now).
        $triggers = collect(\App\Domain\Automation\Enum\TriggerKey::cases())->map(function ($case) {
            $type = $case->value;
            return [
                'key'  => $type,
                'icon' => $this->triggerIcon($type),
                'name' => trans('messages.automation.trigger.' . $type),
                'desc' => trans('messages.automation.trigger.' . $type . '.desc'),
            ];
        });

        return view('refactor.pages.automations.create', [
            'triggers' => $triggers,
            'lists'    => $lists,
        ]);
    }

    /**
     * Store new automation (AJAX POST from wizard).
     */
    public function store(Request $request)
    {
        if (Gate::denies('create', new Automation2())) {
            return response()->json(['status' => 'error', 'message' => 'Unauthorized'], 403);
        }

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

        // Create automation
        $automation = $customer->local()->newDefaultAutomation2();
        $params = $request->all();

        try {
            $validator = $automation->createFromArray($params);
        } catch (\Exception $e) {
            return response()->json([
                'status' => 'error',
                'message' => $e->getMessage(),
            ], 500);
        }

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

        return response()->json([
            'status'  => 'success',
            'message' => trans('refactor/automations.flash.created'),
            'url'     => route('refactor.automations.edit', $automation->uid),
        ]);
    }

    /**
     * AJAX: Get trigger-specific option fields.
     */
    public function triggerOptions(Request $request)
    {
        $triggerType = $request->trigger_type;
        $customer = $request->user()->customer;
        $lists = $customer->local()->lists()->orderBy('name')->get();

        return view('refactor.pages.automations._trigger_options', [
            'triggerType' => $triggerType,
            'lists'       => $lists,
        ]);
    }

    /**
     * AJAX: Get segments for a mail list.
     */
    public function segmentSelect(Request $request)
    {
        $list = MailList::findByUid($request->list_uid);
        if (!$list) {
            return response()->json([]);
        }

        $segments = $list->segments()->orderBy('name')->get()
            ->map(fn($s) => [
                'uid'               => $s->uid,
                'name'              => $s->name,
                'subscribers_count' => $s->subscribersCount(true) ?? 0,
            ]);

        return response()->json($segments->values());
    }

    /**
     * AJAX: Get fields for a mail list.
     */
    public function fieldSelect(Request $request)
    {
        $list = MailList::findByUid($request->list_uid);
        if (!$list) {
            return response()->json([]);
        }

        $fields = $list->fields()->orderBy('sort_order')->get()
            ->map(fn($f) => [
                'uid'   => $f->uid,
                'label' => $f->label,
                'tag'   => $f->tag,
                'type'  => $f->type,
            ]);

        return response()->json($fields->values());
    }

    private function triggerIcon(string $type): string
    {
        return match ($type) {
            'welcome-new-subscriber'  => 'waving_hand',
            'say-happy-birthday'      => 'cake',
            'subscriber-added-date'   => 'event',
            'specific-date'           => 'calendar_today',
            'say-goodbye-subscriber'  => 'waving_hand',
            'api-3-0'                 => 'webhook',
            'weekly-recurring'        => 'date_range',
            'monthly-recurring'       => 'calendar_month',
            'tag-based'               => 'sell',
            'remove-tag'              => 'label_off',
            'attribute-update'        => 'edit_attributes',
            'woo-abandoned-cart'      => 'shopping_cart',
            default                   => 'bolt',
        };
    }

    /**
     * Editor topbar "Settings" — render an McPopup partial with the automation's
     * editable metadata. Mirrors the legacy "Settings" tab (which edited name +
     * mail list); mail-list swap lives on the Subscribers screen because of its
     * cleanup semantics, so this modal only renames the automation and links
     * out to the swap UI.
     *
     *   GET /automations/{uid}/settings
     */
    public function settingsShow(Request $request, $uid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('view', $automation)) {
            return $this->notAuthorized();
        }

        // Each list ships with its total subscriber count so the switcher
        // dropdown items show stats without extra round-trips. Counts come
        // from AppCache so this stays cheap even for customers with many lists.
        $lists = $request->user()->customer->local()->mailLists()->orderBy('name')->get(['id', 'uid', 'name']);
        $mailLists = $lists->map(fn ($l) => [
            'uid'              => $l->uid,
            'name'             => $l->name,
            'subscribers_count' => (int) (\App\Library\Cache\AppCache::for($l)->read('SubscriberCount', 0)),
        ])->values();

        // Stats for the currently-bound list — feeds the mc-perf-card.
        // Total / Active / Inactive (derived = total - active). All AppCache
        // reads, no DB hit unless the cache is cold.
        $currentListStats = null;
        $currentListCreatedAt = null;
        if ($current = $automation->mailList) {
            $cache = \App\Library\Cache\AppCache::for($current);
            $total      = (int) $cache->read('SubscriberCount', 0);
            $subscribed = (int) $cache->read('SubscribeCount',  0);
            $currentListStats = [
                'total'    => $total,
                'active'   => $subscribed,
                'inactive' => max(0, $total - $subscribed),
            ];
            $currentListCreatedAt = $current->created_at?->format('M j, Y');
        }

        // Timezone dropdown source — same shape Tool::allTimeZones returns:
        // [{zone, text, order}, ...] sorted by GMT offset. Fall back to the
        // customer's timezone (or UTC) when the automation has none set.
        $timezones = \App\Library\Tool::allTimeZones();
        $currentTimezone = $automation->time_zone ?: ($automation->customer?->timezone ?: 'UTC');

        return view('refactor.pages.automations._settings', [
            'automation'           => $automation,
            'mailLists'            => $mailLists,
            'currentListStats'     => $currentListStats,
            'currentListCreatedAt' => $currentListCreatedAt,
            'timezones'            => $timezones,
            'currentTimezone'      => $currentTimezone,
        ]);
    }

    /**
     * Persist the renamed automation. JSON response so the caller can update
     * the topbar title + page title without a reload.
     *
     *   PATCH /automations/{uid}/settings
     */
    public function settingsUpdate(Request $request, $uid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        // Each field is an independent commit (inline-edit / dropdown click) —
        // accept any subset on a single PATCH.
        $validated = $request->validate([
            'name'          => 'sometimes|required|string|max:255',
            'mail_list_uid' => 'sometimes|required|string|max:50',
            'time_zone'     => 'sometimes|required|string|max:64',
        ]);

        // Validate timezone is a real PHP zone before persisting (prevents the
        // model from blowing up later in `new DateTimeZone($zone)`).
        if (array_key_exists('time_zone', $validated)) {
            if (!in_array($validated['time_zone'], timezone_identifiers_list(), true)) {
                return response()->json(['status' => 'error', 'message' => 'Unknown timezone'], 422);
            }
        }

        $listChanged = false;
        if (array_key_exists('mail_list_uid', $validated)) {
            $newList = MailList::findByUid($validated['mail_list_uid']);
            if (!$newList) {
                return response()->json(['status' => 'error', 'message' => 'List not found'], 404);
            }
            if ($newList->customer_id !== $automation->customer_id) {
                return response()->json(['status' => 'error', 'message' => 'List does not belong to this customer'], 403);
            }
            $listChanged = $newList->id !== $automation->mail_list_id;
        }

        $dirty = false;
        if (array_key_exists('name', $validated)) {
            $automation->name = $validated['name'];
            $dirty = true;
        }
        if (array_key_exists('time_zone', $validated)) {
            $automation->time_zone = $validated['time_zone'];
            $dirty = true;
        }
        if ($dirty) {
            $automation->save();
        }

        if ($listChanged) {
            // updateMailList() wipes auto_triggers + trigger_sessions for safety.
            $automation->updateMailList($newList);
        }

        return response()->json([
            'status'       => 'success',
            'message'      => $listChanged
                ? trans('refactor/automations.contacts.list_changed_success', ['name' => $newList->name])
                : trans('refactor/automations.settings.saved'),
            'name'         => $automation->name,
            'time_zone'    => $automation->time_zone,
            'list_changed' => $listChanged,
        ]);
    }

    /**
     * Subscribers / contacts panel — show subscribers in the automation's mail list
     * with their flow progress and a manual trigger control.
     */
    public function contacts(Request $request, $uid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('view', $automation)) {
            return $this->notAuthorized();
        }

        // Per-automation subscriber-state breakdown — feeds the mc-pivot-bar
        // chart in the contacts header. Each phase is a single COUNT(*) on
        // auto_triggers (indexed on (automation2_id, phase)).
        // "Not triggered" = subscribers in the mail list who never entered the
        // flow = list_total - autoTriggers_total. Computed via two cheap
        // COUNTs rather than a NOT-IN query (cheaper at scale).
        $phaseEnum  = \App\Domain\Automation\Runtime\Phase::class;
        $base       = $automation->autoTriggers();
        $totalList  = $automation->mailList ? $automation->mailList->subscribers()->count() : 0;
        $running    = (clone $base)->where('phase', $phaseEnum::Running->value)->count();
        $waiting    = (clone $base)->where('phase', $phaseEnum::Waiting->value)->count();
        $completed  = (clone $base)->where('phase', $phaseEnum::Completed->value)->count();
        $failed     = (clone $base)->where('phase', $phaseEnum::Failed->value)->count();
        $triggered  = $running + $waiting + $completed + $failed;
        $stats = [
            'list_total'    => $totalList,
            'not_triggered' => max(0, $totalList - $triggered),
            'running'       => $running,
            'waiting'       => $waiting,
            'completed'     => $completed,
            'failed'        => $failed,
            // Kept for the existing hold filter dropdown — not shown in the bar.
            'held'          => (clone $base)->whereNotNull('held_at')->count(),
            // Backwards-compat alias for any callers still referencing in_workflow.
            'in_workflow'   => $running + $waiting,
        ];

        // Picker rows for the Node Performance section. Server-rendered into
        // the page so the picker is paintable on first byte; the body itself
        // hydrates via the contactsNodePerformance JSON endpoint.
        $nodeOptions = [];
        try {
            $nodeOptions = \App\Domain\Automation\NodePerformance::pickerOptions($automation);
        } catch (\App\Domain\Automation\FlowException $e) {
            // Bad flow JSON — show the page without the section rather than 500.
            $nodeOptions = [];
        }

        return view('refactor.pages.automations.contacts', [
            'automation'  => $automation,
            'stats'       => $stats,
            'nodeOptions' => $nodeOptions,
        ]);
    }

    public function contactsListing(Request $request, $uid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('view', $automation)) {
            return $this->notAuthorized();
        }

        $list = $automation->mailList;
        if (!$list) {
            return view('refactor.pages.automations._contacts_list', [
                'automation'  => $automation,
                'subscribers' => collect()->paginate(25),
                'triggerMap'  => [],
                'nodeLabels'  => [],
            ]);
        }

        $query = $list->subscribers();

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

        // Two independent filter dimensions — phase is execution state (tier-1)
        // and hold is admin override (tier-2). They compose via AND so admins
        // can ask e.g. "rows at Waiting AND held".
        //
        // Phase values:
        //   ''            → no filter (true All — in-workflow + not-in-workflow)
        //   'never'       → has NO trigger row
        //   'in_workflow' → has ANY trigger row (synthetic — no Phase enum)
        //   'running' / 'waiting' / 'completed' / 'failed' → exact phase match
        $automationId = $automation->id;
        $phase = $request->phase;
        $hold  = $request->hold;       // null | 'held' | 'not_held'

        if ($phase === 'never') {
            // No-trigger-row filter — `hold` is meaningless here (no row to hold).
            $query->whereDoesntHave('autoTriggers', function ($q) use ($automationId) {
                $q->where('automation2_id', $automationId);
            });
        } elseif ($phase === 'in_workflow' || in_array($phase, ['running','waiting','completed','failed'], true) || $hold) {
            $query->whereHas('autoTriggers', function ($q) use ($automationId, $phase, $hold) {
                $q->where('automation2_id', $automationId);
                if (in_array($phase, ['running','waiting','completed','failed'], true)) {
                    $q->where('phase', $phase);
                }
                // 'in_workflow' adds no extra phase predicate — just the
                // whereHas existence check is the filter.
                if ($hold === 'held') {
                    $q->whereNotNull('held_at');
                } elseif ($hold === 'not_held') {
                    $q->whereNull('held_at');
                }
            });
        }

        $direction = $request->sort_direction === 'asc' ? 'asc' : 'desc';
        $rawSort   = $request->sort_order;

        // Subscriber-level sort keys map straight to a column on `subscribers`.
        // Trigger-level sort keys (`trigger_*`) map to the latest auto_trigger
        // row for THIS automation per subscriber — implemented as a correlated
        // subquery in orderBy so subscribers without a row still appear (NULL
        // ordering follows the DB default; on MySQL: NULL last for DESC).
        $triggerSortMap = [
            'trigger_updated_at' => 'updated_at',
            'trigger_created_at' => 'created_at',
        ];

        if (isset($triggerSortMap[$rawSort])) {
            $col = $triggerSortMap[$rawSort];
            $subq = \App\Model\AutoTrigger::query()
                ->select($col)
                ->whereColumn('subscriber_id', 'subscribers.id')
                ->where('automation2_id', $automationId)
                ->orderBy('id', 'desc')
                ->limit(1);
            $query->orderBy($subq, $direction);
        } else {
            $sort = in_array($rawSort, ['email', 'created_at', 'updated_at'], true) ? $rawSort : 'created_at';
            $query->orderBy($sort, $direction);
        }

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

        // Hydrate the AutoTrigger row per subscriber for THIS automation (single bulk query).
        $subscriberIds = $subscribers->pluck('id')->all();
        $triggerMap = [];
        if (!empty($subscriberIds)) {
            $rows = $automation->autoTriggers()
                ->whereIn('subscriber_id', $subscriberIds)
                ->orderBy('id', 'desc')
                ->get();
            foreach ($rows as $row) {
                $triggerMap[$row->subscriber_id] ??= $row; // first (=latest) row wins
            }
        }

        // Map node id → human label so the list can show "Step: Send welcome email".
        $nodeLabels = [];
        if (!empty($automation->data)) {
            $flow = \App\Domain\Automation\Flow::fromJson($automation->data);
            foreach ($flow->nodes as $node) {
                $nodeLabels[$node->id] = $this->nodeLabelFor($node);
            }
        }

        return view('refactor.pages.automations._contacts_list', [
            'automation'  => $automation,
            'subscribers' => $subscribers,
            'triggerMap'  => $triggerMap,
            'nodeLabels'  => $nodeLabels,
        ]);
    }

    public function contactsTrigger(Request $request, $uid, $subscriberUid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        $subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
        if (!$subscriber || $subscriber->mail_list_id !== $automation->mail_list_id) {
            return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
        }

        // Precondition checks run BEFORE dispatch so admins see exactly which
        // gate stopped them — TriggerDispatcher::dispatch returns null on any
        // failed gate without distinguishing them, which produced the
        // ambiguous "may be paused, may be missing entry, may be already in
        // workflow" message admins couldn't act on.
        if (!$automation->isActive()) {
            return response()->json([
                'status'  => 'error',
                'message' => trans('refactor/automations.contacts.trigger_failed_paused'),
            ], 422);
        }

        try {
            $flow = \App\Domain\Automation\Flow::fromJson($automation->data);
        } catch (\Throwable $e) {
            return response()->json([
                'status'  => 'error',
                'message' => trans('refactor/automations.contacts.trigger_failed_invalid_flow'),
            ], 422);
        }

        $triggerKey = $flow->trigger()->data['key'] ?? null;
        if (!$triggerKey) {
            return response()->json([
                'status'  => 'error',
                'message' => trans('refactor/automations.contacts.trigger_failed_no_key'),
            ], 422);
        }

        $entry = $flow->outgoing(\App\Domain\Automation\Flow::TRIGGER_ID)[0] ?? null;
        if (!$entry) {
            return response()->json([
                'status'  => 'error',
                'message' => trans('refactor/automations.contacts.trigger_failed_no_entry'),
            ], 422);
        }

        $dispatcher = app(\App\Domain\Automation\Runtime\TriggerDispatcher::class);
        $row = $dispatcher->dispatch($automation, $subscriber, $force = true);

        if (!$row) {
            // After the gates above, the only remaining causes are: existing
            // trigger row (idempotent — dispatch should have returned it, so
            // null here is unexpected) OR segment filter mismatch.
            return response()->json([
                'status'  => 'error',
                'message' => trans('refactor/automations.contacts.trigger_failed_segment'),
            ], 422);
        }

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

    /**
     * Hold a subscriber's run — tier-2 admin override that pauses the row
     * WITHOUT touching its execution status (`phase` / `scheduled_at`).
     * Cron skips held rows; AutomationSendLifecycle drops stale Send-job
     * callbacks for held rows. Reverse with contactsUnhold().
     */
    public function contactsHold(Request $request, $uid, $subscriberUid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        $subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
        if (!$subscriber) {
            return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
        }

        $row = $automation->autoTriggers()
            ->where('subscriber_id', $subscriber->id)
            ->orderBy('id', 'desc')
            ->first();

        if (!$row) {
            return response()->json(['status' => 'error', 'message' => 'No active run to hold'], 404);
        }

        if ($row->held_at !== null) {
            return response()->json([
                'status'  => 'success',
                'message' => trans('refactor/automations.contacts.hold_already'),
            ]);
        }

        $row->held_at = now();
        // History records the override; phase + scheduled_at unchanged. The
        // outcome value `held` is distinct from `fail` so reports can split
        // admin actions from system failures cleanly.
        $row->appendHistory([
            'node'        => $row->current_node_id,
            'outcome'     => 'held',
            'phase_after' => $row->phase,
        ]);
        $row->save();

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

    /**
     * Release a held row back to the cron / executor pipeline. Phase +
     * scheduled_at are unchanged (they were preserved by Hold) so the row
     * picks up on the next due tick.
     */
    public function contactsUnhold(Request $request, $uid, $subscriberUid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        $subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
        if (!$subscriber) {
            return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
        }

        $row = $automation->autoTriggers()
            ->where('subscriber_id', $subscriber->id)
            ->orderBy('id', 'desc')
            ->first();

        if (!$row) {
            return response()->json(['status' => 'error', 'message' => 'No run found'], 404);
        }

        if ($row->held_at === null) {
            return response()->json([
                'status'  => 'success',
                'message' => trans('refactor/automations.contacts.unhold_already'),
            ]);
        }

        $row->held_at = null;
        $row->appendHistory([
            'node'        => $row->current_node_id,
            'outcome'     => 'released',
            'phase_after' => $row->phase,
        ]);
        $row->save();

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

    /**
     * Delete a subscriber's auto_trigger row. Resets their state to "Not
     * triggered" — they become eligible to re-enter the workflow on the next
     * matching event / cron tick. History is wiped along with the row; if
     * an audit trail is needed, query before delete.
     *
     * Distinct from Hold (tier-2 admin override that preserves the row): this
     * is the destructive sibling — no resume point. Useful for rolling back
     * mistaken triggers or re-running a flow from scratch on a single subscriber.
     */
    public function contactsDeleteTrigger(Request $request, $uid, $subscriberUid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        $subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
        if (!$subscriber) {
            return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
        }

        $rowsDeleted = $automation->autoTriggers()
            ->where('subscriber_id', $subscriber->id)
            ->delete();

        if ($rowsDeleted === 0) {
            return response()->json([
                'status'  => 'error',
                'message' => trans('refactor/automations.contacts.delete_trigger_failed_no_row'),
            ], 404);
        }

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

    /**
     * Render the "Progress" modal body for one subscriber's AutoTrigger row.
     * Returns HTML (consumed by McPopup.open({ url })) so the caller doesn't
     * have to render the diagnostic table client-side.
     *
     *   GET /automations/{uid}/contacts/{subscriber_uid}/progress
     */
    public function contactsProgress(Request $request, $uid, $subscriberUid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('view', $automation)) {
            return $this->notAuthorized();
        }

        $subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
        if (!$subscriber) {
            abort(404);
        }

        $row = $automation->autoTriggers()
            ->where('subscriber_id', $subscriber->id)
            ->orderBy('id', 'desc')
            ->first();

        // Build a node-id → human label map from the CURRENT flow so deleted
        // nodes show as "(deleted)" — same convention as the History modal.
        $labels = [];
        if ($row && !empty($automation->data)) {
            $flow = \App\Domain\Automation\Flow::fromJson($automation->data);
            foreach ($flow->nodes as $n) {
                $labels[$n->id] = $this->nodeLabelFor($n);
            }
            $labels[\App\Domain\Automation\Flow::TRIGGER_ID] = $labels[\App\Domain\Automation\Flow::TRIGGER_ID]
                ?? trans('refactor/automations.contacts.step_trigger');
        }

        $nodeLabel = $row && $row->current_node_id && isset($labels[$row->current_node_id])
            ? $labels[$row->current_node_id]
            : null;

        // Resolve history entries the same way contactsHistory() does so the
        // timeline can show "(deleted)" for nodes the editor pruned mid-run.
        $history = [];
        if ($row && is_array($row->history)) {
            $history = array_map(function ($e) use ($labels) {
                $nodeId    = $e['node'] ?? null;
                $nodeLabel = $nodeId
                    ? ($labels[$nodeId] ?? trans('refactor/automations.contacts.step_deleted'))
                    : '—';
                return $e + ['nodeLabel' => $nodeLabel];
            }, $row->history);
        }

        return view('refactor.pages.automations._contacts_progress', [
            'automation' => $automation,
            'subscriber' => $subscriber,
            'row'        => $row,
            'nodeLabel'  => $nodeLabel,
            'history'    => $history,
        ]);
    }

    /**
     * Manually advance the subscriber's AutoTrigger by exactly one step
     * (same primitive the cron uses). Returns the post-advance phase so the
     * Progress modal can refresh itself.
     *
     *   POST /automations/{uid}/contacts/{subscriber_uid}/advance
     */
    /**
     * Retry a Failed AutoTrigger row. Distinct from Advance:
     *  - Advance is a no-op on terminal phases (FlowExecutor::advance L63-65).
     *  - Retry resets `phase=Ready`, clears `error`, appends a 'retry' history
     *    marker, then drains so the handler at `current_node_id` runs again.
     *
     * Only meaningful on Failed rows; returns 422 otherwise so the UI surfaces
     * a clear "nothing to retry" instead of pretending to do work.
     */
    public function contactsRetry(Request $request, $uid, $subscriberUid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        $subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
        if (!$subscriber) {
            return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
        }

        $row = $automation->autoTriggers()
            ->where('subscriber_id', $subscriber->id)
            ->orderBy('id', 'desc')
            ->first();

        if (!$row) {
            return response()->json(['status' => 'error', 'message' => trans('refactor/automations.contacts.progress_no_trigger')], 404);
        }

        if ($row->phase !== \App\Domain\Automation\Runtime\Phase::Failed->value) {
            return response()->json([
                'status'  => 'error',
                'message' => trans('refactor/automations.contacts.retry_not_failed'),
            ], 422);
        }

        // Record what we're retrying (the row's last error) before we wipe it,
        // so the timeline still shows what failed + that an admin retried.
        $previousError = $row->error;
        $row->appendHistory([
            'node'        => $row->current_node_id,
            'outcome'     => 'retry',
            'error'       => $previousError,
            'phase_after' => \App\Domain\Automation\Runtime\Phase::Running->value,
        ]);
        $row->phase        = \App\Domain\Automation\Runtime\Phase::Running->value;
        $row->error        = null;
        $row->scheduled_at = null;
        $row->save();

        // Drain — the same handler at current_node_id runs fresh; if it
        // succeeds + advances, the row continues forward in the same call.
        $phase = app(\App\Domain\Automation\Runtime\FlowExecutor::class)->drain($row);

        return response()->json([
            'status'  => 'success',
            'message' => trans('refactor/automations.contacts.retry_success', ['phase' => $phase->value]),
            'phase'   => $phase->value,
        ]);
    }

    /**
     * Retry every Failed AutoTrigger row for this automation. Bulk version of
     * contactsRetry — same per-row contract (reset phase to Running, clear
     * error, drain) applied to all Failed rows in one HTTP round-trip.
     *
     * Drain is synchronous per row; for an automation with hundreds of failed
     * rows this could be slow. We cap at 500 per call so the worst case is
     * bounded (~10s if each drain is 20ms). If the count is higher, the admin
     * can re-click — subsequent calls will pick up the next batch since
     * successfully-drained rows are no longer Failed.
     *
     * Each row gets its own try/catch so one row's structural failure (deleted
     * node, missing subscriber) doesn't abort the rest.
     */
    public function contactsRetryAll(Request $request, $uid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        $failed = $automation->autoTriggers()
            ->where('phase', \App\Domain\Automation\Runtime\Phase::Failed->value)
            ->limit(500)
            ->get();

        if ($failed->isEmpty()) {
            return response()->json([
                'status'  => 'success',
                'message' => trans('refactor/automations.contacts.retry_all_none'),
                'retried' => 0, 'recovered' => 0, 'still_failed' => 0,
            ]);
        }

        $exec = app(\App\Domain\Automation\Runtime\FlowExecutor::class);
        $recovered = 0;
        $stillFailed = 0;

        foreach ($failed as $row) {
            try {
                $row->appendHistory([
                    'node'        => $row->current_node_id,
                    'outcome'     => 'retry',
                    'error'       => $row->error,
                    'phase_after' => \App\Domain\Automation\Runtime\Phase::Running->value,
                ]);
                $row->phase        = \App\Domain\Automation\Runtime\Phase::Running->value;
                $row->error        = null;
                $row->scheduled_at = null;
                $row->save();

                $phase = $exec->drain($row);
                if ($phase === \App\Domain\Automation\Runtime\Phase::Failed) {
                    $stillFailed++;
                } else {
                    $recovered++;
                }
            } catch (\Throwable $e) {
                // Don't let one bad row kill the loop; surface in count.
                \Log::warning(sprintf(
                    'contactsRetryAll: row #%d threw — %s',
                    $row->id, $e->getMessage()
                ));
                $stillFailed++;
            }
        }

        return response()->json([
            'status'   => 'success',
            'message'  => trans('refactor/automations.contacts.retry_all_success', [
                'retried' => $failed->count(),
                'recovered' => $recovered,
                'still_failed' => $stillFailed,
            ]),
            'retried'      => $failed->count(),
            'recovered'    => $recovered,
            'still_failed' => $stillFailed,
        ]);
    }

    public function contactsAdvance(Request $request, $uid, $subscriberUid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        $subscriber = \App\Model\Subscriber::findByUid($subscriberUid);
        if (!$subscriber) {
            return response()->json(['status' => 'error', 'message' => 'Subscriber not found'], 404);
        }

        $row = $automation->autoTriggers()
            ->where('subscriber_id', $subscriber->id)
            ->orderBy('id', 'desc')
            ->first();

        if (!$row) {
            return response()->json(['status' => 'error', 'message' => trans('refactor/automations.contacts.progress_no_trigger')], 404);
        }

        $phase = app(\App\Domain\Automation\Runtime\FlowExecutor::class)->advance($row);

        return response()->json([
            'status'  => 'success',
            'message' => trans('refactor/automations.contacts.progress_advanced', ['phase' => $phase->value]),
            'phase'   => $phase->value,
        ]);
    }

    /**
     * Swap the automation's mail list. Wipes all auto_triggers + trigger_sessions
     * (existing rows reference subscribers from the old list and would orphan).
     * Mirrors the legacy sidebar's list-change action — uses the same
     * `Automation2::updateMailList()` helper which already calls `resetListRelatedData()`.
     *
     *   POST /automations/{uid}/mail-list  body: { list_uid }
     */
    public function changeMailList(Request $request, $uid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        $validated = $request->validate(['list_uid' => 'required|string|max:50']);

        $newList = MailList::findByUid($validated['list_uid']);
        if (!$newList) {
            return response()->json(['status' => 'error', 'message' => 'List not found'], 404);
        }
        // Ownership guard — never let a customer point an automation at another customer's list.
        if ($newList->customer_id !== $automation->customer_id) {
            return response()->json(['status' => 'error', 'message' => 'List does not belong to this customer'], 403);
        }

        if ($newList->id === $automation->mail_list_id) {
            return response()->json([
                'status'  => 'success',
                'message' => 'No change — already pointing at this list.',
            ]);
        }

        $automation->updateMailList($newList);

        return response()->json([
            'status'  => 'success',
            'message' => trans('refactor/automations.contacts.list_changed_success', ['name' => $newList->name]),
        ]);
    }

    /**
     * Per-node performance JSON endpoint for the picker on the Subscribers
     * screen. Returns the picked node's stats only (the picker itself ships in
     * the page render to keep the JSON light).
     *
     *   GET /automations/{uid}/contacts/node-performance?node={nodeId}
     */
    public function contactsNodePerformance(Request $request, $uid)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('view', $automation)) {
            return response()->json(['ok' => false, 'message' => 'Not authorized'], 403);
        }

        $nodeId = (string) $request->query('node', '');
        if ($nodeId === '') {
            return response()->json(['ok' => false, 'message' => 'node param required'], 422);
        }

        try {
            $payload = \App\Domain\Automation\NodePerformance::forNode($automation, $nodeId);
        } catch (\App\Domain\Automation\FlowException $e) {
            return response()->json(['ok' => false, 'message' => $e->getMessage()], 404);
        }

        return response()->json(['ok' => true] + $payload);
    }

    /**
     * Send-node deep-dive overview. Bound to nodeId so the URL stays stable
     * even if admin replaces the bound email; controller resolves
     * nodeId → AutomationEmail via the Flow JSON.
     */
    public function emailReport(Request $request, $uid, $nodeId)
    {
        [$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
        if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;

        $scope = \App\Library\Cache\AppCache::for($email);

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

        $top = $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, 'first_sent_at' => null, 'last_sent_at' => null,
        ]);

        $reportComputedAt = $email->getOverviewComputedAt();

        return view('refactor.pages.automations.email_report', [
            'automation'         => $automation,
            'email'              => $email,
            'nodeId'             => $nodeId,
            'subscriberCount'    => $subscriberCount,
            'deliveredCount'     => $deliveredCount,
            'failedCount'        => $failedCount,
            'openCount'          => $openCount,
            'openRate'           => $openRate,
            'clickRate'          => $clickRate,
            'deliveredRate'      => $deliveredRate,
            'notOpenRate'        => $notOpenRate,
            'clickCount'         => $clickCount,
            'uniqueClickCount'   => $uniqueClickCount,
            'bounceCount'        => $bounceCount,
            'unsubscribeCount'   => $unsubscribeCount,
            'feedbackCount'      => $feedbackCount,
            'bounceRate'         => $bounceRate,
            'unsubscribeRate'    => $unsubscribeRate,
            'feedbackRate'       => $feedbackRate,
            'topLinks'           => $top['top_links'] ?? collect(),
            'topOpenCountries'   => $top['top_open_countries'] ?? collect(),
            'topClickCountries'  => $top['top_click_countries'] ?? collect(),
            'topActiveSubscribers' => $top['top_active_subscribers'] ?? collect(),
            'topLocations'       => $top['top_locations'] ?? collect(),
            'lastOpenAt'         => $top['last_open_at'] ?? null,
            'lastClickAt'        => $top['last_click_at'] ?? null,
            'firstSentAt'        => $top['first_sent_at'] ?? null,
            'lastSentAt'         => $top['last_sent_at'] ?? null,
            'reportComputedAt'   => $reportComputedAt,
        ]);
    }

    public function emailReportInsights(Request $request, $uid, $nodeId)
    {
        [$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
        if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;

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

        $scope = \App\Library\Cache\AppCache::for($email);
        $subscriberCount = (int) $scope->read('SubscriberCount', 0);
        $deliveredCount  = (int) $scope->read('DeliveredCount', 0);
        $failedCount     = (int) $scope->read('FailedDeliveredCount', 0);

        return view('refactor.pages.automations.email_report_insights', [
            'automation'      => $automation,
            'email'           => $email,
            'nodeId'          => $nodeId,
            'list'            => $list,
            'columns'         => $columns,
            'fields'          => $fields,
            'subscriberCount' => $subscriberCount,
            'deliveredCount'  => $deliveredCount,
            'failedCount'     => $failedCount,
        ]);
    }

    public function emailReportInsightsListing(Request $request, $uid, $nodeId)
    {
        [$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
        if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;

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

        // Subscribers who passed through this Send node (have a tracking_log row).
        $subscribers = \App\Model\Subscriber::query()
            ->select('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')
            ->addSelect('tracking_logs.status AS tracking_status_val')
            ->join('tracking_logs', 'tracking_logs.subscriber_id', '=', 'subscribers.id')
            ->where('tracking_logs.automation_email_id', $email->id)
            ->leftJoin('bounce_logs', 'bounce_logs.tracking_log_id', '=', 'tracking_logs.id')
            ->leftJoin('feedback_logs', 'feedback_logs.tracking_log_id', '=', 'tracking_logs.id')
            ->leftJoin('unsubscribe_logs', 'unsubscribe_logs.message_id', '=', 'tracking_logs.message_id');

        if ($request->open === 'yes') {
            $subscribers->whereExists(function ($q) {
                $q->select(\DB::raw(1))
                    ->from('open_logs')
                    ->whereColumn('open_logs.message_id', 'tracking_logs.message_id');
            });
        } elseif ($request->open === 'not_opened') {
            $subscribers->whereNotExists(function ($q) {
                $q->select(\DB::raw(1))
                    ->from('open_logs')
                    ->whereColumn('open_logs.message_id', 'tracking_logs.message_id');
            });
        }

        if ($request->click === 'clicked') {
            $subscribers->whereExists(function ($q) {
                $q->select(\DB::raw(1))
                    ->from('click_logs')
                    ->whereColumn('click_logs.message_id', 'tracking_logs.message_id');
            });
        } elseif ($request->click === 'not_clicked') {
            $subscribers->whereNotExists(function ($q) {
                $q->select(\DB::raw(1))
                    ->from('click_logs')
                    ->whereColumn('click_logs.message_id', 'tracking_logs.message_id');
            });
        }

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

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

        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->orderBy('tracking_logs.created_at', 'desc');
        }

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

        $list = $email->defaultMailList;
        $columns = $request->user()->getSetting('subscribers_columns') ?? [];
        // Same CSV-bridge as CampaignController::subscribersListing — McList
        // collapses array filters to comma-joined strings, so the picker
        // ships columns_csv and we explode here. Falls back to the saved
        // setting if no explicit selection is present on 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();

        $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.automations._email_report_insights_list', [
            'automation'   => $automation,
            'email'        => $email,
            'nodeId'       => $nodeId,
            'list'         => $list,
            'subscribers'  => $subscribers,
            'fields'       => $fields,
            'columns'      => $columns,
            'filterMode'   => $filterMode,
            'firstNameCol' => $firstNameCol,
            'lastNameCol'  => $lastNameCol,
        ]);
    }

    public function emailReportOpenLog(Request $request, $uid, $nodeId)
    {
        return $this->emailReportLogPage($uid, $nodeId, 'open_log');
    }

    public function emailReportClickLog(Request $request, $uid, $nodeId)
    {
        return $this->emailReportLogPage($uid, $nodeId, 'click_log');
    }

    public function emailReportBounceLog(Request $request, $uid, $nodeId)
    {
        return $this->emailReportLogPage($uid, $nodeId, 'bounce_log');
    }

    public function emailReportOpenMap(Request $request, $uid, $nodeId)
    {
        [$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
        if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;

        $locations = $email->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->enrichLocationsWithNames($locations, $email);

        return view('refactor.pages.automations.email_report_log', [
            'automation' => $automation,
            'email'      => $email,
            'nodeId'     => $nodeId,
            'activeTab'  => 'open_map',
            'locations'  => $locations,
        ]);
    }

    public function emailReportClickMap(Request $request, $uid, $nodeId)
    {
        [$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
        if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;

        $locations = $email->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->enrichLocationsWithNames($locations, $email);

        return view('refactor.pages.automations.email_report_log', [
            'automation' => $automation,
            'email'      => $email,
            'nodeId'     => $nodeId,
            'activeTab'  => 'click_map',
            'locations'  => $locations,
        ]);
    }

    /**
     * Mirror of CampaignController::enrichWithNames — single batch SQL to
     * resolve FIRST_NAME / LAST_NAME columns for marker subscribers, plus a
     * deterministic demo avatar URL when isSiteDemo() is on. Adds first_name,
     * last_name, full_name, initials, avatar_url keys.
     */
    private function enrichLocationsWithNames($locations, \App\Model\AutomationEmail $email)
    {
        $list = $email->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;
        });
    }

    /**
     * Dispatch RefreshCacheableJob for the bound AutomationEmail. Mirrors
     * CampaignController::updateStatistics — async, no page reload, freshness
     * strip will reflect the new "computed at" once the worker finishes.
     */
    public function emailReportRefresh(Request $request, $uid, $nodeId)
    {
        [$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
        if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;

        \App\Jobs\RefreshCacheableJob::dispatchAll($email);

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

    private function emailReportLogPage($uid, $nodeId, $activeTab)
    {
        [$automation, $email] = $this->resolveSendNodeEmail($uid, $nodeId);
        if ($automation instanceof \Symfony\Component\HttpFoundation\Response) return $automation;

        return view('refactor.pages.automations.email_report_log', [
            'automation' => $automation,
            'email'      => $email,
            'nodeId'     => $nodeId,
            'activeTab'  => $activeTab,
        ]);
    }

    /**
     * Resolve a {automation uid, nodeId} pair to its bound AutomationEmail.
     * Returns either [$automation, $email] or a single Response on error so
     * callers can short-circuit with one `instanceof Response` check.
     */
    private function resolveSendNodeEmail($uid, $nodeId)
    {
        $automation = Automation2::findByUid($uid);
        if (!$automation || \Gate::denies('view', $automation)) {
            return [$this->notAuthorized(), null];
        }

        try {
            $flow = \App\Domain\Automation\Flow::fromJson($automation->data);
            $node = $flow->node($nodeId);
        } catch (\App\Domain\Automation\FlowException $e) {
            abort(404, $e->getMessage());
        }

        if ($node->type !== \App\Domain\Automation\Enum\NodeType::Send) {
            abort(422, "Node {$nodeId} is not a Send node");
        }

        $emailUid = $node->data['emailUid'] ?? null;
        if (!$emailUid) {
            abort(422, "Send node {$nodeId} has no email bound yet");
        }

        $email = \App\Model\AutomationEmail::findByUid($emailUid);
        if (!$email) {
            abort(404, 'Email not found');
        }

        return [$automation, $email];
    }

    /**
     * Build a short human label for a node — used in the contacts list to show
     * which step a subscriber is currently at.
     */
    private function nodeLabelFor(\App\Domain\Automation\Node $node): string
    {
        $data = $node->data;

        return match ($node->type) {
            \App\Domain\Automation\Enum\NodeType::Trigger =>
                trans('refactor/automations.trigger.' . ($data['key'] ?? 'unknown'), [], null) ?: trans('refactor/automations.trigger.unknown'),
            \App\Domain\Automation\Enum\NodeType::Send =>
                ($data['emailSubject'] ?? null) ?: trans('refactor/automations.contacts.step_send'),
            \App\Domain\Automation\Enum\NodeType::Wait =>
                trans('refactor/automations.contacts.step_wait'),
            \App\Domain\Automation\Enum\NodeType::WaitUntil =>
                trans('refactor/automations.contacts.step_wait_until'),
            \App\Domain\Automation\Enum\NodeType::Condition =>
                trans('refactor/automations.contacts.step_condition'),
            \App\Domain\Automation\Enum\NodeType::Operation =>
                trans('refactor/automations.contacts.step_operation'),
            \App\Domain\Automation\Enum\NodeType::Webhook =>
                trans('refactor/automations.contacts.step_webhook'),
        };
    }

    public function delete(Request $request)
    {
        $automations = Automation2::whereIn(
            'uid',
            is_array($request->uids) ? $request->uids : explode(',', $request->uids)
        );

        $deleted = 0;
        foreach ($automations->get() as $automation) {
            if ($request->user()->customer->can('delete', $automation)) {
                $automation->delete();
                $deleted++;
            }
        }

        return response()->json([
            'message' => $deleted . ' ' . trans('refactor/automations.flash.deleted'),
        ]);
    }

    public function edit(Request $request, $uid)
    {
        $automation = \App\Model\Automation2::findByUid($uid);

        if (!$automation || \Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

        $automation->updateCacheInBackground();

        // Always use the new editor. Flow::fromJson tolerates null and legacy
        // shape (returns empty) — legacy data gets overwritten on first save.
        $flow = \App\Domain\Automation\Flow::fromJson($automation->data);

        $listFieldOptions = collect($automation->mailList->getFields()->get())
            ->map(fn ($f) => ['value' => $f->uid, 'label' => $f->label, 'type' => $f->type])
            ->values()
            ->all();

        $emailOptions = $automation->emails()
            ->orderBy('created_at', 'desc')
            ->get()
            ->map(fn ($e) => ['value' => $e->uid, 'label' => $e->subject ?: '(untitled)'])
            ->all();

        $mailListOptions = $request->user()->customer->local()
            ->mailLists()
            ->where('id', '<>', $automation->mail_list_id)
            ->orderBy('name')
            ->get()
            ->map(fn ($l) => ['value' => $l->uid, 'label' => $l->name])
            ->all();

        $httpConfigOptions = \App\Model\HttpConfig::where('customer_id', $automation->customer_id)
            ->orderBy('id', 'desc')
            ->get()
            ->map(fn ($c) => ['value' => $c->uid, 'label' => $c->request_url])
            ->all();

        return view('refactor.automations.edit', [
            'automation' => $automation,
            // Use AutomationFlowController's wireFlow helper so the initial
            // payload carries per-node `validation` decoration the same way
            // every REST endpoint does — single source of truth for "is this
            // node ready?" lives in NodeValidator.
            'flow'       => app(\App\Http\Controllers\Refactor\AutomationFlowController::class)
                                ->wireFlow($flow, $automation),
            'ctx'        => [
                'triggerKeys' => array_map(
                    fn ($c) => [
                        'value' => $c->value,
                        // Prefer the (longer, marketing-tone) legacy label for the picker — it
                        // describes the event to non-technical operators better than the short
                        // enum case name (e.g. "Welcome new subscribers" vs "WelcomeNewSubscriber").
                        'label' => trans('messages.automation.trigger.' . $c->value, [], null) ?: $c->name,
                        'desc'  => trans('messages.automation.trigger.' . $c->value . '.desc', [], null) ?: '',
                        'icon'  => $this->triggerIcon($c->value),
                    ],
                    \App\Domain\Automation\Enum\TriggerKey::cases()
                ),
                'listFields'   => $listFieldOptions,
                'emails'       => $emailOptions,
                'mailLists'    => $mailListOptions,
                'httpConfigs'  => $httpConfigOptions,
                // Defaults the inline "create email" wizard prefills with —
                // mailList.from_* are admin-configured per-list defaults.
                'emailDefaults' => [
                    'fromName'  => $automation->mailList->from_name  ?? '',
                    'fromEmail' => $automation->mailList->from_email ?? '',
                ],
                // Per-trigger config the sidebar sub-form needs. Currently:
                //   - api-3-0 → POST endpoint URL the customer's app calls
                //     to push subscribers into the workflow. UID-bound so the
                //     worker can resolve back to this automation.
                'triggerConfig' => [
                    'apiEndpoint' => route('automation_execute', ['uid' => $automation->uid]),
                ],
            ],
        ]);
    }

    public function templateEdit(Request $request, $uid, $email_uid)
    {
        $automation = Automation2::findByUid($uid);
        $email = \App\Model\AutomationEmail::findByUid($email_uid);

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

        if ($request->isMethod('post')) {
            $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)
                && \App\Model\Setting::isYes('campaign.enforce_unsubscribe_url_check')
                && !\App\Library\StringHelper::containsUnsubscribeTag($request->content)) {
                return response()->json(['message' => trans('messages.template.validation.unsubscribe_url_required')], 400);
            }

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

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

        // Guard: builder view derefs $email->getEmailTemplate()->uid — bare
        // AutomationEmail rows from Stage 1 have no template until the user
        // completes Stage 2 (template picker). Bounce them back to the editor
        // with a clear flash rather than crashing on the template view.
        if (!$email->hasTemplate()) {
            return redirect()->route('refactor.automations.edit', $automation->uid)
                ->with('alert-error', trans('refactor/automations.email.template_required_first'));
        }

        return view('refactor.pages.automations.template.edit', [
            'automation' => $automation,
            'list'       => $automation->mailList,
            'email'      => $email,
            'templates'  => $request->user()->customer->getBuilderTemplates(),
            'customer'   => $automation->customer,
        ]);
    }

    /**
     * Paste raw HTML as an automation email's template content. GET renders textarea; POST saves.
     */
    public function customHtml(Request $request, $uid, $email_uid)
    {
        $automation = Automation2::findByUid($uid);
        $email = \App\Model\AutomationEmail::findByUid($email_uid);

        if (!$automation || !$email || Gate::denies('update', $automation)) {
            return $this->notAuthorized();
        }

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

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

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

            return redirect()->route('refactor.automations.edit', $automation->uid);
        }

        return view('refactor.pages.automations.custom_html', [
            'automation' => $automation,
            'email' => $email,
            'currentHtml' => $email->isCustomHtml() ? ($email->template->content ?? '') : '',
        ]);
    }

    public function copy(Request $request, $uid)
    {
        $automation = Automation2::findByUid($uid);

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

        if ($request->isMethod('post')) {
            [$validator, $newAutomation] = $automation->copy($request->name, $request->mail_list_uid);

            if ($validator->fails()) {
                return response()->view('refactor.pages.automations.copy', [
                    'automation'    => $automation,
                    'newAutomation' => $newAutomation,
                    'errors'        => $validator->errors(),
                ], 400);
            }

            return response()->json([
                'status'  => 'success',
                'message' => trans('messages.automation.copied', ['name' => $newAutomation->name]),
            ]);
        }

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

    public function enable(Request $request)
    {
        if (config('custom.japan') && !\App\Helpers\LicenseHelper::hasActiveLicense()) {
            return response()->json([
                'status'  => 'error',
                'message' => trans('messages.license.required'),
            ], 400);
        }

        $automations = Automation2::whereIn(
            'uid',
            is_array($request->uids) ? $request->uids : explode(',', $request->uids)
        );

        foreach ($automations->get() as $automation) {
            if (Gate::allows('enable', $automation)) {
                $automation->enable();
            }
        }

        return response()->json([
            'status'  => 'success',
            'message' => trans_choice('messages.automation.enabled', $automations->count()),
        ]);
    }

    public function disable(Request $request)
    {
        $automations = Automation2::whereIn(
            'uid',
            is_array($request->uids) ? $request->uids : explode(',', $request->uids)
        );

        foreach ($automations->get() as $automation) {
            if (Gate::allows('disable', $automation)) {
                $automation->disable();
            }
        }

        return response()->json([
            'status'  => 'success',
            'message' => trans_choice('messages.automation.disabled', $automations->count()),
        ]);
    }
}