#!/usr/bin/env python3
"""
Safe v1 Nextcloud Talk assistant bridge.

What it does now:
- Polls one configured Talk conversation.
- Responds only to allowed senders.
- Creates pending JSON drafts for capture/remind/project-note/ticket commands.
- Supports approve/edit/discard review flow.
- Posts replies back to Talk when credentials/API are configured.

What it deliberately does NOT do yet:
- Modify repo files, Tasks, Calendar, or infrastructure directly.
- Execute shell/admin commands from chat.
- Store secrets in config/state files.

Secrets:
- Set NEXTCLOUD_APP_PASSWORD in the service environment.
"""

from __future__ import annotations

import argparse
import base64
import datetime as dt
import html
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import time
import uuid
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional


DEFAULT_STATE = {
    "last_message_id": 0,
    "pending_draft": None,
    "processed_message_ids": [],
}


class ConfigError(Exception):
    pass


class TalkClient:
    def __init__(self, base_url: str, username: str, app_password: str) -> None:
        self.base_url = base_url.rstrip("/")
        self.username = username
        self.app_password = app_password

    def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
        url = self.base_url + path
        data = None
        headers = {
            "Accept": "application/json",
            "OCS-APIRequest": "true",
            "User-Agent": "nextcloud-talk-assistant-v1",
        }
        if body is not None:
            data = urllib.parse.urlencode(body).encode("utf-8")
            headers["Content-Type"] = "application/x-www-form-urlencoded"
        token = base64.b64encode(f"{self.username}:{self.app_password}".encode()).decode()
        headers["Authorization"] = f"Basic {token}"
        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                raw = resp.read().decode("utf-8")
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", "replace")
            raise RuntimeError(f"Talk API HTTP {exc.code} for {method} {path}: {detail[:500]}") from exc
        if not raw:
            return None
        parsed = json.loads(raw)
        return parsed.get("ocs", {}).get("data", parsed)

    def get_messages(self, conversation_token: str, last_known_id: int) -> list[dict]:
        # Nextcloud Talk chat API has varied slightly across versions. This path is the
        # common OCS endpoint. If discovery shows this install differs, adjust here.
        params = {
            # Poll for messages newer than lastKnownMessageId.
            "lookIntoFuture": "1",
            "limit": "50",
        }
        if last_known_id:
            params["lastKnownMessageId"] = str(last_known_id)
        query = urllib.parse.urlencode(params)
        path = f"/ocs/v2.php/apps/spreed/api/v1/chat/{urllib.parse.quote(conversation_token)}?{query}"
        data = self._request("GET", path)
        if isinstance(data, list):
            return data
        if isinstance(data, dict) and "messages" in data:
            return data["messages"]
        return []

    def send_message(self, conversation_token: str, message: str) -> None:
        path = f"/ocs/v2.php/apps/spreed/api/v1/chat/{urllib.parse.quote(conversation_token)}"
        self._request("POST", path, {"message": message})

    def raw_request(self, method: str, path: str, data: bytes | None = None, content_type: str | None = None) -> tuple[int, bytes]:
        url = self.base_url + path if path.startswith("/") else path
        headers = {"User-Agent": "nextcloud-talk-assistant-v1"}
        token = base64.b64encode(f"{self.username}:{self.app_password}".encode()).decode()
        headers["Authorization"] = f"Basic {token}"
        if content_type:
            headers["Content-Type"] = content_type
        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                return resp.status, resp.read()
        except urllib.error.HTTPError as exc:
            return exc.code, exc.read()

    def webdav_path(self, file_path: str) -> str:
        quoted = "/".join(urllib.parse.quote(part) for part in file_path.strip("/").split("/"))
        return f"/remote.php/dav/files/{urllib.parse.quote(self.username)}/{quoted}"

    def webdav_get_text(self, file_path: str) -> str:
        status, data = self.raw_request("GET", self.webdav_path(file_path))
        if status == 404:
            return ""
        if status >= 300:
            raise RuntimeError(f"WebDAV GET failed {status} for {file_path}: {data[:200]!r}")
        return data.decode("utf-8", "replace")

    def webdav_put_text(self, file_path: str, text: str) -> None:
        parts = file_path.strip("/").split("/")[:-1]
        current = ""
        for part in parts:
            current = f"{current}/{part}" if current else part
            self.raw_request("MKCOL", self.webdav_path(current))
        status, data = self.raw_request("PUT", self.webdav_path(file_path), text.encode("utf-8"), "text/markdown; charset=utf-8")
        if status >= 300:
            raise RuntimeError(f"WebDAV PUT failed {status} for {file_path}: {data[:200]!r}")

    def put_calendar_object(self, calendar_uri: str, object_name: str, ics: str) -> None:
        path = f"/remote.php/dav/calendars/{urllib.parse.quote(self.username)}/{urllib.parse.quote(calendar_uri)}/{urllib.parse.quote(object_name)}"
        status, data = self.raw_request("PUT", path, ics.encode("utf-8"), "text/calendar; charset=utf-8")
        if status >= 300:
            raise RuntimeError(f"CalDAV PUT failed {status}: {data[:300]!r}")

    def download_url(self, url: str, target: Path) -> None:
        if url.startswith("/"):
            url = self.base_url + url
        headers = {"User-Agent": "nextcloud-talk-assistant-v1"}
        token = base64.b64encode(f"{self.username}:{self.app_password}".encode()).decode()
        headers["Authorization"] = f"Basic {token}"
        req = urllib.request.Request(url, headers=headers, method="GET")
        target.parent.mkdir(parents=True, exist_ok=True)
        with urllib.request.urlopen(req, timeout=120) as resp, target.open("wb") as out:
            shutil.copyfileobj(resp, out)


def load_config(path: Path) -> dict:
    cfg = json.loads(path.read_text())
    required = ["nextcloud_base_url", "username", "conversation_token", "allowed_senders", "state_path"]
    missing = [k for k in required if not cfg.get(k)]
    if missing:
        raise ConfigError(f"Missing config keys: {', '.join(missing)}")
    cfg.setdefault("poll_seconds", 5)
    cfg.setdefault("dry_run_apply", True)
    cfg.setdefault("applied_log_path", "/var/lib/nextcloud-talk-assistant/applied-drafts.jsonl")
    cfg.setdefault("capture_file_path", "Assistant/capture.md")
    cfg.setdefault("pi_request_file_path", "Assistant/pi-requests.md")
    cfg.setdefault("reminder_calendar_uri", "assistant-reminders")
    cfg.setdefault("voice_enabled", False)
    cfg.setdefault("voice_work_dir", "/var/lib/nextcloud-talk-assistant/voice")
    cfg.setdefault("whisper_model", "tiny")
    return cfg


def load_state(path: Path) -> dict:
    if not path.exists():
        return json.loads(json.dumps(DEFAULT_STATE))
    state = json.loads(path.read_text())
    merged = json.loads(json.dumps(DEFAULT_STATE))
    merged.update(state)
    return merged


def save_state(path: Path, state: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
    tmp.replace(path)


def now_iso() -> str:
    return dt.datetime.now(dt.timezone.utc).isoformat()


def clean_message(message: str) -> str:
    # Talk may return HTML-ish message parameters; keep v1 conservative.
    msg = re.sub(r"<[^>]+>", "", message or "")
    return html.unescape(msg).strip()


def sender_id(msg: dict) -> str:
    return str(msg.get("actorId") or msg.get("actorDisplayName") or msg.get("actorType") or "")


def message_id(msg: dict) -> int:
    for key in ("id", "messageId"):
        try:
            return int(msg.get(key) or 0)
        except (TypeError, ValueError):
            pass
    return 0


def make_draft(kind: str, raw_text: str) -> dict:
    text = raw_text.strip()
    title = text
    destination = None

    if kind == "capture":
        destination = "inbox/capture.md"
    elif kind == "project_note":
        destination = "pending project note; choose project doc during review"
    elif kind == "ticket":
        destination = "pending ticket draft"
    elif kind == "reminder":
        destination = "Nextcloud Tasks or Calendar, decide ad hoc"
        if re.fullmatch(r"\s*\d+\s*(minutes?|mins?|hours?|hrs?|days?)\s*", text, flags=re.IGNORECASE):
            title = f"Timer for {text.strip()}"
    elif kind == "assistant_request":
        destination = "Pi assistant request queue"
        title = text[:120] or "Pi request"

    return {
        "id": f"draft-{int(time.time())}",
        "kind": kind,
        "text": text,
        "title": title[:120],
        "destination": destination,
        "created_at": now_iso(),
        "updated_at": now_iso(),
        "status": "pending",
    }


def format_draft(draft: dict) -> str:
    kind = draft['kind'].replace('_', ' ')
    return (
        f"Draft {kind}:\n"
        f"- Title: {draft.get('title') or '(untitled)'}\n"
        f"- Destination: {draft.get('destination') or 'TBD'}\n"
        f"- Text: {draft.get('text') or ''}\n\n"
        "Reply with one of:\n"
        "approve\n"
        "edit: <replacement text>\n"
        "discard"
    )


def classify_command(text: str) -> tuple[str, str]:
    """Classify explicit commands and simple natural-language intents.

    This is intentionally deterministic for v1. A later version can hand intent
    detection to an LLM, but durable actions should still go through drafts.
    """
    stripped = text.strip()
    lowered = stripped.lower()

    if lowered in ("help", "/help", "what can you do?", "what can you do"):
        return "help", ""
    if lowered in ("status", "/status"):
        return "status", ""

    natural_patterns = [
        ("reminder", [
            r"^(please\s+)?remind me to\s+(.+)$",
            r"^(please\s+)?remind me\s+(.+)$",
            r"^can you remind me to\s+(.+)$",
            r"^can you remind me\s+(.+)$",
            r"^(please\s+)?set (a )?timer (for|in)\s+(.+)$",
            r"^(please\s+)?start (a )?timer (for|in)\s+(.+)$",
            r"^(please\s+)?at (a )?timer (for|in)\s+(.+)$",
        ]),
        ("capture", [
            r"^(please\s+)?capture this:?\s+(.+)$",
            r"^(please\s+)?make a note:?\s+(.+)$",
            r"^(please\s+)?note this:?\s+(.+)$",
            r"^i have an idea:?\s+(.+)$",
        ]),
        ("ticket", [
            r"^(please\s+)?create a ticket for\s+(.+)$",
            r"^(please\s+)?make a ticket for\s+(.+)$",
            r"^we need a ticket for\s+(.+)$",
        ]),
        ("assistant_request", [
            r"^(pi|assistant),?\s+(.+)$",
            r"^(please\s+)?ask pi to\s+(.+)$",
            r"^(please\s+)?ask the assistant to\s+(.+)$",
            r"^(please\s+)?work on\s+(.+)$",
            r"^(please\s+)?move forward (with|on)\s+(.+)$",
        ]),
        ("project_note", [
            r"^(please\s+)?add a project note:?\s+(.+)$",
            r"^(please\s+)?project note:?\s+(.+)$",
        ]),
    ]
    for kind, patterns in natural_patterns:
        for pattern in patterns:
            match = re.match(pattern, stripped, flags=re.IGNORECASE)
            if match:
                payload = next((g for g in match.groups()[::-1] if g), "")
                return kind, payload.strip()

    explicit_patterns = [
        ("capture", ["capture ", "note ", "idea "]),
        ("reminder", ["remind ", "reminder "]),
        ("project_note", ["project note ", "project-note "]),
        ("ticket", ["ticket ", "make a ticket ", "create ticket "]),
        ("assistant_request", ["pi ", "ask pi ", "assistant ", "work on ", "move forward with ", "move forward on "]),
    ]
    for kind, prefixes in explicit_patterns:
        for prefix in prefixes:
            if lowered.startswith(prefix):
                return kind, stripped[len(prefix):].strip()

    return "conversation", stripped


def walk_values(value: Any):
    if isinstance(value, dict):
        yield value
        for child in value.values():
            yield from walk_values(child)
    elif isinstance(value, list):
        for child in value:
            yield from walk_values(child)


def extract_audio_attachments(msg: dict) -> list[dict]:
    """Best-effort extraction of Talk audio/file attachment metadata.

    Talk message payloads vary by version and attachment type. This function is
    intentionally broad: if any nested parameter looks like an audio file and has
    a URL/path, return it for transcription.
    """
    attachments: list[dict] = []
    for item in walk_values(msg.get("messageParameters", {})):
        mimetype = str(item.get("mimetype") or item.get("mimeType") or item.get("mime") or "").lower()
        name = str(item.get("name") or item.get("fileName") or item.get("filename") or item.get("path") or "")
        lower_name = name.lower()
        looks_audio = mimetype.startswith("audio/") or lower_name.endswith((".ogg", ".oga", ".opus", ".m4a", ".mp3", ".wav", ".webm"))
        if not looks_audio:
            continue
        url = item.get("link") or item.get("url") or item.get("downloadUrl") or item.get("downloadURL")
        path = item.get("path") or item.get("filePath")
        attachments.append({"name": name or "voice-message", "url": url, "path": path, "mimetype": mimetype})
    return attachments


def transcribe_audio(client: TalkClient, attachment: dict, cfg: dict) -> str:
    work_dir = Path(cfg.get("voice_work_dir") or "/var/lib/nextcloud-talk-assistant/voice")
    safe_name = re.sub(r"[^A-Za-z0-9._-]+", "-", attachment.get("name") or f"voice-{int(time.time())}.ogg")
    if "." not in safe_name:
        safe_name += ".ogg"
    audio_path = work_dir / safe_name

    # Prefer WebDAV path over /f/<id> share links. /f links return the HTML Files
    # UI when accessed by the service account, while WebDAV returns the audio.
    url = None
    if attachment.get("path"):
        quoted = "/".join(urllib.parse.quote(part) for part in str(attachment["path"]).strip("/").split("/"))
        url = f"/remote.php/dav/files/{urllib.parse.quote(client.username)}/{quoted}"
    if not url:
        url = attachment.get("url")
    if not url:
        return ""

    client.download_url(str(url), audio_path)
    out_path = audio_path.with_suffix(".transcript.txt")
    model = str(cfg.get("whisper_model") or "tiny")
    code = r'''
import sys
from faster_whisper import WhisperModel
model_name, audio_path, out_path = sys.argv[1:4]
model = WhisperModel(model_name, device="cpu", compute_type="int8")
segments, info = model.transcribe(audio_path, language="en", vad_filter=True)
text = " ".join(segment.text.strip() for segment in segments).strip()
open(out_path, "w", encoding="utf-8").write(text + "\n")
'''
    logging.info("Transcribing voice attachment %s with faster-whisper model=%s", audio_path.name, model)
    subprocess.run([sys.executable, "-c", code, model, str(audio_path), str(out_path)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, timeout=900)
    return out_path.read_text().strip() if out_path.exists() else ""


def append_applied_log(cfg: dict, draft: dict, result: dict) -> None:
    path = Path(cfg.get("applied_log_path") or "/var/lib/nextcloud-talk-assistant/applied-drafts.jsonl")
    path.parent.mkdir(parents=True, exist_ok=True)
    record = {"applied_at": now_iso(), "draft": draft, "result": result}
    with path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(record, sort_keys=True) + "\n")


def append_draft_to_markdown(client: Optional[TalkClient], cfg: dict, draft: dict, remote_key: str, default_remote: str, local_name: str, local_path_key: str | None = None) -> str:
    line = f"- {dt.datetime.now().strftime('%Y-%m-%d %H:%M')} — {draft.get('kind')}: {draft.get('text','').strip()}\n"
    if local_path_key and cfg.get(local_path_key):
        local_path = Path(cfg[local_path_key])
    else:
        local_base = Path(cfg.get("local_data_dir") or "/var/lib/nextcloud-talk-assistant")
        local_path = local_base / local_name
    local_path.parent.mkdir(parents=True, exist_ok=True)
    with local_path.open("a", encoding="utf-8") as f:
        f.write(line)
    if client:
        remote = cfg.get(remote_key) or default_remote
        existing = client.webdav_get_text(remote)
        if existing and not existing.endswith("\n"):
            existing += "\n"
        client.webdav_put_text(remote, existing + line)
        return f"Saved to Nextcloud Files: {remote}"
    return f"Saved locally: {local_path}"


def append_markdown_capture(client: Optional[TalkClient], cfg: dict, draft: dict) -> str:
    return append_draft_to_markdown(client, cfg, draft, "capture_file_path", "Assistant/capture.md", "capture.md", "local_capture_path")


def append_pi_request(client: Optional[TalkClient], cfg: dict, draft: dict) -> str:
    return append_draft_to_markdown(client, cfg, draft, "pi_request_file_path", "Assistant/pi-requests.md", "pi-requests.md")


def parse_due(text: str) -> tuple[Optional[dt.datetime], str]:
    now = dt.datetime.now().astimezone()
    lowered = text.lower()
    if "tomorrow" in lowered:
        due = (now + dt.timedelta(days=1)).replace(hour=9, minute=0, second=0, microsecond=0)
        title = re.sub(r"\btomorrow\b", "", text, flags=re.IGNORECASE).strip(" .,;:-")
        return due, title or text
    m = re.search(r"\b(?:in|for)?\s*(\d+)\s*(minutes?|mins?|hours?|hrs?|days?)\b", lowered)
    if m:
        amount = int(m.group(1)); unit = m.group(2)
        if unit.startswith(("minute", "min")):
            due = now + dt.timedelta(minutes=amount)
        elif unit.startswith(("hour", "hr")):
            due = now + dt.timedelta(hours=amount)
        else:
            due = now + dt.timedelta(days=amount)
        title = (text[:m.start()] + text[m.end():]).strip(" .,;:-")
        if not title or title == text:
            title = f"Timer for {amount} {unit}"
        return due, title
    if "today" in lowered:
        due = now.replace(hour=17, minute=0, second=0, microsecond=0)
        title = re.sub(r"\btoday\b", "", text, flags=re.IGNORECASE).strip(" .,;:-")
        return due, title or text
    return None, text


def ics_escape(value: str) -> str:
    return value.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")


def create_vtodo(client: TalkClient, cfg: dict, draft: dict) -> str:
    due, title = parse_due(draft.get("text", ""))
    uid = f"{uuid.uuid4()}@nextcloud-talk-assistant"
    stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Pi//Nextcloud Talk Assistant//EN", "BEGIN:VTODO", f"UID:{uid}", f"DTSTAMP:{stamp}", f"SUMMARY:{ics_escape(title)}"]
    if due:
        lines.append("DUE:" + due.astimezone(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ"))
    lines.extend(["STATUS:NEEDS-ACTION", "END:VTODO", "END:VCALENDAR", ""])
    calendar_uri = cfg.get("reminder_calendar_uri") or "assistant-reminders"
    client.put_calendar_object(calendar_uri, uid + ".ics", "\r\n".join(lines))
    return f"Created reminder task in calendar {calendar_uri}: {title}" + (f" due {due.strftime('%Y-%m-%d %H:%M')}" if due else " with no due date")


def apply_draft(draft: dict, cfg: dict, client: Optional[TalkClient]) -> str:
    if cfg.get("dry_run_apply", True):
        return f"Approved, but dry-run is enabled. I would apply this {draft['kind']} to: {draft.get('destination')}"
    kind = draft.get("kind")
    if kind in ("capture", "ticket", "project_note"):
        detail = append_markdown_capture(client, cfg, draft)
        append_applied_log(cfg, draft, {"type": "capture_file", "detail": detail})
        return f"Approved and saved. {detail}"
    if kind == "assistant_request":
        detail = append_pi_request(client, cfg, draft)
        append_applied_log(cfg, draft, {"type": "pi_request", "detail": detail})
        return f"Approved and queued for Pi. {detail}"
    if kind == "reminder":
        if not client:
            raise RuntimeError("Cannot create reminder without Nextcloud client")
        detail = create_vtodo(client, cfg, draft)
        append_applied_log(cfg, draft, {"type": "vtodo", "detail": detail})
        return "Approved. " + detail
    append_applied_log(cfg, draft, {"type": "unknown"})
    return "Approved and logged."


def handle_text(text: str, state: dict, cfg: dict, client: Optional[TalkClient] = None) -> str:
    pending = state.get("pending_draft")
    lowered = text.lower().strip()

    if lowered == "voice message transcription failed":
        return "I received the voice message, but I could not transcribe it. Please try again or send it as text."

    if pending:
        if lowered == "approve":
            try:
                reply = apply_draft(pending, cfg, client)
                state["pending_draft"] = None
                return reply
            except Exception as exc:
                logging.exception("Failed to apply draft")
                return f"I could not apply the draft yet: {exc}\n\n" + format_draft(pending)
        if lowered in ("show draft", "draft", "summary"):
            return format_draft(pending)
        if lowered == "discard":
            kind = pending.get("kind", "draft")
            state["pending_draft"] = None
            return f"Discarded pending {kind}."
        if lowered.startswith("edit:"):
            edit_text = text.split(":", 1)[1].strip()
            if not edit_text:
                return "Please provide edit text after `edit:`."
            pending["text"] = edit_text
            pending["title"] = edit_text[:120]
            pending["updated_at"] = now_iso()
            state["pending_draft"] = pending
            return "Updated draft.\n\n" + format_draft(pending)
        return (
            "A draft is already pending. Please resolve it before starting another command.\n\n"
            + format_draft(pending)
        )

    kind, payload = classify_command(text)
    if kind == "help":
        return (
            "You can chat normally, or ask me to draft durable actions. Examples:\n"
            "- `capture ...` / `make a note ...`\n"
            "- `remind me ...`\n"
            "- `project note ...`\n"
            "- `ticket ...` / `create a ticket for ...`\n"
            "- `pi ...`, `ask pi ...`, or `move forward with ...` to queue work for Pi\n"
            "- `status`\n\n"
            "Durable changes use pending drafts: approve, edit: ..., discard."
        )
    if kind == "status":
        return "Bridge is running. No pending draft." if not state.get("pending_draft") else "Bridge is running. One draft is pending."
    if kind in ("capture", "reminder", "project_note", "ticket", "assistant_request"):
        if not payload:
            return f"Please include text after `{kind}`."
        draft = make_draft(kind, payload)
        state["pending_draft"] = draft
        return format_draft(draft)

    return "I can help from Talk. Use `capture`, `remind`, `project note`, `ticket`, `pi ...`, `help`, or `status`."


def setup_logging(cfg: dict) -> None:
    handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
    if cfg.get("log_path"):
        log_path = Path(cfg["log_path"])
        try:
            log_path.parent.mkdir(parents=True, exist_ok=True)
            handlers.append(logging.FileHandler(log_path))
        except PermissionError:
            # Useful for local self-tests with production-style /var/log config.
            pass
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=handlers)


def run_once(cfg: dict, state: dict, client: Optional[TalkClient]) -> dict:
    allowed = {str(s).lower() for s in cfg.get("allowed_senders", [])}
    messages = client.get_messages(cfg["conversation_token"], int(state.get("last_message_id") or 0)) if client else []
    processed = set(state.get("processed_message_ids", []))

    for msg in sorted(messages, key=message_id):
        mid = message_id(msg)
        if mid <= 0 or mid in processed:
            continue
        state["last_message_id"] = max(int(state.get("last_message_id") or 0), mid)
        processed.add(mid)

        sender = sender_id(msg)
        if sender.lower() == str(cfg.get("username", "")).lower():
            continue
        if allowed and sender.lower() not in allowed:
            logging.info("Ignoring message %s from non-allowed sender %s", mid, sender)
            continue

        text = clean_message(str(msg.get("message") or ""))
        voice_transcript = None
        if cfg.get("voice_enabled"):
            attachments = extract_audio_attachments(msg)
            if attachments:
                try:
                    transcript = transcribe_audio(client, attachments[0], cfg) if client else ""
                except Exception:
                    logging.exception("Voice transcription failed for message %s", mid)
                    transcript = ""
                if transcript:
                    voice_transcript = transcript
                    text = transcript
                else:
                    text = "voice message transcription failed"
        if not text:
            continue
        logging.info("Handling message %s from %s", mid, sender)
        reply = handle_text(text, state, cfg, client)
        if voice_transcript:
            reply = f"I heard: {voice_transcript}\n\n{reply}"
        if client:
            client.send_message(cfg["conversation_token"], reply)
        else:
            print(reply)

    state["processed_message_ids"] = sorted(processed)[-200:]
    return state


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--config", required=True, help="Path to JSON config")
    parser.add_argument("--once", action="store_true", help="Poll once and exit")
    parser.add_argument("--self-test", action="store_true", help="Run local parser/state self-test without Nextcloud")
    args = parser.parse_args()

    cfg = load_config(Path(args.config))
    setup_logging(cfg)
    state_path = Path(cfg["state_path"])
    state = load_state(state_path)

    if args.self_test:
        for sample in ["capture this is an idea", "edit: better idea", "approve", "remind call mom tomorrow", "discard"]:
            print(f"> {sample}")
            print(handle_text(sample, state, cfg))
        save_state(state_path, state)
        return 0

    app_password = os.environ.get("NEXTCLOUD_APP_PASSWORD")
    if not app_password:
        raise ConfigError("Set NEXTCLOUD_APP_PASSWORD in the environment; do not put it in config.json")
    client = TalkClient(cfg["nextcloud_base_url"], cfg["username"], app_password)

    while True:
        try:
            state = load_state(state_path)
            state = run_once(cfg, state, client)
            save_state(state_path, state)
        except TimeoutError:
            # Talk long-polling can time out when no new messages arrive.
            logging.debug("Talk poll timed out with no new messages; retrying")
        except Exception:
            logging.exception("Bridge polling loop failed; will retry")
        if args.once:
            break
        time.sleep(float(cfg.get("poll_seconds", 5)))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
