<?php

namespace App\Components\Dialler;

use App\Actions\Communications\StartOutboundCallAction;
use App\Actions\Communications\TransferCallAction;
use App\Enum\Communication\CommunicationEventType;
use App\Enum\Communication\CommunicationStatus;
use App\Jobs\Synapse\ClassifyAndScoreCallJob;
use App\Models\Client\Client;
use App\Models\Communication\Call;
use App\Models\Communication\Communication;
use App\Models\Communication\Recording;
use App\Models\Dialler\AgentStatus as AgentStatusModel;
use App\Models\Solicitor\Solicitor;
use App\Models\Synapse\CallScore;
use App\Services\Dialler\AgentStatusService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;

/**
 * CallOverlay — global softphone widget (mounted once in the layout header)
 *
 * Combines:
 *  • Agent presence status selector (always visible in the header)
 *  • Full call lifecycle overlay: incoming → ringing → connected → wrap-up
 *
 * Phases:
 *   idle      — overlay hidden; only the status dropdown is shown
 *   incoming  — inbound call awaiting accept / decline
 *   ringing   — outbound call connecting, or inbound call being answered
 *   connected — live call with mute + hangup + timer
 *   wrapup    — post-call disposition form
 *   error     — call failed; brief error message before returning to idle
 */
class CallOverlay extends Component {
    // ── Agent presence ────────────────────────────────────────────────────────
    public ?AgentStatusModel $agentStatus = null;
    public string $agentPresence = 'offline';   // available|busy|wrap_up|do_not_disturb|offline

    // ── Call overlay visibility & phase ──────────────────────────────────────
    public bool   $visible = false;
    public string $phase   = 'idle';   // idle|incoming|ringing|connected|wrapup|error

    // ── Call metadata ─────────────────────────────────────────────────────────
    public string  $direction   = 'outbound';
    public ?string $callerLabel = null;
    public ?string $callerUrl   = null;
    public ?string $callSid     = null;
    public ?string $callerFrom  = null;   // raw From value from Twilio SDK (used for fallback lookup)

    // ── DB record IDs ─────────────────────────────────────────────────────────
    public ?int $communicationId = null;
    public ?int $callId          = null;

    // ── In-call state ─────────────────────────────────────────────────────────
    public bool    $muted        = false;
    public ?string $errorMessage = null;

    // ── Transfer state ────────────────────────────────────────────────────────
    public bool    $showTransferPicker = false;

    public bool    $transferring       = false;

    public ?int    $transferredToId    = null;

    public ?string $transferredToName  = null;

    // ── Wrap-up form ──────────────────────────────────────────────────────────
    public string $disposition = '';

    public string $notes = '';

    public bool $callbackRequired = false;

    public ?string $callbackAt = null;

    // ── Ad-hoc dialpad ────────────────────────────────────────────────────────
    public bool    $showDialpad      = false;

    public ?int    $adHocClientId    = null;   // optional client this ad-hoc call references

    public ?string $adHocClientLabel = null;

    public string  $clientSearch     = '';     // typeahead query

    public ?string $dialpadError     = null;

    // ─────────────────────────────────────────────────────────────────────────

    public function mount(): void
    {
        $solicitorId = app(Solicitor::class)?->id;

        // Without an active workspace there is nothing to be present in.
        if ($solicitorId === null) {
            $this->agentPresence = 'offline';

            return;
        }

        // One presence row per agent (see the agent_statuses unique key). The
        // agent's single Twilio device is bound to whichever workspace they are
        // viewing, so landing here re-binds that row to this workspace — carrying
        // their status across the switch — which makes them reachable here and,
        // by construction, nowhere else.
        $this->agentStatus = AgentStatusModel::firstOrCreate(
            ['agent_id' => Auth::id()],
            ['solicitor_id' => $solicitorId, 'status' => 'offline'],
        );

        if ($this->agentStatus->solicitor_id !== $solicitorId) {
            $this->agentStatus->update(['solicitor_id' => $solicitorId]);
        }

        $this->agentPresence = $this->agentStatus->status ?? 'offline';

        // Heartbeat: record that the agent's softphone is loaded right now. The
        // reaper (ReapStaleAgentStatuses) uses last_seen_at to close sessions
        // abandoned without an explicit logout.
        $this->agentStatus->update(['last_seen_at' => now()]);
    }

    // ── Agent presence ────────────────────────────────────────────────────────

    public function touchPresence(): void
    {
        if ($this->agentStatus && $this->agentPresence !== 'offline') {
            $this->agentStatus->update(['last_seen_at' => now()]);
        }
    }

    public function setPresence(string $status, string $source = 'manual'): void
    {
        $allowed = ['available', 'busy', 'wrap_up', 'do_not_disturb', 'offline'];

        if (! in_array($status, $allowed, true) || ! $this->agentStatus) {
            return;
        }

        $solicitorId = $this->agentStatus?->solicitor_id ?? app(Solicitor::class)?->id;

        if (! $solicitorId) {
            return;
        }

        // Route through the service so the interval history (agent_status_histories)
        // is kept in lock-step with the live snapshot.
        $context = ['source' => $source];

        if (in_array($status, ['busy', 'wrap_up'], true) && $this->communicationId) {
            $context['communication_id'] = $this->communicationId;
        }

        $this->agentStatus = app(AgentStatusService::class)->transitionTo(
            $solicitorId,
            Auth::id(),
            $status,
            $context,
        );

        $this->agentPresence = $status;
    }

    // ── Outbound call initiated (from CallManager::startCall()) ──────────────

    #[On('dialler-call-started')]
    public function handleCallStarted(
        int    $communication_id,
        int    $call_id,
        string $label,
        string $direction = 'outbound'
    ): void {
        $this->resetCallState();

        $this->communicationId = $communication_id;
        $this->callId          = $call_id;
        $this->callerLabel     = $label;
        $this->direction       = $direction;
        $this->phase           = 'ringing';
        $this->visible         = true;
    }

    // ── Inbound call ─────────────────────────────────────────────────────────

    #[On('twilio-incoming-call')]
    public function handleIncomingCall($from = null, $call_sid = null): void
    {
        // Never interrupt an active call (wrapup can be interrupted for transfer-back calls)
        if (in_array($this->phase, ['ringing', 'connected'])) {
            return;
        }

        $this->resetCallState();

        $this->direction   = 'inbound';
        $this->callerLabel = is_string($from) ? $from : 'Unknown Caller';
        $this->callerFrom  = is_string($from) ? $from : null;
        $this->callSid     = is_string($call_sid) ? $call_sid : null;
        $this->phase       = 'incoming';
        $this->visible     = true;

        if ($this->callerFrom) {
            $this->resolveCallerByPhone($this->callerFrom);
        }

        // Webhook fires before the browser SDK event, so the DB record should
        // already exist — resolve it now so wrap-up can save immediately.
        if ($this->callSid) {
            $this->resolveInboundRecord();
        }
    }

    // ── SDK lifecycle events ──────────────────────────────────────────────────

    #[On('twilio-call-ringing')]
    public function handleCallRinging(): void
    {
        // Phase is already 'ringing' for outbound; no change needed.
        // For inbound, this fires after accept() — keep 'ringing' until accepted.
    }

    #[On('twilio-call-accepted')]
    public function handleCallAccepted(): void
    {
        // For OUTBOUND calls the SDK 'accept' event fires when the browser↔Twilio
        // WebRTC media leg is established (for ringback audio) — NOT when the
        // PSTN recipient picks up. We must NOT transition to 'connected' here;
        // pollOutboundConnected() drives that transition from answered_at.
        //
        // This guard is the source of truth even if twilio.js still emits the
        // event from a cached bundle: an outbound call can only enter the
        // connected phase via the poll.
        if ($this->direction === 'outbound' && $this->phase !== 'connected') {
            return;
        }

        // Retry inbound record lookup in case it wasn't ready on incoming event
        if ($this->direction === 'inbound' && ! $this->communicationId && $this->callSid) {
            $this->resolveInboundRecord();
        }

        // Record which agent actually answered. The SDK 'accept' event only fires
        // in the answering agent's own session, so auth()->id() is the true
        // answerer even when the call rang several agents at once.
        if ($this->direction === 'inbound' && $this->callId) {
            $this->stampInboundAnswerer();
        }

        $this->phase   = 'connected';
        $this->muted   = false;
        $this->visible = true;

        // Auto-set agent to busy while on a live call
        if ($this->agentPresence === 'available') {
            $this->setPresence('busy', 'call_accepted');
        }
    }

    // ── Outbound progress polling ─────────────────────────────────────────────
    //
    // The Twilio SDK 'accept' event for outbound calls fires when the
    // browser↔Twilio WebRTC leg is established (for ringback) — NOT when
    // the PSTN recipient picks up. We can only know that from the server-
    // side status callback, which writes answered_at on `in-progress`.
    //
    // While an outbound call is in 'ringing' phase the blade view runs
    // this poll. As soon as answered_at is set we transition to connected
    // and dispatch the same browser event the Alpine timer listens for.

    public function pollOutboundConnected(): void
    {
        if ($this->direction !== 'outbound') {
            return;
        }

        if ($this->phase !== 'ringing') {
            return;
        }

        if (! $this->communicationId) {
            return;
        }

        $answered = Communication::where('id', $this->communicationId)
            ->whereNotNull('answered_at')
            ->exists();

        if (! $answered) {
            return;
        }

        $this->phase   = 'connected';
        $this->muted   = false;
        $this->visible = true;

        if ($this->agentPresence === 'available') {
            $this->setPresence('busy', 'call_accepted');
        }

        // Start the Alpine duration timer (listens on @twilio-call-accepted.window).
        // handleCallAccepted() also fires on the same dispatch but is idempotent.
        $this->dispatch('twilio-call-accepted');
    }

    #[On('twilio-call-disconnected')]
    public function handleCallDisconnected(): void
    {
        $this->muted = false;

        // The Twilio SDK fires `accept` when the browser→Twilio WebRTC leg is
        // established for ringback audio — NOT when the PSTN recipient picks up.
        // The only reliable signal that the remote party actually answered is
        // answered_at, which is set by the status callback when in-progress fires.
        // If it is null the call was never truly answered — skip wrapup.
        if ($this->communicationId) {
            $answered = Communication::where('id', $this->communicationId)
                ->whereNotNull('answered_at')
                ->exists();

            if (! $answered) {
                $this->endCallWithoutAnswer(CommunicationStatus::Missed);
                return;
            }
        }

        $this->phase = $this->communicationId ? 'wrapup' : 'idle';

        // Move from busy → wrap_up so routing rules don't queue new calls
        // during the post-call wrap-up period.
        if ($this->agentPresence === 'busy') {
            $this->setPresence('wrap_up', 'call_ended');
        }

        if ($this->phase === 'idle') {
            $this->dismiss();
        }
    }

    #[On('twilio-call-cancelled')]
    public function handleCallCancelled(): void
    {
        // Agent cancelled before the recipient answered — that's a missed call
        // from the contact's perspective, not a technical failure.
        $this->endCallWithoutAnswer(CommunicationStatus::Missed);
    }

    #[On('twilio-call-rejected')]
    public function handleCallRejected(): void
    {
        $this->dismiss();
    }

    #[On('twilio-call-error')]
    public function handleCallError($message = null): void
    {
        $this->errorMessage = is_string($message) ? $message : 'The call failed.';
        $this->muted        = false;

        $this->phase   = ($this->communicationId && $this->phase === 'connected')
            ? 'wrapup'
            : 'error';
        $this->visible = true;
    }

    // ── Agent call-control actions ────────────────────────────────────────────

    public function accept(): void
    {
        // SDK accept is dispatched immediately client-side via @click on the button.
        // This method just updates the phase; guard against the Livewire round-trip
        // arriving after the SDK 'accept' event has already advanced us to 'connected'.
        if ($this->phase === 'incoming') {
            $this->phase = 'ringing';
        }
    }

    public function reject(): void
    {
        $this->dispatch('twilio-reject-browser-call');
        $this->dismiss();
    }

    public function hangup(): void
    {
        $this->dispatch('twilio-hangup-browser-call');
    }

    public function toggleMute(): void
    {
        if ($this->phase !== 'connected') {
            return;
        }

        $this->muted = ! $this->muted;
        $this->dispatch($this->muted ? 'twilio-mute-browser-call' : 'twilio-unmute-browser-call');
    }

    // ── Ad-hoc dialpad ────────────────────────────────────────────────────────

    public function toggleDialpad(): void
    {
        $this->showDialpad  = ! $this->showDialpad;
        $this->dialpadError = null;
    }

    #[Computed]
    public function clientResults(): array
    {
        $term = trim($this->clientSearch);

        if (strlen($term) < 2) {
            return [];
        }

        $solicitorId = app(Solicitor::class)?->id;

        if (! $solicitorId) {
            return [];
        }

        return Client::query()
            ->where('solicitor_id', $solicitorId)
            ->where(function ($q) use ($term) {
                $q->where('name_first', 'like', "%{$term}%")
                    ->orWhere('name_last', 'like', "%{$term}%")
                    ->orWhere('phone_number', 'like', "%{$term}%");
            })
            ->orderBy('name_last')
            ->limit(8)
            ->get()
            ->map(fn (Client $c) => [
                'id'    => $c->id,
                'label' => $c->getCallableLabel(),
                'phone' => (string) $c->phone_number,
            ])
            ->all();
    }

    public function selectAdHocClient(int $clientId): void
    {
        $client = Client::query()
            ->where('solicitor_id', app(Solicitor::class)?->id)
            ->find($clientId);

        if (! $client) {
            return;
        }

        $this->adHocClientId    = $client->id;
        $this->adHocClientLabel = $client->getCallableLabel();
        $this->clientSearch     = '';
    }

    public function clearAdHocClient(): void
    {
        $this->adHocClientId    = null;
        $this->adHocClientLabel = null;
    }

    public function startAdHocCall(string $number, StartOutboundCallAction $action): void
    {
        abort_unless(auth()->user()?->can('communications.call.adhoc'), 403);

        $this->dialpadError = null;

        $solicitorId = app(Solicitor::class)?->id;

        // Re-resolve the client server-side, scoped to the tenant — never trust the id.
        $client = $this->adHocClientId
            ? Client::query()->where('solicitor_id', $solicitorId)->find($this->adHocClientId)
            : null;

        try {
            $communication = $action->handle(
                subject:     $client,
                toNumber:    $number,
                targetLabel: $client?->getCallableLabel() ?? $number,
                targetType:  'manual',
                callType:    'manual',
                claimId:     $client?->claim?->id,
                solicitorId: $solicitorId,
                record:      $client !== null,   // don't record a client-less external call
                uiOrigin:    'call_overlay_dialpad',
            );
        } catch (\Throwable $e) {
            $this->dialpadError = $e->getMessage();
            report($e);
            return;
        }

        // Hand the call to the overlay lifecycle (mirrors handleCallStarted()).
        $this->resetCallState();

        $this->communicationId = $communication->id;
        $this->callId          = $communication->call->id;
        $this->callerLabel     = $client?->getCallableLabel() ?? $number;
        $this->callerUrl       = $client ? route('clients.show', $client) : null;
        $this->direction       = 'outbound';
        $this->phase           = 'ringing';
        $this->visible         = true;

        $this->showDialpad      = false;
        $this->adHocClientId    = null;
        $this->adHocClientLabel = null;
        $this->clientSearch     = '';

        // Tell the JS layer to connect via the Twilio SDK.
        $this->dispatch('twilio-start-browser-call', call_id: $communication->call->id);
    }

    // ── Wrap-up ───────────────────────────────────────────────────────────────

    public function saveWrapUp(): void
    {
        $this->validate([
            'disposition'      => [$this->transferredToId ? 'nullable' : 'required', 'string', 'max:100'],
            'notes'            => ['nullable', 'string'],
            'callbackRequired' => ['required', 'boolean'],
            'callbackAt'       => ['nullable', 'date', 'required_if:callbackRequired,true'],
        ], [
            'callbackAt.required_if' => 'Please choose a callback date and time.',
        ]);

        abort_unless($this->communicationId && $this->callId, 403);

        DB::transaction(function () {
            $communication = Communication::query()
                ->with(['call', 'communicable'])
                ->findOrFail($this->communicationId);

            $call     = $communication->call;
            $call->update([
                'notes'       => filled($this->notes) ? $this->notes : null,
                'disposition' => filled($this->disposition) ? $this->disposition : null,
                'callback_at' => $this->callbackRequired && $this->callbackAt ? $this->callbackAt : null,
            ]);

            $dirLabel = $this->direction === 'inbound' ? 'Inbound call from' : 'Outbound call to';
            $preview  = $dirLabel.' '.($this->callerLabel ?? 'unknown');

            if (filled($this->disposition)) {
                $preview .= ' · '.str($this->disposition)->replace('_', ' ')->title();
            }

            $communication->update(['preview' => $preview]);

            $communication->events()->create([
                'solicitor_id' => $communication->solicitor_id,
                'call_id' => $call->id,
                'agent_id' => auth()->id(),
                'event' => CommunicationEventType::CallDispositionSaved->value,
                'source' => 'agent',
                'description' => 'Call wrap-up saved.',
                'occurred_at' => now(),
                'payload' => [
                    'disposition' => $this->disposition,
                    'callback_required' => $this->callbackRequired,
                    'callback_at' => $this->callbackAt,
                ],
            ]);

            if (filled($this->notes)) {
                $communication->events()->create([
                    'solicitor_id' => $communication->solicitor_id,
                    'call_id' => $call->id,
                    'agent_id' => auth()->id(),
                    'event' => CommunicationEventType::CallNotesSaved->value,
                    'source' => 'agent',
                    'description' => 'Call notes saved.',
                    'occurred_at' => now(),
                    'payload' => ['notes' => $this->notes],
                ]);
            }

            if ($this->callbackRequired) {
                $communication->events()->create([
                    'solicitor_id' => $communication->solicitor_id,
                    'call_id' => $call->id,
                    'agent_id' => auth()->id(),
                    'event' => CommunicationEventType::CallbackRequested->value,
                    'source' => 'agent',
                    'description' => 'Callback requested.',
                    'occurred_at' => now(),
                    'payload' => ['callback_at' => $this->callbackAt],
                ]);
            }
        });

        // If the recording for this call has already been scored — i.e. the agent
        // wrapped up after the transcription pipeline finished — re-run scoring so
        // this explicit "initial call" choice overrides whatever the AI classifier
        // decided. In the usual case the recording doesn't exist yet and the
        // pipeline reads the saved disposition when it runs (see ClassifyAndScoreCallJob).
        if ($this->disposition === Call::DISPOSITION_INITIAL_CALL) {
            $existingScore = CallScore::where('call_id', $this->callId)->first();

            if ($existingScore?->recording_id) {
                ClassifyAndScoreCallJob::dispatch($existingScore->recording_id, true);
            }
        } elseif ($this->disposition === 'connected_successfully') {
            // Outbound calls are only in scope for the lightweight data-protection
            // audit when the handler confirms a successful connection.
            $recording = Recording::query()
                ->where('call_id', $this->callId)
                ->whereHas('synapseTranscript', fn ($query) => $query->where('status', 'completed'))
                ->latest('id')
                ->first();

            if ($recording) {
                ClassifyAndScoreCallJob::dispatch($recording->id);
            }
        }

        $this->dispatch('toast', message: 'Call wrap-up saved.', type: 'success');
        $this->dismiss();
    }

    public function dismissWrapUp(): void
    {
        if ($this->communicationId && $this->callId) {
            $call = Call::with('communication')->find($this->callId);
            $call?->communication?->events()->create([
                'solicitor_id' => $call->communication->solicitor_id,
                'call_id' => $call->id,
                'agent_id' => auth()->id(),
                'event' => CommunicationEventType::CallWrapUpSkipped->value,
                'source' => 'agent',
                'description' => 'Wrap-up dismissed without saving a disposition.',
                'occurred_at' => now(),
                'payload' => [],
            ]);
        }

        $this->dismiss();
    }

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

    protected function endCallWithoutAnswer(CommunicationStatus $status): void
    {
        if ($this->communicationId) {
            Communication::where('id', $this->communicationId)
                ->whereIn('status', [
                    CommunicationStatus::Initiated->value,
                    CommunicationStatus::Ringing->value,
                ])
                ->update([
                    'status'   => $status->value,
                    'ended_at' => now(),
                ]);
        }

        $this->dismiss();
    }

    public function dismiss(): void
    {
        // Restore availability after a call ends. Only auto-flip when the agent
        // was put into busy/wrap_up by the call lifecycle — leave do_not_disturb
        // and offline alone so agents who deliberately set those aren't interrupted.
        if (in_array($this->agentPresence, ['busy', 'wrap_up'], true)) {
            $this->setPresence('available', 'call_completed');
        }

        $this->phase = 'idle';
        $this->visible = false;
        $this->direction = 'outbound';
        $this->callerLabel = null;
        $this->callerUrl = null;
        $this->callerFrom = null;
        $this->callSid = null;
        $this->communicationId = null;
        $this->callId = null;
        $this->muted = false;
        $this->errorMessage = null;
        $this->disposition = '';
        $this->notes = '';
        $this->callbackRequired = false;
        $this->callbackAt = null;
        $this->showTransferPicker = false;
        $this->transferring = false;
        $this->transferredToId = null;
        $this->transferredToName = null;
        $this->resetValidation();
    }

    protected function resetCallState(): void
    {
        $this->muted = false;
        $this->errorMessage = null;
        $this->communicationId = null;
        $this->callId = null;
        $this->callerUrl = null;
        $this->callerFrom = null;
        $this->callSid = null;
        $this->disposition = '';
        $this->notes = '';
        $this->callbackRequired = false;
        $this->callbackAt = null;
        $this->showTransferPicker = false;
        $this->transferring = false;
        $this->transferredToId = null;
        $this->transferredToName = null;
        $this->resetValidation();
    }

    protected function resolveInboundRecord(): void
    {
        // Primary: match by the call SID Twilio gives the browser SDK.
        // For <Dial><Client> inbound calls this is typically the parent (PSTN) SID
        // which matches provider_call_sid. If it doesn't match (e.g. SDK sends the
        // child leg SID), fall back to the caller's phone number.
        $call = null;

        if ($this->callSid) {
            $call = Call::with('communication.communicable')
                ->where('provider_call_sid', $this->callSid)
                ->first();
        }

        if (! $call && $this->callerFrom && ! str_starts_with($this->callerFrom, 'client:')) {
            $solicitorId = app(Solicitor::class)?->id;
            $call = Call::with('communication.communicable')
                ->whereHas('communication', function ($q) use ($solicitorId) {
                    $q->where('direction', 'inbound')
                        ->where('solicitor_id', $solicitorId);
                })
                ->where('from_number', $this->callerFrom)
                ->latest('id')
                ->first();
        }

        if (! $call) {
            return;
        }

        $this->callId = $call->id;
        $this->communicationId = $call->communication_id;

        $communicable = $call->communication?->communicable;

        if (! $communicable) {
            return;
        }

        if (method_exists($communicable, 'getCallableLabel')) {
            $this->callerLabel = $communicable->getCallableLabel();
        }

        if ($communicable instanceof Client) {
            $this->callerUrl = route('clients.show', $communicable);
        }
    }

    /**
     * Stamp the agent who answered an inbound call onto the Call record (and the
     * Communication's handling agent if it wasn't pre-assigned). Idempotent — the
     * whereNull guards mean a repeated accept event never overwrites the answerer.
     */
    protected function stampInboundAnswerer(): void
    {
        $agentId = Auth::id();

        if (! $agentId || ! $this->callId) {
            return;
        }

        Call::where('id', $this->callId)
            ->whereNull('answered_by_agent_id')
            ->update(['answered_by_agent_id' => $agentId]);

        if ($this->communicationId) {
            Communication::where('id', $this->communicationId)
                ->whereNull('agent_id')
                ->update(['agent_id' => $agentId]);
        }
    }

    // ── UI helpers ────────────────────────────────────────────────────────────

    public function presenceButtonClass(): string
    {
        return match ($this->agentPresence) {
            'available' => 'btn-success',
            'busy' => 'btn-danger',
            'wrap_up' => 'btn-warning',
            'do_not_disturb' => 'btn-secondary',
            default => 'btn-secondary',
        };
    }

    public function presenceDotClass(): string
    {
        return match ($this->agentPresence) {
            'available' => 'bg-success',
            'busy' => 'bg-danger',
            'wrap_up' => 'bg-warning',
            'do_not_disturb' => 'bg-danger',
            default => 'bg-secondary',
        };
    }

    // ── Computed ──────────────────────────────────────────────────────────────

    #[Computed]
    public function dispositionOptions(): array
    {
        return [
            'transferred' => 'Transferred',
            'connected_successfully' => 'Connected Successfully',
            Call::DISPOSITION_INITIAL_CALL => 'Initial Call',
            'left_voicemail' => 'Left Voicemail',
            'no_answer' => 'No Answer',
            'busy' => 'Busy',
            'wrong_number' => 'Wrong Number',
            'callback_required' => 'Callback Required',
            'follow_up_needed' => 'Follow-Up Needed',
        ];
    }

    #[Computed]
    public function availableAgents(): array
    {
        $solicitorId = app(Solicitor::class)?->id;

        return AgentStatusModel::query()
            ->with('agent:id,name_first,name_last')
            ->where('solicitor_id', $solicitorId)
            ->where('agent_id', '!=', Auth::id())
            ->where('status', 'available')
            // A transfer may only target an agent who belongs to this workspace.
            ->whereHas('agent', fn ($q) => $q->where('solicitor_id', $solicitorId))
            ->get()
            ->map(fn ($s) => [
                'id' => $s->agent_id,
                'name' => trim($s->agent?->name_first.' '.$s->agent?->name_last),
            ])
            ->values()
            ->toArray();
    }

    // ── Transfer ──────────────────────────────────────────────────────────────

    public function openTransferPicker(): void
    {
        if ($this->phase !== 'connected') {
            return;
        }
        $this->showTransferPicker = true;
    }

    public function closeTransferPicker(): void
    {
        $this->showTransferPicker = false;
    }

    public function initiateTransfer(int $targetAgentId, TransferCallAction $action): void
    {
        if (! $this->callId || $this->transferring) {
            return;
        }

        $this->transferring = true;

        try {
            $agent = $action->handle($this->callId, $targetAgentId);

            $this->transferredToId = $agent->id;
            $this->transferredToName = $agent->name_first.' '.$agent->name_last;
            $this->showTransferPicker = false;
            $this->disposition = 'transferred';
            $this->phase = 'wrapup';
            $this->visible = true;

        } catch (\Throwable $e) {
            logger()->debug($e->getMessage());
            $this->logTransferFailed($e->getMessage());
            $this->dispatch('toast', message: 'Transfer failed: '.$e->getMessage(), type: 'error');
        } finally {
            $this->transferring = false;
        }
    }

    protected function logTransferFailed(string $error): void
    {
        if (! $this->callId) {
            return;
        }

        $call = Call::with('communication')->find($this->callId);
        $call?->communication?->events()->create([
            'solicitor_id' => $call->communication->solicitor_id,
            'call_id' => $call->id,
            'agent_id' => auth()->id(),
            'event' => CommunicationEventType::CallTransferFailed->value,
            'source' => 'agent',
            'description' => 'Transfer failed: '.$error,
            'occurred_at' => now(),
            'payload' => ['error' => $error],
        ]);
    }

    protected function resolveCallerByPhone(string $from): void
    {
        if (str_starts_with($from, 'client:')) {
            return;
        }

        $normalized = $this->normalizePhoneNumber($from);
        if ($normalized === '') {
            return;
        }

        $solicitorId = app(Solicitor::class)?->id;
        if (! $solicitorId) {
            return;
        }

        $client = Client::where('solicitor_id', $solicitorId)
            ->where('phone_number', $normalized)
            ->first();

        if (! $client) {
            return;
        }

        $this->callerLabel = $client->getCallableLabel();
        $this->callerUrl = route('clients.show', $client);
    }

    protected function normalizePhoneNumber(string $value): string
    {
        $value = trim(preg_replace('/[^\d+]/', '', $value) ?? '');

        if (str_starts_with($value, '00')) {
            $value = '+'.substr($value, 2);
        }

        if (! str_starts_with($value, '+')) {
            $countryCode = config('services.dialler.default_country_code', '44');
            $value = str_starts_with($value, '0')
                ? '+'.$countryCode.substr($value, 1)
                : '+'.$value;
        }

        return $value;
    }

    public function render()
    {
        return view('components.dialler.call-overlay');
    }
}
