//! notes.famzheng.me — 录音 → ASR → LLM 会议纪要。
//!
//! 鉴权：所有 /api/* 必须带 `Authorization: token <PASSPHRASE>` header
//!       （audio 流式播放支持 ?token=<PASSPHRASE> query 兜底，因为 <audio>
//!        标签没法塞自定义 header）。
//! 配置：全部通过环境变量注入（PASSPHRASE / ASR_* / LLM_*）；k8s Secret 挂进来。

use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use axum::{
    body::Body,
    extract::{DefaultBodyLimit, Multipart, Path, Request, State},
    http::{header, StatusCode},
    middleware::{from_fn_with_state, Next},
    response::{IntoResponse, Json as JsonResp, Response},
    routing::{get, post},
    Router,
};
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
use serde_json::{json, Value};
use tokio::io::AsyncWriteExt;
use tower::ServiceExt;

const SINGLE_FILE_BYTES: usize = 500 * 1024 * 1024; // 500 MiB / 单录音
const REQUEST_BYTES: usize = 600 * 1024 * 1024;

#[derive(Clone)]
struct AppState {
    db: Arc<Mutex<Connection>>,
    blobs_dir: PathBuf,
    passphrase: String,
    llm_gateway: String,
    llm_token: String,
    llm_model: String,
    feishu_url: String,
    http: reqwest::Client,
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    cube_core::init_tracing();

    let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into());
    let blobs_dir =
        PathBuf::from(std::env::var("BLOBS_DIR").unwrap_or_else(|_| "/data/blobs".into()));
    let dist = std::env::var("NOTES_DIST_DIR").unwrap_or_else(|_| "/dist".into());

    let passphrase = std::env::var("PASSPHRASE").unwrap_or_default();
    if passphrase.is_empty() {
        tracing::warn!("PASSPHRASE not set — all /api/* will return 401");
    }
    // ASR 现在由 sidecar 调（切片串行），主容器不再直接调外部 ASR
    let llm_gateway =
        std::env::var("LLM_GATEWAY").unwrap_or_else(|_| "http://3.135.65.204:8848/v1".into());
    let llm_token = std::env::var("LLM_TOKEN").unwrap_or_default();
    let llm_model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "gemma-4-31b-it".into());
    let feishu_url =
        std::env::var("FEISHU_URL").unwrap_or_else(|_| "http://localhost:8002".into());

    std::fs::create_dir_all(&blobs_dir).expect("mkdir blobs_dir");

    let conn = Connection::open(&db_path).expect("open sqlite");
    conn.execute_batch(
        "PRAGMA journal_mode=WAL;
         CREATE TABLE IF NOT EXISTS recordings (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            filename TEXT NOT NULL,
            mime TEXT NOT NULL,
            size_bytes INTEGER NOT NULL DEFAULT 0,
            status TEXT NOT NULL DEFAULT 'pending',
            transcript TEXT,
            summary TEXT,
            error TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
         );",
    )
    .expect("init schema");
    // 兼容旧 db 增量加列；已存在忽略错误
    let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_doc_id TEXT", []);
    let _ = conn.execute("ALTER TABLE recordings ADD COLUMN feishu_url TEXT", []);
    let _ = conn.execute("ALTER TABLE recordings ADD COLUMN cleaned TEXT", []);
    tracing::info!(%db_path, blobs = %blobs_dir.display(), "notes ready");

    let http = reqwest::Client::builder()
        .build()
        .expect("build reqwest client");

    let state = AppState {
        db: Arc::new(Mutex::new(conn)),
        blobs_dir,
        passphrase,
        llm_gateway,
        llm_token,
        llm_model,
        feishu_url,
        http,
    };

    // 鉴权 middleware 包到 /api 上
    let protected_api = Router::new()
        .route("/recordings", get(list_recordings).post(upload_recording).layer(
            DefaultBodyLimit::max(REQUEST_BYTES),
        ))
        .route("/recordings/:id", get(get_recording).patch(patch_recording).delete(delete_recording))
        .route("/recordings/:id/audio", get(stream_audio))
        .route("/recordings/:id/retry", post(retry_recording))
        .route("/recordings/:id/feishu", post(convert_feishu))
        .with_state(state.clone())
        .layer(from_fn_with_state(state.clone(), auth_middleware));

    let api = Router::new()
        .route("/health", get(|| async { "ok" }))
        .merge(protected_api);

    // 启动时把上次 pod 死前卡在中间状态的 recording 重新喂给 worker。
    // 状态 transcribing/summarizing 是 worker 进程内存的，pod 重启就丢，
    // db 还停留在原状态 → 不 resume 永远不会再动。
    {
        let stuck: Vec<i64> = {
            let conn = state.db.lock().unwrap();
            let mut stmt = conn
                .prepare(
                    "SELECT id FROM recordings
                     WHERE status IN ('pending', 'transcribing', 'summarizing')
                     ORDER BY id ASC",
                )
                .expect("prepare resume query");
            stmt.query_map([], |r| r.get::<_, i64>(0))
                .expect("run resume query")
                .collect::<Result<Vec<_>, _>>()
                .expect("collect resume ids")
        };
        if !stuck.is_empty() {
            tracing::info!(count = stuck.len(), ids = ?stuck, "resuming stuck recordings");
            for id in stuck {
                let s = state.clone();
                tokio::spawn(async move {
                    // 改回 pending 让 worker 从头跑（idempotent）
                    {
                        let conn = s.db.lock().unwrap();
                        let _ = conn.execute(
                            "UPDATE recordings SET status = 'pending', error = NULL WHERE id = ?1",
                            params![id],
                        );
                    }
                    process_recording(s, id).await;
                });
            }
        }
    }

    let app = cube_core::base(dist).nest("/api", api);
    cube_core::serve(app, 8080).await
}

// ---------- 鉴权 middleware ----------

async fn auth_middleware(
    State(s): State<AppState>,
    req: Request<Body>,
    next: Next,
) -> Response {
    if s.passphrase.is_empty() {
        return (StatusCode::UNAUTHORIZED, "server not configured").into_response();
    }
    // 优先看 Authorization header
    let header_ok = req
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .map(|v| {
            v.strip_prefix("token ")
                .or_else(|| v.strip_prefix("Token "))
                .or_else(|| v.strip_prefix("Bearer "))
                .map(|t| t.trim() == s.passphrase)
                .unwrap_or(false)
        })
        .unwrap_or(false);
    // 再看 ?token= query（给 <audio src> 兜底）
    let query_ok = req.uri().query().and_then(|q| {
        for kv in q.split('&') {
            if let Some(v) = kv.strip_prefix("token=") {
                let decoded = percent_decode(v);
                if decoded == s.passphrase {
                    return Some(true);
                }
            }
        }
        None
    }).unwrap_or(false);
    if header_ok || query_ok {
        next.run(req).await
    } else {
        (StatusCode::UNAUTHORIZED, "unauthorized").into_response()
    }
}

fn percent_decode(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
                out.push((h << 4) | l);
                i += 3;
                continue;
            }
        }
        out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
        i += 1;
    }
    String::from_utf8(out).unwrap_or_default()
}
fn hex(b: u8) -> Option<u8> {
    match b {
        b'0'..=b'9' => Some(b - b'0'),
        b'a'..=b'f' => Some(b - b'a' + 10),
        b'A'..=b'F' => Some(b - b'A' + 10),
        _ => None,
    }
}

// ---------- types ----------

#[derive(Serialize)]
struct RecordingSummary {
    id: i64,
    title: String,
    filename: String,
    mime: String,
    size_bytes: i64,
    status: String,
    created_at: String,
    has_transcript: bool,
    has_summary: bool,
}

#[derive(Serialize)]
struct RecordingDetail {
    id: i64,
    title: String,
    filename: String,
    mime: String,
    size_bytes: i64,
    status: String,
    transcript: Option<String>,
    cleaned: Option<String>,
    summary: Option<String>,
    error: Option<String>,
    created_at: String,
    feishu_doc_id: Option<String>,
    feishu_url: Option<String>,
}

// ---------- handlers ----------

async fn list_recordings(
    State(s): State<AppState>,
) -> Result<JsonResp<Value>, AppError> {
    let conn = s.db.lock().unwrap();
    let mut stmt = conn.prepare(
        "SELECT id, title, filename, mime, size_bytes, status, created_at,
                CASE WHEN transcript IS NOT NULL AND length(transcript) > 0 THEN 1 ELSE 0 END,
                CASE WHEN summary IS NOT NULL AND length(summary) > 0 THEN 1 ELSE 0 END
         FROM recordings ORDER BY created_at DESC, id DESC",
    )?;
    let rows = stmt
        .query_map([], |r| {
            let ht: i64 = r.get(7)?;
            let hs: i64 = r.get(8)?;
            Ok(RecordingSummary {
                id: r.get(0)?,
                title: r.get(1)?,
                filename: r.get(2)?,
                mime: r.get(3)?,
                size_bytes: r.get(4)?,
                status: r.get(5)?,
                created_at: r.get(6)?,
                has_transcript: ht != 0,
                has_summary: hs != 0,
            })
        })?
        .collect::<Result<Vec<_>, _>>()?;
    Ok(JsonResp(json!(rows)))
}

async fn get_recording(
    State(s): State<AppState>,
    Path(id): Path<i64>,
) -> Result<JsonResp<RecordingDetail>, AppError> {
    let conn = s.db.lock().unwrap();
    type Row = (
        String, String, String, i64, String,
        Option<String>, Option<String>, Option<String>, Option<String>, String,
        Option<String>, Option<String>,
    );
    let row: Option<Row> = conn
        .query_row(
            "SELECT title, filename, mime, size_bytes, status,
                    transcript, cleaned, summary, error, created_at,
                    feishu_doc_id, feishu_url
             FROM recordings WHERE id = ?1",
            params![id],
            |r| {
                Ok((
                    r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?,
                    r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?, r.get(9)?,
                    r.get(10)?, r.get(11)?,
                ))
            },
        )
        .optional()?;
    let (title, filename, mime, size_bytes, status, transcript, cleaned, summary, error, created_at,
         feishu_doc_id, feishu_url) = row.ok_or(AppError::NotFound)?;
    Ok(JsonResp(RecordingDetail {
        id, title, filename, mime, size_bytes, status,
        transcript, cleaned, summary, error, created_at,
        feishu_doc_id, feishu_url,
    }))
}

async fn upload_recording(
    State(s): State<AppState>,
    mut form: Multipart,
) -> Result<JsonResp<Value>, AppError> {
    let mut title: Option<String> = None;
    let mut filename: Option<String> = None;
    let mut mime: Option<String> = None;
    let mut tmp_path: Option<PathBuf> = None;
    let mut size: usize = 0;

    while let Some(mut field) = form
        .next_field()
        .await
        .map_err(|e| AppError::bad_request(format!("multipart: {e}")))?
    {
        let name = field.name().unwrap_or("").to_string();
        match name.as_str() {
            "title" => {
                let s = field
                    .text()
                    .await
                    .map_err(|e| AppError::bad_request(format!("title: {e}")))?;
                title = Some(s.trim().to_string());
            }
            "audio" | "file" => {
                let fn_ = field
                    .file_name()
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| "recording".to_string());
                let m = field
                    .content_type()
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| "application/octet-stream".to_string());
                if !m.starts_with("audio/") && !m.starts_with("video/") && m != "application/octet-stream" {
                    return Err(AppError::bad_request(format!("unsupported mime '{m}'")));
                }
                filename = Some(fn_);
                mime = Some(m);
                // 流式落 tmp
                let tmp = s.blobs_dir.join(format!("upload-{}.tmp", std::process::id()));
                let mut f = tokio::fs::File::create(&tmp).await.map_err(AppError::Io)?;
                while let Some(chunk) = field
                    .chunk()
                    .await
                    .map_err(|e| AppError::bad_request(format!("upload: {e}")))?
                {
                    size += chunk.len();
                    if size > SINGLE_FILE_BYTES {
                        let _ = tokio::fs::remove_file(&tmp).await;
                        return Err(AppError::bad_request(format!(
                            "file too large (>{SINGLE_FILE_BYTES} bytes)"
                        )));
                    }
                    f.write_all(&chunk).await.map_err(AppError::Io)?;
                }
                f.sync_all().await.map_err(AppError::Io)?;
                tmp_path = Some(tmp);
            }
            _ => {}
        }
    }

    let filename = filename.ok_or_else(|| AppError::bad_request("missing audio file"))?;
    let mime = mime.unwrap_or_else(|| "audio/mpeg".to_string());
    let tmp_path = tmp_path.ok_or_else(|| AppError::bad_request("no file uploaded"))?;
    // title 完全可选；空时用本地时间 "录音 YYYY-MM-DD HH:MM"，比丑的 filename 好读
    let title = title.filter(|x| !x.is_empty()).unwrap_or_else(|| {
        use std::time::{SystemTime, UNIX_EPOCH};
        let secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0) as i64;
        let bst_offset = 3600; // 简单 BST/UTC+1，cube 在伦敦
        let t = secs + bst_offset;
        let day = t / 86400;
        let h = (t % 86400) / 3600;
        let m = (t % 3600) / 60;
        // 简化日期计算（够看就行）
        let y = 1970 + day / 365;
        let yday = (day % 365) as i64;
        let mo = (yday / 30 + 1).min(12);
        let d = (yday % 30 + 1).min(28);
        format!("录音 {:04}-{:02}-{:02} {:02}:{:02}", y, mo, d, h, m)
    });

    let id = {
        let conn = s.db.lock().unwrap();
        conn.execute(
            "INSERT INTO recordings (title, filename, mime, size_bytes, status)
             VALUES (?1, ?2, ?3, ?4, 'pending')",
            params![title, filename, mime, size as i64],
        )?;
        conn.last_insert_rowid()
    };

    let final_path = s.blobs_dir.join(id.to_string());
    if let Err(e) = tokio::fs::rename(&tmp_path, &final_path).await {
        let _ = tokio::fs::remove_file(&tmp_path).await;
        let conn = s.db.lock().unwrap();
        let _ = conn.execute("DELETE FROM recordings WHERE id = ?1", params![id]);
        return Err(AppError::Io(e));
    }

    // 后台处理
    let state_clone = s.clone();
    tokio::spawn(async move {
        process_recording(state_clone, id).await;
    });

    Ok(JsonResp(json!({ "id": id, "status": "pending" })))
}

async fn process_recording(s: AppState, id: i64) {
    let path = s.blobs_dir.join(id.to_string());
    // 取已有的 transcript（让 retry 跳过 ASR 直接 cleanup + summary）
    let (filename, existing_transcript): (String, Option<String>) = {
        let conn = s.db.lock().unwrap();
        conn.query_row(
            "SELECT filename, transcript FROM recordings WHERE id = ?1",
            params![id],
            |r| Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?)),
        )
        .unwrap_or_else(|_| ("audio".to_string(), None))
    };
    let has_transcript = existing_transcript
        .as_deref()
        .map(|t| !t.trim().is_empty())
        .unwrap_or(false);

    let transcript = if has_transcript {
        tracing::info!(%id, "transcript exists, skip ASR");
        existing_transcript.unwrap()
    } else {
        set_status(&s, id, "transcribing", None, None);
        match call_asr(&s, &path, &filename).await {
            Ok(t) => {
                let conn = s.db.lock().unwrap();
                let _ = conn.execute(
                    "UPDATE recordings SET transcript = ?1, status = 'cleaning' WHERE id = ?2",
                    params![&t, id],
                );
                t
            }
            Err(e) => {
                tracing::error!(%id, error = %e, "ASR failed");
                set_status(&s, id, "failed", None, Some(&format!("ASR: {e}")));
                return;
            }
        }
    };
    // 不管走哪条都进 cleaning
    set_status(&s, id, "cleaning", None, None);

    // LLM cleanup：分段 + 去口语 + 润色 + 高亮（失败也继续 summary，不阻塞）
    match call_llm_cleanup(&s, &transcript).await {
        Ok(c) => {
            let conn = s.db.lock().unwrap();
            let _ = conn.execute(
                "UPDATE recordings SET cleaned = ?1, status = 'summarizing' WHERE id = ?2",
                params![&c, id],
            );
        }
        Err(e) => {
            tracing::warn!(%id, error = %e, "cleanup failed, skip and continue to summary");
            let conn = s.db.lock().unwrap();
            let _ = conn.execute(
                "UPDATE recordings SET status = 'summarizing' WHERE id = ?1",
                params![id],
            );
        }
    }

    // LLM：生成会议纪要 + 标题
    let raw = match call_llm_summary(&s, &transcript).await {
        Ok(t) => t,
        Err(e) => {
            tracing::error!(%id, error = %e, "LLM failed");
            set_status(&s, id, "failed", None, Some(&format!("LLM: {e}")));
            return;
        }
    };
    let (new_title, summary_body) = parse_title_from_summary(&raw);
    {
        let conn = s.db.lock().unwrap();
        if let Some(t) = new_title.as_deref() {
            let _ = conn.execute(
                "UPDATE recordings SET title = ?1, summary = ?2, status = 'done', error = NULL WHERE id = ?3",
                params![t, &summary_body, id],
            );
        } else {
            let _ = conn.execute(
                "UPDATE recordings SET summary = ?1, status = 'done', error = NULL WHERE id = ?2",
                params![&summary_body, id],
            );
        }
    }
    tracing::info!(%id, title = ?new_title, "done");
}

/// 从 LLM 输出剥离 `TITLE: ...\n---\n` 头部。
/// 返回 (Option<title>, summary_body)，title 失败时返回 None + 原文。
fn parse_title_from_summary(raw: &str) -> (Option<String>, String) {
    let mut lines = raw.lines();
    let first = lines.next().unwrap_or("").trim();
    let Some(rest) = first
        .strip_prefix("TITLE:")
        .or_else(|| first.strip_prefix("Title:"))
        .or_else(|| first.strip_prefix("标题:"))
        .or_else(|| first.strip_prefix("标题："))
    else {
        return (None, raw.to_string());
    };
    let title: String = rest.trim().chars().take(80).collect();
    if title.is_empty() {
        return (None, raw.to_string());
    }
    // 吃掉接下来的 `---` separator + 空行
    let body: String = lines
        .skip_while(|l| {
            let t = l.trim();
            t.is_empty() || t == "---" || t.starts_with("---")
        })
        .collect::<Vec<_>>()
        .join("\n");
    (Some(title), body)
}

fn set_status(s: &AppState, id: i64, status: &str, transcript: Option<&str>, error: Option<&str>) {
    let conn = s.db.lock().unwrap();
    let _ = conn.execute(
        "UPDATE recordings SET status = ?1, error = ?2,
            transcript = COALESCE(?3, transcript)
         WHERE id = ?4",
        params![status, error, transcript, id],
    );
}

async fn call_asr(
    s: &AppState,
    path: &std::path::Path,
    _filename: &str,
) -> Result<String, String> {
    // 走 sidecar /transcribe：sidecar 用 ffmpeg 切片 + 串行调外部 ASR，绕过 ASR server 单文件大小限制
    let url = format!("{}/transcribe", s.feishu_url.trim_end_matches('/'));
    let payload = json!({ "audio_path": path.to_string_lossy() });
    let resp = s
        .http
        .post(&url)
        .json(&payload)
        .timeout(std::time::Duration::from_secs(3600))
        .send()
        .await
        .map_err(|e| format!("connect sidecar: {e}"))?;
    if !resp.status().is_success() {
        let st = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(format!("sidecar /transcribe {st}: {body}"));
    }
    let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
    let text = v
        .get("text")
        .and_then(|x| x.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| format!("no 'text' in response: {v}"))?;
    Ok(text)
}

async fn call_llm_cleanup(s: &AppState, transcript: &str) -> Result<String, String> {
    let trimmed = if transcript.chars().count() > 12000 {
        let mut out = String::new();
        for (i, c) in transcript.chars().enumerate() {
            if i >= 12000 { break; }
            out.push(c);
        }
        out + "\n\n[... 后文截断]"
    } else {
        transcript.to_string()
    };
    let payload = json!({
        "model": s.llm_model,
        "messages": [
            { "role": "system", "content":
                "你是 ASR 转写后处理助手。把下面这段连续无标点的转写整理成可读版本：\n\
                 \n\
                 1. **自动分段**：按话题/语义换段，每段 2-5 句\n\
                 2. **加标点**：句号、问号、感叹号、逗号、引号\n\
                 3. **去口语噪音**：删掉「嗯/啊/那个/就是/对/然后...」等填充词，但保留实际含义的连接词\n\
                 4. **轻度润色**：通顺、语法、错别字（结合上下文修 ASR 错字），但**不要总结、不要改变原意、不要添加内容**\n\
                 5. **重点高亮**：把关键判断、结论、决定、数字、名词用 markdown `**...**` 加粗\n\
                 \n\
                 输出纯 markdown 段落，不要标题、不要列表、不要解释。" },
            { "role": "user", "content": trimmed },
        ],
        "temperature": 0.3,
    });
    let url = format!("{}/chat/completions", s.llm_gateway.trim_end_matches('/'));
    let resp = s
        .http
        .post(&url)
        .bearer_auth(&s.llm_token)
        .json(&payload)
        .timeout(std::time::Duration::from_secs(600))
        .send()
        .await
        .map_err(|e| format!("connect: {e}"))?;
    if !resp.status().is_success() {
        let st = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(format!("LLM {st}: {body}"));
    }
    let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
    let text = v
        .get("choices").and_then(|c| c.get(0))
        .and_then(|c| c.get("message"))
        .and_then(|m| m.get("content"))
        .and_then(|c| c.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| format!("LLM no content: {v}"))?;
    Ok(text)
}

async fn call_llm_summary(s: &AppState, transcript: &str) -> Result<String, String> {
    let trimmed = if transcript.chars().count() > 12000 {
        let mut out = String::new();
        for (i, c) in transcript.chars().enumerate() {
            if i >= 12000 { break; }
            out.push(c);
        }
        out + "\n\n[... 后文截断]"
    } else {
        transcript.to_string()
    };
    let payload = json!({
        "model": s.llm_model,
        "messages": [
            { "role": "system", "content":
                "你是一个会议纪要助手。根据语音转写输出：\n\
                 \n\
                 第一行：`TITLE: <8-20 字符的会议主题>`（不含日期/时间，提取核心议题）\n\
                 第二行：`---`\n\
                 之后是 markdown 纪要：\n\
                 1. **概要**：1-2 句话总结\n\
                 2. **关键讨论点**：bullet 列出\n\
                 3. **决定 / 结论**\n\
                 4. **行动项 (action items)**：每条用 markdown checkbox 格式 `- [ ] 谁 · 做什么 · 何时`\n\
                 5. **待跟进 / 未决问题**：bullet 列出\n\
                 \n\
                 转写可能有 ASR 错字，结合上下文合理修正；遇到模糊处标 [？]。\n\
                 不要编造没说过的内容。" },
            { "role": "user", "content": trimmed },
        ],
        "temperature": 0.3,
    });
    let url = format!("{}/chat/completions", s.llm_gateway.trim_end_matches('/'));
    let resp = s
        .http
        .post(&url)
        .bearer_auth(&s.llm_token)
        .json(&payload)
        .timeout(std::time::Duration::from_secs(300))
        .send()
        .await
        .map_err(|e| format!("connect: {e}"))?;
    if !resp.status().is_success() {
        let st = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(format!("LLM {st}: {body}"));
    }
    let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
    let text = v
        .get("choices").and_then(|c| c.get(0))
        .and_then(|c| c.get("message"))
        .and_then(|m| m.get("content"))
        .and_then(|c| c.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| format!("LLM no content: {v}"))?;
    Ok(text)
}

#[derive(serde::Deserialize)]
struct PatchRecording {
    title: Option<String>,
}

async fn patch_recording(
    State(s): State<AppState>,
    Path(id): Path<i64>,
    JsonResp(body): JsonResp<PatchRecording>,
) -> Result<JsonResp<Value>, AppError> {
    let conn = s.db.lock().unwrap();
    let exists: bool = conn
        .query_row("SELECT 1 FROM recordings WHERE id = ?1", params![id], |_| Ok(true))
        .optional()?
        .unwrap_or(false);
    if !exists { return Err(AppError::NotFound); }
    if let Some(t) = body.title.as_ref() {
        let t = t.trim();
        if t.is_empty() { return Err(AppError::bad_request("title can't be blank")); }
        let t: String = t.chars().take(120).collect();
        conn.execute("UPDATE recordings SET title = ?1 WHERE id = ?2", params![&t, id])?;
    }
    Ok(JsonResp(json!({ "ok": true })))
}

async fn delete_recording(
    State(s): State<AppState>,
    Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
    let n = {
        let conn = s.db.lock().unwrap();
        conn.execute("DELETE FROM recordings WHERE id = ?1", params![id])?
    };
    if n == 0 {
        return Err(AppError::NotFound);
    }
    let _ = tokio::fs::remove_file(s.blobs_dir.join(id.to_string())).await;
    Ok(JsonResp(json!({ "ok": true })))
}

async fn retry_recording(
    State(s): State<AppState>,
    Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
    {
        let conn = s.db.lock().unwrap();
        let exists: bool = conn
            .query_row("SELECT 1 FROM recordings WHERE id = ?1", params![id], |_| Ok(true))
            .optional()?
            .unwrap_or(false);
        if !exists { return Err(AppError::NotFound); }
        conn.execute(
            "UPDATE recordings SET status = 'pending', error = NULL WHERE id = ?1",
            params![id],
        )?;
    }
    let sc = s.clone();
    tokio::spawn(async move { process_recording(sc, id).await; });
    Ok(JsonResp(json!({ "ok": true, "status": "pending" })))
}

/// `POST /api/recordings/:id/feishu` — 把转写 + 纪要 push 成飞书 docx。
/// 已经转过的 piece 仍 update 同一个 doc（markdown-to-feishu 自带 --update）。
async fn convert_feishu(
    State(s): State<AppState>,
    Path(id): Path<i64>,
) -> Result<JsonResp<Value>, AppError> {
    let row: (String, String, Option<String>, Option<String>, String, Option<String>) = {
        let conn = s.db.lock().unwrap();
        conn.query_row(
            "SELECT title, filename, transcript, summary, status, feishu_doc_id
             FROM recordings WHERE id = ?1",
            params![id],
            |r| {
                Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
            },
        )
        .optional()?
        .ok_or(AppError::NotFound)?
    };
    let (title, filename, transcript, summary, status, existing_doc) = row;
    if status != "done" {
        return Err(AppError::bad_request(format!(
            "recording not ready (status={status})"
        )));
    }
    let summary = summary.unwrap_or_default();
    let transcript = transcript.unwrap_or_default();

    // 拼 markdown
    let ext = std::path::Path::new(&filename)
        .extension()
        .and_then(|x| x.to_str())
        .unwrap_or("m4a")
        .to_string();
    let audio_name = format!("audio.{ext}");
    let md = format!(
        "# {title}\n\n\
         ## 📋 会议纪要\n\n\
         {summary}\n\n\
         ---\n\n\
         ## 📎 原始材料\n\n\
         - [📄 转录原文](./transcript.txt)\n\
         - [🎙️ 原始录音](./{audio_name})\n\n\
         ---\n\n\
         ## 🎙️ 转录全文\n\n\
         {transcript}\n",
    );

    // 落到 PVC 共享目录，sidecar 同样挂这个卷
    let work_dir = std::path::PathBuf::from(format!("/data/feishu-tmp/{id}"));
    tokio::fs::create_dir_all(&work_dir).await.map_err(AppError::Io)?;
    let md_path = work_dir.join("note.md");
    tokio::fs::write(&md_path, md).await.map_err(AppError::Io)?;
    tokio::fs::write(work_dir.join("transcript.txt"), &transcript)
        .await
        .map_err(AppError::Io)?;
    // 拷 audio（用 copy，sidecar 跑期间不会被改）
    let audio_src = s.blobs_dir.join(id.to_string());
    let audio_dst = work_dir.join(&audio_name);
    tokio::fs::copy(&audio_src, &audio_dst).await.map_err(AppError::Io)?;

    // 调 sidecar
    let url = format!("{}/convert", s.feishu_url.trim_end_matches('/'));
    let mut payload = json!({
        "md_path": md_path.to_string_lossy(),
        "title": title,
    });
    if let Some(d) = existing_doc.as_deref().filter(|x| !x.is_empty()) {
        payload["existing_doc_id"] = json!(d);
    }
    let resp = s
        .http
        .post(&url)
        .json(&payload)
        .timeout(std::time::Duration::from_secs(300))
        .send()
        .await
        .map_err(|e| AppError::bad_request(format!("feishu sidecar: {e}")))?;
    if !resp.status().is_success() {
        let st = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(AppError::bad_request(format!("feishu {st}: {body}")));
    }
    let body: Value = resp.json().await.map_err(|e| AppError::bad_request(format!("decode: {e}")))?;
    let doc_id = body.get("doc_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
    let doc_url = body.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
    if doc_id.is_empty() || doc_url.is_empty() {
        return Err(AppError::bad_request(format!("feishu bad response: {body}")));
    }

    {
        let conn = s.db.lock().unwrap();
        conn.execute(
            "UPDATE recordings SET feishu_doc_id = ?1, feishu_url = ?2 WHERE id = ?3",
            params![&doc_id, &doc_url, id],
        )?;
    }

    Ok(JsonResp(json!({ "doc_id": doc_id, "url": doc_url })))
}

async fn stream_audio(
    State(s): State<AppState>,
    Path(id): Path<i64>,
    req: Request<Body>,
) -> Result<Response, AppError> {
    let row: Option<(String, String)> = {
        let conn = s.db.lock().unwrap();
        conn.query_row(
            "SELECT mime, filename FROM recordings WHERE id = ?1",
            params![id],
            |r| Ok((r.get(0)?, r.get(1)?)),
        )
        .optional()?
    };
    let (mime, _filename) = row.ok_or(AppError::NotFound)?;
    let path = s.blobs_dir.join(id.to_string());
    let mime_hv: header::HeaderValue = mime
        .parse()
        .unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream"));
    let svc = tower_http::services::ServeFile::new(&path);
    let mut resp = svc
        .oneshot(req)
        .await
        .map_err(|e| AppError::Io(std::io::Error::other(e.to_string())))?
        .into_response();
    resp.headers_mut().insert(header::CONTENT_TYPE, mime_hv);
    Ok(resp)
}

// ---------- error type ----------

enum AppError {
    BadRequest(String),
    NotFound,
    Db(rusqlite::Error),
    Io(std::io::Error),
}
impl AppError {
    fn bad_request(m: impl Into<String>) -> Self { Self::BadRequest(m.into()) }
}
impl From<rusqlite::Error> for AppError {
    fn from(e: rusqlite::Error) -> Self { Self::Db(e) }
}
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        match self {
            Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m).into_response(),
            Self::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
            Self::Db(e) => {
                tracing::error!(error = %e, "db");
                (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
            }
            Self::Io(e) => {
                tracing::error!(error = %e, "io");
                (StatusCode::INTERNAL_SERVER_ERROR, "io error").into_response()
            }
        }
    }
}
