import asyncio
import json
import logging
import mimetypes
import re
import threading
from pathlib import Path
from typing import Any

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

from config import paths
from vibe.runtime import get_ui_dist_path, get_working_dir

logger = logging.getLogger(__name__)

app = Flask(__name__, static_folder=None)

# Global server instance for graceful shutdown on reload
_server = None

# Disable Flask's default logging
log = logging.getLogger("werkzeug")
log.setLevel(logging.WARNING)


def _run_async(coro, timeout: float = 10.0) -> dict:
    """Run async coroutine in a separate thread with timeout."""
    result: dict[str, Any] = {}
    error: str | None = None
    lock = threading.Event()

    def _runner():
        nonlocal result, error
        try:
            result = asyncio.run(coro)
        except Exception as exc:
            error = str(exc)
        finally:
            lock.set()

    threading.Thread(target=_runner, daemon=True).start()
    lock.wait(timeout=timeout)
    if not lock.is_set():
        return {"ok": False, "error": "Request timed out"}
    if error:
        return {"ok": False, "error": error}
    return result


# =============================================================================
# Error Handler
# =============================================================================


@app.errorhandler(Exception)
def handle_exception(e):
    """Global exception handler - ensures all errors return JSON."""
    from werkzeug.exceptions import HTTPException

    # Preserve HTTP status codes for client errors (4xx)
    if isinstance(e, HTTPException):
        return jsonify({"error": e.description}), e.code

    # Log and return 500 for unexpected server errors
    logger.exception("Unhandled exception in UI server")
    return jsonify({"error": str(e)}), 500


# =============================================================================
# GET Endpoints
# =============================================================================


@app.route("/health")
def health():
    return jsonify({"status": "ok"})


@app.route("/status")
def status():
    from vibe import runtime

    payload = runtime.read_status()
    pid_path = paths.get_runtime_pid_path()
    pid = pid_path.read_text(encoding="utf-8").strip() if pid_path.exists() else None
    running = bool(pid and pid.isdigit() and runtime.pid_alive(int(pid)))
    payload["running"] = running
    payload["pid"] = int(pid) if pid and pid.isdigit() else None
    if running:
        payload["service_pid"] = payload.get("service_pid") or payload["pid"]
    elif payload.get("state") == "running":
        runtime.write_status("stopped", "process not running", None, payload.get("ui_pid"))
        payload = runtime.read_status()
        payload["running"] = False
        payload["pid"] = None
    return jsonify(payload)


@app.route("/doctor", methods=["GET"])
def doctor_get():
    payload = {}
    doctor_path = paths.get_runtime_doctor_path()
    if doctor_path.exists():
        payload = json.loads(doctor_path.read_text(encoding="utf-8"))
    return jsonify(payload)


@app.route("/config", methods=["GET"])
def config_get():
    from vibe import api

    config = api.load_config()
    return jsonify(api.config_to_payload(config))


@app.route("/settings", methods=["GET"])
def settings_get():
    from vibe import api

    return jsonify(api.get_settings())


@app.route("/cli/detect")
def cli_detect():
    from vibe import api

    binary = request.args.get("binary", "")
    return jsonify(api.detect_cli(binary))


@app.route("/slack/manifest")
def slack_manifest():
    from vibe import api

    return jsonify(api.get_slack_manifest())


@app.route("/version")
def version():
    from vibe import api

    return jsonify(api.get_version_info())


# =============================================================================
# POST Endpoints
# =============================================================================


@app.route("/control", methods=["POST"])
def control():
    from vibe import runtime
    from vibe.cli import _stop_opencode_server

    payload = request.json or {}
    action = payload.get("action")
    status = runtime.read_status()
    status["last_action"] = action
    if action == "start":
        runtime.ensure_config()
        runtime.stop_service()
        service_pid = runtime.start_service()
        runtime.write_status("running", "started", service_pid, status.get("ui_pid"))
    elif action == "stop":
        runtime.stop_service()
        _stop_opencode_server()
        runtime.write_status("stopped")
    elif action == "restart":
        import time

        runtime.stop_service()
        _stop_opencode_server()
        time.sleep(3)
        runtime.ensure_config()
        service_pid = runtime.start_service()
        runtime.write_status("running", "restarted", service_pid, status.get("ui_pid"))
    return jsonify({"ok": True, "action": action, "status": runtime.read_status()})


@app.route("/config", methods=["POST"])
def config_post():
    from vibe import api

    payload = request.json or {}
    config = api.save_config(payload)
    api.init_sessions()
    return jsonify(api.config_to_payload(config))


@app.route("/ui/reload", methods=["POST"])
def ui_reload():
    from vibe import runtime

    payload = request.json or {}
    host = payload.get("host")
    port = payload.get("port")
    if not host or not port:
        return jsonify({"error": "host_and_port_required"}), 400
    try:
        port = int(port)
    except (TypeError, ValueError):
        return jsonify({"error": "invalid_port"}), 400

    status = runtime.read_status()

    def _restart():
        global _server
        import subprocess
        import sys
        import time
        from config import paths as config_paths

        working_dir = get_working_dir()
        command = f"from vibe.ui_server import run_ui_server; run_ui_server('{host}', {port})"
        stdout_path = config_paths.get_runtime_dir() / "ui_stdout.log"
        stderr_path = config_paths.get_runtime_dir() / "ui_stderr.log"
        stdout = stdout_path.open("ab")
        stderr = stderr_path.open("ab")
        process = subprocess.Popen(
            [sys.executable, "-c", command],
            stdout=stdout,
            stderr=stderr,
            start_new_session=True,
            cwd=str(working_dir),
            close_fds=True,
        )
        stdout.close()
        stderr.close()
        config_paths.get_runtime_ui_pid_path().write_text(str(process.pid), encoding="utf-8")
        runtime.write_status(
            status.get("state", "running"),
            status.get("detail"),
            status.get("service_pid"),
            process.pid,
        )
        time.sleep(0.2)
        # Shutdown the old server to release the port
        if _server:
            _server.shutdown()

    # Schedule restart after response is sent
    threading.Thread(target=_restart).start()
    return jsonify({"ok": True, "host": host, "port": port})


@app.route("/settings", methods=["POST"])
def settings_post():
    from vibe import api

    payload = request.json or {}
    return jsonify(api.save_settings(payload))


@app.route("/slack/auth_test", methods=["POST"])
def slack_auth_test():
    from vibe import api

    payload = request.json or {}
    result = api.slack_auth_test(payload.get("bot_token", ""))
    return jsonify(result)


@app.route("/slack/channels", methods=["POST"])
def slack_channels():
    from vibe import api

    payload = request.json or {}
    return jsonify(
        api.list_channels(
            payload.get("bot_token", ""),
            browse_all=payload.get("browse_all", False),
        )
    )


@app.route("/discord/auth_test", methods=["POST"])
def discord_auth_test():
    from vibe import api

    payload = request.json or {}
    result = api.discord_auth_test(payload.get("bot_token", ""))
    return jsonify(result)


@app.route("/discord/guilds", methods=["POST"])
def discord_guilds():
    from vibe import api

    payload = request.json or {}
    return jsonify(api.discord_list_guilds(payload.get("bot_token", "")))


@app.route("/discord/channels", methods=["POST"])
def discord_channels():
    from vibe import api

    payload = request.json or {}
    return jsonify(api.discord_list_channels(payload.get("bot_token", ""), payload.get("guild_id", "")))


@app.route("/lark/auth_test", methods=["POST"])
def lark_auth_test():
    from vibe import api

    payload = request.json or {}
    result = api.lark_auth_test(
        payload.get("app_id", ""), payload.get("app_secret", ""), payload.get("domain", "feishu")
    )
    return jsonify(result)


@app.route("/lark/chats", methods=["POST"])
def lark_chats():
    from vibe import api

    payload = request.json or {}
    return jsonify(
        api.lark_list_chats(payload.get("app_id", ""), payload.get("app_secret", ""), payload.get("domain", "feishu"))
    )


@app.route("/lark/temp_ws/start", methods=["POST"])
def lark_temp_ws_start():
    from vibe import api

    payload = request.json or {}
    return jsonify(
        api.lark_temp_ws_start(
            payload.get("app_id", ""), payload.get("app_secret", ""), payload.get("domain", "feishu")
        )
    )


@app.route("/lark/temp_ws/stop", methods=["POST"])
def lark_temp_ws_stop():
    from vibe import api

    return jsonify(api.lark_temp_ws_stop())


@app.route("/doctor", methods=["POST"])
def doctor_post():
    from vibe.cli import _doctor

    result = _doctor()
    return jsonify(result)


@app.route("/logs", methods=["POST"])
def logs():
    payload = request.json or {}
    lines = payload.get("lines", 500)
    log_path = paths.get_logs_dir() / "vibe_remote.log"
    if not log_path.exists():
        return jsonify({"logs": [], "total": 0})
    try:
        with open(log_path, "r", encoding="utf-8", errors="replace") as f:
            all_lines = f.readlines()
            recent_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
        log_pattern = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+-\s+([\w.]+)\s+-\s+(\w+)\s+-\s+(.*)$")
        logs_list = []
        for line in recent_lines:
            line = line.rstrip("\n")
            match = log_pattern.match(line)
            if match:
                logs_list.append(
                    {
                        "timestamp": match.group(1),
                        "logger": match.group(2),
                        "level": match.group(3),
                        "message": match.group(4),
                    }
                )
            elif logs_list and line:
                logs_list[-1]["message"] += "\n" + line
        return jsonify({"logs": logs_list, "total": len(all_lines)})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/opencode/options", methods=["POST"])
def opencode_options():
    from vibe import api

    payload = request.json or {}
    result = _run_async(
        api.opencode_options_async(payload.get("cwd", ".")),
        timeout=12.0,
    )
    return jsonify(result)


@app.route("/upgrade", methods=["POST"])
def upgrade():
    from vibe import api

    result = api.do_upgrade()
    return jsonify(result)


@app.route("/opencode/setup-permission", methods=["POST"])
def opencode_setup_permission():
    from vibe import api

    return jsonify(api.setup_opencode_permission())


@app.route("/claude/agents", methods=["GET"])
def claude_agents():
    from vibe import api

    cwd = request.args.get("cwd")
    if cwd:
        # Expand ~ first, then check if absolute
        expanded = Path(cwd).expanduser()
        if not expanded.is_absolute():
            cwd = str(get_working_dir() / cwd)
        else:
            cwd = str(expanded)

    return jsonify(api.claude_agents(cwd))


@app.route("/claude/models", methods=["GET"])
def claude_models():
    from vibe import api

    return jsonify(api.claude_models())


@app.route("/codex/models", methods=["GET"])
def codex_models():
    from vibe import api

    return jsonify(api.codex_models())


@app.route("/agent/<name>/install", methods=["POST"])
def agent_install(name):
    """Install an agent CLI tool (opencode, claude, codex).

    Security: Only allow requests from localhost to prevent CSRF/RCE attacks.
    """
    from urllib.parse import urlparse

    # Security: Only allow local requests
    remote_addr = request.remote_addr or ""
    if remote_addr not in {"127.0.0.1", "::1"}:
        return jsonify({"ok": False, "message": "Forbidden: local access only"}), 403

    # Security: Require Origin or Referer header for CSRF protection
    origin = request.headers.get("Origin")
    referer = request.headers.get("Referer")
    if not origin and not referer:
        return jsonify({"ok": False, "message": "Forbidden: missing origin header"}), 403

    # Validate Origin/Referer is from localhost
    check_header = origin or referer
    parsed = urlparse(check_header)
    if parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
        return jsonify({"ok": False, "message": "Forbidden: invalid origin"}), 403

    # Security: Allowlist validation
    allowed_agents = {"opencode", "claude", "codex"}
    if name not in allowed_agents:
        return jsonify({"ok": False, "message": f"Unknown agent: {name}"}), 400

    from vibe import api

    result = api.install_agent(name)
    return jsonify(result)


@app.route("/browse", methods=["POST"])
def browse_directory():
    """List sub-directories of a given path for the directory picker UI."""
    from vibe import api

    payload = request.json or {}
    return jsonify(
        api.browse_directory(
            payload.get("path", "~"),
            show_hidden=bool(payload.get("show_hidden", False)),
        )
    )


# =============================================================================
# User & Bind Code Endpoints
# =============================================================================


@app.route("/api/users", methods=["GET"])
def users_get():
    from vibe import api

    return jsonify(api.get_users())


@app.route("/api/users", methods=["POST"])
def users_post():
    from vibe import api

    payload = request.json or {}
    return jsonify(api.save_users(payload))


@app.route("/api/users/<user_id>/admin", methods=["POST"])
def users_toggle_admin(user_id):
    from vibe import api

    payload = request.json or {}
    return jsonify(api.toggle_admin(user_id, payload.get("is_admin", False)))


@app.route("/api/users/<user_id>", methods=["DELETE"])
def users_delete(user_id):
    from vibe import api

    result = api.remove_user(user_id)
    if not result.get("ok"):
        return jsonify(result), 400
    return jsonify(result)


@app.route("/api/bind-codes", methods=["GET"])
def bind_codes_get():
    from vibe import api

    return jsonify(api.get_bind_codes())


@app.route("/api/bind-codes", methods=["POST"])
def bind_codes_post():
    from vibe import api

    payload = request.json or {}
    result = api.create_bind_code(
        code_type=payload.get("type", "one_time"),
        expires_at=payload.get("expires_at"),
    )
    if not result.get("ok"):
        return jsonify(result), 400
    return jsonify(result)


@app.route("/api/bind-codes/<code>", methods=["DELETE"])
def bind_codes_delete(code):
    from vibe import api

    result = api.delete_bind_code(code)
    if not result.get("ok"):
        return jsonify(result), 404
    return jsonify(result)


@app.route("/api/setup/first-bind-code", methods=["GET"])
def setup_first_bind_code():
    from vibe import api

    return jsonify(api.get_first_bind_code())


# =============================================================================
# E2E Test-Only Endpoints (gated by E2E_TEST_MODE env var)
# =============================================================================

import os as _os

if _os.environ.get("E2E_TEST_MODE", "").lower() in ("true", "1", "yes"):
    logger.warning(
        "E2E_TEST_MODE is ENABLED. /e2e/* endpoints are registered. "
        "These endpoints allow unauthenticated config mutation. "
        "Do NOT enable in production."
    )

    @app.route("/e2e/simulate-interaction", methods=["POST"])
    def e2e_simulate_interaction():
        """Simulate a modal submission via the settings/config APIs.

        Only registered when E2E_TEST_MODE=true.

        NOTE: Button clicks (cmd_settings, cmd_routing, etc.) should be
        triggered by sending text commands via Bot B (/settings, /routing, etc.).
        This endpoint handles modal *submissions* that Bot B cannot trigger
        because they require UI interaction (select dropdowns, click Save).

        The UI server and the service process are separate processes, so this
        endpoint operates through the SettingsStore (shared JSON file) rather
        than invoking the controller directly.

        JSON fields:
            action (str):       "settings_submit" | "routing_submit" | "cwd_submit"
            modal_values (dict): the values to submit
        """
        payload = request.json or {}
        action = payload.get("action", "")
        modal_values = payload.get("modal_values", {})

        if not action:
            return jsonify({"ok": False, "error": "action required"}), 400

        try:
            if action == "settings_submit":
                # Merge settings into existing store (not wholesale replace)
                from config.v2_settings import SettingsStore, ChannelSettings, normalize_show_message_types
                from vibe.api import _parse_routing
                from vibe.api import _current_platform

                settings_key = modal_values.get("settings_key") or modal_values.get("channel_id")
                if not settings_key:
                    return jsonify({"ok": False, "error": "settings_key or channel_id required in modal_values"}), 400

                store = SettingsStore.get_instance()
                store.maybe_reload()
                platform = _current_platform()
                ch = store.find_channel(settings_key, platform=platform)
                if not ch:
                    ch = ChannelSettings(enabled=True)
                    store.update_channel(settings_key, ch, platform=platform)

                if "show_message_types" in modal_values:
                    ch.show_message_types = normalize_show_message_types(modal_values["show_message_types"])
                if "custom_cwd" in modal_values:
                    ch.custom_cwd = modal_values["custom_cwd"]
                if "require_mention" in modal_values:
                    ch.require_mention = modal_values["require_mention"]
                if "routing" in modal_values:
                    ch.routing = _parse_routing(modal_values["routing"])

                store.save()
                return jsonify({"ok": True, "action": action})

            elif action == "routing_submit":
                # Write routing config for a specific channel/user
                channel_id = modal_values.get("channel_id") or modal_values.get("settings_key")
                if not channel_id:
                    return jsonify({"ok": False, "error": "channel_id required in modal_values"}), 400

                store = SettingsStore.get_instance()
                store.maybe_reload()
                from vibe.api import _current_platform

                platform = _current_platform()
                ch = store.find_channel(channel_id, platform=platform)
                if ch:
                    from config.v2_settings import RoutingSettings

                    ch.routing = RoutingSettings(
                        agent_backend=modal_values.get("backend", "opencode"),
                        opencode_agent=modal_values.get("opencode_agent"),
                        opencode_model=modal_values.get("opencode_model"),
                        opencode_reasoning_effort=modal_values.get("opencode_reasoning_effort"),
                        claude_agent=modal_values.get("claude_agent"),
                        claude_model=modal_values.get("claude_model"),
                        claude_reasoning_effort=modal_values.get("claude_reasoning_effort"),
                        codex_model=modal_values.get("codex_model"),
                        codex_reasoning_effort=modal_values.get("codex_reasoning_effort"),
                    )
                    store.save()
                    return jsonify({"ok": True, "action": action})
                else:
                    return jsonify({"ok": False, "error": f"channel {channel_id} not found in settings"}), 404

            elif action == "cwd_submit":
                # Merge CWD into existing config (load → modify → save)
                from vibe import api as vibe_api

                current = vibe_api.config_to_payload(vibe_api.load_config())
                current.setdefault("runtime", {})
                current["runtime"]["default_cwd"] = modal_values.get("cwd", "/tmp")
                result = vibe_api.save_config(current)
                return jsonify({"ok": True, "action": action})

            elif action == "routing_submit":
                # Write routing config for a specific channel/user
                channel_id = modal_values.get("channel_id") or modal_values.get("settings_key")
                if not channel_id:
                    return jsonify({"ok": False, "error": "channel_id required in modal_values"}), 400

                store = SettingsStore.get_instance()
                store.maybe_reload()
                from vibe.api import _current_platform

                platform = _current_platform()
                ch = store.find_channel(channel_id, platform=platform)
                if ch:
                    from config.v2_settings import RoutingSettings

                    ch.routing = RoutingSettings(
                        agent_backend=modal_values.get("backend", "opencode"),
                        opencode_agent=modal_values.get("opencode_agent"),
                        opencode_model=modal_values.get("opencode_model"),
                        opencode_reasoning_effort=modal_values.get("opencode_reasoning_effort"),
                        claude_agent=modal_values.get("claude_agent"),
                        claude_model=modal_values.get("claude_model"),
                        claude_reasoning_effort=modal_values.get("claude_reasoning_effort"),
                        codex_model=modal_values.get("codex_model"),
                        codex_reasoning_effort=modal_values.get("codex_reasoning_effort"),
                    )
                    store.save()
                    return jsonify({"ok": True, "action": action})
                else:
                    return jsonify({"ok": False, "error": f"channel {channel_id} not found in settings"}), 404

            elif action == "cwd_submit":
                # Update CWD via config API
                new_cwd = modal_values.get("cwd", "/tmp")
                result = vibe_api.save_config({"runtime": {"default_cwd": new_cwd}})
                return jsonify({"ok": True, "action": action, "result": result})

            else:
                return jsonify({"ok": False, "error": f"unknown action: {action}"}), 400

        except Exception as e:
            logger.exception("E2E simulate-interaction failed")
            return jsonify({"ok": False, "error": str(e)}), 500

    @app.route("/e2e/ping", methods=["GET"])
    def e2e_ping():
        """Simple check that E2E test mode is active."""
        return jsonify({"ok": True, "e2e_test_mode": True})

    logger.info("E2E_TEST_MODE enabled: /e2e/* endpoints registered")


# =============================================================================
# Static Files (SPA)
# =============================================================================


@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve_static(path):
    """Serve static files from ui/dist, with SPA fallback to index.html."""
    ui_dist = get_ui_dist_path()

    if path.startswith("assets/"):
        file_path = ui_dist / path
    elif not path or path == "index.html":
        file_path = ui_dist / "index.html"
    else:
        file_path = ui_dist / path

    resolved_path = file_path.resolve()

    # Security check: ensure path is within ui_dist
    if ui_dist.resolve() not in resolved_path.parents and resolved_path != ui_dist.resolve():
        return jsonify({"error": "not_found"}), 404

    if resolved_path.exists() and resolved_path.is_file():
        mime_type, _ = mimetypes.guess_type(str(resolved_path))
        return send_file(resolved_path, mimetype=mime_type or "application/octet-stream")

    # SPA fallback: serve index.html for routes without file extension
    if "." not in path:
        index_path = ui_dist / "index.html"
        if index_path.exists():
            return send_file(index_path, mimetype="text/html")

    return jsonify({"error": "not_found"}), 404


# =============================================================================
# Server Entry Point
# =============================================================================


def run_ui_server(host: str, port: int) -> None:
    """Start the Flask UI server."""
    global _server
    import time
    from werkzeug.serving import make_server

    paths.ensure_data_dirs()
    print(f"UI Server running at http://{host}:{port}")

    # Use make_server directly for better compatibility with subprocess/multiprocessing
    # app.run() has issues when launched in child processes
    # Retry binding in case of TIME_WAIT or port still held by old server during reload
    for attempt in range(10):
        try:
            _server = make_server(host, port, app, threaded=True)
            _server.serve_forever()
            break
        except OSError as e:
            if e.errno == 48 and attempt < 9:  # Address already in use (macOS)
                print(f"Port {port} in use, retrying in 1s... (attempt {attempt + 1})")
                time.sleep(1)
            elif e.errno == 98 and attempt < 9:  # Address already in use (Linux)
                print(f"Port {port} in use, retrying in 1s... (attempt {attempt + 1})")
                time.sleep(1)
            else:
                raise
