<?php

namespace App\Http\Controllers\Api\Mobile;

use App\Http\Controllers\Controller;
use App\Models\Attachment;
use App\Models\Conversation;
use App\Models\EmailAccount;
use App\Models\Message;
use App\Models\Tag;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;

/**
 * Mobile-app inbox endpoints.
 *
 * All routes are bearer-token authenticated and scoped to the user's
 * active workspace. Responses are shaped specifically for the mobile UI
 * (smaller payloads than web /inbox/api/*) — mobile gets only what each
 * screen renders, no extra eager-loaded relations.
 */
class InboxController extends Controller
{
    private const ATTACHMENT_RULE = 'file|max:25600|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,csv,txt,zip,mp4,mp3';

    /**
     * GET /api/v1/mobile/inbox/conversations
     * Query: ?folder=primary&channel=email&search=foo&starred=1&page=1&per_page=25
     */
    public function index(Request $request): JsonResponse
    {
        $user = $request->user();
        $workspaceId = $user->active_workspace_id;
        if (!$workspaceId) {
            return response()->json(['data' => [], 'meta' => $this->emptyMeta()]);
        }

        // Pagination removed at client request — Flutter team wants the full
        // list in one shot. Hard-cap at MAX_ROWS to protect against runaway
        // workspaces (10k+ conversations would OOM the request). If a
        // workspace ever exceeds this, the cap kicks in and a follow-up
        // can add server-side filtering before re-enabling paging.
        $MAX_ROWS = 5000;

        $q = Conversation::query()
            ->where('workspace_id', $workspaceId)
            ->with([
                'contact:id,first_name,last_name,email,phone,avatar_path,company',
                'emailAccount:id,display_name,email,provider,status',
            ])
            ->orderByDesc('last_message_at')
            ->orderByDesc('id');

        // Folder filter
        //
        // Definition of a "draft" message — kept in sync with
        // InboxDraftsController::draftQuery() so the Drafts folder in
        // the inbox list and the dedicated /drafts surface always
        // agree on which messages count.
        $draftPredicate = function ($m) {
            $m->where('direction', 'outbound')
                ->where('delivery_status', 'queued')
                ->whereNull('scheduled_at')
                ->whereNull('sent_at');
        };

        switch ($request->query('folder')) {
            case 'starred':  $q->where('is_starred', true)->whereNull('deleted_at'); break;
            case 'snoozed':
                $q->whereNull('deleted_at')
                    ->where(function ($w) {
                        $w->where('status', 'snoozed')
                            ->orWhere(function ($inner) {
                                $inner->whereNotNull('snoozed_until')->where('snoozed_until', '>', now());
                            });
                    });
                break;
            case 'closed':   $q->where('status', 'closed')->whereNull('deleted_at'); break;
            case 'spam':     $q->where('status', 'spam')->whereNull('deleted_at'); break;
            case 'unread':   $q->where('is_read', false)->whereNull('deleted_at'); break;
            case 'archived': $q->where('status', 'closed')->whereNull('deleted_at'); break;
            case 'bin':
            case 'trash':    $q->onlyTrashed(); break;
            case 'all_mail': $q->whereNull('deleted_at'); break;
            case 'sent':
                $q->whereNull('deleted_at')
                  ->whereHas('messages', fn ($m) => $m->where('direction', 'outbound')->whereNotNull('sent_at'));
                break;
            // Drafts: any non-trashed conversation that contains at
            // least one message matching the draft predicate above.
            // The shapeRow() output also flips `has_draft=true` for
            // these rows so the Flutter app can render a "Draft"
            // chip without needing a second roundtrip.
            case 'draft':
            case 'drafts':
                $q->whereNull('deleted_at')->whereHas('messages', $draftPredicate);
                break;
            case 'primary':
            case 'inbox':
            // Default (no folder param) — same rule as inbox: real
            // threads only, no drafts, no trashed. Drafts live under
            // ?folder=drafts; trashed under ?folder=bin.
            //
            // A conversation counts as "real" when it has at least one
            // non-draft message — i.e. ANY inbound message OR ANY
            // outbound message that is NOT a queued draft (sent,
            // scheduled, failed, etc all count as non-draft).
            default:
                $q->whereNull('deleted_at')
                  ->whereHas('messages', function ($m) {
                      $m->where(function ($w) {
                          $w->where('direction', 'inbound')
                            ->orWhere('delivery_status', '!=', 'queued')
                            ->orWhereNotNull('scheduled_at')
                            ->orWhereNotNull('sent_at');
                      });
                  });
                // `inbox` also restricts to active statuses; default
                // returns everything that has at least one real message
                // regardless of status (mirrors Gmail's "All Mail").
                if (in_array($request->query('folder'), ['primary', 'inbox'], true)) {
                    $q->whereIn('status', ['open', 'pending']);
                }
                break;
        }

        // Channel filter
        if ($ch = $request->query('channel')) {
            if ($ch !== 'all') $q->where('channel', $ch);
        }

        // Search (subject / preview / contact name / email)
        if ($search = trim((string) $request->query('search', ''))) {
            $like = '%' . $search . '%';
            $q->where(function ($w) use ($like) {
                $w->where('subject', 'like', $like)
                  ->orWhere('preview', 'like', $like)
                  ->orWhereHas('contact', fn ($c) => $c->where('name', 'like', $like)->orWhere('email', 'like', $like));
            });
        }

        // Starred-only override
        if ($request->boolean('starred')) {
            $q->where('is_starred', true);
        }

        $rows = $q->limit($MAX_ROWS)->get();

        return response()->json([
            'data' => $rows->map(fn (Conversation $c) => $this->shapeRow($c))->all(),
        ]);
    }

    /**
     * GET /api/v1/mobile/inbox/conversations/{id}
     * Returns conversation + messages array (newest at the bottom).
     */
    public function show(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $workspaceId = $user->active_workspace_id;
        $userTimezone = $this->userTimezone($user);

        // withTrashed() so the Bin folder is actually openable. The list
        // endpoint correctly returns soft-deleted conversations when
        // `folder=bin|trash` is requested, but show() was filtering them
        // out — tapping a conversation in the Bin returned 404. Restore
        // semantics also rely on this: the Flutter app needs to render
        // the trashed thread first, THEN call action=restore.
        $conv = Conversation::withTrashed()
            ->with([
                'contact:id,first_name,last_name,email,phone,avatar_path,company',
                'emailAccount:id,display_name,email,provider,status',
                'assignedTo:id,name',
                'tagModels:id,name,color',
            ])
            ->where('workspace_id', $workspaceId)
            ->where('id', $id)
            ->first();

        if (!$conv) {
            return response()->json(['message' => 'Conversation not found.'], 404);
        }

        // Mark as read on view — but ONLY when the conversation isn't
        // soft-deleted. Auto-marking a trashed thread as read would
        // change its state during a passive open, which is confusing
        // for the user (they were just peeking inside the Bin).
        if (!$conv->is_read && !$conv->trashed()) {
            $conv->forceFill(['is_read' => true])->save();
        }

        $messages = Message::with(['sender:id,name', 'attachments'])
            ->where('conversation_id', $conv->id)
            ->orderBy('created_at')
            ->orderBy('id')
            ->get()
            ->map(fn (Message $m) => $this->shapeMessage($m, $conv->channel ?? 'email', $userTimezone))
            ->values()
            ->all();

        return response()->json([
            'conversation' => $this->shapeRow($conv, full: true),
            'messages'     => $messages,
        ]);
    }

    /**
     * POST /api/v1/mobile/inbox/conversations/{id}/summarize
     *
     * AI Recap — generates a 2-line summary of the entire thread so an
     * agent can pick up a long conversation in seconds. Identical
     * prompting + caching strategy to the web /inbox/api/.../summarize
     * endpoint (cache key includes message count so a new inbound message
     * auto-busts the cache).
     */
    public function summarize(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $workspaceId = $user->active_workspace_id;

        $conv = Conversation::where('workspace_id', $workspaceId)->find($id);
        if (!$conv) {
            return response()->json(['message' => 'Conversation not found.'], 404);
        }

        // Skip AI drafts and system events — they're noise for a recap.
        $messages = $conv->messages()
            ->where('type', '!=', 'ai_draft')
            ->where('type', '!=', 'system_event')
            ->orderBy('created_at', 'asc')
            ->orderBy('id', 'asc')
            ->limit(50)
            ->get(['direction', 'sender_type', 'body_text', 'body_html', 'from_name', 'created_at']);

        if ($messages->isEmpty()) {
            return response()->json([
                'summary'       => 'No messages in this thread yet.',
                'cached'        => false,
                'message_count' => 0,
            ]);
        }

        $cacheKey = "conv-summary:{$conv->id}:" . $messages->count();
        $cached = \Illuminate\Support\Facades\Cache::has($cacheKey);

        try {
            $summary = \Illuminate\Support\Facades\Cache::remember($cacheKey, now()->addMinutes(30), function () use ($conv, $messages) {
                $transcript = $messages->map(function ($m) {
                    $who = $m->direction === 'inbound'
                        ? 'CUSTOMER (' . ($m->from_name ?: 'unknown') . ')'
                        : 'AGENT';
                    $body = trim(strip_tags($m->body_text ?: $m->body_html ?: ''));
                    if (mb_strlen($body) > 800) {
                        $body = mb_substr($body, 0, 800) . '…';
                    }
                    return "{$who} [{$m->created_at->format('M j, H:i')}]: {$body}";
                })->implode("\n\n");

                $totalMsgs = $messages->count();
                $inbound   = $messages->where('direction', 'inbound')->count();
                $outbound  = $totalMsgs - $inbound;

                $prompt = "You are summarizing the ENTIRE multi-message support thread below "
                    . "for an agent who is picking it up cold. The thread contains "
                    . "{$totalMsgs} messages ({$inbound} from the customer, {$outbound} from agents). "
                    . "Read EVERY message — do NOT just summarize the last one. Track how the "
                    . "issue evolved across the whole thread.\n\n"
                    . "Output EXACTLY 2 short lines, plain text, no Markdown, no bullets, no quotes:\n"
                    . "Line 1 — What the customer wants / the core issue across the whole thread (1 sentence).\n"
                    . "Line 2 — Current status: what has been done so far + what the agent should do next (1 sentence).\n\n"
                    . "===== THREAD ({$totalMsgs} messages) START =====\n"
                    . "{$transcript}\n"
                    . "===== THREAD END =====\n\n"
                    . "Two-line summary:";

                $response = app(\App\Services\AI\AIManager::class)->generateCompose(
                    $conv->workspace,
                    $prompt,
                    'professional',
                    [
                        'subject'     => $conv->subject,
                        'sender_name' => 'AI Recap',
                    ],
                );

                $clean = trim(strip_tags($response->content));
                $clean = trim($clean, "\"'`\u{201C}\u{201D}\u{2018}\u{2019}");
                return $clean !== '' ? $clean : 'AI could not summarize this thread.';
            });

            return response()->json([
                'summary'       => $summary,
                'cached'        => $cached,
                'message_count' => $messages->count(),
            ]);
        } catch (\Throwable $e) {
            // Surface the resolved provider + model so the mobile dev can
            // see which AI key actually fired — same diagnostic the web
            // endpoint returns. Saves a round of "why is it calling X
            // when I picked Y" debugging.
            $aiConfig = $conv->workspace->aiConfig ?? null;
            $resolvedProvider = $aiConfig?->provider
                ?: (\App\Models\SystemSetting::get('ai_default_provider') ?:
                    (config('services.mistral.api_key') ? 'mistral'
                    : (config('services.anthropic.api_key') ? 'anthropic'
                    : (config('services.openai.api_key') ? 'openai' : 'gemini'))));
            $resolvedModel = $aiConfig?->model
                ?: (\App\Models\SystemSetting::get('ai_default_model') ?: 'platform default');

            \Illuminate\Support\Facades\Log::warning('Mobile summarize endpoint failed', [
                'conversation_id' => $id,
                'provider'        => $resolvedProvider,
                'model'           => $resolvedModel,
                'error'           => $e->getMessage(),
            ]);

            return response()->json([
                'message'  => "AI Recap failed (using {$resolvedProvider} / {$resolvedModel}): " . $e->getMessage(),
                'provider' => $resolvedProvider,
                'model'    => $resolvedModel,
            ], 502);
        }
    }

    /**
     * POST /api/v1/mobile/inbox/conversations/{id}/reply
     * Body: { body: string, mode?: reply|note, cc?:[], bcc?:[], scheduled_at?: ISO8601, attachments?: [...] }
     */
    public function reply(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $workspaceId = $user->active_workspace_id;

        $conv = Conversation::with(['contact', 'emailAccount'])
            ->where('workspace_id', $workspaceId)
            ->find($id);
        if (!$conv) {
            return response()->json(['message' => 'Conversation not found.'], 404);
        }

        $validator = Validator::make($request->all(), [
            'body'         => ['required', 'string', 'min:1'],
            'mode'         => ['sometimes', 'string', 'in:reply,note'],
            'cc'           => ['sometimes', 'array'],
            'cc.*'         => ['email'],
            'bcc'          => ['sometimes', 'array'],
            'bcc.*'        => ['email'],
            'scheduled_at' => ['sometimes', 'date'],
        ]);

        $files = $this->validatedAttachments($request);
        $mode = (string) $request->input('mode', 'reply');

        $validator->after(function ($validator) use ($conv, $mode, $request, $files) {
            if ($mode === 'note') {
                if ($request->filled('scheduled_at')) {
                    $validator->errors()->add('scheduled_at', 'Scheduling is not supported for internal notes.');
                }
                if ($request->filled('cc') || $request->filled('bcc')) {
                    $validator->errors()->add('cc', 'CC/BCC is not supported for internal notes.');
                }
                return;
            }

            if ($conv->channel === 'email' && empty($conv->contact?->email)) {
                $validator->errors()->add('body', 'This conversation has no contact email to reply to.');
            }

            if ($conv->channel !== 'email') {
                if ($request->filled('cc') || $request->filled('bcc')) {
                    $validator->errors()->add('cc', 'CC/BCC is available only for email replies in the web app.');
                }
                if ($request->filled('scheduled_at')) {
                    $validator->errors()->add('scheduled_at', 'Scheduled send is available only for email replies in the web app.');
                }
            }

            if (!empty($files) && !in_array($conv->channel, ['email'], true)) {
                $validator->errors()->add('attachments', 'Attachments are supported only for email replies and internal notes in the web app.');
            }
        });

        $data = $validator->validate();

        if ($mode !== 'note' && $conv->status === 'spam') {
            return response()->json([
                'message' => 'Cannot reply to a spam conversation. Remove it from spam first.',
            ], 422);
        }

        if ($mode !== 'note' && $conv->status === 'closed') {
            $conv->forceFill(['status' => 'open', 'resolved_at' => null])->save();
        }

        $channel = $conv->channel ?? 'email';
        $storedBodyHtml = $channel === 'email' ? $this->autoLink($data['body']) : '';
        $messageData = [
            'conversation_id' => $conv->id,
            'workspace_id'    => $workspaceId,
            'sender_type'     => 'agent',
            'sender_id'       => $user->id,
            'direction'       => 'outbound',
            'type'            => $mode === 'note' ? 'note' : 'message',
            'body_html'       => $storedBodyHtml,
            'body_text'       => $data['body'],
            'from_email'      => $conv->emailAccount?->email ?? $user->email,
            'from_name'       => $user->name,
        ];

        if ($mode !== 'note') {
            $messageData['subject'] = 'Re: ' . preg_replace('/^(re:\s*)+/i', '', (string) $conv->subject);
            $messageData['to_emails'] = $conv->contact?->email ? [$conv->contact->email] : [];
            $messageData['delivery_status'] = 'queued';

            $lastInbound = $conv->messages()
                ->where('direction', 'inbound')
                ->whereNotNull('message_id_header')
                ->latest()
                ->first();

            if ($lastInbound) {
                $messageData['in_reply_to'] = $lastInbound->message_id_header;
                $refs = $lastInbound->references_header ?? [];
                $refs[] = $lastInbound->message_id_header;
                $messageData['references_header'] = array_values(array_unique($refs));
            }

            if (!empty($data['cc'])) {
                $messageData['cc_emails'] = array_values($data['cc']);
            }
            if (!empty($data['bcc'])) {
                $messageData['bcc_emails'] = array_values($data['bcc']);
            }
            if (!empty($data['scheduled_at'])) {
                $messageData['scheduled_at'] = $this->toUtcTimestamp((string) $data['scheduled_at'], $user);
                $messageData['schedule_status'] = 'pending';
            }
        }

        $reply = Message::create($messageData);
        $this->storeAttachments($files, $reply, $workspaceId);

        if ($mode !== 'note' && empty($data['scheduled_at'])) {
            try {
                $this->deliverByChannel($reply, $conv, $workspaceId);
            } catch (\Throwable $e) {
                $reply->forceFill([
                    'delivery_status' => 'failed',
                    'delivery_error'  => Str::limit($e->getMessage(), 250),
                ])->save();

                return response()->json([
                    'message' => 'Reply saved but delivery failed: ' . $e->getMessage(),
                    'reply'   => ['id' => $reply->id, 'delivery_status' => 'failed'],
                ], 502);
            }
        }

        if ($mode !== 'note') {
            $conv->forceFill([
                'preview'           => Str::limit(strip_tags($data['body']), 200),
                'last_message_at'   => now(),
                'first_response_at' => $conv->first_response_at ?: now(),
                'messages_count'    => ($conv->messages_count ?? 0) + 1,
                'is_read'           => true,
            ])->save();
        }

        return response()->json([
            'message' => $mode === 'note'
                ? 'Note added.'
                : (!empty($data['scheduled_at']) ? 'Reply scheduled.' : 'Reply sent.'),
            'reply'   => $this->shapeMessage(
                $reply->fresh(['sender:id,name', 'attachments']),
                $channel,
                $this->userTimezone($user)
            ),
        ], 201);
    }

    /**
     * POST /api/v1/mobile/inbox/messages/{messageId}/forward
     * Body: { to: string[], subject?: string, body?: string, channel?: string }
     *
     * Forwards an existing message either as a brand-new email/SMS/etc. to a
     * fresh recipient list. Creates a new Conversation per primary recipient.
     */
    public function forward(Request $request, int $messageId): JsonResponse
    {
        $validator = Validator::make($request->all(), [
            'to'      => ['required', 'array', 'min:1'],
            'to.*'    => ['string'],
            'cc'      => ['sometimes', 'array'],
            'cc.*'    => ['email'],
            'bcc'     => ['sometimes', 'array'],
            'bcc.*'   => ['email'],
            'subject' => ['sometimes', 'string', 'max:255'],
            'body'    => ['sometimes', 'string'],
            'channel' => ['sometimes', 'string', 'in:email,whatsapp,sms,telegram,slack'],
            'scheduled_at' => ['sometimes', 'date'],
        ]);

        $user = $request->user();
        $workspaceId = $user->active_workspace_id;
        $files = $this->validatedAttachments($request);

        $original = Message::with('conversation.emailAccount')
            ->whereHas('conversation', fn ($q) => $q->where('workspace_id', $workspaceId))
            ->find($messageId);

        if (!$original) {
            return response()->json(['message' => 'Message not found.'], 404);
        }

        $requestedChannel = (string) $request->input('channel', $original->conversation?->channel ?? 'email');
        $validator->after(function ($validator) use ($request, $requestedChannel, $files) {
            if ($requestedChannel === 'email') {
                foreach ((array) $request->input('to', []) as $recipient) {
                    if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
                        $validator->errors()->add('to', "{$recipient} is not a valid email address.");
                    }
                }
                return;
            }

            if ($requestedChannel !== 'email') {
                if ($request->filled('cc') || $request->filled('bcc')) {
                    $validator->errors()->add('cc', 'CC/BCC is available only for email forwards in the web app.');
                }
                if ($request->filled('scheduled_at')) {
                    $validator->errors()->add('scheduled_at', 'Scheduled send is available only for email forwards in the web app.');
                }
                if (!empty($files)) {
                    $validator->errors()->add('attachments', 'Attachments are supported only for email forwards in the web app.');
                }
            }
        });

        $data = $validator->validate();
        $channel = $data['channel'] ?? $original->conversation?->channel ?? 'email';
        $subject = $data['subject'] ?? ('Fwd: ' . preg_replace('/^(fwd:\s*)+/i', '', (string) $original->subject));
        $bodyHeader = $data['body'] ?? '';
        $forwardedBody = ($bodyHeader ? rtrim($bodyHeader) . "\n\n" : '')
            . "---------- Forwarded message ----------\n"
            . "From: " . ($original->from_name ?: $original->from_email ?: 'Unknown') . "\n"
            . "Date: " . ($original->created_at?->toRfc1123String() ?? '') . "\n"
            . "Subject: " . (string) $original->subject . "\n\n"
            . trim((string) ($original->body_text ?: strip_tags((string) $original->body_html)));

        $primary = (string) $data['to'][0];
        $contact = $this->resolveContactForChannel($workspaceId, $channel, $primary);

        $emailAccount = $original->conversation?->emailAccount
            ?? \App\Models\EmailAccount::where('workspace_id', $workspaceId)->where('status', 'connected')->first();

        $newConv = Conversation::create([
            'workspace_id'     => $workspaceId,
            'contact_id'       => $contact?->id,
            'email_account_id' => $emailAccount?->id,
            'channel'          => $channel,
            'status'           => 'open',
            'subject'          => $subject,
            'preview'          => mb_substr($forwardedBody, 0, 200),
            'last_message_at'  => now(),
            'is_read'          => true,
            'messages_count'   => 1,
        ]);

        $messageData = [
            'conversation_id' => $newConv->id,
            'workspace_id'    => $workspaceId,
            'sender_type'     => 'agent',
            'sender_id'       => $user->id,
            'direction'       => 'outbound',
            'type'            => 'message',
            'subject'         => $subject,
            'body_html'       => $channel === 'email' ? $this->autoLink($forwardedBody) : '',
            'body_text'       => $forwardedBody,
            'from_email'      => $emailAccount?->email ?? $user->email,
            'from_name'       => $user->name,
            'to_emails'       => array_values($data['to']),
            'delivery_status' => 'queued',
        ];

        if ($channel === 'email') {
            $messageData['message_id_header'] = '<' . Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST) . '>';
            if (!empty($data['cc'])) {
                $messageData['cc_emails'] = array_values($data['cc']);
            }
            if (!empty($data['bcc'])) {
                $messageData['bcc_emails'] = array_values($data['bcc']);
            }
            if (!empty($data['scheduled_at'])) {
                $messageData['scheduled_at'] = $this->toUtcTimestamp((string) $data['scheduled_at'], $user);
                $messageData['schedule_status'] = 'pending';
            }
        }

        $msg = Message::create($messageData);
        $this->storeAttachments($files, $msg, $workspaceId);

        if (empty($data['scheduled_at'])) {
            try {
                $this->deliverByChannel($msg, $newConv, $workspaceId);
            } catch (\Throwable $e) {
                $msg->forceFill([
                    'delivery_status' => 'failed',
                    'delivery_error'  => Str::limit($e->getMessage(), 250),
                ])->save();

                return response()->json([
                    'message'      => 'Forward saved but delivery failed: ' . $e->getMessage(),
                    'conversation' => $this->shapeRow($this->reloadConversation($newConv, withTrashed: false), full: true),
                ], 502);
            }
        }

        return response()->json([
            'message'      => !empty($data['scheduled_at']) ? 'Forward scheduled.' : 'Forwarded.',
            'conversation' => $this->shapeRow($this->reloadConversation($newConv, withTrashed: false), full: true),
            'forward'      => $this->shapeMessage(
                $msg->fresh(['sender:id,name', 'attachments']),
                $channel,
                $this->userTimezone($user)
            ),
            'forwarded_from' => [
                'message_id'      => $original->id,
                'conversation_id' => $original->conversation_id,
            ],
        ], 201);
    }

    /**
     * POST /api/v1/mobile/inbox/conversations/{id}/action
     * Body: { action: star|unstar|archive|snooze|close|reopen|read|unread|spam|trash, duration?: '1h'|'tomorrow'|... }
     */
    public function action(Request $request, int $id): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'action'   => ['required', 'string', 'in:star,unstar,archive,unarchive,snooze,close,reopen,read,unread,mark_read,mark_unread,spam,unspam,trash,delete,restore,force_delete,assign,priority,add_tag,remove_tag'],
            'duration' => ['sometimes', 'string', 'in:1h,3h,tomorrow,next_week'],
            'user_id'  => ['sometimes', 'nullable', 'integer'],
            'value'    => ['sometimes', 'string'],
            'tag_id'   => ['sometimes', 'integer'],
        ])->validate();

        $user = $request->user();
        $workspaceId = $user->active_workspace_id;

        $baseQuery = in_array($data['action'], ['restore', 'force_delete'], true)
            ? Conversation::withTrashed()->where('workspace_id', $workspaceId)
            : Conversation::where('workspace_id', $workspaceId);

        $conv = $baseQuery->find($id);
        if (!$conv) {
            return response()->json(['message' => 'Conversation not found.'], 404);
        }

        switch ($data['action']) {
            case 'star':    $conv->is_starred = true;  break;
            case 'unstar':  $conv->is_starred = false; break;
            case 'read':    $conv->is_read    = true;  break;
            case 'unread':  $conv->is_read    = false; break;
            case 'mark_read':
                $conv->is_read = true;
                break;
            case 'mark_unread':
                $conv->is_read = false;
                break;
            case 'archive':
            case 'close':
                $conv->status = 'closed';
                $conv->resolved_at = now();
                $conv->snoozed_until = null;
                break;
            case 'reopen':
            case 'unarchive':
                $conv->status = 'open';
                $conv->resolved_at = null;
                $conv->snoozed_until = null;
                break;
            case 'spam':
                $conv->status = 'spam';
                $conv->resolved_at = null;
                $conv->snoozed_until = null;
                break;
            case 'unspam':
                // Restore a spam-flagged conversation back to the active
                // inbox. Mirrors the web "Not spam" action — flips status
                // back to 'open' and clears the spam state.
                $conv->status = 'open';
                $conv->resolved_at = null;
                $conv->snoozed_until = null;
                break;
            case 'snooze':
                $base = match ($data['duration'] ?? '1h') {
                    '1h'        => now()->addHour(),
                    '3h'        => now()->addHours(3),
                    'tomorrow'  => now()->addDay()->setTime(9, 0),
                    'next_week' => now()->addDays(7 - now()->dayOfWeek + 1)->setTime(9, 0),
                    default     => now()->addHour(),
                };
                // Cast back to a fresh Carbon instance so Eloquent's column
                // type-hint (Illuminate\Support\Carbon|null) is satisfied.
                $conv->status = 'snoozed';
                $conv->snoozed_until = \Illuminate\Support\Carbon::instance($base);
                break;
            case 'trash':
            case 'delete':
                $conv->delete();
                return response()->json(['message' => 'Conversation moved to trash.']);
            case 'restore':
                $conv->restore();
                return response()->json([
                    'message'      => 'Conversation restored.',
                    'conversation' => $this->shapeRow($this->reloadConversation($conv, withTrashed: true), full: true),
                ]);
            case 'force_delete':
                $conv->forceDelete();
                return response()->json(['message' => 'Conversation permanently deleted.']);
            case 'assign':
                if (array_key_exists('user_id', $data) && $data['user_id'] !== null) {
                    $assigneeExists = User::whereKey($data['user_id'])
                        ->whereHas('workspaces', fn ($q) => $q->where('workspaces.id', $workspaceId))
                        ->exists();
                    if (!$assigneeExists) {
                        return response()->json([
                            'message' => 'Selected assignee does not belong to this workspace.',
                            'errors'  => ['user_id' => ['Invalid workspace assignee.']],
                        ], 422);
                    }
                }
                $conv->assigned_to = $data['user_id'] ?? null;
                break;
            case 'priority':
                if (!in_array($data['value'] ?? null, ['low', 'normal', 'high', 'urgent'], true)) {
                    return response()->json([
                        'message' => 'Invalid priority value.',
                        'errors'  => ['value' => ['Allowed: low, normal, high, urgent.']],
                    ], 422);
                }
                $conv->priority = $data['value'];
                break;
            case 'add_tag':
            case 'remove_tag':
                $tagId = (int) ($data['tag_id'] ?? 0);
                $tag = Tag::where('workspace_id', $workspaceId)->find($tagId);
                if (!$tag) {
                    return response()->json([
                        'message' => 'Tag not found.',
                        'errors'  => ['tag_id' => ['Invalid workspace tag.']],
                    ], 422);
                }
                if ($data['action'] === 'add_tag') {
                    $conv->tagModels()->syncWithoutDetaching([$tag->id]);
                } else {
                    $conv->tagModels()->detach([$tag->id]);
                }
                return response()->json([
                    'message'      => $data['action'] === 'add_tag' ? 'Tag added.' : 'Tag removed.',
                    'conversation' => $this->shapeRow($this->reloadConversation($conv, withTrashed: false), full: true),
                ]);
        }
        $conv->save();

        return response()->json([
            'message'      => 'Done.',
            'conversation' => $this->shapeRow($this->reloadConversation($conv, withTrashed: false), full: true),
        ]);
    }

    /**
     * POST /api/v1/mobile/inbox/conversations/{id}/restore
     *
     * Dedicated restore route for the Bin folder. Same effect as posting
     * `{"action":"restore"}` to /action, but easier for the Flutter side
     * to wire (no body required) and the response shape matches what
     * show() returns so the app can drop straight into the conversation
     * detail screen after the restore — no extra refetch needed.
     *
     * 404 when the conversation either doesn't belong to the caller's
     * workspace OR isn't actually trashed (so calling restore on an
     * already-restored thread surfaces an explicit error instead of
     * silently succeeding).
     */
    public function restore(Request $request, int $id): JsonResponse
    {
        $workspaceId = $request->user()->active_workspace_id;

        $conv = Conversation::onlyTrashed()
            ->where('workspace_id', $workspaceId)
            ->find($id);

        if (!$conv) {
            return response()->json([
                'message' => 'Conversation not found in trash. It may already be restored or permanently deleted.',
            ], 404);
        }

        $conv->restore();

        return response()->json([
            'message'      => 'Conversation restored.',
            'conversation' => $this->shapeRow($this->reloadConversation($conv, withTrashed: true), full: true),
        ]);
    }

    /**
     * POST /api/v1/mobile/inbox/conversations/restore
     * Body: { ids: number[] }
     *
     * Bulk restore for the multi-select bottom-sheet on the Bin screen
     * (long-press a conversation → select more → tap "Restore"). One DB
     * round-trip per row is acceptable here because the SoftDeletes
     * `restore()` call fires the restoring/restored model events that
     * downstream observers (cache busts, search index refresh) depend
     * on — bypassing the events with a single UPDATE would skip those.
     */
    public function restoreBulk(Request $request): JsonResponse
    {
        $data = Validator::make($request->all(), [
            'ids'   => ['required', 'array', 'min:1', 'max:200'],
            'ids.*' => ['integer'],
        ])->validate();

        $workspaceId = $request->user()->active_workspace_id;

        $rows = Conversation::onlyTrashed()
            ->where('workspace_id', $workspaceId)
            ->whereIn('id', $data['ids'])
            ->get();

        $restored = 0;
        foreach ($rows as $row) {
            $row->restore();
            $restored++;
        }

        // ids the caller asked for that we couldn't restore — either not
        // in trash, not in this workspace, or never existed. Surfaced so
        // the Flutter UI can show "3 of 5 restored, 2 not found" instead
        // of a generic success banner.
        $skipped = array_values(array_diff(
            $data['ids'],
            $rows->pluck('id')->all(),
        ));

        return response()->json([
            'message'  => "Restored {$restored} conversation(s).",
            'restored' => $restored,
            'skipped'  => $skipped,
        ]);
    }

    /**
     * DELETE /api/v1/mobile/inbox/conversations/trash
     *
     * Empty Bin — permanently deletes every soft-deleted conversation in
     * the caller's workspace. Force-delete cascades to messages +
     * attachments via the model's deleting observers, so this is NOT a
     * cheap operation; we cap the batch at MAX_PER_CALL per request so a
     * runaway 10k-conversation Bin doesn't time out, and return
     * `has_more=true` if any rows remain — the Flutter side just calls
     * the endpoint again until that flag flips false.
     */
    public function emptyTrash(Request $request): JsonResponse
    {
        $MAX_PER_CALL = 200;
        $workspaceId  = $request->user()->active_workspace_id;

        $rows = Conversation::onlyTrashed()
            ->where('workspace_id', $workspaceId)
            ->limit($MAX_PER_CALL)
            ->get();

        $deleted = 0;
        foreach ($rows as $row) {
            $row->forceDelete();
            $deleted++;
        }

        $remaining = (int) Conversation::onlyTrashed()
            ->where('workspace_id', $workspaceId)
            ->count();

        return response()->json([
            'message'   => $remaining > 0
                ? "Permanently deleted {$deleted} conversation(s). {$remaining} still in trash — call again to continue."
                : "Trash emptied. Permanently deleted {$deleted} conversation(s).",
            'deleted'   => $deleted,
            'remaining' => $remaining,
            'has_more'  => $remaining > 0,
        ]);
    }

    /**
     * POST /api/v1/mobile/inbox/compose
     * Body: { from_account_id, to[], cc?[], bcc?[], subject, body, channel? }
     */
    public function compose(Request $request): JsonResponse
    {
        $validator = Validator::make($request->all(), [
            'from_account_id' => ['sometimes', 'integer'],
            'to'              => ['sometimes', 'array'],
            'to.*'            => ['string', 'min:1'],
            'cc'              => ['sometimes', 'array'],
            'cc.*'            => ['email'],
            'bcc'             => ['sometimes', 'array'],
            'bcc.*'           => ['email'],
            'subject'         => ['sometimes', 'string', 'max:255'],
            'body'            => ['sometimes', 'string'],
            'channel'         => ['sometimes', 'string', 'in:email,whatsapp,sms,telegram,slack,chat'],
            'scheduled_at'    => ['sometimes', 'date'],
            'save_as_draft'   => ['sometimes', 'boolean'],
        ]);

        $user = $request->user();
        $workspaceId = $user->active_workspace_id;
        $channel = (string) $request->input('channel', 'email');
        $saveAsDraft = $request->boolean('save_as_draft');
        $files = $this->validatedAttachments($request);

        $validator->after(function ($validator) use ($request, $channel, $saveAsDraft, $files) {
            $to = $request->input('to', []);
            $body = trim((string) $request->input('body', ''));

            if (!$saveAsDraft && empty($to)) {
                $validator->errors()->add('to', 'At least one recipient is required.');
            }

            if (!$saveAsDraft && $body === '') {
                $validator->errors()->add('body', 'The body field is required.');
            }

            if ($saveAsDraft && $channel !== 'email') {
                $validator->errors()->add('save_as_draft', 'Draft save is available only for email compose in the web app.');
            }

            if ($request->filled('scheduled_at') && $channel !== 'email') {
                $validator->errors()->add('scheduled_at', 'Scheduled send is available only for email compose in the web app.');
            }

            if ($saveAsDraft && $request->filled('scheduled_at')) {
                $validator->errors()->add('scheduled_at', 'A draft cannot be scheduled at the same time.');
            }

            if (($request->filled('cc') || $request->filled('bcc')) && $channel !== 'email') {
                $validator->errors()->add('cc', 'CC/BCC is available only for email compose in the web app.');
            }

            if (!empty($files) && $channel !== 'email') {
                $validator->errors()->add('attachments', 'Attachments are supported only for email compose in the web app.');
            }
        });

        $data = $validator->validate();

        // Email-specific validation: must have a from_account_id pointing to a
        // connected mailbox. Other channels don't use email accounts.
        $account = null;
        if ($channel === 'email') {
            if (!$saveAsDraft && empty($data['from_account_id'])) {
                return response()->json([
                    'message' => 'from_account_id is required for email channel.',
                    'errors'  => ['from_account_id' => ['Required for email.']],
                ], 422);
            }
            if (!empty($data['from_account_id'])) {
                $account = EmailAccount::where('workspace_id', $workspaceId)
                    ->where('id', $data['from_account_id'])
                    ->first();
            }
            if (!empty($data['from_account_id']) && !$account) {
                return response()->json([
                    'message' => 'Selected from-account is not connected to this workspace.',
                    'errors'  => ['from_account_id' => ['Invalid email account.']],
                ], 422);
            }
            // Email recipients must be valid emails
            foreach (($data['to'] ?? []) as $to) {
                if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
                    return response()->json([
                        'message' => 'Invalid email address in to list.',
                        'errors'  => ['to' => ["{$to} is not a valid email address."]],
                    ], 422);
                }
            }
        }

        $primary = (string) (($data['to'][0] ?? ''));
        $contact = $primary !== '' ? $this->resolveContactForChannel($workspaceId, $channel, $primary) : null;
        $body = (string) ($data['body'] ?? '');

        $conv = Conversation::create([
            'workspace_id'     => $workspaceId,
            'contact_id'       => $contact?->id,
            'email_account_id' => $account?->id,
            'channel'          => $channel,
            'status'           => 'open',
            'subject'          => $data['subject'] ?? ($saveAsDraft ? '(Draft)' : mb_substr($body, 0, 80)),
            'preview'          => mb_substr($body, 0, 200),
            'last_message_at'  => now(),
            'is_read'          => true,
            'messages_count'   => 1,
        ]);

        $messageData = [
            'conversation_id' => $conv->id,
            'workspace_id'    => $workspaceId,
            'sender_type'     => 'agent',
            'sender_id'       => $user->id,
            'direction'       => 'outbound',
            'type'            => 'message',
            'subject'         => $data['subject'] ?? null,
            'body_html'       => $channel === 'email' ? $this->autoLink($body) : '',
            'body_text'       => $body,
            'from_email'      => $account?->email ?? $user->email,
            'from_name'       => $user->name,
            'to_emails'       => array_values($data['to'] ?? []),
            'cc_emails'       => array_values($data['cc'] ?? []),
            'bcc_emails'      => array_values($data['bcc'] ?? []),
            'delivery_status' => 'queued',
        ];

        if ($channel === 'email' && !$saveAsDraft) {
            $messageData['message_id_header'] = '<' . Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST) . '>';
        }

        if (!empty($data['scheduled_at'])) {
            $messageData['scheduled_at'] = $this->toUtcTimestamp((string) $data['scheduled_at'], $user);
            $messageData['schedule_status'] = 'pending';
        }

        $msg = Message::create($messageData);
        $this->storeAttachments($files, $msg, $workspaceId);

        if (!$saveAsDraft && empty($data['scheduled_at'])) {
            try {
                $this->deliverByChannel($msg, $conv, $workspaceId);
            } catch (\Throwable $e) {
                $rawError = $e->getMessage();
                $msg->forceFill([
                    'delivery_status' => 'failed',
                    'delivery_error'  => Str::limit($rawError, 250),
                ])->save();

                // Translate Google's "refresh token must be passed in" error
                // into actionable copy. This fires when the connected Gmail
                // account has no oauth_refresh_token saved (mobile google_sign_in
                // doesn't return one) and the 1-hour access_token has expired.
                // The user has to reconnect Gmail with prompt=consent to force
                // Google to issue a fresh refresh_token.
                $friendly = $this->translateDeliveryError($rawError, $account);

                return response()->json([
                    'message'      => $friendly,
                    'error_code'   => $this->classifyDeliveryError($rawError),
                    'conversation' => $this->shapeRow($this->reloadConversation($conv, withTrashed: false), full: true),
                ], 502);
            }
        }

        return response()->json([
            'message'      => $saveAsDraft ? 'Draft saved.' : (!empty($data['scheduled_at']) ? 'Scheduled.' : 'Sent.'),
            'conversation' => $this->shapeRow($this->reloadConversation($conv, withTrashed: false), full: true),
            'message_data' => $this->shapeMessage(
                $msg->fresh(['sender:id,name', 'attachments']),
                $channel,
                $this->userTimezone($user)
            ),
        ], 201);
    }

    /**
     * GET /api/v1/mobile/inbox/sidebar
     * Folder + label counts for the drawer.
     */
    public function sidebar(Request $request): JsonResponse
    {
        $workspaceId = $request->user()->active_workspace_id;

        $base = Conversation::where('workspace_id', $workspaceId)->whereNull('deleted_at');
        $channelBase = Conversation::where('workspace_id', $workspaceId)
            ->whereNull('deleted_at')
            ->whereIn('status', ['open', 'pending']);

        $unread   = (int) (clone $base)->where('is_read', false)->count();
        $starred  = (int) (clone $base)->where('is_starred', true)->count();
        $snoozed  = (int) (clone $base)
            ->where(function ($q) {
                $q->where('status', 'snoozed')
                    ->orWhere(function ($inner) {
                        $inner->whereNotNull('snoozed_until')->where('snoozed_until', '>', now());
                    });
            })
            ->count();
        $closed   = (int) (clone $base)->where('status', 'closed')->count();
        $spam     = (int) (clone $base)->where('status', 'spam')->count();
        $allMail  = (int) (clone $base)->count();
        $primary  = (int) (clone $base)->whereIn('status', ['open', 'pending'])->count();
        $trash    = (int) Conversation::where('workspace_id', $workspaceId)->onlyTrashed()->count();
        $sent     = (int) Conversation::where('workspace_id', $workspaceId)
            ->whereNull('deleted_at')
            ->whereHas('messages', fn ($m) => $m->where('direction', 'outbound'))
            ->count();

        return response()->json([
            'folders' => [
                'all'     => $allMail,
                'all_mail'=> $allMail,
                'inbox'   => $primary,
                'unread'  => $unread,
                'starred' => $starred,
                'snoozed' => $snoozed,
                'closed'  => $closed,
                'sent'    => $sent,
                'drafts'  => 0,
                'spam'    => $spam,
                'trash'   => $trash,
            ],
            'channels' => [
                'email'    => (int) (clone $channelBase)->where('channel', 'email')->count(),
                'whatsapp' => (int) (clone $channelBase)->where('channel', 'whatsapp')->count(),
                'sms'      => (int) (clone $channelBase)->where('channel', 'sms')->count(),
                'telegram' => (int) (clone $channelBase)->where('channel', 'telegram')->count(),
                'slack'    => (int) (clone $channelBase)->where('channel', 'slack')->count(),
                'chat'     => (int) (clone $channelBase)->where('channel', 'chat')->count(),
            ],
        ]);
    }

    /**
     * GET /api/v1/mobile/inbox/accounts
     * Connected mailboxes for the "MailTrixy accounts" switcher bottom-sheet
     * + the "From" picker in compose.
     *
     * By default returns only mailboxes with status='connected' (since the
     * switcher can't usefully select a broken/disconnected account). Pass
     * ?include=all to see every mailbox (useful for an admin / settings
     * screen that wants to surface disconnected ones with an error chip).
     */
    public function accounts(Request $request): JsonResponse
    {
        $workspaceId = $request->user()->active_workspace_id;
        $include = (string) $request->query('include', 'connected');

        $query = EmailAccount::where('workspace_id', $workspaceId)->orderBy('id');
        if ($include !== 'all') {
            $query->where('status', 'connected');
        }
        $rows = $query->get(['id', 'display_name', 'email', 'provider', 'status']);

        // Per-account unread counts (single query, grouped)
        $unread = Conversation::where('workspace_id', $workspaceId)
            ->where('is_read', false)
            ->select('email_account_id', \Illuminate\Support\Facades\DB::raw('COUNT(*) as c'))
            ->groupBy('email_account_id')
            ->pluck('c', 'email_account_id');

        return response()->json([
            'data' => $rows->map(fn ($a) => [
                'id'           => $a->id,
                'name'         => $a->display_name ?: $a->email,
                'email'        => $a->email,
                'provider'     => $a->provider,
                'status'       => $a->status,
                'is_connected' => $a->status === 'connected',
                'avatar_color' => $this->avatarColor((int) $a->id),
                'initials'     => $this->initials($a->display_name ?: $a->email ?: '?'),
                'unread_count' => (int) ($unread[$a->id] ?? 0),
            ])->values()->all(),
        ]);
    }

    // ── Helpers ───────────────────────────────────────────────────────

    /**
     * Shape a Conversation for the mobile UI. `$full` adds tags + message count.
     */
    private function shapeRow(Conversation $c, bool $full = false): array
    {
        // Two derived flags the Flutter app needs to render the right
        // chip on each row in the inbox list:
        //   - is_trashed: thread is in Bin (still openable via show()
        //                 after the earlier fix; lets the UI render a
        //                 strike-through or "in Trash" badge).
        //   - has_draft:  at least one outbound message in this thread
        //                 is a saved draft (matches the same predicate
        //                 the /drafts surface uses, so a draft chip
        //                 here and the Drafts folder count never drift).
        $hasDraft = \App\Models\Message::where('conversation_id', $c->id)
            ->where('direction', 'outbound')
            ->where('delivery_status', 'queued')
            ->whereNull('scheduled_at')
            ->whereNull('sent_at')
            ->exists();

        $row = [
            'id'              => $c->id,
            'uuid'            => $c->uuid,
            'subject'         => $c->subject,
            'preview'         => $c->preview,
            'channel'         => $c->channel,
            'status'          => $c->status,
            'is_unread'       => !(bool) $c->is_read,
            'is_starred'      => (bool) $c->is_starred,
            'is_pinned'       => (bool) $c->is_pinned,
            'is_trashed'      => $c->trashed(),
            'has_draft'       => $hasDraft,
            'messages_count'  => (int) ($c->messages_count ?? 0),
            'last_message_at' => $c->last_message_at?->toIso8601String(),
            'snoozed_until'   => $c->snoozed_until?->toIso8601String(),
            'contact'         => $c->contact ? [
                'id'         => $c->contact->id,
                'name'       => $c->contact->full_name ?: $c->contact->email,
                'email'      => $c->contact->email,
                'phone'      => $c->contact->phone,
                'company'    => $c->contact->company ?? null,
                'avatar_url' => $c->contact->avatar_path ? asset('storage/' . $c->contact->avatar_path) : null,
                'initials'   => $this->initials($c->contact->full_name ?: $c->contact->email ?: '?'),
            ] : null,
            'email_account'   => $c->emailAccount ? [
                'id'       => $c->emailAccount->id,
                'name'     => $c->emailAccount->display_name ?: $c->emailAccount->email,
                'email'    => $c->emailAccount->email,
                'provider' => $c->emailAccount->provider,
            ] : null,
        ];

        if ($full) {
            $row['tags'] = $c->relationLoaded('tagModels')
                ? $c->tagModels->map(fn ($tag) => ['id' => $tag->id, 'name' => $tag->name, 'color' => $tag->color])->values()->all()
                : (is_array($c->tags) ? $c->tags : (json_decode((string) $c->tags, true) ?: []));
            $row['priority'] = $c->priority;
            $row['sentiment'] = $c->sentiment;
            $row['assigned_to'] = $c->relationLoaded('assignedTo') && $c->assignedTo
                ? [
                    'id'       => $c->assignedTo->id,
                    'name'     => $c->assignedTo->name,
                    'initials' => $this->initials($c->assignedTo->name),
                ]
                : null;
            $row['created_at'] = $c->created_at?->toIso8601String();
        }

        return $row;
    }

    private function shapeMessage(Message $message, string $channel, string $timezone): array
    {
        return [
            'id'                  => $message->id,
            'direction'           => $message->direction,
            'type'                => $message->type,
            'subject'             => $message->subject,
            'body'                => trim((string) ($message->body_text ?: strip_tags((string) $message->body_html))),
            'body_html'           => $message->body_html,
            'from_email'          => $message->from_email,
            'from_name'           => $message->from_name,
            'to_emails'           => is_array($message->to_emails) ? $message->to_emails : (json_decode((string) $message->to_emails, true) ?: []),
            'cc_emails'           => is_array($message->cc_emails) ? $message->cc_emails : (json_decode((string) $message->cc_emails, true) ?: []),
            'bcc_emails'          => is_array($message->bcc_emails) ? $message->bcc_emails : (json_decode((string) $message->bcc_emails, true) ?: []),
            'sentiment'           => $message->sentiment,
            'delivery_status'     => $message->delivery_status,
            'delivery_error'      => $message->delivery_error,
            'schedule_status'     => $message->schedule_status,
            'scheduled_at'        => $message->scheduled_at?->toIso8601String(),
            'scheduled_at_pretty' => $message->scheduled_at?->copy()->setTimezone($timezone)?->format('M j, g:i A'),
            'created_at'          => $message->created_at?->toIso8601String(),
            'sent_at'             => $message->sent_at?->toIso8601String(),
            'channel'             => $channel,
            'attachments'         => $this->shapeAttachments($message),
        ];
    }

    private function initials(?string $name): string
    {
        if (!$name) return '?';
        $parts = preg_split('/\s+/', trim($name));
        return strtoupper(substr($parts[0] ?? '', 0, 1) . substr($parts[1] ?? '', 0, 1)) ?: '?';
    }

    /**
     * Deterministic per-account avatar color for the initials circle in the
     * account-switcher bottom sheet. Hashes the account id to a fixed
     * palette so the color is stable across requests / sessions.
     */
    private function avatarColor(int $id): string
    {
        $palette = ['#ef4444', '#3b82f6', '#a855f7', '#f59e0b', '#22c55e', '#14b8a6', '#ec4899', '#f97316'];
        return $palette[$id % count($palette)];
    }

    /**
     * Convert plain-text URLs to anchors so the body_html column is at least
     * minimally rich. Mobile doesn't post HTML; we wrap newlines in <br>.
     */
    private function autoLink(string $text): string
    {
        $escaped = e($text);
        $linked = preg_replace(
            '~\b((?:https?|ftp)://[^\s<]+)~i',
            '<a href="$1">$1</a>',
            $escaped
        );
        return nl2br($linked);
    }

    private function shapeAttachments(Message $message): array
    {
        $attachments = $message->relationLoaded('attachments')
            ? $message->attachments
            : $message->attachments()->get();

        return $attachments
            // Hide attachments that are embedded inside the HTML body via
            // CID references. Use the dedicated `is_inline` flag — older
            // code keyed on `content_id` but that field is sometimes left
            // null by IMAP imports, so real inline images were leaking
            // into the visible list while genuine attachments without a
            // CID were being filtered out.
            ->filter(fn (Attachment $attachment) => !$attachment->is_inline)
            ->map(function (Attachment $attachment) {
                $url = $this->resolveAttachmentUrl($attachment->storage_path);
                $thumb = $attachment->thumbnail_path
                    ? $this->resolveAttachmentUrl($attachment->thumbnail_path)
                    : null;
                $isImage = is_string($attachment->mime_type)
                    && str_starts_with($attachment->mime_type, 'image/');

                return [
                    'id'            => $attachment->id,
                    'filename'      => $attachment->original_filename,
                    'mime_type'     => $attachment->mime_type,
                    'size'          => (int) $attachment->size,
                    'size_human'    => $attachment->size_for_humans,
                    'is_image'      => $isImage,
                    'url'           => $url,
                    // Download URL is identical to the view URL today, but
                    // exposing both keys lets the Flutter side wire each
                    // independently (e.g. show inline vs. trigger save).
                    'download_url'  => $url,
                    // Prefer the small thumbnail when present (saves the
                    // mobile UI from downloading a 5 MB image just to
                    // render a 60px tile). Falls back to the full image
                    // URL so the app always has something to render.
                    'thumbnail_url' => $thumb ?? ($isImage ? $url : null),
                ];
            })
            ->values()
            ->all();
    }

    /**
     * Build a fully-qualified URL for an attachment storage path.
     *
     * Handles three cases:
     *   1. Demo / external storage — `storage_path` is already an http(s) URL.
     *      Return verbatim so we don't double-prefix.
     *   2. Local public-disk path — use Storage::url() which respects
     *      the configured driver (local / S3 / R2) and APP_URL, instead of
     *      asset('storage/...') which only works when php artisan storage:link
     *      was run AND APP_URL is correct.
     *   3. Empty / null — return null so the client can show a "missing
     *      file" placeholder instead of broken image.
     */
    private function resolveAttachmentUrl(?string $path): ?string
    {
        if (!$path) {
            return null;
        }
        if (preg_match('~^https?://~i', $path)) {
            return $path;
        }
        try {
            return Storage::disk('public')->url($path);
        } catch (\Throwable $e) {
            return asset('storage/' . ltrim($path, '/'));
        }
    }

    /**
     * @return array<int, UploadedFile>
     */
    private function validatedAttachments(Request $request): array
    {
        $files = $request->file('attachments', []);
        if ($files instanceof UploadedFile) {
            $files = [$files];
        }
        if (!is_array($files)) {
            return [];
        }

        $files = array_values(array_filter($files, fn ($file) => $file instanceof UploadedFile));
        if (!empty($files)) {
            Validator::make(['attachments' => $files], [
                'attachments.*' => self::ATTACHMENT_RULE,
            ])->validate();
        }

        return $files;
    }

    /**
     * @param  array<int, UploadedFile>  $files
     */
    private function storeAttachments(array $files, Message $message, int $workspaceId): void
    {
        foreach ($files as $index => $file) {
            try {
                $realPath = $file->getRealPath();
                if (!$realPath || !file_exists($realPath)) {
                    continue;
                }

                $originalName = $file->getClientOriginalName();
                $filename = time() . '_' . Str::random(8) . '_' . $originalName;
                $storagePath = "attachments/{$workspaceId}/{$filename}";

                Storage::disk('local')->put($storagePath, file_get_contents($realPath));

                Attachment::create([
                    'message_id'         => $message->id,
                    'filename'           => $filename,
                    'original_filename'  => $originalName,
                    'mime_type'          => $file->getMimeType() ?: 'application/octet-stream',
                    'size'               => filesize($realPath),
                    'storage_path'       => $storagePath,
                    'is_inline'          => false,
                ]);
            } catch (\Throwable $e) {
                report($e);
            }
        }
    }

    private function userTimezone($user): string
    {
        return $user->timezone
            ?: $user->activeWorkspace?->timezone
            ?: config('app.timezone', 'UTC');
    }

    private function toUtcTimestamp(string $value, $user): Carbon
    {
        return Carbon::parse($value, $this->userTimezone($user))->utc();
    }

    private function reloadConversation(Conversation $conversation, bool $withTrashed): ?Conversation
    {
        $query = $withTrashed ? Conversation::withTrashed() : Conversation::query();

        return $query->with([
            'contact:id,first_name,last_name,email,phone,avatar_path,company',
            'emailAccount:id,display_name,email,provider,status',
            'assignedTo:id,name',
            'tagModels:id,name,color',
        ])->find($conversation->id);
    }

    private function emptyMeta(): array
    {
        return ['current_page' => 1, 'per_page' => 25, 'total' => 0, 'has_more' => false];
    }

    // ── Per-channel delivery (mirrors web ReplyComposer) ─────────────

    /**
     * Route an outbound Message to its channel's send service.
     * Sets delivery_status = 'sent' on success, 'failed' on error.
     * For channels with no integration configured, marks the message
     * 'failed' with a clear delivery_error and returns (no exception).
     */
    private function deliverByChannel(Message $message, Conversation $conv, int $workspaceId): void
    {
        switch ($conv->channel) {
            case 'email':    $this->deliverEmail($message, $conv);    break;
            case 'sms':      $this->deliverSms($message, $conv, $workspaceId); break;
            case 'whatsapp': $this->deliverWhatsApp($message, $conv, $workspaceId); break;
            case 'telegram': $this->deliverTelegram($message, $conv, $workspaceId); break;
            case 'slack':    $this->deliverSlack($message, $conv, $workspaceId); break;
            case 'chat':
                // Live chat is broadcast over Pusher by the chat widget channel
                // listener — nothing to "send" from the agent side besides
                // saving the Message. Mark sent immediately.
                $message->forceFill(['delivery_status' => 'sent', 'sent_at' => now()])->save();
                break;
            default:
                $message->forceFill([
                    'delivery_status' => 'failed',
                    'delivery_error'  => 'Unsupported channel: ' . (string) $conv->channel,
                ])->save();
        }
    }

    private function deliverEmail(Message $message, Conversation $conv): void
    {
        $account = $conv->emailAccount;
        if (!$account) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'No email account on conversation'])->save();
            return;
        }
        $sent = app(\App\Services\Email\EmailSendService::class)->sendReply($message, $account);
        if (!$sent) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'Email send returned false'])->save();
            return;
        }
        $message->forceFill(['delivery_status' => 'sent', 'sent_at' => now()])->save();
    }

    private function deliverSms(Message $message, Conversation $conv, int $workspaceId): void
    {
        $integration = \App\Models\ChannelIntegration::where('workspace_id', $workspaceId)
            ->where('channel', 'sms')->where('status', 'active')->first();
        if (!$integration) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'SMS not connected'])->save();
            return;
        }
        $toPhone = $conv->contact?->phone;
        if (!$toPhone) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'No phone number for contact'])->save();
            return;
        }
        $creds = $integration->credentials ?? [];
        $smsService = app(\App\Services\Channels\TwilioSMSService::class);
        $result = $smsService->sendSMS(
            $toPhone,
            (string) $message->body_text,
            $creds['phone_number'] ?? null,
            $creds['sid'] ?? null,
            $creds['auth_token'] ?? null,
        );
        $message->forceFill([
            'delivery_status'    => 'sent',
            'sent_at'            => now(),
            'channel_message_id' => $result->sid ?? null,
        ])->save();
    }

    private function deliverWhatsApp(Message $message, Conversation $conv, int $workspaceId): void
    {
        $integration = \App\Models\ChannelIntegration::where('workspace_id', $workspaceId)
            ->where('channel', 'whatsapp')->where('status', 'active')->first();
        if (!$integration) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'WhatsApp not connected'])->save();
            return;
        }
        $toPhone = $conv->contact?->phone;
        if (!$toPhone) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'No phone number for contact'])->save();
            return;
        }
        $creds = $integration->credentials ?? [];
        $waService = app(\App\Services\Channels\WhatsAppService::class);
        $result = $waService->sendText(
            $creds['phone_number_id'] ?? '',
            $creds['access_token'] ?? '',
            $toPhone,
            (string) $message->body_text,
        );
        $message->forceFill([
            'delivery_status'    => 'sent',
            'sent_at'            => now(),
            'channel_message_id' => $result['messages'][0]['id'] ?? null,
        ])->save();
    }

    private function deliverTelegram(Message $message, Conversation $conv, int $workspaceId): void
    {
        $integration = \App\Models\ChannelIntegration::where('workspace_id', $workspaceId)
            ->where('channel', 'telegram')->where('status', 'active')->first();
        if (!$integration) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'Telegram not connected'])->save();
            return;
        }
        $chatId = $conv->channel_conversation_id;
        if (!$chatId) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'No Telegram chat ID for conversation'])->save();
            return;
        }
        $creds = $integration->credentials ?? [];
        $service = new \App\Services\Channels\TelegramService((string) ($creds['bot_token'] ?? ''));
        $result = $service->sendMessage($chatId, (string) $message->body_text);
        $message->forceFill([
            'delivery_status'    => 'sent',
            'sent_at'            => now(),
            'channel_message_id' => isset($result['message_id']) ? "tg_{$chatId}_{$result['message_id']}" : null,
        ])->save();
    }

    private function deliverSlack(Message $message, Conversation $conv, int $workspaceId): void
    {
        $integration = \App\Models\ChannelIntegration::where('workspace_id', $workspaceId)
            ->where('channel', 'slack')->where('status', 'active')->first();
        if (!$integration) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'Slack not connected'])->save();
            return;
        }
        $channelId = $conv->channel_conversation_id;
        if (!$channelId) {
            $message->forceFill(['delivery_status' => 'failed', 'delivery_error' => 'No Slack channel ID for conversation'])->save();
            return;
        }
        $creds = $integration->credentials ?? [];
        $slackService = app(\App\Services\Channels\SlackService::class);
        $result = $slackService->postMessage(
            (string) ($creds['bot_token'] ?? ''),
            $channelId,
            (string) $message->body_text,
        );
        $message->forceFill([
            'delivery_status'    => 'sent',
            'sent_at'            => now(),
            'channel_message_id' => $result['ts'] ?? null,
        ])->save();
    }

    /**
     * Resolve (or create) the right Contact for a given channel + recipient
     * address. For email we match on the email column; for phone-based
     * channels (SMS/WhatsApp) we match on phone; otherwise we just look up
     * by name. Used by compose() + forward().
     */
    /**
     * Turn a raw delivery-layer error into one customer-facing sentence the
     * mobile app can show as-is. Falls back to the raw message when no rule
     * matches so we don't accidentally hide a useful error.
     */
    private function translateDeliveryError(string $rawError, ?EmailAccount $account): string
    {
        $err = strtolower($rawError);
        $provider = $account?->provider ?? 'email';

        if (str_contains($err, 'refresh token must be passed') || str_contains($err, 'refresh_token')) {
            return ucfirst($provider) . ' needs to be reconnected. Open Settings → Email, remove this mailbox and reconnect — make sure to grant permission again so we receive a fresh refresh token.';
        }

        if (str_contains($err, 'invalid_grant') || str_contains($err, 'token has been expired or revoked')) {
            return ucfirst($provider) . ' authorization was revoked or expired. Reconnect the mailbox in Settings → Email.';
        }

        if (str_contains($err, 'smtp') && (str_contains($err, '535') || str_contains($err, 'authentication') || str_contains($err, 'username and password not accepted'))) {
            return 'SMTP authentication failed. Check the password (Gmail / Outlook usually need an App Password, not your login password) in Settings → Email.';
        }

        if (str_contains($err, 'smtp') && str_contains($err, '436')) {
            return 'SMTP rejected the From address. Make sure the from-account email matches the SMTP login in Settings → Email.';
        }

        if (str_contains($err, 'connection') && (str_contains($err, 'refused') || str_contains($err, 'timed out') || str_contains($err, 'timeout'))) {
            return 'Could not reach the mail server. Check the SMTP host/port in Settings → Email and try again.';
        }

        return 'Saved but delivery failed: ' . $rawError;
    }

    /**
     * Short machine-readable code so the mobile app can branch on it
     * (e.g. show a "Reconnect Gmail" button instead of a plain dismiss).
     */
    private function classifyDeliveryError(string $rawError): string
    {
        $err = strtolower($rawError);

        return match (true) {
            str_contains($err, 'refresh token must be passed'),
            str_contains($err, 'refresh_token'),
            str_contains($err, 'invalid_grant'),
            str_contains($err, 'token has been expired or revoked') => 'oauth_reconnect_required',

            str_contains($err, '535'),
            str_contains($err, 'username and password not accepted'),
            (str_contains($err, 'smtp') && str_contains($err, 'authentication')) => 'smtp_auth_failed',

            str_contains($err, '436') => 'smtp_from_mismatch',

            str_contains($err, 'refused'),
            str_contains($err, 'timed out'),
            str_contains($err, 'timeout') => 'smtp_unreachable',

            default => 'unknown',
        };
    }

    private function resolveContactForChannel(int $workspaceId, string $channel, string $address): ?\App\Models\Contact
    {
        $address = trim($address);
        if ($address === '') return null;

        if ($channel === 'email') {
            $email = strtolower($address);
            // withTrashed() — the (workspace_id, email) unique constraint does
            // NOT exclude soft-deleted rows, so a plain firstOrCreate sees the
            // default scope (deleted_at IS NULL), finds nothing, then crashes
            // on the unique constraint when it tries to INSERT over the
            // still-physical soft-deleted row. Resurrect-or-create instead.
            $contact = \App\Models\Contact::withTrashed()
                ->where('workspace_id', $workspaceId)
                ->where('email', $email)
                ->first();

            if ($contact) {
                if ($contact->trashed()) {
                    $contact->restore();
                    $contact->update(['status' => 'active']);
                }
                return $contact;
            }

            return \App\Models\Contact::create([
                'workspace_id' => $workspaceId,
                'email'        => $email,
                'first_name'   => explode('@', $email)[0],
                'status'       => 'active',
            ]);
        }

        if (in_array($channel, ['sms', 'whatsapp'], true)) {
            // Same soft-delete trap as the email branch above.
            $contact = \App\Models\Contact::withTrashed()
                ->where('workspace_id', $workspaceId)
                ->where('phone', $address)
                ->first();

            if ($contact) {
                if ($contact->trashed()) {
                    $contact->restore();
                    $contact->update(['status' => 'active']);
                }
                return $contact;
            }

            return \App\Models\Contact::create([
                'workspace_id' => $workspaceId,
                'phone'        => $address,
                'first_name'   => $address,
                'status'       => 'active',
            ]);
        }

        // Telegram / Slack — channel_conversation_id on the conversation is
        // the real routing identifier; the contact is best-effort by name.
        return \App\Models\Contact::where('workspace_id', $workspaceId)
            ->where('first_name', $address)
            ->first();
    }
}
