//! `/api/chat` — 浏览器 ↔ LLM gateway 中转 + `create_issue` 工具调用。
//!
//! 单步 tool calling：拿到用户消息 → 调一次 LLM with tools → 如果 LLM 决定调
//! `create_issue` 就同步建 issue，把结果（issue 编号 + URL）当作 reply 返回给前端。
//! 不做 agent loop，不递归把工具结果喂回 LLM（重新调一次是浪费，issue 已经建好，
//! 直接告诉用户就行）。

use std::sync::Arc;

use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Clone)]
pub struct Config {
    pub gateway: String,    // http://3.135.65.204:8848/v1
    pub llm_token: String,  // Bearer for LLM gateway
    pub llm_model: String,  // gemma-4-31b-it
    pub gitea_url: String,  // https://famzheng.me/gitea
    pub gitea_token: String,
    pub issue_repo: String, // fam/cube
}

impl Config {
    pub fn from_env() -> Self {
        Self {
            gateway: env_or("LLM_GATEWAY", "http://3.135.65.204:8848/v1"),
            llm_token: env_or("LLM_API_TOKEN", ""),
            llm_model: env_or("LLM_MODEL", "gemma-4-31b-it"),
            gitea_url: env_or("GITEA_URL", "https://famzheng.me/gitea"),
            gitea_token: env_or("GITEA_TOKEN", ""),
            issue_repo: env_or("ISSUE_REPO", "fam/cube"),
        }
    }
}

fn env_or(key: &str, fallback: &str) -> String {
    std::env::var(key).unwrap_or_else(|_| fallback.to_string())
}

#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ChatMessage {
    pub role: String, // "user" | "assistant" | "system" | "tool"
    pub content: String,
}

#[derive(Deserialize)]
pub struct ChatRequest {
    pub messages: Vec<ChatMessage>,
}

#[derive(Serialize)]
pub struct CreatedIssue {
    pub number: u64,
    pub url: String,
    pub title: String,
}

#[derive(Serialize)]
pub struct ChatResponse {
    pub reply: String,
    pub created_issue: Option<CreatedIssue>,
}

pub enum ChatError {
    UpstreamLlm(String),
    UpstreamGitea(String),
    Empty,
}

impl IntoResponse for ChatError {
    fn into_response(self) -> axum::response::Response {
        match self {
            Self::UpstreamLlm(msg) => {
                tracing::error!(%msg, "llm upstream failed");
                (StatusCode::BAD_GATEWAY, format!("LLM upstream error: {msg}")).into_response()
            }
            Self::UpstreamGitea(msg) => {
                tracing::error!(%msg, "gitea upstream failed");
                (StatusCode::BAD_GATEWAY, format!("Gitea upstream error: {msg}")).into_response()
            }
            Self::Empty => (StatusCode::BAD_REQUEST, "messages 不能为空").into_response(),
        }
    }
}

/// apps.json 是 SSOT — 前端 apps.ts 也 import 同一份。
const APPS_JSON: &str = include_str!("../frontend/src/apps.json");

#[derive(Deserialize)]
struct AppInfo {
    slug: String,
    description: String,
    url: String,
    status: String,
}

/// 把 apps.json 渲染成 markdown bullet list 注进 system prompt。
/// 解析失败兜底成 raw JSON（让 LLM 自己 grok），不让 chatbot 因为 ssot 坏掉而 500。
pub fn render_apps_list() -> String {
    match serde_json::from_str::<Vec<AppInfo>>(APPS_JSON) {
        Ok(apps) => apps
            .iter()
            .map(|a| {
                format!(
                    "- **{slug}** ({status}) — {desc} <{url}>",
                    slug = a.slug,
                    status = a.status,
                    desc = a.description,
                    url = a.url,
                )
            })
            .collect::<Vec<_>>()
            .join("\n"),
        Err(_) => APPS_JSON.trim().to_string(),
    }
}

pub fn system_prompt(repo: &str) -> String {
    format!(
        "你是 cube 平台（cube.famzheng.me，Fam 的小 app 平台）入口页上的聊天助手。\n\
         \n\
         当前 cube 上线的 app 列表（status: live=可用 / pending=迁移中 / tbd=待定）：\n\
         {apps}\n\
         \n\
         你可以做两件事：\n\
         1. 回答用户关于上面这些 app 的问题，简短直接。**回答时只能基于上面列表的事实**，\n\
            不要凭训练知识瞎猜不存在的 app 或功能。如果用户问的 app 不在列表里，直说没有。\n\
         2. 当用户表达 bug / feature request / feedback / 想反馈给 Fam 时，调用 `create_issue`\n\
            工具，把它整理成 issue 创建到 `{repo}`。标题简洁明确（≤60 字符），body 包含足够上下文\n\
            （涉及哪个 app / 重现步骤 / 期望行为 / 用户原话等）。\n\
         \n\
         不要主动鼓励用户提 issue —— 只在他明确表达想反馈时才创建。\n\
         同一次对话只创建一个 issue。Reply 要简短，不写长段散文。",
        apps = render_apps_list(),
        repo = repo,
    )
}

pub fn create_issue_tool_schema() -> Value {
    json!({
        "type": "function",
        "function": {
            "name": "create_issue",
            "description": "在 fam/cube 仓库创建一个 issue，用于收集用户反馈、bug 报告或 feature request",
            "parameters": {
                "type": "object",
                "properties": {
                    "title": {
                        "type": "string",
                        "description": "Issue 标题，简洁明确，不超过 60 个字符"
                    },
                    "body": {
                        "type": "string",
                        "description": "Issue 正文 Markdown，包含重现步骤 / 期望行为 / 用户原话等上下文"
                    }
                },
                "required": ["title", "body"]
            }
        }
    })
}

pub async fn handle(
    State(cfg): State<Arc<Config>>,
    Json(req): Json<ChatRequest>,
) -> Result<Json<ChatResponse>, ChatError> {
    if req.messages.is_empty() {
        return Err(ChatError::Empty);
    }

    // 拼 messages：注入 system + 用户历史
    let mut messages: Vec<Value> = vec![json!({
        "role": "system",
        "content": system_prompt(&cfg.issue_repo),
    })];
    for m in &req.messages {
        messages.push(json!({ "role": m.role, "content": m.content }));
    }

    let body = json!({
        "model": cfg.llm_model,
        "messages": messages,
        "tools": [create_issue_tool_schema()],
        "tool_choice": "auto",
        "stream": false,
        "temperature": 0.6,
    });

    let endpoint = format!("{}/chat/completions", cfg.gateway.trim_end_matches('/'));
    let client = reqwest::Client::new();
    let resp = client
        .post(&endpoint)
        .bearer_auth(&cfg.llm_token)
        .json(&body)
        .send()
        .await
        .map_err(|e| ChatError::UpstreamLlm(e.to_string()))?;

    let status = resp.status();
    let bytes = resp
        .bytes()
        .await
        .map_err(|e| ChatError::UpstreamLlm(e.to_string()))?;
    if !status.is_success() {
        let body = String::from_utf8_lossy(&bytes);
        return Err(ChatError::UpstreamLlm(format!("{status}: {body}")));
    }
    let v: Value = serde_json::from_slice(&bytes)
        .map_err(|e| ChatError::UpstreamLlm(format!("invalid json: {e}")))?;

    let choice = v
        .pointer("/choices/0/message")
        .ok_or_else(|| ChatError::UpstreamLlm("no choices/0/message".into()))?;

    // 拿 tool_calls 数组；如果有 create_issue 调用就执行，否则返回 content
    if let Some(tool_call) = first_create_issue_call(choice) {
        let (title, body_md) = extract_issue_args(&tool_call).map_err(ChatError::UpstreamLlm)?;
        let created = create_gitea_issue(&cfg, &title, &body_md).await?;
        let llm_text = choice
            .get("content")
            .and_then(Value::as_str)
            .unwrap_or("")
            .trim();
        let reply = if llm_text.is_empty() {
            format!("已记下 → issue #{}: {}", created.number, created.title)
        } else {
            format!("{llm_text}\n\n→ issue #{}: {}", created.number, created.title)
        };
        return Ok(Json(ChatResponse {
            reply,
            created_issue: Some(created),
        }));
    }

    // 没工具调用 — 普通回复
    let text = choice
        .get("content")
        .and_then(Value::as_str)
        .unwrap_or("")
        .trim()
        .to_string();
    Ok(Json(ChatResponse {
        reply: if text.is_empty() {
            "嗯？没听清，再说一遍？".to_string()
        } else {
            text
        },
        created_issue: None,
    }))
}

/// 从 LLM 返回的 message 里挑第一个 `create_issue` tool_call。
pub fn first_create_issue_call(message: &Value) -> Option<Value> {
    let arr = message.get("tool_calls")?.as_array()?;
    arr.iter()
        .find(|tc| {
            tc.pointer("/function/name").and_then(Value::as_str) == Some("create_issue")
        })
        .cloned()
}

/// arguments 是 JSON 字符串（OpenAI 协议），需要二次解析。
pub fn extract_issue_args(tool_call: &Value) -> Result<(String, String), String> {
    let args_raw = tool_call
        .pointer("/function/arguments")
        .and_then(Value::as_str)
        .ok_or_else(|| "tool_call 缺少 arguments".to_string())?;
    let args: Value = serde_json::from_str(args_raw)
        .map_err(|e| format!("arguments 不是合法 JSON: {e}"))?;
    let title = args
        .get("title")
        .and_then(Value::as_str)
        .ok_or_else(|| "tool 调用缺少 title".to_string())?
        .trim()
        .to_string();
    let body = args
        .get("body")
        .and_then(Value::as_str)
        .ok_or_else(|| "tool 调用缺少 body".to_string())?
        .trim()
        .to_string();
    if title.is_empty() {
        return Err("title 为空".to_string());
    }
    Ok((title, body))
}

async fn create_gitea_issue(
    cfg: &Config,
    title: &str,
    body: &str,
) -> Result<CreatedIssue, ChatError> {
    let url = format!(
        "{}/api/v1/repos/{}/issues",
        cfg.gitea_url.trim_end_matches('/'),
        cfg.issue_repo
    );
    let body_md = format!(
        "{body}\n\n---\n_via cube portal chatbot · cube.famzheng.me_"
    );
    let payload = json!({
        "title": title,
        "body": body_md,
        "labels": ["chatbot"],
    });
    let client = reqwest::Client::new();
    let resp = client
        .post(&url)
        .header("Authorization", format!("token {}", cfg.gitea_token))
        .json(&payload)
        .send()
        .await
        .map_err(|e| ChatError::UpstreamGitea(e.to_string()))?;
    let status = resp.status();
    let bytes = resp
        .bytes()
        .await
        .map_err(|e| ChatError::UpstreamGitea(e.to_string()))?;
    if !status.is_success() {
        return Err(ChatError::UpstreamGitea(format!(
            "{status}: {}",
            String::from_utf8_lossy(&bytes)
        )));
    }
    let issue: Value = serde_json::from_slice(&bytes)
        .map_err(|e| ChatError::UpstreamGitea(format!("invalid json: {e}")))?;
    Ok(CreatedIssue {
        number: issue.get("number").and_then(Value::as_u64).unwrap_or(0),
        url: issue
            .get("html_url")
            .and_then(Value::as_str)
            .unwrap_or("")
            .to_string(),
        title: issue
            .get("title")
            .and_then(Value::as_str)
            .unwrap_or(title)
            .to_string(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn system_prompt_includes_repo() {
        let p = system_prompt("fam/cube");
        assert!(p.contains("fam/cube"));
        assert!(p.contains("create_issue"));
    }

    #[test]
    fn render_apps_list_parses_ssot() {
        let list = render_apps_list();
        // 兜底分支会返回 raw JSON（含 `{`），正常解析后是 markdown bullet（每行以 `- ` 起头）
        assert!(list.starts_with("- "), "render output should be markdown bullets, got: {list}");
        // 抽查几个已知 slug 在里面
        for slug in ["cube", "werewolf", "articulate", "karaoke", "music"] {
            assert!(list.contains(slug), "apps list 应该含 {slug}: {list}");
        }
    }

    #[test]
    fn system_prompt_includes_apps_list() {
        let p = system_prompt("fam/cube");
        // 至少一个 app 的 slug 出现在 prompt 里，说明 list 真的被注进去了
        assert!(p.contains("werewolf"));
        assert!(p.contains("live"));
        // 防回退：旧 prompt 写死了 "werewolf / articulate / karaoke / music / simpleasm"，
        // 现在不再用那种枚举句式 —— 列表是数据驱动的
        assert!(!p.contains("werewolf / articulate / karaoke / music / simpleasm"));
    }

    #[test]
    fn tool_schema_shape() {
        let s = create_issue_tool_schema();
        assert_eq!(s.pointer("/type").and_then(Value::as_str), Some("function"));
        assert_eq!(
            s.pointer("/function/name").and_then(Value::as_str),
            Some("create_issue")
        );
        let req = s
            .pointer("/function/parameters/required")
            .and_then(Value::as_array)
            .unwrap();
        assert!(req.iter().any(|v| v == "title"));
        assert!(req.iter().any(|v| v == "body"));
    }

    #[test]
    fn first_create_issue_call_picks_only_matching_tool() {
        let m = json!({
            "tool_calls": [
                {"id": "x1", "type": "function", "function": {"name": "other_tool", "arguments": "{}"}},
                {"id": "x2", "type": "function", "function": {"name": "create_issue", "arguments": "{\"title\":\"t\",\"body\":\"b\"}"}}
            ]
        });
        let tc = first_create_issue_call(&m).expect("should find one");
        assert_eq!(tc.pointer("/function/name").and_then(Value::as_str), Some("create_issue"));
    }

    #[test]
    fn first_create_issue_call_returns_none_if_absent() {
        let m = json!({ "tool_calls": [] });
        assert!(first_create_issue_call(&m).is_none());
        let m = json!({});
        assert!(first_create_issue_call(&m).is_none());
    }

    #[test]
    fn extract_issue_args_parses_string_arguments() {
        let tc = json!({
            "function": {
                "name": "create_issue",
                "arguments": "{\"title\":\"狼人杀: swipe 失灵\",\"body\":\"  iOS Safari 上无法 swipe \"}"
            }
        });
        let (t, b) = extract_issue_args(&tc).unwrap();
        assert_eq!(t, "狼人杀: swipe 失灵");
        assert_eq!(b, "iOS Safari 上无法 swipe");
    }

    #[test]
    fn extract_issue_args_rejects_empty_title() {
        let tc = json!({"function": {"arguments": "{\"title\":\"\",\"body\":\"x\"}"}});
        assert!(extract_issue_args(&tc).is_err());
    }

    #[test]
    fn extract_issue_args_rejects_malformed_args() {
        let tc = json!({"function": {"arguments": "not json"}});
        assert!(extract_issue_args(&tc).is_err());
    }

    #[test]
    fn extract_issue_args_rejects_missing_field() {
        let tc = json!({"function": {"arguments": "{\"title\":\"x\"}"}});
        assert!(extract_issue_args(&tc).is_err());
    }
}
