#!/usr/bin/env python3.11
"""
ALICE — Complete Flask API for Palm Coast Local
================================================
Port: 5058
"""

import os
import re
import sys
import time
import json
import logging
import threading
import subprocess
import urllib.request
import requests
from datetime import datetime
from flask import Flask, request, jsonify
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import ollama
from qdrant_client import QdrantClient
from qdrant_client.models import (
    Filter, FieldCondition, MatchValue
)

# ─────────────────────────────────────────────
#  CONFIG
# ─────────────────────────────────────────────
FLASK_PORT   = 5058
PIPER_BIN    = "/usr/local/bin/piper"
VOICE_PATH   = "/home/palmco/public_html/alice/voices/en_US-hfc_female-medium.onnx"
AUDIO_DIR    = "/home/palmco/public_html/alice/audio"
LOG_DIR      = "/home/palmco/public_html/alice/logs"
DOMAIN       = "https://palmcoastlocal.com"
COLLECTION   = "palmco"
EMBED_MODEL  = "nomic-embed-text"
RAG_MODEL    = "llama3.2:3b"
QDRANT_URL   = "http://127.0.0.1:6333"
AUDIO_MAX_AGE_MINUTES = 30

# ── LuxTTS via Voicebox ──
VOICEBOX_URL     = "http://127.0.0.1:17493"
VOICEBOX_PROFILE = "f9ba55fa-2627-4990-b002-5506af7db33c"
VOICEBOX_OUTPUT  = "/home/palmco/public_html/alice/voicebox/output"

os.makedirs(AUDIO_DIR, exist_ok=True)
os.makedirs(LOG_DIR,   exist_ok=True)
os.chmod(AUDIO_DIR, 0o777)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-7s  %(message)s",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler(os.path.join(LOG_DIR, "alice_api.log"))
    ]
)
log = logging.getLogger("ALICE")

app      = Flask(__name__)
CORS(app, resources={
    r"/*": {
        "origins": [
            "https://palmcoastlocal.com",
            "https://www.palmcoastlocal.com",
            "http://127.0.0.1",
            "http://localhost"
        ],
        "methods": ["GET", "POST", "OPTIONS"],
        "allow_headers": ["Content-Type", "Authorization"]
    }
})

# ── RATE LIMITING ──
limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["200 per hour", "20 per minute"],
    storage_uri="memory://"
)
q_client = QdrantClient(url=QDRANT_URL)

# ══════════════════════════════════════════════
#  CATEGORY FILTERS
# ══════════════════════════════════════════════
CATEGORY_FILTERS = {
    "hotel":          ["Hotel & Resort", "Where To Stay in Palm Coast", "Featured Hotel in Palm Coast"],
    "holiday inn":    ["Hotel & Resort", "Where To Stay in Palm Coast", "Featured Hotel in Palm Coast"],
    "hammock beach":  ["Hotel & Resort", "Where To Stay in Palm Coast", "Featured Hotel in Palm Coast"],
    "hampton inn":    ["Hotel & Resort", "Where To Stay in Palm Coast", "Featured Hotel in Palm Coast"],
    "resort":         ["Hotel & Resort", "Where To Stay in Palm Coast", "Featured Hotel in Palm Coast"],
    "suites":         ["Hotel & Resort", "Where To Stay in Palm Coast", "Featured Hotel in Palm Coast"],
    "stay":           ["Hotel & Resort", "Where To Stay in Palm Coast", "Featured Hotel in Palm Coast"],
    "motel":          ["Hotel & Resort", "Where To Stay in Palm Coast"],
    "inn":            ["Hotel & Resort", "Where To Stay in Palm Coast"],
    "lodge":          ["Hotel & Resort", "Where To Stay in Palm Coast"],
    "lodging":        ["Hotel & Resort", "Where To Stay in Palm Coast"],
    "restaurant":     ["Restaurant/Food/Eatery", "Catering/Food/Take out", "Pizza", "Bar/saloon"],
    "eat":            ["Restaurant/Food/Eatery", "Catering/Food/Take out"],
    "dining":         ["Restaurant/Food/Eatery"],
    "food":           ["Restaurant/Food/Eatery", "Catering/Food/Take out", "Pizza"],
    "burger":         ["Restaurant/Food/Eatery", "Bar/saloon"],
    "beer":           ["Bars, Pubs & Saloons", "Restaurant/Food/Eatery"],
    "beer bar":       ["Bars, Pubs & Saloons"],
    "sports bar":     ["Bars, Pubs & Saloons"],
    "bar":            ["Bars, Pubs & Saloons", "Restaurant/Food/Eatery"],
    "pub":            ["Bars, Pubs & Saloons", "Restaurant/Food/Eatery"],
    "saloon":         ["Bars, Pubs & Saloons"],
    "pizza":          ["Pizza", "Restaurant/Food/Eatery"],
    "wings":          ["Restaurant/Food/Eatery", "Bar/saloon"],
    "chicken":        ["Restaurant/Food/Eatery"],
    "italian":        ["Restaurant/Food/Eatery"],
    "chinese":        ["Restaurant/Food/Eatery"],
    "sushi":          ["Restaurant/Food/Eatery"],
    "steak":          ["Restaurant/Food/Eatery"],
    "seafood":        ["Restaurant/Food/Eatery"],
    "breakfast":      ["Restaurant/Food/Eatery"],
    "lunch":          ["Restaurant/Food/Eatery"],
    "dinner":         ["Restaurant/Food/Eatery"],
    "buffet":         ["Restaurant/Food/Eatery"],
    "takeout":        ["Catering/Food/Take out", "Restaurant/Food/Eatery"],
    "take out":       ["Catering/Food/Take out", "Restaurant/Food/Eatery"],
    "delivery":       ["Catering/Food/Take out", "Restaurant/Food/Eatery"],
    "catering":       ["Catering/Food/Take out"],
    "craft beer":     ["Bars, Pubs & Saloons", "Restaurant/Food/Eatery"],
    "wine":           ["Bars, Pubs & Saloons", "Restaurant/Food/Eatery"],
    "ice cream":      ["Restaurant/Food/Eatery", "Bakery/Cakes"],
    "frozen dessert": ["Restaurant/Food/Eatery", "Bakery/Cakes"],
    "frozen yogurt":  ["Restaurant/Food/Eatery", "Bakery/Cakes"],
    "italian ice":    ["Restaurant/Food/Eatery", "Bakery/Cakes"],
    "dessert":        ["Restaurant/Food/Eatery", "Bakery/Cakes"],
    "desserts":       ["Restaurant/Food/Eatery", "Bakery/Cakes"],
    "gelato":         ["Restaurant/Food/Eatery", "Bakery/Cakes"],
    "cookies":        ["Bakery/Cakes", "Restaurant/Food/Eatery"],
    "bakery":         ["Bakery/Cakes", "Restaurant/Food/Eatery"],
    "funnel cake":    ["Bakery/Cakes", "Fast food"],
    "sweet treats":   ["Restaurant/Food/Eatery", "Bakery/Cakes"],
    "karaoke":        ["Bar/saloon", "Entertainment", "Restaurant/Food/Eatery"],
    "plumber":        ["Plumbing Services", "plumber"],
    "plumbing":       ["Plumbing Services", "plumber"],
    "drain":          ["Plumbing Services", "plumber"],
    "roofer":         ["Roofing Contractor"],
    "roofing":        ["Roofing Contractor"],
    "roof":           ["Roofing Contractor"],
    "roof leak":      ["Roofing Contractor"],
    "leak in my roof":["Roofing Contractor"],
    "roof is leaking":["Roofing Contractor"],
    "roof repair":    ["Roofing Contractor"],
    "leaking roof":   ["Roofing Contractor"],
    "ac":             ["AC & Heating"],
    "hvac":           ["AC & Heating"],
    "air condition":  ["AC & Heating"],
    "heating":        ["AC & Heating"],
    "heat pump":      ["AC & Heating"],
    "landscap":       ["Landscaping & Garden", "Lawns", "Home & Garden"],
    "lawn":           ["Landscaping & Garden", "Lawns", "Home & Garden"],
    "painter":        ["Painter"],
    "painting":       ["Painter"],
    "pool":           ["Pool Builder/Contractor", "Pool Cleaning"],
    "carpet":         ["Carpet-Tile-Rug Cleaning", "Carpet, Tile and Flooring"],
    "flooring":       ["Carpet, Tile and Flooring", "Carpet-Tile-Rug Cleaning"],
    "tile":           ["Carpet, Tile and Flooring", "Carpet-Tile-Rug Cleaning"],
    "fence":          ["Fence, Gate & Railing"],
    "gutter":         ["Gutters"],
    "handyman":       ["Handyman", "Home Improvement", "Other Home Service"],
    "garage door":    ["Garage Door"],
    "pressure wash":  ["Pressure Washing"],
    "power wash":     ["Pressure Washing"],
    "tree":           ["Tree Services"],
    "screen enclos":  ["Screen Enclosure"],
    "lanai":          ["Screen Enclosure"],
    "irrigation":     ["Irrigation & Wells"],
    "solar":          ["Solar Energy"],
    "duct":           ["Duct and Dryer Vent Cleaning"],
    "dryer vent":     ["Duct and Dryer Vent Cleaning"],
    "appliance":      ["Appliance Repair"],
    "blind":          ["Blinds, Window, Door Covering"],
    "window tint":    ["Blinds, Window, Door Covering"],
    "shutter":        ["Blinds, Window, Door Covering"],
    "paver":          ["Pavers/Concrete"],
    "concrete":       ["Pavers/Concrete"],
    "driveway":       ["Pavers/Concrete"],
    "junk":           ["Debris & Junk Removal", "junk", "Haulers"],
    "debris":         ["Debris & Junk Removal", "Haulers"],
    "hauling":        ["Haulers", "Debris & Junk Removal"],
    "dumpster":       ["Haulers", "Debris & Junk Removal"],
    "moving":         ["Moving and Storage"],
    "storage":        ["Moving and Storage"],
    "home inspect":   ["Home Inspector"],
    "inspection":     ["Home Inspector"],
    "remodel":        ["General Contractor", "Home Improvement"],
    "renovation":     ["General Contractor", "Home Improvement"],
    "general contr":  ["General Contractor", "Home Improvement"],
    "property manag": ["Property Management"],
    "security":       ["Alarm/Security"],
    "alarm":          ["Alarm/Security"],
    "chiropractor":   ["Chiropractor"],
    "chiropractic":   ["Chiropractor"],
    "back pain":      ["Chiropractor"],
    "spinal":         ["Chiropractor"],
    "neck pain":      ["Chiropractor"],
    "massage":        ["Health and Wellness", "Health & Fitness"],
    "wellness":       ["Health and Wellness", "Health & Fitness"],
    "doctor":         ["Health & Fitness", "Health and Wellness"],
    "dentist":        ["Health & Fitness", "Health and Wellness"],
    "weight loss":    ["Health and Wellness", "Health & Fitness"],
    "hearing":        ["Health and Wellness", "Health & Fitness"],
    "audiology":      ["Health and Wellness", "Health & Fitness"],
    "senior":         ["Senior Services", "Independent Living Facilities", "In Home Care"],
    "assisted living":["Senior Services", "Independent Living Facilities"],
    "memory care":    ["Senior Services", "Independent Living Facilities"],
    "in home care":   ["In Home Care", "Senior Services"],
    "elder":          ["Senior Services", "In Home Care"],
    "realtor":        ["Real Estate Agent"],
    "real estate":    ["Real Estate Agent"],
    "mortgage":       ["Mortgage & Lending"],
    "home loan":      ["Mortgage & Lending"],
    "insurance":      ["Insurance Help"],
    "flood insurance":["Insurance Help"],
    "home insurance": ["Insurance Help"],
    "homeowners insurance": ["Insurance Help"],
    "auto insurance": ["Insurance Help"],
    "wind mitigation":["Insurance Help"],
    "insurance agent":["Insurance Help"],
    "legal":          ["Legal Help"],
    "lawyer":         ["Legal Help"],
    "attorney":       ["Legal Help"],
    "tax":            ["Tax/Bookkeeping Help"],
    "accountant":     ["Tax/Bookkeeping Help"],
    "computer":       ["Computer/IT"],
    "it service":     ["Computer/IT"],
    "web design":     ["Graphic/Web Design"],
    "graphic":        ["Graphic/Web Design"],
    "print":          ["Printing/Printer Service"],
    "banner":         ["Printing/Printer Service"],
    "towing":         ["Towing Service"],
    "roadside":       ["Towing Service"],
    "auto repair":    ["Automotive Repairs", "Auto Sales, Service & Repairs"],
    "mechanic":       ["Automotive Repairs", "Auto Sales, Service & Repairs"],
    "car repair":     ["Automotive Repairs", "Auto Sales, Service & Repairs"],
    "park":           ["Parks & Recreation", "Nature Parks"],
    "parks":          ["Parks & Recreation", "Nature Parks"],
    "nature":         ["Parks & Recreation", "Nature Parks"],
    "trail":          ["Parks & Recreation", "Nature Parks"],
    "trails":         ["Parks & Recreation", "Nature Parks"],
    "hiking":         ["Parks & Recreation", "Nature Parks"],
    "hike":           ["Parks & Recreation", "Nature Parks"],
    "outdoor":        ["Parks & Recreation", "Nature Parks"],
    "outdoors":       ["Parks & Recreation", "Nature Parks"],
    "nature trail":   ["Parks & Recreation", "Nature Parks"],
    "gamble rogers":  ["Parks & Recreation", "Nature Parks"],
    "fishing":        ["Parks & Recreation", "Fishing in Flagler County"],
    "kayak":          ["Parks & Recreation", "Nature Parks"],
    "canoe":          ["Parks & Recreation", "Nature Parks"],
    "camping":        ["Parks & Recreation", "Nature Parks"],
    "swimming":       ["Parks & Recreation", "Nature Parks"],
    "things to do":   ["Parks & Recreation", "Entertainment", "Social Clubs", "Nature Parks"],
    "fun things":     ["Parks & Recreation", "Entertainment", "Nature Parks"],
    "activities":     ["Parks & Recreation", "Entertainment", "Social Clubs"],
    "pest":           ["Pest Control"],
    "marine":         ["Marine Services"],
    "dock":           ["Marine Services"],
    "seawall":        ["Marine Services"],
    "church":         ["Churches & Places of Worship"],
    "worship":        ["Churches & Places of Worship"],
    "photographer":   ["Photographer"],
    "photo":          ["Photographer"],
    "florist":        ["Florist"],
    "flower":         ["Florist"],
    "funeral":        ["Funeral Services"],
    "cremation":      ["Funeral Services"],
    "entertainment":  ["Entertainment", "Event Pros"],
    "venue":          ["Venue", "Event Pros"],
    "non profit":     ["Non-Profit Foundations"],
    "nonprofit":      ["Non-Profit Foundations"],
    "charity":        ["Non-Profit Foundations"],
    "veteran":        ["Non-Profit Foundations", "Social Clubs", "Community & Social Clubs"],
    "veterans":       ["Non-Profit Foundations", "Social Clubs", "Community & Social Clubs"],
    "shopping":       ["Shopping/Retail/Online"],
    "retail":         ["Shopping/Retail/Online"],
    "social":         ["Social Clubs", "Community & Social Clubs"],
    "club":           ["Social Clubs", "Community & Social Clubs", "Entertainment"],
    "bowling":        ["Parks & Recreation", "Entertainment"],
    "martial arts":   ["Martial Arts & Self Defense", "Health & Fitness"],
    "self defense":   ["Martial Arts & Self Defense", "Health & Fitness"],
    "karate":         ["Martial Arts & Self Defense", "Health & Fitness"],
    "vfw":            ["Social Clubs", "Community & Social Clubs", "Non-Profit Foundations"],
    "veterans club":  ["Social Clubs", "Community & Social Clubs", "Non-Profit Foundations"],
    "post 8696":      ["Social Clubs", "Community & Social Clubs"],
    "social club":    ["Social Clubs", "Community & Social Clubs"],
    "lions club":     ["Social Clubs", "Community & Social Clubs", "Non-Profit Foundations"],
    "rotary":         ["Social Clubs", "Community & Social Clubs", "Non-Profit Foundations"],
    "club 51":        ["Social Clubs", "Community & Social Clubs"],
    "bingo":          ["Social Clubs", "Entertainment"],
    "meet up":        ["Social Clubs", "Community & Social Clubs"],
    "elks":           ["Social Clubs", "Community & Social Clubs", "Non-Profit Foundations"],
    "moose lodge":    ["Social Clubs", "Community & Social Clubs", "Non-Profit Foundations"],
    "american legion":["Social Clubs", "Community & Social Clubs", "Non-Profit Foundations"],
    "fraternal":      ["Social Clubs", "Community & Social Clubs"],
}

QUERY_EXPANSIONS = {
    "farmers market": "farmers market vendors produce local food European Village Palm Coast weekend",
    "government":     "government city council Palm Coast ordinance municipal policy mayor commission",
    "utility":       "utility utilities water sewer trash electric phone city services Palm Coast contact",
    "utilities":     "utility utilities water sewer trash electric phone city services Palm Coast contact",
    "city services": "utility utilities water sewer trash electric phone city services Palm Coast contact 386-986-2360",
    "city hall":      "government city council Palm Coast ordinance municipal policy mayor",
    "city council":   "government city council Palm Coast ordinance municipal policy mayor",
    "flagler county": "flagler county government commission ordinance policy bunnell",
    "ordinance":      "government city council Palm Coast ordinance municipal policy",
    "hotel":          "hotel motel inn lodging stay overnight accommodation resort",
    "stay":           "hotel motel inn lodging stay overnight accommodation",
    "sleep":          "hotel motel inn lodging stay overnight accommodation",
    "restaurant":     "restaurant food dining eat lunch dinner cuisine",
    "eat":            "restaurant food dining eat lunch dinner cuisine",
    "food":           "restaurant food dining eat lunch dinner cuisine",
    "burger":         "burger restaurant food eat dining lunch dinner grill",
    "beer":           "beer bar craft beer restaurant food drink pub happy hour",
    "pizza":          "pizza restaurant food eat dining delivery takeout",
    "wings":          "wings chicken restaurant food eat dining bar",
    "steak":          "steak steakhouse restaurant food eat dining",
    "seafood":        "seafood fish shrimp restaurant food eat dining",
    "breakfast":      "breakfast brunch restaurant food eat morning diner",
    "lunch":          "lunch restaurant food eat dining",
    "dinner":         "dinner restaurant food eat dining evening",
    "italian":        "italian restaurant pasta food eat dining",
    "chinese":        "chinese restaurant asian food eat dining buffet",
    "sushi":          "sushi japanese restaurant food eat dining",
    "buffet":         "buffet restaurant food eat all you can eat dining",
    "bar":            "bar pub restaurant food drink beer wine",
    "karaoke":        "karaoke bar restaurant entertainment singing music",
    "ice cream":      "ice cream dessert gelato frozen yogurt sweet treat",
    "doctor":         "doctor physician medical health clinic urgent care",
    "dentist":        "dentist dental teeth oral",
    "lawyer":         "lawyer attorney legal law",
    "plumber":        "plumber plumbing pipe water leak",
    "electrician":    "electrician electrical wiring power",
    "roofer":         "roofer roofing roof repair",
    "park":           "park outdoor nature recreation trail",
    "parks":          "parks outdoor nature recreation trails",
    "beach":          "beach ocean water coast outdoor",
    "hike":           "hiking trail nature park outdoor walk recreation flagler",
    "hiking":         "hiking trail nature park outdoor walk recreation flagler",
    "trail":          "nature trail hiking park outdoor walk recreation",
    "trails":         "nature trails hiking park outdoor walk recreation",
    "nature":         "nature park trail outdoor recreation wildlife",
    "outdoor":        "outdoor park trail nature recreation activities",
    "outdoors":       "outdoor park trail nature recreation activities",
    "gamble rogers":  "Gamble Rogers park flagler beach camping fishing nature trails recreation",
    "kayak":          "kayak canoe water outdoor recreation park nature",
    "camping":        "camping park nature outdoor recreation",
    "fishing":        "fishing park recreation outdoor water flagler",
    "things to do":   "activities entertainment parks recreation events fun outdoor nature",
    "fun things":     "activities entertainment parks recreation events fun outdoor",
    "activities":     "activities entertainment parks recreation events outdoor things to do",
    "what is there to do": "activities entertainment parks recreation events fun outdoor nature things to do",
    "what to do":          "activities entertainment parks recreation events fun outdoor nature",
    "event":          "event festival concert show happening",
    "deal":           "deal discount offer coupon save special",
    "shop":           "shop store retail buy purchase",
    "gym":            "gym fitness workout exercise health",
    "salon":          "salon hair beauty spa",
    "realtor":        "realtor real estate agent property buy sell home",
    "vfw":            "VFW veterans of foreign wars palm coast post 8696 veterans club",
    "veteran":        "veteran VFW veterans of foreign wars community club nonprofit",
}


# ══════════════════════════════════════════════
#  URL HELPERS
# ══════════════════════════════════════════════

def url_to_alias(raw):
    if not raw:
        return ""
    raw = raw.strip()
    raw = re.sub(r"^https?://palmcoastlocal\.com/?", "", raw, flags=re.IGNORECASE)
    return raw.lstrip("/")


def alias_to_full(alias):
    if not alias:
        return DOMAIN
    alias = alias.lstrip("/")
    if alias.startswith("http"):
        return alias
    return f"{DOMAIN}/{alias}"


# ══════════════════════════════════════════════
#  TEXT UTILITIES
# ══════════════════════════════════════════════

def clean_text(text):
    if not text:
        return ""
    text = (str(text)
            .replace("\xa0", " ")
            .replace("\u2013", "-")
            .replace("\u2014", "-")
            .replace("\u2018", "'")
            .replace("\u2019", "'"))
    return re.sub(r"\s+", " ", text).strip()


def phone_to_speech(text):
    def expand_phone(m):
        digits = re.sub(r"\D", "", m.group(0))
        if len(digits) == 10:
            return f"{' '.join(digits[:3])}, {' '.join(digits[3:6])}, {' '.join(digits[6:])}"
        return m.group(0)
    return re.sub(r'\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}', expand_phone, text)


def strip_html(text):
    if not text:
        return ""
    return re.sub(r'<[^>]+>', ' ', text)


def normalize_for_tts(text):
    if not text:
        return ""
    text = strip_html(text)
    text = re.sub(r'\*{1,2}(.*?)\*{1,2}', r'\1', text)
    text = re.sub(r'https?://\S+', '', text)
    text = re.sub(r'[\[\](){}<>|\\]', ' ', text)

    abbrevs = [
        (r'\bDr\.?\b(?=\s+[A-Z])',     'Drive'),
        (r'\bDr\.?\b(?=\s+\d)',        'Drive'),
        (r'(?<=[NSEW])\s+Dr\.?\b',     ' Drive'),
        (r'\bDr\.?\s*$',               'Drive'),
        (r'\bDr\.?\b',                 'Doctor'),
        (r'\bFL\b',                    'Florida'),
        (r'\bAve\.?\b',                'Avenue'),
        (r'\bBlvd\.?\b',               'Boulevard'),
        (r'\bRd\.?\b',                 'Road'),
        (r'\bSt\.\s',                  'Saint '),
        (r'\bSte\.?\b',                'Suite'),
        (r'\bHwy\.?\b',                'Highway'),
        (r'\bPkwy\.?\b',               'Parkway'),
        (r'\bLn\.?\b',                 'Lane'),
        (r'\bCt\.?\b',                 'Court'),
        (r'\bPl\.?\b',                 'Place'),
        (r'\bNE\b',                    'Northeast'),
        (r'\bNW\b',                    'Northwest'),
        (r'\bSE\b',                    'Southeast'),
        (r'\bSW\b',                    'Southwest'),
        (r'\bN\b(?=\s+\w)',            'North'),
        (r'\bS\b(?=\s+[A-Z])',            'South'),
        (r'\bE\b(?=\s+\w)',            'East'),
        (r'\bW\b(?=\s+\w)',            'West'),
        (r'\bBunnell\b',               'Buh-nell'),
        (r'\bFlagler\b',               'Flag-ler'),
        (r'\bMatanzas\b',              'Matanziss'),
        (r'\bPonte Vedra\b',           'Pontay Vedra'),
        (r'\bBimini\b',               'Bimihnee'),
        (r'\bHammock\b',              'Hammick'),
        (r'\bbuffet\b',                'boo-fay'),
        (r'(?<= )0(?= )',               'zero'),
    ]

    for pattern, replacement in abbrevs:
        text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)

    text = re.sub(r"(?<= )0(?=[ ,])", "zero", text)
    text = phone_to_speech(text)
    text = re.sub(
        r'(\d{1,2})(am|pm)\s*[-–]\s*(\d{1,2})(am|pm)',
        lambda m: f"{m.group(1)} {m.group(2)} to {m.group(3)} {m.group(4)}",
        text, flags=re.IGNORECASE
    )
    text = re.sub(r"[^\w\s\.,!\?\-\']", ' ', text)
    text = text.replace('\r\n', ' ').replace('\r', ' ').replace('\n', ' ')
    text = re.sub(r'\s{2,}', ' ', text)
    return text.strip()


def trim_to_sentence(text, max_chars=300):
    if len(text) <= max_chars:
        return text
    chunk = text[:max_chars]
    last = -1
    for punct in ['. ', '! ', '? ']:
        pos = chunk.rfind(punct)
        if pos > last:
            last = pos + 1
    if last > 40:
        return chunk[:last].strip()
    last_space = chunk.rfind(' ')
    if last_space > 40:
        return chunk[:last_space].strip() + '.'
    return chunk.strip()


# ══════════════════════════════════════════════
#  VOICE ENGINE — LuxTTS via Voicebox
# ══════════════════════════════════════════════

def generate_voice(text, audio_id):
    mp3 = os.path.join(AUDIO_DIR, f"{audio_id}.mp3")
    clean = normalize_for_tts(text)
    clean = trim_to_sentence(clean)
    clean = re.sub(r"([.!?])\s+", r"\1... ", clean)
    log.info(f"LUXTTS INPUT: >>>{clean}<<<")
    if not clean:
        return False
    try:
        # Step 1 — submit generation to Voicebox
        payload = json.dumps({
            "profile_id": VOICEBOX_PROFILE,
            "text": clean,
            "engine": "luxtts"
        }).encode("utf-8")
        req = urllib.request.Request(
            f"{VOICEBOX_URL}/generate",
            data=payload,
            headers={"Content-Type": "application/json"},
            method="POST"
        )
        with urllib.request.urlopen(req, timeout=10) as res:
            gen = json.loads(res.read())
        gen_id = gen["id"]

        # Step 2 — poll for completion (max 15 seconds)
        wav_src = None
        for _ in range(30):
            time.sleep(1.5)
            with urllib.request.urlopen(f"{VOICEBOX_URL}/history/{gen_id}", timeout=5) as res:
                status = json.loads(res.read())
            if status["status"] == "completed":
                wav_src = os.path.join(VOICEBOX_OUTPUT, f"{gen_id}.wav")
                break
            elif status["status"] == "failed":
                raise Exception(f"LuxTTS failed: {status.get('error')}")

        if not wav_src or not os.path.exists(wav_src):
            raise Exception("LuxTTS output WAV not found")

        # Step 3 — convert to MP3 with atempo=0.88 for smooth delivery
        subprocess.run(
            ["ffmpeg", "-y", "-i", wav_src,
             "-filter:a", "atempo=0.80",
             "-codec:a", "libmp3lame", "-b:a", "128k", mp3],
            check=True, capture_output=True, timeout=30
        )
        os.chmod(mp3, 0o644)
        log.info(f"Voice OK: {audio_id}.mp3")
        return True

    except Exception as e:
        log.error(f"LuxTTS failed: {e} — falling back to Piper")
        # Fallback to Piper if LuxTTS fails
        try:
            wav = os.path.join(AUDIO_DIR, f"{audio_id}.wav")
            subprocess.run(
                [PIPER_BIN, "--model", VOICE_PATH, "--output_file", wav,
                 "--length_scale", ".55", "--noise_scale", "0.32", "--noise_w", "0.15"],
                input=clean.encode("utf-8"),
                check=True, capture_output=True, timeout=30
            )
            subprocess.run(
                ["ffmpeg", "-y", "-i", wav, "-codec:a", "libmp3lame",
                 "-b:a", "128k", "-ar", "22050", mp3],
                check=True, capture_output=True, timeout=30
            )
            if os.path.exists(wav):
                os.remove(wav)
            os.chmod(mp3, 0o644)
            log.info(f"Voice OK (Piper fallback): {audio_id}.mp3")
            return True
        except Exception as e2:
            log.error(f"Piper fallback also failed: {e2}")
            return False


# ══════════════════════════════════════════════
#  MP3 CLEANUP
# ══════════════════════════════════════════════

def cleanup_old_audio():
    try:
        now = time.time()
        max_age = AUDIO_MAX_AGE_MINUTES * 60
        deleted = 0
        for fname in os.listdir(AUDIO_DIR):
            if not fname.endswith(".mp3"):
                continue
            if fname.startswith("alice_greeting") or fname.startswith("alice_searching") or fname.startswith("alice_promo") or fname.startswith("alice_welcome"):
                continue
            fpath = os.path.join(AUDIO_DIR, fname)
            if os.path.isfile(fpath) and now - os.path.getmtime(fpath) > max_age:
                os.remove(fpath)
                deleted += 1
        if deleted:
            log.info(f"Cleanup: removed {deleted} old MP3s")
    except Exception as e:
        log.warning(f"Cleanup error: {e}")


# ══════════════════════════════════════════════
#  INTENT DETECTION
# ══════════════════════════════════════════════

def detect_intent(q):
    q_lower = q.lower()
    return {
        "is_breaking_news": any(w in q_lower for w in [
            "breaking news", "latest news", "what is happening", "what happened", "news today",
            "recent news", "palm coast news", "flagler news", "what is new"
        ]),
        "is_breaking_news": any(w in q_lower for w in [
            "breaking news", "latest news", "what is happening", "what happened", "news today",
            "recent news", "palm coast news", "flagler news", "what is new"
        ]),
        "is_article_explicit": any(w in q_lower for w in [
            "search articles", "find article", "information please",
            "info please", "tell me about", "article on",
            "read about", "learn about", "history of", "history about",
            "explain", "what is the history", "brief history",
            "give me info", "give me information",
            "things to do", "what to do", "what is there to do",
            "fun things", "activities in", "explore flagler", "explore palm coast",
            "kayaking", "canoeing", "fishing", "hiking", "trails", "golf",
            "camping", "swimming", "biking", "paddling", "snorkeling",
            "beaches in", "beach guide", "beach access", "beach map",
            "dog friendly beach", "where is the beach"
        ]),
        "is_event":    any(w in q_lower for w in [
            "event", "when", "festival", "concert", "happening",
            "this weekend", "schedule", "show", "fair", "parade",
            "live music", "tonight", "today", "this week", "band",
            "shows", "nightlife", "this evening", "farmers market",
            "going on", "to do tonight", "to do today"
        ]),
        "is_offer":    (any(w in q_lower for w in [
            "deal", "discount", "offer", "coupon", "save",
            "free", "sale", "%", "promo", "special"
        ]) and not any(s in q_lower for s in ["garage sale","yard sale","estate sale","rummage sale","garage sales","yard sales"])),
        "is_article":  any(w in q_lower for w in [
            "how", "why", "guide", "article", "history",
            "tips", "garden", "info", "learn", "what is"
        ]),
        "is_business": any(w in q_lower for w in [
            "where", "restaurant", "shop", "store", "open",
            "near", "find", "contact", "phone", "hours", "address",
            "hotel", "stay", "eat", "food", "burger", "beer", "pizza",
            "wings", "steak", "seafood", "breakfast", "lunch", "dinner",
            "italian", "chinese", "sushi", "buffet", "bar", "pub",
            "karaoke", "bowling", "craft beer", "wine", "ice cream",
            "takeout", "take out", "delivery", "catering",
            "how about", "i want", "i need", "i'm looking", "im looking",
            "looking for", "recommend", "suggestion", "good place",
            "best place", "where can i", "who does", "who makes",
            "can you find", "know a good",
            "hike", "hiking", "trail", "trails", "nature", "park", "parks",
            "outdoor", "outdoors", "kayak", "canoe", "camping", "fishing",
            "gamble rogers", "things to do", "fun things", "activities",
            "what is there to do", "what to do", "what can i do", "what should i do",
            "vfw", "veteran", "veterans", "club", "lodge", "legion",
            "rotary", "lions", "elks", "moose", "fraternal", "nonprofit",
            "social club", "community org", "post 8696", "bingo",
        ]),
    }


def expand_query(q):
    q_lower = q.lower()
    for keyword in sorted(QUERY_EXPANSIONS.keys(), key=len, reverse=True):
        if re.search(r"\b" + re.escape(keyword) + r"\b", q_lower):
            return f"{q} {QUERY_EXPANSIONS[keyword]}"
    return q


# ══════════════════════════════════════════════
#  FLASK ROUTES
# ══════════════════════════════════════════════

@app.route("/ask", methods=["POST"])
@limiter.limit("30 per minute; 100 per hour")
def ask():
    try:
        data  = request.get_json(force=True)
        raw_q = data.get("question", "").strip()
        if not raw_q:
            return jsonify({"status": "error", "message": "No question provided."})

        q      = raw_q.lower()
        intent = detect_intent(q)
        # SMART FIX: any query matching a trade/service keyword IS a business search.
        # (people type "asap plumber" or "ac repair" wanting a business, not an article)
        _ql = q.lower()
        for _kw in CATEGORY_FILTERS.keys():
            if re.search(r"\b" + re.escape(_kw) + r"\b", _ql):
                intent["is_business"] = True
                break
        log.info(f"/ask — '{raw_q[:80]}'")

        threading.Thread(target=cleanup_old_audio, daemon=True).start()

        clean_q = re.sub(
            r'\b(in |near |around |at )\b',
            '', q, flags=re.IGNORECASE
        ).strip()
        clean_q = re.sub(r'\s+', ' ', clean_q).strip() or q

        clean_q = re.sub(
            r'^(how about|i want|i need|i\'m looking for|im looking for|'
            r'looking for|can you find|do you know|where can i find|'
            r'what about|tell me about|find me)\s+',
            '', clean_q, flags=re.IGNORECASE
        ).strip() or clean_q

        expanded_q = expand_query(clean_q)
        log.info(f"Clean query: {clean_q} | Expanded: {expanded_q[:100]}")
        embed_res = ollama.embed(model=EMBED_MODEL, input=f"search_query: {expanded_q}")
        if not embed_res.get("embeddings"):
            return jsonify({"status": "error", "message": "Embedding failed."})

        query_word_count = len(clean_q.split())
        use_category_filter = query_word_count <= 2

        search_filter = None
        if intent["is_event"] and not intent["is_business"]:
            search_filter = Filter(must=[FieldCondition(key="category", match=MatchValue(value="event"))])
        elif intent["is_offer"] and not intent["is_business"]:
            search_filter = Filter(must=[FieldCondition(key="category", match=MatchValue(value="offer"))])
        elif intent["is_breaking_news"]:
            from qdrant_client.models import MatchAny
            search_filter = Filter(must=[
                FieldCondition(key="category", match=MatchValue(value="article")),
                FieldCondition(key="knowledge_category", match=MatchValue(value="Breaking News"))
            ])
            log.info("Breaking news search triggered")
        elif intent["is_article_explicit"]:
            search_filter = Filter(must=[FieldCondition(key="category", match=MatchValue(value="article"))])
            log.info("Explicit article search triggered")
        else:
            from qdrant_client.models import MatchAny
            for keyword in sorted(CATEGORY_FILTERS.keys(), key=len, reverse=True):
                if re.search(r'\b' + re.escape(keyword) + r'\b', q, re.IGNORECASE):
                    cats = CATEGORY_FILTERS[keyword]
                    search_filter = Filter(must=[
                        FieldCondition(key="jbd_categories", match=MatchAny(any=cats))
                    ])
                    log.info(f"Category filter: {keyword} → {cats}")
                    break

        _main_limit = 40 if intent["is_event"] else 5
        results = q_client.query_points(
            collection_name=COLLECTION,
            query=embed_res["embeddings"][0],
            query_filter=search_filter,
            limit=_main_limit,
            score_threshold=0.19
        )
        if not results.points and search_filter:
            results = q_client.query_points(
                collection_name=COLLECTION,
                query=embed_res["embeddings"][0],
                limit=5,
                score_threshold=0.15
            )
        # Two-pass article search disabled — using keyword trigger instead
        if not results.points:
            results = q_client.query_points(
                collection_name=COLLECTION,
                query=embed_res["embeddings"][0],
                limit=5,
                score_threshold=0.10
            )
        # ── KEYWORD RESCUE: catch proper nouns the embedding model misses ──
        # (e.g. "ponte vedra" — nomic-embed is weak on named entities)
        # GUARD: skip the rescue when a category filter is active — the filter
        # already narrowed to the right category, and a generic-word rescue
        # (e.g. "home" in "home insurance") would drag in unrelated businesses.
        try:
            import re as _re
            _skip_rescue = (search_filter is not None)  # honor category filter — no cross-category rescue
            stop = {"the","a","an","in","of","to","for","and","is","at","on",
                    "what","where","when","who","how","find","me","about","tell",
                    "near","around","can","i","get","you","do","does","are","it"}
            terms = [w for w in _re.findall(r"[a-zA-Z]{3,}", clean_q.lower()) if w not in stop]
            if terms and not _skip_rescue:
                existing_ids = {p.id for p in results.points}
                scan = q_client.query_points(
                    collection_name=COLLECTION,
                    query=embed_res["embeddings"][0],
                    limit=60
                )
                def _wordmatch(term, text):
                    # whole-word match, not substring (stops "medical" matching random text)
                    return _re.search(r"\b" + _re.escape(term) + r"\b", text) is not None

                for p in scan.points:
                    if p.id in existing_ids:
                        continue
                    pay = p.payload
                    name = (pay.get("title") or pay.get("name") or "").lower()
                    btype = (pay.get("business_type") or "").lower()
                    cats = ""
                    jc = pay.get("jbd_categories")
                    if isinstance(jc, list):
                        cats = " ".join(str(x) for x in jc).lower()
                    content = (pay.get("content") or "").lower()

                    # Name/type/category text is the STRONG signal; content is weak.
                    strong = name + " " + btype + " " + cats
                    full   = strong + " " + content

                    # how many query terms hit the strong fields vs. anywhere
                    strong_hits = sum(1 for t in terms if _wordmatch(t, strong))
                    all_hits    = sum(1 for t in terms if _wordmatch(t, full))

                    rescue = False
                    score = 0.0
                    # Best: the business NAME/TYPE/CATEGORY contains the distinctive terms
                    if strong_hits >= 1 and all_hits == len(terms):
                        rescue = True
                        score = 0.86 if strong_hits >= 2 else 0.82
                    # OK: for 3+ term queries, allow ONE term missing (proper-noun tolerance).
                    # For 1-2 term queries, require ALL terms to match — stops a single
                    # common word (e.g. "school") from rescuing dozens of articles.
                    elif len(terms) >= 3 and strong_hits >= 2 and all_hits >= len(terms) - 1:
                        rescue = True
                        score = 0.80

                    if rescue:
                        p.score = max(p.score, score)
                        results.points.append(p)
                        existing_ids.add(p.id)
                        log.info(f"Keyword rescue: '{clean_q}' -> {pay.get('title') or pay.get('name')} (strong={strong_hits}/{len(terms)})")
                results.points.sort(key=lambda x: x.score, reverse=True)
        except Exception as e:
            log.warning(f"Keyword rescue failed: {e}")

        # Merge Knowledge Base (200000+) and Breaking News (100000-199999) entries
        try:
            kb_results = q_client.query_points(
                collection_name=COLLECTION,
                query=embed_res["embeddings"][0],
                limit=5,
                score_threshold=0.55
            )
            kb_points = [p for p in kb_results.points if p.id >= 100000]
            if kb_points:
                existing_ids = {p.id for p in results.points}
                added = 0
                for p in kb_points:
                    if p.id not in existing_ids:
                        results.points.append(p)
                        added += 1
                if added:
                    log.info(f"KB/News merge: added {added} entries")
        except Exception as e:
            log.warning(f"KB merge failed: {e}")
        if not results.points:
            return jsonify({"status": "not_found",
                            "message": "I could not find anything matching that in Palm Coast Local."})

        if results.points:
            top_score = results.points[0].score
            min_score = max(top_score * 0.78, 0.35)
            results.points = [p for p in results.points if p.score >= min_score or (p.payload.get("category")=="article" and not p.payload.get("knowledge_category"))]

        if results.points:
            query_words = set(clean_q.lower().split())
            # Informational query keywords that should favor articles
            info_keywords = {'history', 'about', 'what', 'explain', 'tell', 'brief',
                             'learn', 'information', 'info', 'how', 'why', 'when',
                             'founded', 'created', 'started', 'origin', 'background'}
            is_info_query = bool(query_words & info_keywords)
            for pt in results.points:
                title_words = set(pt.payload.get('title', '').lower().split())
                matched = query_words & title_words
                if matched:
                    # Reward matching MORE query words (per-word), so a full match
                    # ("school map" -> "Interactive School Map") beats a partial
                    # match ("school" only in a news headline).
                    frac = len(matched) / max(1, len(query_words))
                    if pt.payload.get('category') == 'article' and not pt.payload.get('knowledge_category'):
                        pt.score += 0.10 + (0.20 * frac)
                    else:
                        pt.score += 0.05 + (0.10 * frac)
                # Boost articles on informational queries
                if is_info_query and pt.payload.get('category') == 'article' and not pt.payload.get('knowledge_category'):
                    pt.score += 0.20
            results.points.sort(key=lambda x: x.score, reverse=True)

        if len(results.points) > 1:
            top_cats = set(results.points[0].payload.get('jbd_categories', []))
            if top_cats:
                for pt in results.points[1:]:
                    # Never penalize articles — they have no jbd_categories
                    if pt.payload.get('category') == 'article':
                        continue
                    pt_cats = set(pt.payload.get('jbd_categories', []))
                    if pt_cats and not top_cats & pt_cats:
                        pt.score -= 0.15
                results.points.sort(key=lambda x: x.score, reverse=True)
                if results.points:
                    top_score = results.points[0].score
                    results.points = [p for p in results.points if p.score >= top_score * 0.82]
                log.info(f"Category cross-check: top cats={top_cats}")

        buckets = {"event": [], "offer": [], "article": [], "business": []}
        seen    = set()
        for pt in results.points:
            p     = pt.payload
            alias = p.get("url", "")
            if alias in seen:
                continue
            seen.add(alias)
            intro = p.get("spoken_intro", "") or p.get("content", "")[:350]
            intro = strip_html(intro)
            entry = {
                "title":        p.get("title",    "Palm Coast Local"),
                "url":          alias_to_full(alias),
                "category":     p.get("category", "business"),
                "intro":        intro,
                "spoken_intro": strip_html(p.get("spoken_intro", "")),
                "phone":        p.get("phone",    ""),
                "address":      p.get("address",  ""),
                "hours":        p.get("hours",    ""),
                "date":         p.get("date",     ""),
                "discount":     p.get("discount", ""),
                "website":      p.get("website",  ""),
                "email":        p.get("email",    ""),
            }
            cat = entry["category"] if entry["category"] in buckets else "business"
            buckets[cat].append(entry)

        # EVENT DATE SORTING: soonest upcoming first, drop past events
        if buckets["event"]:
            _today_str = datetime.now().strftime('%Y-%m-%d')
            def _evdate(ev):
                d = (ev.get('date') or '').strip()[:10]
                return d if len(d) == 10 else '9999-12-31'
            _up = [e for e in buckets['event'] if _evdate(e) >= _today_str]
            buckets['event'] = sorted(_up if _up else buckets['event'], key=_evdate)

        if intent["is_event"] and buckets["event"]:
            top = buckets["event"][0]
        elif intent["is_offer"] and buckets["offer"]:
            top = buckets["offer"][0]
        elif intent["is_article"] and buckets["article"] and not intent["is_business"]:
            top = buckets["article"][0]
        else:
            all_flat = (buckets["business"] + buckets["event"] +
                        buckets["article"]  + buckets["offer"])
            # If the user wants a business/service, lead with a business.
            if intent["is_business"] and buckets["business"]:
                top = buckets["business"][0]
            elif buckets["article"]:
                top = buckets["article"][0]
            else:
                top = all_flat[0]

        if intent["is_business"]:
            sorted_results = (
                buckets["business"] +
                (buckets["event"] if intent["is_event"] else []) +
                (buckets["offer"] if intent["is_offer"] else []) +
                buckets["article"]
            )
        else:
            sorted_results = (
                (buckets["event"]   if intent["is_event"]   else []) +
                (buckets["offer"]   if intent["is_offer"]   else []) +
                (buckets["article"]) +
                buckets["business"]
            )
        final      = []
        final_seen = set()
        for item in sorted_results:
            if item["url"] not in final_seen:
                final.append({
                    "title":    item["title"],
                    "url":      item["url"],
                    "category": item["category"],
                    "intro":    item["intro"],
                    "phone":    item["phone"],
                    "address":  item["address"],
                    "hours":    item["hours"],
                    "date":     item["date"],
                    "discount": item["discount"],
                    "website":  item["website"],
                    "email":    item["email"],
                })
                final_seen.add(item["url"])

        context_blocks = []
        for item in final[:5]:
            block  = f"TYPE: {item['category'].upper()} | NAME: {item['title']}\n"
            block += f"INFO: {item['intro']}\n"
            if item.get("phone"):   block += f"PHONE: {item['phone']}\n"
            if item.get("hours"):   block += f"HOURS: {item['hours']}\n"
            if item.get("address"): block += f"ADDRESS: {item['address']}\n"
            if item.get("date"):    block += f"DATE: {item['date']}\n"
            context_blocks.append(block)

        context = "\n---\n".join(context_blocks)

        _today = datetime.now().strftime("%A, %B %d, %Y")
        rag_prompt = f"""You are ALICE, a friendly local guide for Palm Coast and all of Flagler County, Florida, including Flagler Beach, Bunnell, Marineland, Beverly Beach, and surrounding areas.
Today's date is {_today}. Use this when the visitor asks about today, tonight, tomorrow, this weekend, or this week. Always state the correct city or town for each place based on the context. Never assume a place is in Palm Coast or Flagler County. St. Augustine and Ponte Vedra are in St. Johns County. Daytona Beach and Ormond Beach are in Volusia County. Palatka is in Putnam County. Always use the exact city and county stated in the context, and never default to Flagler County.
A visitor asked: "{raw_q}"
Use ONLY the context below. Be warm and conversational. Maximum 3 sentences. For articles summarize the key information naturally.
Do NOT mention URLs or websites. Do NOT mention phone numbers.
Do NOT invent anything not in the context.
The context below DOES contain relevant results. Describe them warmly and helpfully. Never say you cannot find anything.

CONTEXT:
{context}

Your response:"""

        speech_text = top.get("spoken_intro") or top.get("intro", "")
        try:
            rag_res = ollama.chat(
                model=RAG_MODEL,
                messages=[{"role": "user", "content": rag_prompt}],
                options={"num_ctx": 2048, "temperature": 0.3},
                keep_alive="2h"
            )
            rag_answer = rag_res["message"]["content"].strip()
            if rag_answer:
                speech_text = rag_answer
                if len(speech_text) > 400:
                    speech_text = speech_text[:400].rsplit(".", 1)[0] + "."
            log.info(f"RAG answer: {speech_text[:100]}")
        except Exception as e:
            log.warning(f"RAG failed: {e}")

        audio_id  = f"alice_{int(time.time())}"
        voice_ok  = generate_voice(speech_text, audio_id)
        voice_url = f"{DOMAIN}/alice/audio/{audio_id}.mp3" if voice_ok else None

        response = {"status": "success", "choices": final[:5]}
        if voice_url:
            response["voice_url"] = voice_url
        return jsonify(response)

    except Exception as e:
        log.error(f"/ask error: {e}", exc_info=True)
        return jsonify({"status": "error", "message": str(e)})


@app.route("/admin/reindex-all", methods=["POST"])
def admin_reindex_all():
    threading.Thread(target=lambda: os.system(
        "cd /home/palmco/public_html/alice && /bin/python3.11 alice_indexer.py"
    ), daemon=True).start()
    return jsonify({"status": "started", "message": "Full re-index running in background."})


@app.route("/ticker", methods=["GET"])
def ticker():
    """Breaking News for the header — straight from the DB.
    Only catid 83, published, last 14 days."""
    import mysql.connector
    try:
        conn = mysql.connector.connect(
            host="localhost", user="palmco_chance", password="chance1009",
            database="palmco_ALICE2", unix_socket="/var/lib/mysql/mysql.sock"
        )
        cur = conn.cursor(dictionary=True)
        cur.execute("""
            SELECT id, title, created
            FROM pc1_content
            WHERE catid = 83 AND state = 1
              AND created > DATE_SUB(NOW(), INTERVAL 14 DAY)
            ORDER BY created DESC LIMIT 6
        """)
        rows = cur.fetchall()
        cur.close(); conn.close()
        items = [{
            "title": r["title"],
            "date": r["created"].strftime("%b %-d") if r.get("created") else "",
            "url": f"{DOMAIN}/index.php?option=com_content&view=article&id={r['id']}"
        } for r in rows]
        return jsonify({"status": "ok", "count": len(items), "items": items})
    except Exception as e:
        log.error(f"/ticker error: {e}")
        return jsonify({"status": "error", "count": 0, "items": []})


@app.route("/health", methods=["GET"])
def health():
    try:
        count = q_client.get_collection(COLLECTION).points_count
    except Exception:
        count = 0
    try:
        mp3_count = len([f for f in os.listdir(AUDIO_DIR) if f.endswith(".mp3")])
    except Exception:
        mp3_count = 0
    return jsonify({
        "status":      "ok",
        "agent":       "ALICE",
        "collection":  COLLECTION,
        "indexed":     count,
        "rag_model":   RAG_MODEL,
        "voice":       "LuxTTS via Voicebox",
        "audio_files": mp3_count,
        "timestamp":   datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    })


# ══════════════════════════════════════════════
#  ENTRY POINT
# ══════════════════════════════════════════════
if __name__ == "__main__":
    log.info("━" * 60)
    log.info("  ALICE — Palm Coast Local AI Agent")
    log.info("━" * 60)
    log.info(f"  Port   : {FLASK_PORT}")
    log.info(f"  Qdrant : {QDRANT_URL} / {COLLECTION}")
    log.info(f"  RAG    : {RAG_MODEL}")
    log.info(f"  Voice  : LuxTTS via Voicebox")
    log.info(f"  Piper  : Fallback only")
    log.info("━" * 60)
    app.run(host="0.0.0.0", port=FLASK_PORT, debug=False)