# LifeLine - Linux side (Python on the UNO Q)
#
# Listens to the USB microphone, classifies sounds ON THE BOARD with an
# Edge Impulse model, shows an icon on the LED matrix, and sends the family a
# short text-only alert. Raw audio is never stored or transmitted.

import logging
import smtplib
import ssl
import base64
import itertools
import json
import urllib.error
import urllib.parse
import urllib.request
from xml.sax.saxutils import escape as xml_escape
import threading
import time
from collections import deque
from pathlib import Path
from datetime import datetime
from email.message import EmailMessage

try:
    from zoneinfo import ZoneInfo
except ImportError:  # pragma: no cover
    ZoneInfo = None

from arduino.app_utils import App, Bridge, Logger
from arduino.app_bricks.audio_classification import AudioClassification
from arduino.app_bricks.web_ui import WebUI

logger = Logger("LifeLine", level=logging.INFO)

# ---------------------------------------------------------------------------
# CONFIG - edit these
# ---------------------------------------------------------------------------

# How the app gets its input:
#   "live"     - real USB microphone (normal use)
#   "simulate" - no mic, no model: plays SIMULATE_SCRIPT (fake detections,
#                some followed by "cancel") to test the matrix and email alerts
#   "files"    - no mic: runs the real model on .wav files in assets/test_audio/
MODE = "simulate"
# (label, seconds to wait before the next step)
SIMULATE_SCRIPT = [
    ("fire_alarm", 16),    # not cancelled -> alert shown + email sent
    ("glass_break", 4),    # ...cancelled 4 s into the countdown
    ("cancel", 8),
    ("falling", 16),       # not cancelled
    ("help", 6),           # ...cancelled 6 s in
    ("cancel", 8),
]
FILES_EVERY_SEC = 10

# After a detection, count down this many seconds on the matrix before
# alerting. Saying/raising the CANCEL_LABEL during the countdown stops it.
COUNTDOWN_SEC = 10
CANCEL_LABEL = "cancel"

# Minimum model confidence (0-1) before we treat a detection as real.
CONFIDENCE = 0.80

# Don't send more than one remote notification per event type in this window.
NOTIFY_COOLDOWN_SEC = 60

# Email alerts. For Gmail: turn on 2-Step Verification, then create an
# "App password" and paste it below (NOT your normal password).
EMAIL_ENABLED = True
SMTP_HOST = "smtp.gmail.com"
SMTP_PORT = 465
SMTP_USER = "r.biglou@gmail.com"
SMTP_APP_PASSWORD = "ooml jevp kyzu wzct"
# Add a phone's email-to-SMS gateway address here to get a text too,
# e.g. "7145551234@tmomail.net" (T-Mobile) or "7145551234@txt.att.net" (AT&T).
ALERT_RECIPIENTS = ["ryan.biglou@gmail.com"]

HOME_NAME = "Grandma's apartment"

# Phone calls (Twilio). When an alert goes out, the board calls CALL_CHAIN in
# order. The first person who picks up (a human, not voicemail) hears a spoken
# message and the chain stops. If nobody answers, the next person is called.
# Set up: twilio.com -> Account SID, Auth Token, and a Twilio phone number.
# A free trial account can only call numbers you verify in the Twilio console.
CALLS_ENABLED = True
TWILIO_ACCOUNT_SID = "AC97b9fe30717c4c6aa6a25038b91058c2"
TWILIO_AUTH_TOKEN = "92d266ce35728dcba5951a409a6e8319"
TWILIO_FROM_NUMBER = "+19163844650"          # your Twilio number, E.164 format
CALL_CHAIN = [                                # (name, number) in call order
    ("Edward", "+19166647766"),
    # ("Neighbor", "+17145550199"),
]
CALL_RING_SEC = 25        # how long each phone rings before moving on
CALL_ROUNDS = 2           # go through the whole chain this many times
CALL_COOLDOWN_SEC = 60    # at most one call chain per minute
# Voicemail detection (Twilio "answering machine detection") is NOT available on
# trial accounts. When off, a call counts as answered if it lasted at least
# CALL_MIN_ANSWER_SEC (the message is ~25 s; a trial call where nobody presses a
# key, or a quick hang-up, is much shorter).
VOICEMAIL_DETECTION = False
CALL_MIN_ANSWER_SEC = 8

# Twilio TRIAL accounts may only place calls that play one of Twilio's own
# sample messages (no custom spoken message, no caller-ID choice). With this on,
# the phone still rings from your trial number, but the caller hears Twilio's
# sample text-to-speech message instead of the alert details (those are in the
# email). Set to False after upgrading the Twilio account to speak the real alert.
TWILIO_TRIAL = True
TWILIO_TRIAL_TEMPLATE_URL = "https://webhooks.twilio.com/v1/Voice/Template/voice_text_to_speech"

# The board clock is usually UTC; timestamps and night hours use this zone.
TIMEZONE = "America/Los_Angeles"

# Hours (24h, board-local time) when a voice counts as "unfamiliar/night".
NIGHT_START_HOUR = 23
NIGHT_END_HOUR = 6

# ---------------------------------------------------------------------------
# Sound classes. Keys MUST match your Edge Impulse label names
# (case-insensitive). Labels not listed here (e.g. "background", "noise")
# are ignored. The "icon" number must match iconFor() in sketch.ino.
# ---------------------------------------------------------------------------
ICON_FIRE, ICON_GLASS, ICON_FALL, ICON_VOICE, ICON_CHECK, ICON_SOS = 1, 2, 3, 4, 5, 6
ICON_CANCELLED = 7

EVENTS = {
    "fire_alarm": {
        "icon": ICON_FIRE,
        "title": "Fire alarm",
        "detail": "A fire alarm is sounding.",
    },
    "glass_break": {
        "icon": ICON_GLASS,
        "title": "Glass breaking",
        "detail": "The sound of breaking glass was detected.",
    },
    # Label used by Arduino's built-in glass-breaking model, so the app
    # works out of the box before you train your own model.
    "glass_breaking": {
        "icon": ICON_GLASS,
        "title": "Glass breaking",
        "detail": "The sound of breaking glass was detected.",
    },
    "falling": {
        "icon": ICON_FALL,
        "title": "Possible fall",
        "detail": "A fall was detected.",
    },
    "voice": {
        "icon": ICON_VOICE,
        "title": "Voice at night",
        "detail": "A voice was detected during night hours.",
        "night_only": True,
    },
    # Spoken keywords (keyword spotting). They share one title, so they also
    # share one notification cooldown: "help... call an ambulance" = 1 alert.
    "help": {
        "icon": ICON_SOS,
        "title": "Call for help",
        "detail": "Someone called out for help.",
    },
    "hospital": {
        "icon": ICON_SOS,
        "title": "Call for help",
        "detail": "Someone said they need the hospital.",
    },
    "ambulance": {
        "icon": ICON_SOS,
        "title": "Call for help",
        "detail": "Someone asked for an ambulance.",
    },
}

# ---------------------------------------------------------------------------

def local_now() -> datetime:
    if ZoneInfo:
        try:
            return datetime.now(ZoneInfo(TIMEZONE))
        except Exception:
            pass
    return datetime.now()


_last_notified = {}
_notify_lock = threading.Lock()


def is_night(now: datetime) -> bool:
    h = now.hour
    if NIGHT_START_HOUR > NIGHT_END_HOUR:  # window crosses midnight
        return h >= NIGHT_START_HOUR or h < NIGHT_END_HOUR
    return NIGHT_START_HOUR <= h < NIGHT_END_HOUR


def send_email(subject: str, body: str) -> None:
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = f"LifeLine <{SMTP_USER}>"
    msg["To"] = ", ".join(ALERT_RECIPIENTS)
    msg.set_content(body)
    ctx = ssl.create_default_context()
    with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ctx, timeout=15) as s:
        s.login(SMTP_USER, SMTP_APP_PASSWORD)
        s.send_message(msg)


def notify_family(label: str, event: dict, when: datetime) -> str:
    """Send a text-only alert. Runs in a background thread.
    Returns "sent", "failed", "off" (email disabled) or "cooldown"."""
    with _notify_lock:
        last = _last_notified.get(event["title"], 0)
        if time.time() - last < NOTIFY_COOLDOWN_SEC:
            logger.info(f"Notification for '{event['title']}' suppressed (cooldown)")
            return "cooldown"
        _last_notified[event["title"]] = time.time()

    stamp = when.strftime("%I:%M %p on %b %d").lstrip("0")
    subject = f"LifeLine alert: {event['title']} - {HOME_NAME}"
    # Short body so it also fits in an SMS via a carrier gateway.
    body = f"{event['detail']} {HOME_NAME}, {stamp}. (No audio was recorded or sent.)"

    if not EMAIL_ENABLED:
        logger.info(f"[email disabled] {subject} | {body}")
        return "off"
    try:
        send_email(subject, body)
        logger.info(f"Family notified: {subject}")
        return "sent"
    except Exception as e:
        logger.error(f"Failed to send notification: {e}")
        return "failed"


# ---------------------------------------------------------------------------
# Phone calls (Twilio REST API, standard library only)
# ---------------------------------------------------------------------------

TWILIO_API = "https://api.twilio.com/2010-04-01/Accounts/{sid}/Calls"
CALL_DONE = {"completed", "busy", "failed", "no-answer", "canceled"}

_last_call_chain = 0.0
_call_lock = threading.Lock()


def _twilio(method: str, url: str, data: dict = None) -> dict:
    auth = base64.b64encode(f"{TWILIO_ACCOUNT_SID}:{TWILIO_AUTH_TOKEN}".encode()).decode()
    body = urllib.parse.urlencode(data).encode() if data else None
    req = urllib.request.Request(url, data=body, method=method,
                                 headers={"Authorization": f"Basic {auth}"})
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            return json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        detail = e.read().decode(errors="replace")[:300]
        raise RuntimeError(f"Twilio HTTP {e.code}: {detail}") from None


def call_message(events: list, when: datetime) -> str:
    details = " ".join(e["detail"] for e in events)
    stamp = when.strftime("%I:%M %p").lstrip("0")
    return (f"This is LifeLine at {HOME_NAME}. {details} "
            f"This was detected at {stamp}. Please check on them now. "
            f"If this is an emergency, call 9 1 1.")


_trial_mode = TWILIO_TRIAL   # switched on automatically if Twilio rejects a custom call


def _start_call(number: str, twiml: str) -> dict:
    global _trial_mode
    base = TWILIO_API.format(sid=TWILIO_ACCOUNT_SID)
    trial_params = {"To": number, "Url": TWILIO_TRIAL_TEMPLATE_URL}
    if _trial_mode:
        return _twilio("POST", base + ".json", trial_params)

    params = {"To": number, "From": TWILIO_FROM_NUMBER, "Twiml": twiml,
              "Timeout": str(CALL_RING_SEC)}
    if VOICEMAIL_DETECTION:
        params["MachineDetection"] = "Enable"
    try:
        return _twilio("POST", base + ".json", params)
    except RuntimeError as e:
        if "trial accounts have limited parameter" in str(e):
            logger.warning("Twilio trial account: switching to Twilio's sample call "
                           "message (upgrade Twilio to speak the real alert)")
            _trial_mode = True
            return _twilio("POST", base + ".json", trial_params)
        raise


def place_call(name: str, number: str, message: str) -> str:
    """Call one person. Returns "answered", "voicemail", "no-answer", "busy" or "failed"."""
    say = xml_escape(message)
    twiml = f'<Response><Pause length="1"/><Say loop="2">{say}</Say></Response>'
    base = TWILIO_API.format(sid=TWILIO_ACCOUNT_SID)
    call = _start_call(number, twiml)
    sid = call["sid"]
    logger.info(f"Calling {name} ({number})...")

    use_amd = VOICEMAIL_DETECTION and not _trial_mode
    deadline = time.time() + CALL_RING_SEC + 120
    while time.time() < deadline:
        time.sleep(3)
        call = _twilio("GET", f"{base}/{sid}.json")
        status = call.get("status")
        answered_by = call.get("answered_by") or ""

        if use_amd:
            if status == "in-progress" and answered_by in ("human", "unknown"):
                return "answered"
            if status == "completed":
                return "voicemail" if answered_by.startswith("machine") else "answered"
        elif status == "completed":
            # No voicemail detection: judge by how long the call lasted.
            duration = int(call.get("duration") or 0)
            return "answered" if duration >= CALL_MIN_ANSWER_SEC else "no-answer"

        if status in CALL_DONE:
            return status
    return "no-answer"


def call_chain(events: list, when: datetime, records: list) -> None:
    """Call CALL_CHAIN in order until a person answers. Runs in a background thread."""
    global _last_call_chain
    if not CALLS_ENABLED:
        update_records(records, call="off")
        return
    with _call_lock:
        if time.time() - _last_call_chain < CALL_COOLDOWN_SEC:
            logger.info("Call chain suppressed (cooldown)")
            update_records(records, call="cooldown")
            return
        _last_call_chain = time.time()

    message = call_message(events, when)
    for round_no in range(1, CALL_ROUNDS + 1):
        for name, number in CALL_CHAIN:
            update_records(records, call=f"calling:{name}")
            try:
                result = place_call(name, number, message)
            except Exception as e:
                logger.error(f"Call to {name} failed: {e}")
                result = "failed"
            logger.info(f"Call to {name}: {result}")
            if result == "answered":
                update_records(records, call=f"answered:{name}")
                return
    logger.error("Nobody answered the alert calls")
    update_records(records, call="unanswered")


# ---------------------------------------------------------------------------
# Family dashboard (WebUI brick) - open http://<board-name>.local:7000
# Shows what the device is displaying and a log of recent events.
# Only labels and times are kept, in memory; never audio.
# ---------------------------------------------------------------------------

DASHBOARD_MAX_EVENTS = 100

_log = deque(maxlen=DASHBOARD_MAX_EVENTS)   # newest first
_log_lock = threading.Lock()
_ids = itertools.count(1)
_display = {"kind": "idle", "icon": None, "seconds": None, "started_ms": 0}
_started_at = ""


def fmt_time(dt: datetime) -> str:
    return dt.strftime("%I:%M %p").lstrip("0")


def log_event(label: str, event: dict, when: datetime, status: str) -> dict:
    rec = {
        "id": next(_ids),
        "label": label,
        "title": event["title"],
        "icon": event["icon"],
        "time": fmt_time(when),
        "date": when.strftime("%Y-%m-%d"),
        "day": when.strftime("%a, %b %d").replace(" 0", " "),
        "status": status,        # counting | alerted | cancelled
        "email": None,           # sent | failed | off | cooldown | sending
        "call": None,            # off | cooldown | calling:<name> | answered:<name> | unanswered
    }
    with _log_lock:
        _log.appendleft(rec)
    return rec


def update_records(records: list, **fields) -> None:
    with _log_lock:
        for r in records:
            r.update(fields)
    push_state()


def set_display(kind: str, icon=None, seconds=None) -> None:
    global _display
    _display = {"kind": kind, "icon": icon, "seconds": seconds,
                "started_ms": int(time.time() * 1000)}


def snapshot() -> dict:
    with _log_lock:
        events = [dict(r) for r in _log]
    return {
        "home": HOME_NAME,
        "mode": MODE,
        "email_enabled": EMAIL_ENABLED,
        "calls_enabled": CALLS_ENABLED,
        "listening_since": _started_at,
        "server_now_ms": int(time.time() * 1000),
        "today": local_now().strftime("%Y-%m-%d"),
        "display": dict(_display),
        "events": events,
    }


ui = WebUI()


def push_state() -> None:
    """Send the latest state to every open dashboard (WebSocket)."""
    try:
        ui.send_message("state", snapshot())
    except Exception as e:  # e.g. before the server has started
        logger.debug(f"Dashboard push skipped: {e}")


ui.expose_api("GET", "/state", snapshot)
ui.on_connect(lambda sid: push_state())


# ---------------------------------------------------------------------------
# Countdown / cancel
#
# detection -> 10 s countdown on the matrix -> (no cancel) alert icon + email
#                                            -> ("cancel" heard) nothing sent
# More events during a running countdown join it: one "cancel" cancels all of
# them, otherwise all of them are sent when the countdown ends.
# ---------------------------------------------------------------------------

_pending = None          # {"events": [(label, event, when, record)], "timer": Timer}
_pending_lock = threading.Lock()


def matrix(func: str, *args, attempts: int = 3) -> None:
    """Tell the sketch what to draw. Retries, since a busy MCU can miss a message."""
    for attempt in range(1, attempts + 1):
        try:
            Bridge.call(func, *args, timeout=3)
            return
        except Exception as e:
            if attempt == attempts:
                logger.error(f"Could not update LED matrix ({func}) after {attempts} tries: {e}")
            else:
                logger.warning(f"LED matrix ({func}) attempt {attempt} failed: {e} - retrying")


def handle_detection(label: str) -> None:
    global _pending

    if label == CANCEL_LABEL:
        handle_cancel()
        return

    event = EVENTS[label]
    now = local_now()

    if event.get("night_only") and not is_night(now):
        logger.info(f"'{label}' heard during the day - ignored")
        return

    with _pending_lock:
        if _pending is not None:
            titles = [e["title"] for _, e, _, _ in _pending["events"]]
            if event["title"] not in titles:
                rec = log_event(label, event, now, "counting")
                _pending["events"].append((label, event, now, rec))
                logger.info(f"DETECTED: {event['title']} - added to running countdown")
                joined = True
            else:
                joined = False
            new_countdown = False
        else:
            logger.info(
                f"DETECTED: {event['title']} (label '{label}') - "
                f"{COUNTDOWN_SEC} s countdown started, say '{CANCEL_LABEL}' to stop it"
            )
            rec = log_event(label, event, now, "counting")
            pending = {"events": [(label, event, now, rec)]}
            pending["timer"] = threading.Timer(COUNTDOWN_SEC, countdown_finished, args=(pending,))
            pending["timer"].daemon = True
            _pending = pending
            set_display("countdown", event["icon"], int(COUNTDOWN_SEC))
            new_countdown = True
            joined = False

    if new_countdown:
        push_state()
        matrix("show_countdown", event["icon"], int(COUNTDOWN_SEC))
        pending["timer"].start()
    elif joined:
        push_state()


def handle_cancel() -> None:
    global _pending
    with _pending_lock:
        pending, _pending = _pending, None
    if pending is None:
        logger.info(f"'{CANCEL_LABEL}' heard, but no countdown is running")
        return
    pending["timer"].cancel()
    titles = ", ".join(e["title"] for _, e, _, _ in pending["events"])
    logger.info(f"CANCELLED: {titles} - no alert sent")
    set_display("cancelled", ICON_CANCELLED)
    update_records([r for _, _, _, r in pending["events"]], status="cancelled")
    matrix("show_alert", ICON_CANCELLED)


def _notify_and_record(label: str, event: dict, when: datetime, rec: dict) -> None:
    result = notify_family(label, event, when)
    update_records([rec], email=result)


def countdown_finished(pending: dict) -> None:
    global _pending
    with _pending_lock:
        if _pending is not pending:  # cancelled in the meantime
            return
        _pending = None

    first_event = pending["events"][0][1]
    logger.info(f"Countdown ended without cancel - ALERTING: "
                + ", ".join(e["title"] for _, e, _, _ in pending["events"]))

    set_display("alert", first_event["icon"])
    update_records([r for _, _, _, r in pending["events"]], status="alerted", email="sending")

    # 1) Remote alert(s) first, in the background, so a slow or stuck matrix
    #    can never delay the family being notified.
    for label, event, when, rec in pending["events"]:
        threading.Thread(
            target=_notify_and_record, args=(label, event, when, rec), daemon=True
        ).start()

    # Phone calls (one chain for the whole batch of events)
    threading.Thread(
        target=call_chain,
        args=([e for _, e, _, _ in pending["events"]], pending["events"][0][2],
              [r for _, _, _, r in pending["events"]]),
        daemon=True,
    ).start()

    # 2) Local visual alert on the LED matrix.
    matrix("show_alert", first_event["icon"])


def make_callback(label: str):
    # The brick requires a plain function with no arguments.
    def callback():
        handle_detection(label)
    return callback


def self_test() -> None:
    """Flash each icon once at startup so you can check the matrix."""
    time.sleep(3)  # give the sketch time to boot and register its functions
    try:
        for icon in (ICON_FIRE, ICON_GLASS, ICON_FALL, ICON_VOICE, ICON_SOS, ICON_CHECK):
            Bridge.call("show_alert", icon)
            time.sleep(1.2)
        Bridge.call("clear_alert")
    except Exception as e:
        logger.error(f"Self-test failed: {e}")


# ---------------------------------------------------------------------------
# Test modes (no microphone needed)
# ---------------------------------------------------------------------------

TEST_AUDIO_DIR = Path(__file__).resolve().parent.parent / "assets" / "test_audio"


def simulate_loop() -> None:
    """Replay SIMULATE_SCRIPT forever."""
    time.sleep(12)  # let the self-test finish first
    while True:
        for label, wait in SIMULATE_SCRIPT:
            logger.info(f"[simulate] pretending to hear '{label}'")
            handle_detection(label)
            time.sleep(wait)


def files_loop() -> None:
    """Run the real model on each .wav in assets/test_audio/, repeatedly."""
    time.sleep(12)
    while True:
        wavs = sorted(TEST_AUDIO_DIR.glob("*.wav"))
        if not wavs:
            logger.error(f"[files] no .wav files found in {TEST_AUDIO_DIR}")
            time.sleep(30)
            continue
        for wav in wavs:
            try:
                result = AudioClassification.classify_from_file(str(wav), CONFIDENCE)
            except Exception as e:
                logger.error(f"[files] {wav.name}: classification failed: {e}")
                continue
            if not result:
                logger.info(f"[files] {wav.name}: nothing above {CONFIDENCE:.0%}")
            else:
                label = str(result["class_name"]).lower()
                conf = float(result["confidence"])
                logger.info(f"[files] {wav.name}: '{label}' ({conf:.0%})")
                if label in EVENTS or label == CANCEL_LABEL:
                    handle_detection(label)
            time.sleep(FILES_EVERY_SEC)


# ---------------------------------------------------------------------------
# Start
# ---------------------------------------------------------------------------

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

if MODE == "live":
    classifier = AudioClassification(confidence=CONFIDENCE)
    for label in list(EVENTS) + [CANCEL_LABEL]:
        classifier.on_detect(label, make_callback(label))
    logger.info(f"LIVE: listening for {', '.join(EVENTS)} (+ '{CANCEL_LABEL}')")
elif MODE == "simulate":
    logger.info("SIMULATE mode: no microphone or model used")
    threading.Thread(target=simulate_loop, daemon=True).start()
elif MODE == "files":
    logger.info(f"FILES mode: classifying .wav files in {TEST_AUDIO_DIR}")
    threading.Thread(target=files_loop, daemon=True).start()
else:
    logger.error(f"Unknown MODE '{MODE}' - use 'live', 'simulate' or 'files'")

_started_at = local_now().strftime("%a %I:%M %p").replace(" 0", " ")
logger.info("Family dashboard: http://<board-name>.local:7000")

App.run()
