# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# Licensed under the MIT License

"""Chat and Round Table routes — /api/chat, /api/roundtable/*."""

from __future__ import annotations

import json
import logging
import os
import queue
import threading
from datetime import datetime
from pathlib import Path

from flask import Blueprint, Response, jsonify, request, send_file

from ..auth import require_auth
from ..helpers import get_agent, get_service_manager, get_wizard_host, DATA_DIR, CONFIG_FILE, safe_error
from ..helpers import scheduled_messages

logger = logging.getLogger(__name__)

bp = Blueprint("chat", __name__)


@bp.route("/api/chat", methods=["POST"])
# rate limiting handled at app level
@require_auth
def api_chat():
    """Send a message to the agent."""
    data = request.json or {}
    message = data.get("message", "")
    files = data.get("files", [])

    if not message and not files:
        return jsonify({"error": "No message provided"}), 400

    # Prepend file context so the agent sees attached file paths
    if files:
        lines = ["[Attached files — use read_document or appropriate filereader tool to process]"]
        for f in files:
            fname = f.get("filename", "unknown")
            fpath = f.get("path", "")
            fsize = f.get("size", 0)
            if fsize >= 1024 * 1024:
                size_str = f"{fsize / (1024 * 1024):.1f} MB"
            elif fsize >= 1024:
                size_str = f"{fsize / 1024:.1f} KB"
            else:
                size_str = f"{fsize} B"
            lines.append(f"  - {fname} ({size_str}): {fpath}")
        file_context = "\n".join(lines) + "\n\n"
        message = file_context + (message or "Please analyze the attached file(s).")

    # Route /connect commands to the wizard
    stripped = message.strip()
    if stripped.lower().startswith("/connect"):
        args = stripped.split()[1:]  # drop the "/connect" prefix
        host = get_wizard_host()
        messages = host.start_wizard(args)
        return jsonify({"wizard_messages": messages, "timestamp": datetime.now().isoformat()})

    # Route /start to the tarot onboarding spread
    if stripped.lower().startswith("/start"):
        host = get_wizard_host()
        messages = host.start()
        return jsonify({"wizard_messages": messages, "timestamp": datetime.now().isoformat()})

    # If wizard is active, intercept text input (unless caller requests agent directly)
    force_agent = data.get("force_agent", False)
    if not force_agent:
        host = get_wizard_host()
        if host.is_active():
            messages = host.send_text(message)
            return jsonify({"wizard_messages": messages, "timestamp": datetime.now().isoformat()})

    # Action Router — handle common messages without involving the LLM.
    # This lets even a 0.5B model work because it never sees "show my email".
    if not force_agent and not files:
        try:
            from familiar.dashboard.action_router import match_action

            action = match_action(stripped)
            if action is not None:
                return jsonify(
                    {
                        "action": action,
                        "response": action.get("response", ""),
                        "timestamp": datetime.now().isoformat(),
                    }
                )
        except Exception as e:
            logger.debug("Action router error (falling through to LLM): %s", e)

    agent = get_agent()

    # Enforce budget config on every chat request so restarts/reloads can't revert it.
    if hasattr(agent, "sessions"):
        _sess = agent.sessions.get_or_create_session("dashboard", "web")
        _budget_enabled = False
        _budget_amount = 10.0
        try:
            import yaml as _yaml

            _cfg_path = CONFIG_FILE
            if _cfg_path.exists():
                with open(_cfg_path) as _f:
                    _cfg = _yaml.safe_load(_f) or {}
                _budget_enabled = _cfg.get("agent", {}).get("budget_enabled", False)
                _budget_amount = float(_cfg.get("agent", {}).get("daily_budget_amount", 10.0))
        except Exception:
            pass
        _target_budget = _budget_amount if _budget_enabled else 999999.0
        if _sess.daily_budget != _target_budget:
            _sess.daily_budget = _target_budget

    result = agent.chat_with_results(message, user_id="dashboard", channel="web")

    response_data = {"response": result.text, "timestamp": datetime.now().isoformat()}

    # Surface billing warnings when the response indicates a provider issue
    response_lower = (result.text or "").lower()
    if any(kw in response_lower for kw in ["insufficient credits", "credit balance", "top up"]):
        _billing_urls = {
            "anthropic": "https://console.anthropic.com/settings/plans",
            "openai": "https://platform.openai.com/account/billing",
            "gemini": "https://aistudio.google.com/billing",
        }
        provider_name = getattr(getattr(agent, "provider", None), "provider_key", "anthropic")
        response_data["provider_warning"] = {
            "type": "billing",
            "provider": provider_name,
            "message": f"{provider_name.title()} account needs credits",
            "billing_url": _billing_urls.get(provider_name),
        }

    return jsonify(response_data)


@bp.route("/api/roundtable", methods=["POST"])
# rate limiting handled at app level
@require_auth
def api_roundtable():
    """Fan a message out to multiple job class agents in parallel and return all responses."""
    import concurrent.futures

    data = request.json or {}
    message = (data.get("message") or "").strip()
    seats = data.get("seats") or []  # list of job_class_key strings

    if not message:
        return jsonify({"error": "No message provided"}), 400
    if not seats:
        return jsonify({"error": "No seats selected"}), 400

    from familiar.jobs import get_job_class, list_job_classes

    valid_keys = {jc.key for jc in list_job_classes()}
    seats = [s for s in seats if s in valid_keys]
    if not seats:
        return jsonify({"error": "No valid job class seats provided"}), 400

    # Hard cap: cloud providers handle 9 fine; local Ollama serialises anyway
    # so no benefit beyond ~3, but we allow up to all 9 job classes.
    MAX_SEATS = 9
    seats = seats[:MAX_SEATS]

    agent = get_agent()

    # Detect whether we're on a local provider so the UI can surface a warning
    provider_name = getattr(getattr(agent, "provider", None), "name", "") or ""
    is_local = "ollama" in provider_name.lower()

    def _ask_seat(job_class_key: str) -> dict:
        jc = get_job_class(job_class_key)
        try:
            result = agent.chat_with_results(
                message,
                user_id=f"roundtable-{job_class_key}",
                channel="web",
                include_history=False,
                job_class_key=job_class_key,
            )
            return {
                "job_class_key": job_class_key,
                "name": jc.name if jc else job_class_key,
                "icon": jc.icon if jc else "fa-user",
                "response": result.text,
                "error": None,
            }
        except Exception as e:
            logger.error("Round table seat %s failed: %s", job_class_key, e)
            return {
                "job_class_key": job_class_key,
                "name": jc.name if jc else job_class_key,
                "icon": jc.icon if jc else "fa-user",
                "response": None,
                "error": safe_error(e),
            }

    # Local Ollama serialises internally; cap parallelism at 3 to avoid piling up
    effective_workers = 3 if is_local else len(seats)

    with concurrent.futures.ThreadPoolExecutor(max_workers=effective_workers) as pool:
        futures = {pool.submit(_ask_seat, key): key for key in seats}
        responses = [f.result() for f in concurrent.futures.as_completed(futures)]

    # Return in same order as seats were requested
    order = {key: i for i, key in enumerate(seats)}
    responses.sort(key=lambda r: order.get(r["job_class_key"], 99))

    return jsonify(
        {
            "responses": responses,
            "timestamp": datetime.now().isoformat(),
            "provider": provider_name,
            "is_local": is_local,
        }
    )


@bp.route("/api/roundtable/convene", methods=["POST"])
# rate limiting handled at app level
@require_auth
def api_roundtable_convene():
    """
    Chairman-led Round Table session.

    Phase 1 — Chairman routing: determine which advisors to call and how to frame
              the question for each.
    Phase 2 — Parallel fan-out: each advisor responds with their Chairman framing.
    Phase 3 — Chairman synthesis: Chairman reads all responses and delivers a
              unified recommendation.
    """
    import concurrent.futures
    import json
    import re

    data = request.json or {}
    message = (data.get("message") or "").strip()
    if not message:
        return jsonify({"error": "No message provided"}), 400

    from familiar.jobs import get_job_class, list_job_classes
    from familiar.jobs.chairman import build_team_manifest

    agent = get_agent()
    provider_name = getattr(getattr(agent, "provider", None), "name", "") or ""
    is_local = "ollama" in provider_name.lower()

    # ── Phase 1: Chairman routing ────────────────────────────────────────────
    team_manifest = build_team_manifest()
    routing_message = f"{team_manifest}\n\n**User's question:** {message}"

    routing_result = agent.chat_with_results(
        routing_message,
        user_id="roundtable-chairman",
        channel="web",
        include_history=False,
        job_class_key="chairman",
    )

    # Parse Chairman's routing JSON
    raw = routing_result.text or ""
    match = re.search(r"```json\s*(.*?)\s*```", raw, re.DOTALL)
    routing = {}
    if match:
        try:
            routing = json.loads(match.group(1))
        except json.JSONDecodeError:
            pass

    # If Chairman answered directly, return immediately
    if routing.get("action") == "direct":
        return jsonify(
            {
                "mode": "direct",
                "chairman_intro": routing.get("response", raw),
                "responses": [],
                "chairman_synthesis": None,
                "timestamp": datetime.now().isoformat(),
            }
        )

    # Extract seats and per-seat framings
    valid_keys = {jc.key for jc in list_job_classes()}
    seats = [s for s in (routing.get("seats") or []) if s in valid_keys]
    framings = routing.get("framings") or {}
    chairman_intro = routing.get("intro") or raw

    if not seats:
        # Chairman gave a malformed response — fall back to direct
        return jsonify(
            {
                "mode": "direct",
                "chairman_intro": raw,
                "responses": [],
                "chairman_synthesis": None,
                "timestamp": datetime.now().isoformat(),
            }
        )

    # ── Phase 2: Parallel fan-out ────────────────────────────────────────────
    def _ask_seat(job_class_key: str) -> dict:
        jc = get_job_class(job_class_key)
        framing = framings.get(job_class_key, "")
        framed_message = f"{framing}\n\n{message}" if framing else message
        try:
            result = agent.chat_with_results(
                framed_message,
                user_id=f"roundtable-{job_class_key}",
                channel="web",
                include_history=False,
                job_class_key=job_class_key,
            )
            return {
                "job_class_key": job_class_key,
                "name": jc.name if jc else job_class_key,
                "icon": jc.icon if jc else "fa-user",
                "response": result.text,
                "error": None,
            }
        except Exception as e:
            logger.error("Convene seat %s failed: %s", job_class_key, e)
            return {
                "job_class_key": job_class_key,
                "name": jc.name if jc else job_class_key,
                "icon": jc.icon if jc else "fa-user",
                "response": None,
                "error": safe_error(e),
            }

    effective_workers = 3 if is_local else len(seats)
    with concurrent.futures.ThreadPoolExecutor(max_workers=effective_workers) as pool:
        futures = {pool.submit(_ask_seat, key): key for key in seats}
        responses = [f.result() for f in concurrent.futures.as_completed(futures)]

    # Preserve seat order
    order = {key: i for i, key in enumerate(seats)}
    responses.sort(key=lambda r: order.get(r["job_class_key"], 99))

    # ── Phase 3: Chairman synthesis ──────────────────────────────────────────
    synthesis_parts = [
        team_manifest,
        f"\n**Original question:** {message}\n",
        "**Advisor responses:**\n",
    ]
    for resp in responses:
        if resp.get("response"):
            synthesis_parts.append(f"**{resp['name']}:** {resp['response']}\n")

    synthesis_parts.append(
        "\nYou have heard from the advisors. Now provide your synthesis: "
        "what they agreed on, where they diverged, and your clear recommendation. "
        "Write in plain prose, 2-4 sentences, no JSON."
    )

    synthesis_result = agent.chat_with_results(
        "\n".join(synthesis_parts),
        user_id="roundtable-chairman-synthesis",
        channel="web",
        include_history=False,
        job_class_key="chairman",
    )

    return jsonify(
        {
            "mode": "convene",
            "chairman_intro": chairman_intro,
            "responses": responses,
            "chairman_synthesis": synthesis_result.text,
            "timestamp": datetime.now().isoformat(),
            "provider": provider_name,
            "is_local": is_local,
        }
    )


# ── Round Table Groups ────────────────────────────────────────────────────────

_RT_GROUPS_FILE = DATA_DIR / "roundtable_groups.json"


def _load_rt_groups() -> list:
    try:
        if _RT_GROUPS_FILE.exists():
            return json.loads(_RT_GROUPS_FILE.read_text()) or []
    except Exception:
        pass
    return []


def _save_rt_groups(groups: list) -> None:
    _RT_GROUPS_FILE.parent.mkdir(parents=True, exist_ok=True)
    _RT_GROUPS_FILE.write_text(json.dumps(groups, indent=2))


@bp.route("/api/roundtable/groups", methods=["GET"])
@require_auth
def api_roundtable_groups_list():
    """List all saved advisor groups."""
    return jsonify({"groups": _load_rt_groups()})


@bp.route("/api/roundtable/groups", methods=["POST"])
@require_auth
def api_roundtable_groups_save():
    """Save or update a named advisor group."""
    data = request.json or {}
    name = (data.get("name") or "").strip()
    seats = data.get("seats") or []

    if not name:
        return jsonify({"error": "Group name is required"}), 400
    if not seats:
        return jsonify({"error": "At least one seat is required"}), 400

    from familiar.jobs import list_job_classes

    valid_keys = {jc.key for jc in list_job_classes()}
    seats = [s for s in seats if s in valid_keys]
    if not seats:
        return jsonify({"error": "No valid seats provided"}), 400

    groups = _load_rt_groups()
    # Update if name exists, otherwise append
    for g in groups:
        if g["name"] == name:
            g["seats"] = seats
            _save_rt_groups(groups)
            return jsonify({"saved": True, "group": g})

    group = {"name": name, "seats": seats}
    groups.append(group)
    _save_rt_groups(groups)
    return jsonify({"saved": True, "group": group})


@bp.route("/api/roundtable/groups/<name>", methods=["DELETE"])
@require_auth
def api_roundtable_groups_delete(name):
    """Delete a named advisor group."""
    groups = [g for g in _load_rt_groups() if g["name"] != name]
    _save_rt_groups(groups)
    return jsonify({"deleted": True})


@bp.route("/api/voice/stt", methods=["POST"])
# rate limiting handled at app level
@require_auth
def api_voice_stt():
    """Transcribe uploaded audio and send transcript to agent chat."""
    import tempfile as _tempfile

    if "audio" not in request.files:
        return jsonify({"error": "No audio file provided"}), 400

    audio_file = request.files["audio"]
    tmp_path = None
    try:
        fd, tmp_path = _tempfile.mkstemp(suffix=".webm")
        os.close(fd)
        audio_file.save(tmp_path)

        from familiar.skills.voice.skill import _transcribe_with_whisper

        transcript = _transcribe_with_whisper(tmp_path)
        text = transcript.text.strip()
        if not text:
            return jsonify({"error": "No speech detected"}), 400

        agent = get_agent()
        result = agent.chat_with_results(text, user_id="dashboard", channel="web")

        return jsonify(
            {
                "transcript": text,
                "response": result.text,
                "timestamp": datetime.now().isoformat(),
            }
        )
    except Exception:
        logger.exception("Voice STT error")
        return jsonify({"error": "Internal server error"}), 500
    finally:
        if tmp_path:
            try:
                os.unlink(tmp_path)
            except OSError as e:
                logger.debug("I/O error suppressed: %s", e)


@bp.route("/api/voice/tts", methods=["POST"])
# rate limiting handled at app level
@require_auth
def api_voice_tts():
    """Generate speech audio from text."""
    import tempfile as _tempfile

    data = request.json or {}
    text = data.get("text", "").strip()
    if not text:
        return jsonify({"error": "No text provided"}), 400

    # Try Piper first (local, high-quality WAV)
    try:
        from familiar.skills.voice.document_reader import speak_with_piper

        fd, tmp_path = _tempfile.mkstemp(suffix=".wav")
        os.close(fd)
        success, result = speak_with_piper(text, output_path=tmp_path, play=False)
        if success:
            return send_file(tmp_path, mimetype="audio/wav")
    except Exception:
        logger.debug("Piper TTS unavailable, trying fallbacks", exc_info=True)

    # Fallback: gTTS (MP3)
    try:
        from gtts import gTTS

        fd, tmp_path = _tempfile.mkstemp(suffix=".mp3")
        os.close(fd)
        gTTS(text=text, lang="en").save(tmp_path)
        return send_file(tmp_path, mimetype="audio/mpeg")
    except Exception:
        logger.debug("gTTS unavailable, trying edge-tts", exc_info=True)

    # Fallback: edge-tts (MP3)
    try:
        import asyncio

        import edge_tts

        fd, tmp_path = _tempfile.mkstemp(suffix=".mp3")
        os.close(fd)
        asyncio.run(edge_tts.Communicate(text, "en-US-AriaNeural").save(tmp_path))
        return send_file(tmp_path, mimetype="audio/mpeg")
    except Exception:
        logger.debug("edge-tts unavailable", exc_info=True)

    return jsonify({"error": "No TTS engine available"}), 503


@bp.route("/api/terminal/stream", methods=["POST"])
# rate limiting handled at app level
@require_auth
def api_terminal_stream():
    """Stream a chat response via SSE for the terminal panel."""
    from familiar.core.providers import StreamEventType

    data = request.json or {}
    message = data.get("message", "")

    if not message:
        return jsonify({"error": "No message provided"}), 400

    # Route /connect commands to the wizard (non-streaming)
    stripped = message.strip()
    if stripped.lower().startswith("/connect"):
        args = stripped.split()[1:]
        host = get_wizard_host()
        messages = host.start_wizard(args)
        text = "\n".join(m.get("text", str(m)) for m in messages)

        def _wizard_gen():
            yield f"data: {json.dumps({'type': 'text', 'content': text})}\n\n"
            yield f"data: {json.dumps({'type': 'done'})}\n\n"

        resp = Response(_wizard_gen(), mimetype="text/event-stream")
        resp.headers["Cache-Control"] = "no-cache"
        resp.headers["X-Accel-Buffering"] = "no"
        return resp

    # Route /start to tarot onboarding (non-streaming)
    if stripped.lower().startswith("/start"):
        host = get_wizard_host()
        messages = host.start()
        text = "\n".join(m.get("text", str(m)) for m in messages)

        def _start_gen():
            yield f"data: {json.dumps({'type': 'text', 'content': text})}\n\n"
            yield f"data: {json.dumps({'type': 'done'})}\n\n"

        resp = Response(_start_gen(), mimetype="text/event-stream")
        resp.headers["Cache-Control"] = "no-cache"
        resp.headers["X-Accel-Buffering"] = "no"
        return resp

    # If wizard is active, route text input through it
    host = get_wizard_host()
    if host.is_active():
        messages = host.send_text(message)
        text = "\n".join(m.get("text", str(m)) for m in messages)

        def _wizard_input_gen():
            yield f"data: {json.dumps({'type': 'text', 'content': text})}\n\n"
            yield f"data: {json.dumps({'type': 'done'})}\n\n"

        resp = Response(_wizard_input_gen(), mimetype="text/event-stream")
        resp.headers["Cache-Control"] = "no-cache"
        resp.headers["X-Accel-Buffering"] = "no"
        return resp

    agent = get_agent()
    msg_queue = queue.Queue()

    def _run_stream():
        try:
            for event in agent.chat_stream(message, user_id="terminal", channel="terminal"):
                if event.type == StreamEventType.TEXT:
                    msg_queue.put({"type": "text", "content": event.content})
                elif event.type == StreamEventType.TOOL_START:
                    msg_queue.put({"type": "tool_start", "tool_name": event.tool_name or ""})
                elif event.type == StreamEventType.TOOL_END:
                    msg_queue.put({"type": "tool_end", "tool_name": event.tool_name or ""})
                elif event.type == StreamEventType.ERROR:
                    msg_queue.put({"type": "error", "error": event.error or "Unknown error"})
                elif event.type == StreamEventType.DONE:
                    pass  # Handled after loop
        except Exception as e:
            logger.exception("Terminal stream error")
            msg_queue.put({"type": "error", "error": safe_error(e)})
        finally:
            msg_queue.put(None)  # Sentinel

    t = threading.Thread(target=_run_stream, daemon=True)
    t.start()

    def generate():
        while True:
            try:
                item = msg_queue.get(timeout=120)
            except queue.Empty:
                yield f"data: {json.dumps({'type': 'error', 'error': 'Timeout'})}\n\n"
                yield f"data: {json.dumps({'type': 'done'})}\n\n"
                break
            if item is None:
                yield f"data: {json.dumps({'type': 'done'})}\n\n"
                break
            yield f"data: {json.dumps(item)}\n\n"

    resp = Response(generate(), mimetype="text/event-stream")
    resp.headers["Cache-Control"] = "no-cache"
    resp.headers["X-Accel-Buffering"] = "no"
    return resp

