<?php

declare(strict_types=1);

namespace App\Controllers;

use App\Core\App;
use App\Core\Availability;
use App\Core\Database as DB;
use App\Core\RateLimiter;
use App\Core\Request;
use App\Core\Response;
use App\Core\Schema;
use App\Core\Stay;

/** Public read API consumed by the React frontend, plus the contact form POST. */
final class ApiController
{
    /** Settings that must never leave the server via the public API. */
    private const SECRET_SETTINGS = [
        'ai_api_key',
        'paypal_secret',
        'wipay_account_number',
        'wipay_api_key',
    ];

    public function settings(): never
    {
        $rows = DB::select('SELECT name, value FROM settings');
        $raw = [];
        $out = [];
        foreach ($rows as $row) {
            $raw[$row['name']] = (string) $row['value'];
            if (in_array($row['name'], self::SECRET_SETTINGS, true)) {
                continue;
            }
            $decoded = json_decode($row['value'], true);
            $out[$row['name']] = $decoded ?? $row['value'];
        }

        // Payment providers only look enabled to the frontend when they are
        // fully configured server-side (keys never leave the server).
        $out['paypal_enabled'] = ($raw['paypal_enabled'] ?? '') === '1'
            && trim($raw['paypal_client_id'] ?? '') !== ''
            && trim($raw['paypal_secret'] ?? '') !== '' ? '1' : '0';
        $out['wipay_enabled'] = ($raw['wipay_enabled'] ?? '') === '1'
            && (($raw['wipay_sandbox'] ?? '') === '1'
                || (trim($raw['wipay_account_number'] ?? '') !== ''
                    && trim($raw['wipay_api_key'] ?? '') !== '')) ? '1' : '0';

        Response::json($out);
    }

    public function homepage(): never
    {
        $rows = DB::select('SELECT section, content FROM homepage_sections WHERE active = 1');
        $out = [];
        foreach ($rows as $row) {
            $out[$row['section']] = json_decode($row['content'], true);
        }
        Response::json($out);
    }

    public function treatments(): never
    {
        $rows = DB::select(
            'SELECT t.id, t.slug, t.name, t.description, t.duration, t.price, t.image,
                    t.featured, c.name AS category
             FROM treatments t
             LEFT JOIN treatment_categories c ON c.id = t.category_id
             WHERE t.status = 1 ORDER BY t.sort_order, t.id',
        );
        Response::json(array_map(fn($r) => [
            ...$r,
            'price'    => (float) $r['price'],
            'featured' => (bool) $r['featured'],
            'category' => $r['category'] ?? 'Massage',
        ], $rows));
    }

    public function rooms(): never
    {
        Schema::heal();
        $rows = DB::select('SELECT id, slug, name, description, image FROM rooms WHERE active = 1 ORDER BY sort_order, id');
        Response::json(array_map(fn($r) => [...$r, 'id' => (int) $r['id']], $rows));
    }

    /** Open booking slots for a date (?date=YYYY-MM-DD&room_id=optional), grouped by room. */
    public function availability(Request $request): never
    {
        $date = $request->query('date');
        $roomId = $request->query('room_id');
        Response::json(Availability::forDate($date, $roomId === '' ? null : (int) $roomId));
    }

    /** The single guest-house listing (content + specs), for the /guest-house page. */
    public function guesthouse(): never
    {
        Schema::heal();
        $row = DB::selectOne('SELECT * FROM guesthouse WHERE active = 1 ORDER BY id LIMIT 1');
        if ($row === null) {
            Response::error('Not found', 404);
        }
        Response::json([
            'title'           => (string) $row['title'],
            'subtitle'        => (string) $row['subtitle'],
            'description'     => (string) $row['description'],
            'image'           => (string) $row['image'],
            'gallery'         => self::jsonList($row['gallery'] ?? '[]'),
            'amenities'       => self::jsonList($row['amenities'] ?? '[]'),
            'price_per_night' => (float) $row['price_per_night'],
            'max_guests'      => (int) $row['max_guests'],
            'bedrooms'        => (int) $row['bedrooms'],
            'bathrooms'       => (int) $row['bathrooms'],
            'min_nights'      => max(1, (int) $row['min_nights']),
        ]);
    }

    /**
     * Unavailable guest-house nights within a window (?from=&to=, default the
     * next ~4 months), plus the min-nights and nightly price for the booking UI.
     */
    public function stayAvailability(Request $request): never
    {
        Schema::heal();
        $from = $request->query('from') ?: date('Y-m-d');
        $to = $request->query('to') ?: date('Y-m-d', (int) strtotime('+120 days'));
        $row = DB::selectOne('SELECT min_nights, price_per_night FROM guesthouse WHERE active = 1 ORDER BY id LIMIT 1');
        Response::json([
            'unavailable'     => Stay::unavailableDates($from, $to),
            'min_nights'      => max(1, (int) ($row['min_nights'] ?? 1)),
            'price_per_night' => (float) ($row['price_per_night'] ?? 0),
        ]);
    }

    /** Decodes a JSON array column to a clean list of strings. @return list<string> */
    private static function jsonList(mixed $raw): array
    {
        $decoded = json_decode((string) $raw, true);
        if (!is_array($decoded)) {
            return [];
        }
        return array_values(array_filter(
            array_map(static fn ($v): string => is_string($v) ? $v : '', $decoded),
            static fn (string $v): bool => $v !== '',
        ));
    }

    public function packages(): never
    {
        $rows = DB::select(
            'SELECT id, slug, name, tag, dates_label, description, included, price,
                    price_label, promo_price, promo_label, image
             FROM packages WHERE active = 1 ORDER BY sort_order, id',
        );
        Response::json(array_map(fn($r) => [
            ...$r,
            'included'    => json_decode($r['included'] ?: '[]', true),
            'price'       => (float) $r['price'],
            'promo_price' => $r['promo_price'] !== null ? (float) $r['promo_price'] : null,
        ], $rows));
    }

    public function gallery(): never
    {
        $rows = DB::select(
            'SELECT i.id, i.src, i.alt, i.size, g.name AS category
             FROM gallery_images i
             LEFT JOIN gallery g ON g.id = i.gallery_id
             WHERE i.active = 1 ORDER BY i.sort_order, i.id',
        );
        Response::json(array_map(fn($r) => [...$r, 'category' => $r['category'] ?? 'Spa'], $rows));
    }

    public function testimonials(): never
    {
        Response::json(DB::select(
            'SELECT id, client_name, location, review, rating, photo, date
             FROM testimonials WHERE approved = 1 ORDER BY sort_order, date DESC',
        ));
    }

    public function team(): never
    {
        $rows = DB::select(
            'SELECT id, name, position, bio, photo, socials FROM team_members
             WHERE active = 1 ORDER BY sort_order, id',
        );
        Response::json(array_map(fn($r) => [
            ...$r,
            'socials' => json_decode($r['socials'] ?: '{}', true),
        ], $rows));
    }

    public function blog(): never
    {
        $rows = DB::select(
            "SELECT b.id, b.slug, b.title, b.excerpt, b.body, b.image, b.published_at, b.tags,
                    c.name AS category
             FROM blogs b
             LEFT JOIN blog_categories c ON c.id = b.category_id
             WHERE b.status = 'published' AND b.published_at <= ?
             ORDER BY b.published_at DESC",
            [date('Y-m-d H:i:s')],
        );
        Response::json(array_map(fn($r) => [
            ...$r,
            'category' => $r['category'] ?? 'Wellness',
            'tags'     => json_decode($r['tags'] ?: '[]', true),
        ], $rows));
    }

    public function faqs(): never
    {
        Response::json(DB::select(
            'SELECT id, question, answer FROM faqs WHERE active = 1 ORDER BY sort_order, id',
        ));
    }

    /**
     * Active pages for the site menu (slug + title only). Privacy and terms
     * stay footer-only, so they are excluded here.
     */
    public function pages(): never
    {
        Response::json(DB::select(
            "SELECT slug, title FROM pages
             WHERE active = 1 AND slug NOT IN ('privacy', 'terms') ORDER BY id",
        ));
    }

    /** @param array<string, string> $params */
    public function page(Request $request, array $params): never
    {
        Schema::heal(); // image/subtitle columns exist on pre-existing installs
        $page = DB::selectOne(
            'SELECT slug, title, body, image, image_layout, image_size, subtitle
             FROM pages WHERE slug = ? AND active = 1',
            [$params['slug']],
        );
        $page === null ? Response::error('Not found', 404) : Response::json($page);
    }

    public function contact(Request $request): never
    {
        $cfg = App::config('security')['rate_limit'];
        if (!RateLimiter::check('contact:' . $request->ip(), $cfg['max'], $cfg['window'])) {
            Response::error('Too many requests — please try again later.', 429);
        }

        $name = $request->str('name');
        $email = $request->str('email');
        $message = $request->str('message');
        $phone = $request->str('phone');

        if (mb_strlen($name) < 2 || mb_strlen($name) > 120) {
            Response::error('Please provide your name.');
        }
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            Response::error('Please provide a valid email address.');
        }
        if (mb_strlen($message) < 10 || mb_strlen($message) > 5000) {
            Response::error('Please provide a message (10–5000 characters).');
        }

        DB::insert('contacts', [
            'name'       => $name,
            'email'      => $email,
            'phone'      => mb_substr($phone, 0, 40),
            'message'    => $message,
            'ip'         => $request->ip(),
            'created_at' => date('Y-m-d H:i:s'),
        ]);

        $mail = App::config('mail');
        if (!empty($mail['host'])) {
            // Fire-and-forget notification; failures never block the response.
            @mail(
                $mail['to'],
                'New enquiry from ' . $name,
                "From: $name <$email>\nPhone: $phone\n\n$message",
                'From: ' . $mail['from'],
            );
        }

        Response::json(['ok' => true], 201);
    }

    // --- AI assistant -----------------------------------------------------------

    /**
     * Website chat assistant ("Skai"). Answers from the site's own data first
     * (treatments, packages, FAQs, contact/hours) — free and instant — and only
     * hands genuinely free-form questions to the Claude API. With no API key, or
     * if Claude is unreachable, it degrades to a local answer instead of failing.
     */
    public function chat(Request $request): never
    {
        if (!RateLimiter::check('chat:' . $request->ip(), 10, 60)) {
            Response::error('Too many messages — please wait a moment.', 429);
        }

        $settings = $this->settingsMap();
        if (($settings['ai_enabled'] ?? '') !== '1') {
            Response::error('The assistant is not available right now.', 503);
        }
        $apiKey = trim($settings['ai_api_key'] ?? '');

        // Sanitize the client-supplied history: valid roles, plain strings,
        // bounded length, and it must start with (and end on) a user turn.
        $messages = [];
        $incoming = $request->input('messages');
        foreach (array_slice(is_array($incoming) ? $incoming : [], -12) as $m) {
            $role = is_array($m) ? ($m['role'] ?? '') : '';
            $text = is_array($m) && is_string($m['content'] ?? null) ? trim($m['content']) : '';
            if (!in_array($role, ['user', 'assistant'], true) || $text === '') {
                continue;
            }
            $messages[] = ['role' => $role, 'content' => mb_substr($text, 0, 2000)];
        }
        while ($messages !== [] && $messages[0]['role'] !== 'user') {
            array_shift($messages);
        }
        if ($messages === [] || end($messages)['role'] !== 'user') {
            Response::error('Please provide a message.');
        }
        $lastUser = end($messages)['content'];

        // Live site content — shared by the local engine and the Claude prompt.
        $treatments = $this->activeTreatments();
        $packages   = $this->activePackages();
        $faqs       = $this->activeFaqs();

        // 1) Answer straight from the database when we're confident. Free,
        //    instant, and works even with no API key configured.
        $local = $this->localAnswer($lastUser, $settings, $treatments, $packages, $faqs);
        if ($local !== null) {
            Response::json(['reply' => $local]);
        }

        // 2) Free-form question. Hand it to Claude when configured; otherwise
        //    fall back to a local "here's what I can help with" menu.
        if ($apiKey === '') {
            Response::json(['reply' => $this->localFallback($settings)]);
        }

        $payload = [
            'model'      => trim($settings['ai_model'] ?? '') ?: 'claude-opus-4-8',
            'max_tokens' => 700,
            'system'     => [[
                'type'          => 'text',
                'text'          => $this->assistantContext($settings, $treatments, $packages, $faqs),
                // Stable per-site context: let the API cache it across chats.
                'cache_control' => ['type' => 'ephemeral'],
            ]],
            'messages'   => $messages,
        ];

        // If Claude is unreachable, degrade to the local menu rather than error.
        $result = $this->anthropic($apiKey, $payload);
        if ($result === null) {
            Response::json(['reply' => $this->localFallback($settings)]);
        }
        if (($result['stop_reason'] ?? '') === 'refusal') {
            Response::json(['reply' => "I can't help with that one — but I'm happy to answer "
                . 'questions about our treatments, packages, prices, or opening hours!']);
        }
        $reply = '';
        foreach ($result['content'] ?? [] as $block) {
            if (($block['type'] ?? '') === 'text') {
                $reply .= $block['text'];
            }
        }
        if (trim($reply) === '') {
            Response::json(['reply' => $this->localFallback($settings)]);
        }
        Response::json(['reply' => $reply]);
    }

    /** @return list<array<string,mixed>> active treatments in display order */
    private function activeTreatments(): array
    {
        return DB::select('SELECT name, duration, price, description FROM treatments WHERE status = 1 ORDER BY sort_order, id');
    }

    /** @return list<array<string,mixed>> active packages in display order */
    private function activePackages(): array
    {
        return DB::select('SELECT name, description, included, price, price_label, promo_price FROM packages WHERE active = 1 ORDER BY sort_order, id');
    }

    /** @return list<array<string,mixed>> active FAQs in display order */
    private function activeFaqs(): array
    {
        return DB::select('SELECT question, answer FROM faqs WHERE active = 1 ORDER BY sort_order, id');
    }

    /** @return array<string, string> raw settings name => value */
    private function settingsMap(): array
    {
        $out = [];
        foreach (DB::select('SELECT name, value FROM settings') as $row) {
            $out[$row['name']] = (string) $row['value'];
        }
        return $out;
    }

    /**
     * Builds the assistant system prompt from live site content.
     *
     * @param list<array<string,mixed>> $treatments
     * @param list<array<string,mixed>> $packages
     * @param list<array<string,mixed>> $faqs
     */
    private function assistantContext(array $settings, array $treatments, array $packages, array $faqs): string
    {
        $company = $settings['company_name'] ?? 'Touch Therapies Antigua';
        $name = trim($settings['ai_name'] ?? '') ?: 'the website assistant';

        $lines = [];
        $lines[] = "You are {$name}, the friendly virtual assistant on the website of {$company},"
            . ' a massage and wellness spa. Answer briefly and warmly (2–4 sentences unless a list'
            . ' is needed). Only discuss this business — its treatments, packages, prices, hours,'
            . ' location, and booking. If asked about anything unrelated, politely steer back.'
            . ' Never invent prices or services that are not listed below. You cannot make'
            . ' bookings yourself: to book, direct guests to WhatsApp or phone.';

        $lines[] = "\n## Business details";
        foreach (['address' => 'Address', 'phone' => 'Phone', 'whatsapp' => 'WhatsApp', 'email' => 'Email'] as $key => $label) {
            if (!empty($settings[$key])) {
                $lines[] = "$label: {$settings[$key]}";
            }
        }
        foreach (json_decode($settings['hours'] ?? '[]', true) ?: [] as $h) {
            if (is_array($h)) {
                $lines[] = 'Hours ' . ($h['day'] ?? '') . ': ' . ($h['time'] ?? '');
            }
        }
        $methods = json_decode($settings['payment_methods'] ?? '[]', true);
        if (is_array($methods) && $methods !== []) {
            $lines[] = 'Payment methods accepted: ' . implode(', ', $methods);
        }

        $lines[] = "\n## Treatments (name — duration — price USD)";
        foreach ($treatments as $t) {
            $lines[] = "- {$t['name']} — {$t['duration']} — \${$t['price']}: " . mb_substr($t['description'], 0, 200);
        }

        $lines[] = "\n## Packages";
        foreach ($packages as $p) {
            $included = implode(', ', json_decode($p['included'] ?: '[]', true) ?: []);
            $price = $p['promo_price'] !== null ? "\${$p['promo_price']} (promo, normally \${$p['price']})" : "\${$p['price']}";
            $lines[] = "- {$p['name']} — {$price} {$p['price_label']}: " . mb_substr($p['description'], 0, 200)
                . ($included !== '' ? " Includes: $included." : '');
        }

        $lines[] = "\n## Frequently asked questions";
        foreach ($faqs as $f) {
            $lines[] = "Q: {$f['question']}\nA: " . mb_substr(strip_tags($f['answer']), 0, 400);
        }

        return implode("\n", $lines);
    }

    // ---- Local answer engine ------------------------------------------------
    // Answers the common questions a spa actually gets — specific treatments and
    // packages, FAQs, prices, hours, location, booking, payment, contact —
    // directly from the database, with no AI call. Returns null when it isn't
    // confident, so the caller can fall back to Claude (or the local menu).

    /**
     * @param list<array<string,mixed>> $treatments
     * @param list<array<string,mixed>> $packages
     * @param list<array<string,mixed>> $faqs
     */
    private function localAnswer(string $q, array $settings, array $treatments, array $packages, array $faqs): ?string
    {
        $norm = $this->normalize($q);
        if ($norm === '') {
            return null;
        }
        $qk = $this->keywords($q);
        if ($qk === []) {
            return null;
        }
        $qkSet = array_flip($qk);
        // Words too generic to identify a *specific* service on their own.
        $generic = array_flip(['massage', 'treatment', 'treatments', 'facial', 'facials',
            'therapy', 'therapies', 'spa', 'session', 'sessions', 'service', 'services']);

        // Specific treatment or package named in the question.
        $treatment = $this->matchByName($qkSet, $treatments, $generic);
        if ($treatment !== null) {
            return $this->treatmentReply($treatment, $settings);
        }
        $package = $this->matchByName($qkSet, $packages, $generic);
        if ($package !== null) {
            return $this->packageReply($package, $settings);
        }

        // A stored FAQ that strongly overlaps the question. Words too weak to
        // anchor a match on their own (so "what services do you offer" falls
        // through to the treatment list, not the mobile-services FAQ).
        $lowSignal = array_flip(['offer', 'offers', 'offered', 'service', 'services',
            'provide', 'provides', 'available', 'need', 'know']);
        $faq = null;
        $bestShared = 0;
        foreach ($faqs as $f) {
            $fk = $this->keywords((string) $f['question']);
            if ($fk === []) {
                continue;
            }
            $shared = 0;
            foreach ($fk as $w) {
                if (isset($qkSet[$w]) && !isset($lowSignal[$w])) {
                    $shared++;
                }
            }
            $ratio = $shared / count($qk);
            if ($shared >= 2 && ($ratio >= 0.6 || $shared >= 3) && $shared > $bestShared) {
                $bestShared = $shared;
                $faq = $f;
            }
        }
        if ($faq !== null) {
            return trim(preg_replace('/\s+/', ' ', strip_tags((string) $faq['answer'])) ?? '');
        }

        // Intent keywords. Specific-service checks above already ran, so a bare
        // "how much is a massage?" correctly lands on the full price list here.
        if ($this->anyPhrase($norm, ['price', 'prices', 'pricing', 'cost', 'costs', 'how much', 'rate', 'rates', 'fee', 'fees', 'charge', 'charges', 'expensive', 'afford'])) {
            return $this->priceListReply($treatments, $settings);
        }
        if ($this->anyPhrase($norm, ['hours', 'open', 'opening', 'close', 'closing', 'closed', 'what time', 'opening time', 'schedule', 'today'])) {
            return $this->hoursReply($settings);
        }
        if ($this->anyPhrase($norm, ['where', 'location', 'located', 'address', 'directions', 'find you', 'how to get', 'parking'])) {
            return $this->locationReply($settings);
        }
        if ($this->anyPhrase($norm, ['book', 'booking', 'appointment', 'appointments', 'reserve', 'reservation', 'schedule an'])) {
            return $this->bookingReply($settings);
        }
        if ($this->anyPhrase($norm, ['payment', 'pay', 'cash', 'card', 'cards', 'visa', 'mastercard', 'credit', 'debit', 'paypal', 'zelle', 'deposit'])) {
            return $this->paymentReply($settings);
        }
        if ($this->anyPhrase($norm, ['contact', 'phone', 'call', 'email', 'number', 'reach you', 'whatsapp'])) {
            return $this->contactReply($settings);
        }
        if ($this->anyPhrase($norm, ['treatments', 'services', 'offer', 'menu', 'what do you do', 'list of'])) {
            return $this->treatmentListReply($treatments, $settings);
        }

        return null;
    }

    /**
     * Best treatment/package whose name is clearly named in the question. Needs
     * a strong name-token overlap and at least one non-generic word so
     * "swedish massage" matches but a bare "massage" does not.
     *
     * @param array<string,int>          $qkSet keyword set of the question
     * @param list<array<string,mixed>>  $rows
     * @param array<string,int>          $generic
     * @return array<string,mixed>|null
     */
    private function matchByName(array $qkSet, array $rows, array $generic): ?array
    {
        $best = null;
        $bestScore = 0.0;
        foreach ($rows as $row) {
            $nameKw = $this->keywords((string) ($row['name'] ?? ''));
            if ($nameKw === []) {
                continue;
            }
            $hit = 0;
            $distinct = 0;
            foreach ($nameKw as $w) {
                if (isset($qkSet[$w])) {
                    $hit++;
                    if (!isset($generic[$w])) {
                        $distinct++;
                    }
                }
            }
            $ratio = $hit / count($nameKw);
            if ($distinct >= 1 && $ratio >= 0.6) {
                $score = $distinct + $ratio;
                if ($score > $bestScore) {
                    $bestScore = $score;
                    $best = $row;
                }
            }
        }
        return $best;
    }

    private function treatmentReply(array $t, array $settings): string
    {
        $name = (string) $t['name'];
        $price = $this->money($t['price'] ?? '');
        $dur = trim((string) ($t['duration'] ?? ''));
        if ($dur !== '' && $price !== '') {
            $lead = "Our {$name} is {$dur} for {$price}.";
        } elseif ($price !== '') {
            $lead = "Our {$name} is {$price}.";
        } elseif ($dur !== '') {
            $lead = "Our {$name} runs {$dur}.";
        } else {
            $lead = "Our {$name}.";
        }
        $desc = $this->firstSentences(strip_tags((string) ($t['description'] ?? '')), 220);
        return trim($lead . ($desc !== '' ? ' ' . $desc : '') . ' ' . $this->bookingTail($settings));
    }

    private function packageReply(array $p, array $settings): string
    {
        $name = (string) $p['name'];
        $promo = $p['promo_price'] ?? null;
        if ($promo !== null && $promo !== '' && is_numeric($promo)) {
            $price = $this->money($promo) . ' (promo, normally ' . $this->money($p['price'] ?? '') . ')';
        } else {
            $price = $this->money($p['price'] ?? '');
        }
        $label = trim((string) ($p['price_label'] ?? ''));
        $lead = "Our {$name} package is {$price}" . ($label !== '' ? " {$label}" : '') . '.';
        $desc = $this->firstSentences(strip_tags((string) ($p['description'] ?? '')), 200);
        $included = json_decode((string) ($p['included'] ?? '[]'), true) ?: [];
        if (is_array($included) && $included !== []) {
            $desc = trim($desc . ' Includes: ' . implode(', ', array_map('strval', $included)) . '.');
        }
        return trim($lead . ($desc !== '' ? ' ' . $desc : '') . ' ' . $this->bookingTail($settings));
    }

    private function priceListReply(array $treatments, array $settings): string
    {
        if ($treatments === []) {
            return $this->contactReply($settings);
        }
        $lines = [];
        foreach (array_slice($treatments, 0, 30) as $t) {
            $price = $this->money($t['price'] ?? '');
            $dur = trim((string) ($t['duration'] ?? ''));
            $lines[] = '• ' . $t['name'] . ($dur !== '' ? " ({$dur})" : '') . ($price !== '' ? " — {$price}" : '');
        }
        return "Here's our treatment pricing:\n" . implode("\n", $lines) . "\n\n" . $this->bookingTail($settings);
    }

    private function treatmentListReply(array $treatments, array $settings): string
    {
        if ($treatments === []) {
            return $this->contactReply($settings);
        }
        $names = array_map(static fn ($t): string => (string) $t['name'], $treatments);
        return 'We offer: ' . implode(', ', $names) . '. Ask me about any one for prices and details, or '
            . $this->contactHow($settings) . ' to book.';
    }

    private function hoursReply(array $settings): string
    {
        $rows = json_decode($settings['hours'] ?? '[]', true) ?: [];
        $lines = [];
        foreach ($rows as $h) {
            if (is_array($h) && ($h['day'] ?? '') !== '') {
                $lines[] = $h['day'] . ': ' . ($h['time'] ?? '');
            }
        }
        if ($lines === []) {
            return $this->contactReply($settings);
        }
        return "Our opening hours:\n" . implode("\n", $lines);
    }

    private function locationReply(array $settings): string
    {
        $addr = trim((string) ($settings['address'] ?? ''));
        if ($addr === '') {
            return $this->contactReply($settings);
        }
        return "You'll find us at {$addr}. " . $this->bookingTail($settings);
    }

    private function bookingReply(array $settings): string
    {
        return "We'd love to see you! To book, " . $this->contactHow($settings)
            . " and we'll arrange your appointment.";
    }

    private function paymentReply(array $settings): string
    {
        $methods = json_decode($settings['payment_methods'] ?? '[]', true);
        if (!is_array($methods) || $methods === []) {
            return $this->contactReply($settings);
        }
        return "We accept:\n" . implode("\n", array_map(static fn ($m): string => '• ' . $m, $methods));
    }

    private function contactReply(array $settings): string
    {
        $bits = [];
        if (($phone = trim((string) ($settings['phone'] ?? ''))) !== '') {
            $bits[] = "call {$phone}";
        }
        if (trim((string) ($settings['whatsapp'] ?? '')) !== '') {
            $bits[] = 'message us on WhatsApp';
        }
        if (($email = trim((string) ($settings['email'] ?? ''))) !== '') {
            $bits[] = "email {$email}";
        }
        if ($bits === []) {
            return 'Please reach out and our team will be glad to help.';
        }
        return 'You can ' . $this->joinNaturally($bits) . '.';
    }

    /** Shared "how to reach us" clause, e.g. "call … or message us on WhatsApp". */
    private function contactHow(array $settings): string
    {
        $bits = [];
        if (trim((string) ($settings['whatsapp'] ?? '')) !== '') {
            $bits[] = 'message us on WhatsApp';
        }
        if (($phone = trim((string) ($settings['phone'] ?? ''))) !== '') {
            $bits[] = "call {$phone}";
        }
        return $bits === [] ? 'contact us' : $this->joinNaturally($bits);
    }

    private function bookingTail(array $settings): string
    {
        return 'To book, ' . $this->contactHow($settings) . '.';
    }

    /** "Here's what I can help with" menu — used when no confident local match. */
    private function localFallback(array $settings): string
    {
        return 'I can help with our treatments, prices, packages, opening hours, location, '
            . 'payment options, and booking. Try asking "What treatments do you offer?" or '
            . '"How much is a back massage?" — or ' . $this->contactHow($settings) . ' for anything else.';
    }

    // ---- Local engine text helpers -----------------------------------------

    /** Lowercased, punctuation-free copy for phrase/keyword checks. */
    private function normalize(string $s): string
    {
        $s = strtolower($s);
        $s = preg_replace('/[^a-z0-9\s]+/', ' ', $s) ?? '';
        return trim(preg_replace('/\s+/', ' ', $s) ?? '');
    }

    /**
     * Significant words (stopwords and 1-char tokens removed), de-duplicated.
     *
     * @return list<string>
     */
    private function keywords(string $s): array
    {
        static $stop = null;
        if ($stop === null) {
            $stop = array_flip(['the', 'a', 'an', 'and', 'or', 'of', 'to', 'for', 'in', 'on', 'at',
                'is', 'are', 'am', 'be', 'do', 'does', 'did', 'you', 'your', 'yours', 'i', 'me', 'my',
                'we', 'our', 'us', 'it', 'its', 'this', 'that', 'these', 'those', 'can', 'could',
                'would', 'will', 'with', 'how', 'what', 'whats', 'when', 'where', 'which', 'who',
                'why', 'much', 'many', 'have', 'has', 'had', 'get', 'got', 'there', 'here', 'please',
                'hi', 'hello', 'hey', 'thanks', 'thank', 'some', 'any', 'if', 'so', 'also', 'just',
                'about', 'want', 'need', 'like', 'give', 'tell']);
        }
        $out = [];
        foreach (explode(' ', $this->normalize($s)) as $w) {
            if ($w === '' || strlen($w) < 2 || isset($stop[$w])) {
                continue;
            }
            $out[$w] = true;
        }
        return array_keys($out);
    }

    /** True if any needle appears as a whole word (or, if multi-word, a phrase). */
    private function anyPhrase(string $norm, array $needles): bool
    {
        $padded = ' ' . $norm . ' ';
        foreach ($needles as $n) {
            if (strpos($n, ' ') !== false) {
                if (strpos($norm, $n) !== false) {
                    return true;
                }
            } elseif (strpos($padded, ' ' . $n . ' ') !== false) {
                return true;
            }
        }
        return false;
    }

    /** Formats a numeric price as $45 / $45.50; passes through non-numeric labels. */
    private function money(mixed $v): string
    {
        $v = trim((string) $v);
        if ($v === '') {
            return '';
        }
        if (is_numeric($v)) {
            $n = number_format((float) $v, 2, '.', ',');
            $n = str_contains($n, '.') ? rtrim(rtrim($n, '0'), '.') : $n;
            return '$' . $n;
        }
        return $v;
    }

    /** First one or two sentences of text, trimmed to about $max characters. */
    private function firstSentences(string $s, int $max): string
    {
        $s = trim(preg_replace('/\s+/', ' ', $s) ?? '');
        if ($s === '' || mb_strlen($s) <= $max) {
            return $s;
        }
        $cut = mb_substr($s, 0, $max);
        $stop = max(strrpos($cut, '. '), strrpos($cut, '! '), strrpos($cut, '? '));
        if ($stop !== false && $stop > 40) {
            return rtrim(mb_substr($cut, 0, $stop + 1));
        }
        return rtrim($cut) . '…';
    }

    /** Joins a list as "a", "a or b", "a, b or c". */
    private function joinNaturally(array $items): string
    {
        $items = array_values(array_filter($items, static fn ($i): bool => trim((string) $i) !== ''));
        if ($items === []) {
            return '';
        }
        if (count($items) === 1) {
            return $items[0];
        }
        $last = array_pop($items);
        return implode(', ', $items) . ' or ' . $last;
    }

    /**
     * Minimal Claude API client (no Composer on this stack): curl when the
     * extension exists (production), stream context otherwise (local dev).
     * Returns null on any failure so the caller can degrade gracefully.
     *
     * @param array<string, mixed> $payload
     * @return array<string, mixed>|null
     */
    private function anthropic(string $apiKey, array $payload): ?array
    {
        $base = App::config('ai')['base_url'] ?? 'https://api.anthropic.com';
        $url = rtrim($base, '/') . '/v1/messages';
        $body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        $headers = [
            'Content-Type: application/json',
            'x-api-key: ' . $apiKey,
            'anthropic-version: 2023-06-01',
        ];

        if (function_exists('curl_init')) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_POST           => true,
                CURLOPT_POSTFIELDS     => $body,
                CURLOPT_HTTPHEADER     => $headers,
                CURLOPT_TIMEOUT        => 60,
            ]);
            $raw = curl_exec($ch);
            $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            $err = curl_error($ch);
            curl_close($ch);
        } else {
            $ctx = stream_context_create(['http' => [
                'method'        => 'POST',
                'header'        => implode("\r\n", $headers),
                'content'       => $body,
                'timeout'       => 60,
                'ignore_errors' => true,
            ]]);
            $raw = @file_get_contents($url, false, $ctx);
            $status = 0;
            foreach ($http_response_header ?? [] as $h) {
                if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) {
                    $status = (int) $m[1];
                }
            }
            $err = $raw === false ? 'request failed' : '';
        }

        $decoded = is_string($raw) ? json_decode($raw, true) : null;
        if ($raw === false || !is_array($decoded)) {
            error_log("AI chat: transport error ($err)");
            return null;
        }
        if ($status < 200 || $status >= 300) {
            error_log('AI chat: API error ' . $status . ' ' . ($decoded['error']['message'] ?? ''));
            return null;
        }
        return $decoded;
    }
}
