<?php

namespace App\Controllers;

use App\Libraries\PhoneNumber;
use App\Models\BookingModel;
use App\Models\EventModel;
use App\Models\FolioModel;
use App\Models\GuestModel;
use App\Models\ComplaintModel;
use App\Models\EmailLogModel;
use App\Models\BenefitUsageLogModel;
use App\Models\DiningMembershipModel;
use App\Models\GuestPreferenceModel;
use App\Models\LoyaltyAccountModel;
use App\Models\LoyaltyTransactionModel;
use App\Models\MessageLogModel;
use App\Models\RoomTypeModel;
use Config\Lookups;

class GuestController extends BaseController
{
    /**
     * Redesigned to match a user-supplied "Guest CRM" mockup. Its guest
     * detail panel showed room/bed/pillow/smoking/language "Preferences" —
     * left as free-text `notes` at first (see AddSegmentAndBlacklistToGuests's
     * doc comment) but now real, structured data via guest_preferences /
     * GuestPreferenceModel (room type, bed, dietary, seating, allergies,
     * smoking). "Total Stays" and "Lifetime Value" are real, computed
     * from each guest's actual bookings/folios, not placeholder numbers.
     */
    public function index()
    {
        $guestsModel = new GuestModel();

        $filters = [
            'q'           => trim((string) $this->request->getGet('q')),
            'segment'     => $this->request->getGet('segment'),
            'nationality' => $this->request->getGet('nationality'),
            'tab'         => $this->request->getGet('tab') ?: 'all',
        ];

        $results = $guestsModel->filterList($filters)->paginate(10);
        $pager   = $guestsModel->pager;

        $bookings = new BookingModel();
        $folios   = new FolioModel();
        $loyaltyByGuest = (new LoyaltyAccountModel())->forGuests(array_map(static fn ($g) => $g->id, $results));
        $diningByGuest  = (new DiningMembershipModel())->forGuests(array_map(static fn ($g) => $g->id, $results));

        foreach ($results as $g) {
            $g->loyalty_tier = $loyaltyByGuest[$g->id]->tier ?? null;
            $g->dining_tier  = $diningByGuest[$g->id]->tier ?? null;
            $stays = $bookings->where('guest_id', $g->id)
                ->whereIn('status', ['pending_deposit', 'confirmed', 'checked_in', 'checked_out'])
                ->orderBy('check_in_date', 'desc')
                ->findAll();

            $lifetimeValue = '0.00';

            foreach ($stays as $stay) {
                $folio = $folios->findForBooking($stay->id);

                if ($folio !== null) {
                    $lifetimeValue = money_add($lifetimeValue, $folios->totalPaid($folio->id));
                }
            }

            $g->total_stays    = count($stays);
            $g->lifetime_value = $lifetimeValue;
            $g->last_stay      = $stays[0]->check_in_date ?? null;
        }

        $total     = (new GuestModel())->countAllResults();
        $repeatIds = (new GuestModel())->repeatGuestIds();

        return view('guests/index', [
            'pageTitle'     => 'Guest CRM',
            'pageSubtitle'  => 'Search, segment, and manage every guest who has stayed.',
            'guests'        => $results,
            'pager'         => $pager,
            'filters'       => $filters,
            'nationalities' => (new GuestModel())->distinctNationalities(),
            'lookups'       => new Lookups(),
            'stats'         => [
                'total'     => $total,
                'repeat'    => count($repeatIds),
                'new'       => $total - count($repeatIds),
                'vip'       => (new GuestModel())->countBySegment('vip'),
                'countries' => (new GuestModel())->countCountries(),
                'openComplaints' => (new ComplaintModel())->countUnresolved(),
            ],
        ]);
    }

    public function new()
    {
        return view('guests/form', [
            'pageTitle'   => 'New Guest',
            'guest'       => null,
            'preferences' => null,
            'roomTypes'   => (new RoomTypeModel())->where('is_active', 1)->orderBy('name')->findAll(),
            'lookups'     => new Lookups(),
        ]);
    }

    public function create()
    {
        $data = $this->collectInput();

        if (! $this->request->getPost('confirm_duplicate')) {
            $duplicates = (new GuestModel())->findPossibleDuplicates($data['phone'], $data['id_number'] ?? null);

            if (! empty($duplicates)) {
                return view('guests/duplicate_warning', [
                    'pageTitle'  => 'Possible Duplicate Guest',
                    'duplicates' => $duplicates,
                    'formData'   => $data,
                    'formAction' => site_url('guests'),
                ]);
            }
        }

        $model = new GuestModel();

        if (! $model->insert($data)) {
            return $this->redirectWithModelErrors($model);
        }

        $guestId = $model->getInsertID();
        (new GuestPreferenceModel())->saveForGuest($guestId, $this->collectPreferenceInput());

        $this->logActivity('guest', 'Created guest "' . $data['full_name'] . '".');

        return redirect()->to('guests/' . $guestId)->with('success', 'Guest created.');
    }

    public function show(int $id)
    {
        $guest = (new GuestModel())->find($id);

        if ($guest === null) {
            return redirect()->to('guests')->with('error', 'Guest not found.');
        }

        $bookings = (new BookingModel())->withDetails()->where('bookings.guest_id', $id)->orderBy('bookings.check_in_date', 'desc')->findAll();

        $folios = new FolioModel();
        foreach ($bookings as $booking) {
            $folio = $folios->findForBooking($booking->id);
            $booking->folio_id      = $folio->id ?? null;
            $booking->folio_total   = $folio ? $folios->totalCharges($folio->id) : '0.00';
            $booking->folio_balance = $folio ? $folios->balanceDue($folio->id) : '0.00';
            $booking->folio_paid    = $folio ? $folios->totalPaid($folio->id) : '0.00';
        }

        $noShowCount = (new BookingModel())->where('guest_id', $id)->where('status', 'no_show')->countAllResults();
        $preferences = (new GuestPreferenceModel())->forGuest($id);
        $preferredRoomType = $preferences && $preferences->room_type_id ? (new RoomTypeModel())->find($preferences->room_type_id) : null;

        // Same "stays"/"lifetime value" definition as the guest list
        // (index()) — real stays only (excludes cancelled/no_show),
        // lifetime value is money actually paid, not just charged.
        $realStays = array_values(array_filter(
            $bookings,
            static fn ($b) => in_array($b->status, ['pending_deposit', 'confirmed', 'checked_in', 'checked_out'], true)
        ));

        $lifetimeValue = '0.00';
        $totalNights   = 0;

        foreach ($realStays as $stay) {
            if ($stay->folio_id) {
                $lifetimeValue = money_add($lifetimeValue, $stay->folio_paid ?? '0.00');
            }

            $totalNights += max(1, (strtotime($stay->check_out_date) - strtotime($stay->check_in_date)) / 86400);
        }

        return view('guests/show', [
            'pageTitle'   => $guest->full_name,
            'guest'       => $guest,
            'bookings'    => $bookings,
            'noShowCount' => $noShowCount,
            'events'      => (new EventModel())->historyForGuest($id),
            'preferences' => $preferences,
            'preferredRoomType' => $preferredRoomType,
            'timeline'    => $this->buildTimeline($id),
            'loyaltyAccount' => (new LoyaltyAccountModel())->forGuest($id),
            'loyaltyTransactions' => (new LoyaltyTransactionModel())->forGuest($id),
            'diningMembership' => $diningMembership = (new DiningMembershipModel())->forGuest($id),
            'benefitUsage' => $diningMembership ? (new BenefitUsageLogModel())->forMembership($diningMembership->id) : [],
            'diningBenefitsText' => $diningMembership ? service('settings')->get('dining.' . $diningMembership->tier . '_benefits') : null,
            'stats'       => [
                'totalStays'    => count($realStays),
                'totalNights'   => (int) $totalNights,
                'lifetimeValue' => $lifetimeValue,
                'lastStay'      => $realStays[0]->check_in_date ?? null,
            ],
            'lookups'     => new Lookups(),
        ]);
    }

    /**
     * Every outbound WhatsApp/SMS (message_log), every email (email_log),
     * and every manually-logged call/note (message_log, channel 'call'/
     * 'note') for this guest, merged into one chronological list — the
     * "every touchpoint in one guest timeline" piece. Two small tables,
     * not a new unified log table: each already exists for its own real
     * reason (message_log predates this, email_log is Phase 2's own
     * addition), and a guest profile page is exactly the place two
     * queries can reasonably be merged in PHP rather than forcing one
     * shared schema on genuinely different data shapes.
     */
    private function buildTimeline(int $guestId): array
    {
        $messages = (new MessageLogModel())->where('guest_id', $guestId)->findAll();
        $emails   = (new EmailLogModel())->where('guest_id', $guestId)->findAll();

        $entries = [];

        foreach ($messages as $m) {
            $entries[] = [
                'type'      => $m->channel, // whatsapp, sms, call, note
                'summary'   => in_array($m->channel, ['call', 'note'], true)
                    ? $m->provider_response
                    : ucwords(str_replace('_', ' ', $m->message_type)),
                'detail'    => in_array($m->channel, ['call', 'note'], true) ? null : $m->provider_response,
                'status'    => $m->status,
                'createdAt' => $m->created_at,
            ];
        }

        foreach ($emails as $e) {
            $entries[] = [
                'type'      => 'email',
                'summary'   => $e->subject,
                'detail'    => $e->status === 'failed' ? $e->error : null,
                'status'    => $e->status,
                'createdAt' => $e->created_at,
            ];
        }

        usort($entries, static fn ($a, $b) => strcmp($b['createdAt'], $a['createdAt']));

        return $entries;
    }

    /**
     * Quick-add on the guest profile for a call or note that happened
     * outside this app — there's no telephony integration to capture a
     * call automatically (see MessageLogModel::logManual()).
     */
    public function logCommunication(int $id)
    {
        $guest = (new GuestModel())->find($id);

        if ($guest === null) {
            return redirect()->to('guests')->with('error', 'Guest not found.');
        }

        $channel = $this->request->getPost('channel');
        $notes   = trim((string) $this->request->getPost('notes'));

        if (! in_array($channel, ['call', 'note'], true) || $notes === '') {
            return redirect()->to('guests/' . $id)->with('error', 'Choose Call or Note and enter what happened.');
        }

        $phone = $channel === 'call' ? $guest->phone : null;

        (new MessageLogModel())->logManual($id, $channel, $notes, $phone, $this->currentUserId());

        $this->logActivity('guest', 'Logged a ' . $channel . ' for "' . $guest->full_name . '".');

        return redirect()->to('guests/' . $id)->with('success', 'Logged.');
    }

    /**
     * Executive Dining is staff-enrolled, not self-service or automatic —
     * a manager/front-desk decision, unlike the loyalty programme which
     * enrolls a guest implicitly the first time they earn points.
     */
    public function joinDining(int $id)
    {
        $guest = (new GuestModel())->find($id);

        if ($guest === null) {
            return redirect()->to('guests')->with('error', 'Guest not found.');
        }

        $tier = $this->request->getPost('tier');

        if (! in_array($tier, array_keys((new Lookups())->diningMembershipTiers), true)) {
            return redirect()->to('guests/' . $id)->with('error', 'Choose a valid membership tier.');
        }

        $months = (int) (service('settings')->get('dining.membership_duration_months') ?: 12);
        $joinedAt = date('Y-m-d');
        $renewal  = date('Y-m-d', strtotime("+{$months} months"));

        $memberships = new DiningMembershipModel();
        $existing    = $memberships->forGuest($id);

        if ($existing !== null) {
            $memberships->update($existing->id, ['tier' => $tier, 'status' => 'active', 'joined_at' => $joinedAt, 'renewal_date' => $renewal]);
        } else {
            $memberships->insert(['guest_id' => $id, 'tier' => $tier, 'status' => 'active', 'joined_at' => $joinedAt, 'renewal_date' => $renewal]);
        }

        $this->logActivity('guest', 'Enrolled "' . $guest->full_name . '" in Executive Dining (' . ucfirst($tier) . ').');

        return redirect()->to('guests/' . $id)->with('success', 'Enrolled in Executive Dining.');
    }

    public function renewDining(int $id)
    {
        $guest       = (new GuestModel())->find($id);
        $memberships = new DiningMembershipModel();
        $membership  = $memberships->forGuest($id);

        if ($guest === null || $membership === null) {
            return redirect()->to('guests/' . $id)->with('error', 'No membership to renew.');
        }

        $months  = (int) (service('settings')->get('dining.membership_duration_months') ?: 12);
        $renewal = date('Y-m-d', strtotime("+{$months} months"));

        $memberships->update($membership->id, ['status' => 'active', 'renewal_date' => $renewal]);

        $this->logActivity('guest', 'Renewed Executive Dining membership for "' . $guest->full_name . '" to ' . $renewal . '.');

        return redirect()->to('guests/' . $id)->with('success', 'Membership renewed.');
    }

    public function cancelDining(int $id)
    {
        $guest       = (new GuestModel())->find($id);
        $memberships = new DiningMembershipModel();
        $membership  = $memberships->forGuest($id);

        if ($guest === null || $membership === null) {
            return redirect()->to('guests/' . $id)->with('error', 'No membership to cancel.');
        }

        $memberships->update($membership->id, ['status' => 'cancelled']);

        $this->logActivity('guest', 'Cancelled Executive Dining membership for "' . $guest->full_name . '".');

        return redirect()->to('guests/' . $id)->with('success', 'Membership cancelled.');
    }

    /** Makes "benefits used this cycle" a real fact rather than just a claim on the tier card. */
    public function logBenefitUsage(int $id)
    {
        $guest      = (new GuestModel())->find($id);
        $membership = (new DiningMembershipModel())->forGuest($id);

        if ($guest === null || $membership === null || $membership->status !== 'active') {
            return redirect()->to('guests/' . $id)->with('error', 'No active membership on file.');
        }

        $description = trim((string) $this->request->getPost('benefit_description'));

        if ($description === '') {
            return redirect()->to('guests/' . $id)->with('error', 'Describe the benefit used.');
        }

        (new BenefitUsageLogModel())->insert([
            'membership_id'        => $membership->id,
            'benefit_description'  => $description,
            'used_at'              => date('Y-m-d H:i:s'),
        ]);

        $this->logActivity('guest', 'Logged Executive Dining benefit used by "' . $guest->full_name . '": ' . $description . '.');

        return redirect()->to('guests/' . $id)->with('success', 'Benefit usage logged.');
    }

    public function edit(int $id)
    {
        $guest = (new GuestModel())->find($id);

        if ($guest === null) {
            return redirect()->to('guests')->with('error', 'Guest not found.');
        }

        return view('guests/form', [
            'pageTitle'   => 'Edit Guest',
            'guest'       => $guest,
            'preferences' => (new GuestPreferenceModel())->forGuest($id),
            'roomTypes'   => (new RoomTypeModel())->where('is_active', 1)->orderBy('name')->findAll(),
            'lookups'     => new Lookups(),
        ]);
    }

    public function update(int $id)
    {
        $model = new GuestModel();

        if ($model->find($id) === null) {
            return redirect()->to('guests')->with('error', 'Guest not found.');
        }

        $data = $this->collectInput();

        if (! $this->request->getPost('confirm_duplicate')) {
            $duplicates = $model->findPossibleDuplicates($data['phone'], $data['id_number'] ?? null, $id);

            if (! empty($duplicates)) {
                return view('guests/duplicate_warning', [
                    'pageTitle'  => 'Possible Duplicate Guest',
                    'duplicates' => $duplicates,
                    'formData'   => $data,
                    'formAction' => site_url('guests/' . $id),
                ]);
            }
        }

        if (! $model->update($id, $data)) {
            return $this->redirectWithModelErrors($model);
        }

        (new GuestPreferenceModel())->saveForGuest($id, $this->collectPreferenceInput());

        $this->logActivity('guest', 'Updated guest "' . $data['full_name'] . '".');

        return redirect()->to('guests/' . $id)->with('success', 'Guest updated.');
    }

    /**
     * Used by the booking-creation form's phone lookup (fetch/JS), and
     * reusable anywhere else a quick "does this guest already exist"
     * check is needed.
     */
    public function lookup()
    {
        $phone      = (string) $this->request->getGet('phone');
        $normalized = PhoneNumber::normalize($phone);

        if ($normalized === null) {
            return $this->response->setJSON(['found' => false]);
        }

        $guest = (new GuestModel())->where('phone', $normalized)->first();

        if ($guest === null) {
            return $this->response->setJSON(['found' => false]);
        }

        return $this->response->setJSON([
            'found' => true,
            'guest' => [
                'id'        => $guest->id,
                'full_name' => $guest->full_name,
                'email'     => $guest->email,
                'phone'     => $guest->phone,
            ],
        ]);
    }

    /**
     * "Existing Guest" tab of the New Booking wizard — real multi-result
     * search (GuestModel::search() already matches name/phone/id_number),
     * as distinct from lookup() above which is an exact-phone-match
     * convenience for the older single-field lookup pattern still used
     * elsewhere.
     */
    public function searchAjax()
    {
        $term = trim((string) $this->request->getGet('q'));

        if (mb_strlen($term) < 2) {
            return $this->response->setJSON(['results' => []]);
        }

        $guests = (new GuestModel())->search($term)->orderBy('full_name')->findAll(10);

        return $this->response->setJSON([
            'results' => array_map(static fn ($g) => [
                'id'          => $g->id,
                'full_name'   => $g->full_name,
                'phone'       => $g->phone,
                'email'       => $g->email,
                'nationality' => $g->nationality,
                'id_number'   => $g->id_number,
                'address'     => $g->address,
            ], $guests),
        ]);
    }

    private function collectPreferenceInput(): array
    {
        $roomTypeId = (int) $this->request->getPost('pref_room_type_id');

        return [
            'room_type_id'          => $roomTypeId > 0 ? $roomTypeId : null,
            'bed_type'              => $this->request->getPost('pref_bed_type') ?: null,
            'dietary_restrictions'  => $this->request->getPost('pref_dietary') ?: null,
            'seating_preference'    => $this->request->getPost('pref_seating') ?: null,
            'allergies'             => $this->request->getPost('pref_allergies') ?: null,
            'smoking_preference'    => $this->request->getPost('pref_smoking') ?: null,
            'other_notes'           => $this->request->getPost('pref_other') ?: null,
        ];
    }

    private function collectInput(): array
    {
        return [
            'full_name'      => $this->request->getPost('full_name'),
            'phone'          => $this->request->getPost('phone'),
            'email'          => $this->request->getPost('email') ?: null,
            'id_type'        => $this->request->getPost('id_type') ?: null,
            'id_number'      => $this->request->getPost('id_number') ?: null,
            'nationality'    => $this->request->getPost('nationality') ?: null,
            'segment'        => $this->request->getPost('segment') ?: null,
            'is_blacklisted' => $this->request->getPost('is_blacklisted') ? 1 : 0,
            'address'        => $this->request->getPost('address') ?: null,
            'date_of_birth'  => $this->request->getPost('date_of_birth') ?: null,
            'anniversary_date' => $this->request->getPost('anniversary_date') ?: null,
            'notes'          => $this->request->getPost('notes') ?: null,
        ];
    }

    /**
     * Natural-language guest search — "find the guest who complained
     * about AC last month" — via a real tool call, not a fuzzy string
     * match and not Claude inventing a guest. Claude interprets the
     * query into a structured search_records tool call (source/keywords/
     * category/dates); searchRecordsTool() below runs the actual SQL
     * against guests.notes, guest_preferences, or complaints and returns
     * real rows. The guest cards shown to staff are built directly from
     * every tool call's real results (aggregated in PHP), never from
     * Claude's own prose — Claude's text is shown only as an explanation
     * alongside them. Scoped down from the original brief: feedback
     * *records* aren't searchable because no post-stay feedback reply is
     * ever captured anywhere in this schema yet (see the Phase 5 plan's
     * item 13) — only notes, preferences, and complaints are real data
     * sources today.
     */
    public function smartSearch()
    {
        if (service('settings')->get('ai.smart_guest_search_enabled', '1') !== '1') {
            return $this->response->setJSON(['success' => false, 'error' => 'Smart Guest Search is turned off — enable it under Settings > AI Features.', 'csrf_hash' => csrf_hash()]);
        }

        $query = trim((string) $this->request->getPost('query'));

        if ($query === '') {
            return $this->response->setJSON(['success' => false, 'error' => 'Enter a question first.', 'csrf_hash' => csrf_hash()]);
        }

        $categories = array_keys((new Lookups())->complaintCategories);

        $tools = [[
            'name'        => 'search_records',
            'description' => 'Searches this hotel\'s real guest notes, guest preferences, or complaints for the given keywords. Call this as many times as needed with different keyword variants to answer the question — never answer from your own knowledge.',
            'input_schema' => [
                'type'       => 'object',
                'properties' => [
                    'source' => [
                        'type'        => 'string',
                        'enum'        => ['guest_notes', 'guest_preferences', 'complaints'],
                        'description' => 'Which real data source to search.',
                    ],
                    'keywords' => [
                        'type'        => 'array',
                        'items'       => ['type' => 'string'],
                        'description' => 'One or more keywords/phrases to search for (OR-matched).',
                    ],
                    'category' => [
                        'type'        => 'string',
                        'enum'        => $categories,
                        'description' => 'Only applies to source=complaints.',
                    ],
                    'date_from' => ['type' => 'string', 'description' => 'YYYY-MM-DD, only applies to source=complaints.'],
                    'date_to'   => ['type' => 'string', 'description' => 'YYYY-MM-DD, only applies to source=complaints.'],
                ],
                'required' => ['source', 'keywords'],
            ],
        ]];

        $allMatches = []; // keyed by guest_id, aggregated across every tool call — the real, grounded result set.

        $executor = function (string $name, array $input) use (&$allMatches) {
            if ($name !== 'search_records') {
                return 'Unknown tool.';
            }

            $rows = $this->searchRecordsTool($input);

            foreach ($rows as $row) {
                $allMatches[$row['guest_id']] = $row;
            }

            return json_encode($rows);
        };

        $systemPrompt = "Today's date is " . date('Y-m-d') . '. You help hotel staff find guests by searching real '
            . 'records via the search_records tool — you never answer from memory or invent a guest, a date, or a '
            . 'detail not returned by a tool call. Interpret the staff member\'s question into one or more '
            . 'search_records calls (try relevant keyword variants — e.g. "AC" and "air conditioning"). After '
            . 'searching, write a short explanation of what you found and why it answers the question, referencing '
            . 'only guests/details the tool actually returned. If nothing matches, say so plainly.';

        $result = service('claudeService')->chat(
            'smart_guest_search',
            $systemPrompt,
            [['role' => 'user', 'content' => $query]],
            $tools,
            $executor,
            'guest_search',
            null,
            $this->currentUserId(),
        );

        if (! $result['success']) {
            return $this->response->setJSON(['success' => false, 'error' => $result['error'], 'csrf_hash' => csrf_hash()]);
        }

        $this->logActivity('guest', 'Ran a smart guest search: "' . $query . '".');

        return $this->response->setJSON([
            'success'     => true,
            'explanation' => $result['text'],
            'matches'     => array_values($allMatches),
            'csrf_hash'   => csrf_hash(),
        ]);
    }

    /**
     * The real query behind search_records — every branch hits an actual
     * table via LIKE matching on real free-text columns. Capped at 10
     * rows per call so a broad query can't flood the response.
     *
     * @return list<array{guest_id: int, guest_name: string, source: string, snippet: string, date: ?string}>
     */
    private function searchRecordsTool(array $input): array
    {
        $source   = $input['source'] ?? '';
        $keywords = array_filter((array) ($input['keywords'] ?? []), static fn ($k) => trim((string) $k) !== '');

        if ($keywords === []) {
            return [];
        }

        if ($source === 'guest_notes') {
            $builder = (new GuestModel())->where('notes IS NOT NULL', null, false)->groupStart();
            foreach ($keywords as $i => $kw) {
                $i === 0 ? $builder->like('notes', $kw) : $builder->orLike('notes', $kw);
            }
            $rows = $builder->groupEnd()->limit(10)->findAll();

            return array_map(static fn ($g) => [
                'guest_id' => $g->id, 'guest_name' => $g->full_name, 'source' => 'guest_notes', 'snippet' => $g->notes, 'date' => null,
            ], $rows);
        }

        if ($source === 'guest_preferences') {
            $builder = (new GuestPreferenceModel())
                ->select('guest_preferences.*, guests.full_name as guest_name')
                ->join('guests', 'guests.id = guest_preferences.guest_id')
                ->groupStart();
            foreach ($keywords as $i => $kw) {
                $method = $i === 0 ? 'like' : 'orLike';
                $builder->{$method}('other_notes', $kw)->orLike('dietary_restrictions', $kw)->orLike('allergies', $kw);
            }
            $rows = $builder->groupEnd()->limit(10)->findAll();

            return array_map(static fn ($p) => [
                'guest_id' => $p->guest_id, 'guest_name' => $p->guest_name, 'source' => 'guest_preferences',
                'snippet'  => trim(implode(' · ', array_filter([$p->dietary_restrictions, $p->allergies, $p->other_notes]))),
                'date'     => null,
            ], $rows);
        }

        if ($source === 'complaints') {
            $builder = (new ComplaintModel())->withDetails()->groupStart();
            foreach ($keywords as $i => $kw) {
                $i === 0 ? $builder->like('complaints.description', $kw) : $builder->orLike('complaints.description', $kw);
            }
            $builder->groupEnd();

            if (! empty($input['category'])) {
                $builder->where('complaints.category', $input['category']);
            }

            if (! empty($input['date_from'])) {
                $builder->where('complaints.created_at >=', $input['date_from'] . ' 00:00:00');
            }

            if (! empty($input['date_to'])) {
                $builder->where('complaints.created_at <=', $input['date_to'] . ' 23:59:59');
            }

            $rows = $builder->orderBy('complaints.created_at', 'desc')->limit(10)->findAll();

            return array_map(static fn ($c) => [
                'guest_id' => $c->guest_id, 'guest_name' => $c->guest_name, 'source' => 'complaints',
                'snippet'  => $c->description, 'date' => $c->created_at,
            ], $rows);
        }

        return [];
    }
}
