use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::Json,
    routing::{delete, get, post},
    Router,
};
use once_cell::sync::OnceCell;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::Row;
use std::time::Instant;
use tower_http::cors::{Any, CorsLayer};

use crate::core::cache::{keys, MongoCache, RedisCache};
use crate::core::db::schema;
use crate::core::db::{Database, PostgresDb, QdrantDb, RedisClient, VideoRecord, VideoStatus};
use crate::core::text::tokenizer::tokenize_chinese_text;
use crate::{Embedder, FileManager};

use super::agent_api;
use super::face_recognition;
use super::five_w1h_agent_api;
use super::identities;
use super::identity_api;
use super::identity_binding;
use super::middleware::api_key_validation;
use super::visual_chunk_search;
use crate::core::chunk::types::Chunk;

static DEMO_USER_API_KEY: &str = "muser_test_001";

fn hash_password(password: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(password.as_bytes());
    format!("{:x}", hasher.finalize())
}

#[derive(Debug, Deserialize)]
struct LoginRequest {
    username: String,
    password: String,
}

#[derive(Debug, Serialize)]
struct LoginResponse {
    success: bool,
    message: Option<String>,
    api_key: Option<String>,
    user: Option<UserInfo>,
}

#[derive(Debug, Serialize)]
struct UserInfo {
    username: String,
}

// Global State
static SERVER_START: OnceCell<Instant> = OnceCell::new();

fn get_uptime_ms() -> u64 {
    SERVER_START
        .get()
        .map(|i| i.elapsed().as_millis() as u64)
        .unwrap_or(0)
}

#[derive(Debug, Serialize)]
struct HealthResponse {
    status: String,
    version: String,
    uptime_ms: u64,
}

#[derive(Debug, Serialize)]
struct JobListResponse {
    jobs: Vec<JobInfoResponse>,
    count: i64,
    page: usize,
    page_size: usize,
}

#[derive(Debug, Deserialize)]
struct JobsQuery {
    page: Option<usize>,
    page_size: Option<usize>,
    status: Option<String>,
}

#[derive(Debug, Serialize)]
struct JobInfoResponse {
    id: i32,
    uuid: String,
    status: String,
    current_processor: Option<String>,
    progress_current: i32,
    progress_total: i32,
    created_at: String,
    started_at: Option<String>,
}

#[derive(Debug, Serialize)]
struct JobDetailResponse {
    id: i32,
    uuid: String,
    status: String,
    current_processor: Option<String>,
    progress_current: i32,
    progress_total: i32,
    processors: Vec<ProcessorInfoResponse>,
    created_at: String,
    started_at: Option<String>,
    updated_at: Option<String>,
}

#[derive(Debug, Serialize)]
struct ProcessorInfoResponse {
    processor_type: String,
    status: String,
    started_at: Option<String>,
    completed_at: Option<String>,
    duration_secs: Option<f64>,
    error_message: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum SearchMode {
    Vector,
    Smart,
}

#[derive(Debug, Deserialize)]
struct SearchRequest {
    query: String,
    mode: Option<SearchMode>,
    collection: Option<String>,
    uuid: Option<String>,
    limit: Option<usize>,
    vector_weight: Option<f32>,
    bm25_weight: Option<f32>,
}

#[derive(Debug, Deserialize)]
struct CacheToggleRequest {
    enabled: bool,
}

#[derive(Debug, Serialize)]
struct CacheToggleResponse {
    success: bool,
    cache_enabled: bool,
    message: String,
}

// Missing structs added
#[derive(Debug, Deserialize)]
struct RegisterRequest {
    path: String,
}

#[derive(Debug, Deserialize)]
struct RegisterFileRequest {
    file_path: String,
    user_id: Option<i64>,
}

#[derive(Debug, Serialize)]
struct RegisterFileResponse {
    success: bool,
    file_uuid: String,
    file_name: String,
    file_path: String,
    file_type: Option<String>,
    duration: f64,
    width: u32,
    height: u32,
    fps: f64,
    total_frames: u64,
    registration_time: Option<String>,
    already_exists: bool,
    message: String,
}

#[derive(Debug, Serialize)]
struct RegisterResponse {
    file_uuid: String,
    file_id: i64,
    job_id: i32,
    file_name: String,
    duration: f64,
    width: u32,
    height: u32,
    already_exists: bool,
}

#[derive(Debug, Deserialize)]
struct ProbeRequest {
    path: String,
}

#[derive(Debug, Serialize)]
struct ProbeResponse {
    uuid: String,
    file_name: String,
    duration: f64,
    width: u32,
    height: u32,
    fps: f64,
    cached: bool,
    format: crate::core::probe::FormatInfo,
    streams: Vec<crate::core::probe::StreamInfo>,
}

#[derive(Debug, Deserialize)]
struct UnregisterRequest {
    uuid: String,
}

#[derive(Debug, Serialize)]
struct UnregisterResponse {
    success: bool,
    uuid: String,
    message: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct SearchResult {
    uuid: String,
    chunk_id: String,
    chunk_type: String,
    start_time: f64,
    end_time: f64,
    text: String,
    score: f32,
}

#[derive(Debug, Serialize, Deserialize)]
struct SearchResponse {
    results: Vec<SearchResult>,
    query: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct N8nSearchHit {
    id: String,
    vid: String,
    start_frame: i64,
    end_frame: i64,
    fps: f64,
    start: f64,
    end: f64,
    title: String,
    text: String,
    score: f32,
    #[serde(skip_serializing_if = "Option::is_none")]
    file_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    has_visual_stats: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    parent_id: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
struct N8nSearchResponse {
    query: String,
    count: usize,
    hits: Vec<N8nSearchHit>,
}

// --- P0 API Structs ---
#[derive(Debug, Deserialize)]
struct ProcessRequest {
    rules: Option<Vec<String>>,
    processors: Option<Vec<String>>,
}

#[derive(Debug, Serialize)]
struct FrameProgress {
    total_frames: i64,
    processed_frames: i64,
    progress_percent: f64,
}

#[derive(Debug, Serialize)]
struct AssetStatusResponse {
    uuid: String,
    file_name: String,
    registration_time: String,
    processing_status: String,
    current_job_id: Option<String>,
    frame_progress: Option<FrameProgress>,
}

#[derive(Debug, Serialize)]
struct JobStatusResponse {
    job_id: String,
    asset_uuid: String,
    rule: String,
    status: String,
    current_processor_id: Option<String>,
    frame_progress: FrameProgress,
}

#[derive(Debug, Serialize)]
struct RuleStatusResponse {
    rule: String,
    supported_processor_ids: Vec<String>,
    active_jobs: Vec<JobStatusResponse>,
}

// --- End P0 API Structs ---

#[derive(Debug, Deserialize)]
struct HybridSearchRequest {
    query: String,
    limit: Option<usize>,
    uuid: Option<String>,
    vector_weight: Option<f32>,
    bm25_weight: Option<f32>,
}

#[derive(Debug, Serialize, Deserialize)]
struct HybridSearchResult {
    uuid: String,
    chunk_id: String,
    chunk_type: String,
    start_time: f64,
    end_time: f64,
    text: String,
    vector_score: f64,
    bm25_score: f64,
    combined_score: f64,
}

#[derive(Debug, Serialize, Deserialize)]
struct HybridSearchResponse {
    results: Vec<HybridSearchResult>,
    query: String,
}

fn extract_text_from_content(content: &serde_json::Value) -> String {
    let raw_text = content
        .get("data")
        .and_then(|data| data.get("text"))
        .and_then(|v| v.as_str())
        .or_else(|| content.get("text").and_then(|v| v.as_str()))
        .unwrap_or("");

    tokenize_chinese_text(raw_text)
}

fn extract_title_from_content(content: &serde_json::Value) -> String {
    content
        .get("data")
        .and_then(|data| data.get("title"))
        .and_then(|v| v.as_str())
        .or_else(|| content.get("title").and_then(|v| v.as_str()))
        .unwrap_or("")
        .to_string()
}

#[derive(Debug, Deserialize)]
struct LookupQuery {
    path: Option<String>,
    uuid: Option<String>,
}

#[derive(Debug, Serialize)]
struct LookupResponse {
    file_uuid: String,
    file_path: Option<String>,
    file_name: Option<String>,
    duration: Option<f64>,
}

#[derive(Debug, Serialize, Deserialize)]
struct FileInfoResponse {
    file_uuid: String,
    file_path: String,
    file_name: String,
    file_type: Option<String>,
    duration: f64,
    width: u32,
    height: u32,
    status: String,
    processing_status: Option<serde_json::Value>,
    birth_registration: Option<serde_json::Value>,
    created_at: Option<String>,
    registration_time: Option<String>,
    file_size: Option<i64>,
    probe_json: Option<String>,
    total_frames: u64,
}

#[derive(Debug, Serialize, Deserialize)]
struct FilesResponse {
    files: Vec<FileInfoResponse>,
    count: i64,
    page: usize,
    page_size: usize,
}

#[derive(Debug, Deserialize)]
struct FilesQuery {
    page: Option<usize>,
    page_size: Option<usize>,
    status: Option<String>,
    q: Option<String>,
    uuid: Option<String>,
}

#[derive(Clone)]
pub struct AppState {
    pub db: std::sync::Arc<crate::core::db::PostgresDb>,
    pub embedder: std::sync::Arc<crate::Embedder>,
    pub embedder_model: String,
    pub mongo_cache: crate::core::cache::MongoCache,
    pub redis_cache: crate::core::cache::RedisCache,
    pub api_state: super::middleware::ApiState,
}

#[derive(Debug, Serialize)]
struct DetailedHealthResponse {
    status: String,
    version: String,
    uptime_ms: u64,
    services: ServiceHealth,
}

#[derive(Debug, Serialize)]
struct ServiceHealth {
    postgres: ServiceStatus,
    redis: ServiceStatus,
    qdrant: ServiceStatus,
    mongodb: ServiceStatus,
}

#[derive(Debug, Serialize)]
struct ServiceStatus {
    status: String,
    latency_ms: Option<u64>,
    error: Option<String>,
}

async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
    if let Ok(Some(status)) = state.redis_cache.get_health().await {
        return Json(HealthResponse {
            status,
            version: env!("BUILD_VERSION").to_string(),
            uptime_ms: get_uptime_ms(),
        });
    }

    let status = "ok".to_string();
    let _ = state.redis_cache.set_health(&status).await;

    Json(HealthResponse {
        status,
        version: env!("BUILD_VERSION").to_string(),
        uptime_ms: get_uptime_ms(),
    })
}

async fn health_detailed(State(state): State<AppState>) -> Json<DetailedHealthResponse> {
    let postgres = check_postgres().await;
    let redis = check_redis().await;
    let qdrant = check_qdrant().await;
    let mongodb = check_mongodb(&state.mongo_cache).await;

    let overall_status = if postgres.status == "ok"
        && redis.status == "ok"
        && qdrant.status == "ok"
        && mongodb.status == "ok"
    {
        "ok"
    } else {
        "degraded"
    };

    Json(DetailedHealthResponse {
        status: overall_status.to_string(),
        version: env!("BUILD_VERSION").to_string(),
        uptime_ms: get_uptime_ms(),
        services: ServiceHealth {
            postgres,
            redis,
            qdrant,
            mongodb,
        },
    })
}

async fn login(Json(req): Json<LoginRequest>) -> Json<LoginResponse> {
    if req.username == "demo" && req.password == "demo" {
        Json(LoginResponse {
            success: true,
            message: Some("Login successful".to_string()),
            api_key: Some(DEMO_USER_API_KEY.to_string()),
            user: Some(UserInfo {
                username: "demo".to_string(),
            }),
        })
    } else {
        Json(LoginResponse {
            success: false,
            message: Some("Invalid username or password".to_string()),
            api_key: None,
            user: None,
        })
    }
}

async fn logout() -> Json<serde_json::Value> {
    Json(serde_json::json!({ "success": true }))
}

async fn check_postgres() -> ServiceStatus {
    let start = Instant::now();
    match PostgresDb::init().await {
        Ok(db) => match db.list_files(1, 0).await {
            Ok(_) => ServiceStatus {
                status: "ok".to_string(),
                latency_ms: Some(start.elapsed().as_millis() as u64),
                error: None,
            },
            Err(e) => ServiceStatus {
                status: "error".to_string(),
                latency_ms: Some(start.elapsed().as_millis() as u64),
                error: Some(e.to_string()),
            },
        },
        Err(e) => ServiceStatus {
            status: "error".to_string(),
            latency_ms: None,
            error: Some(e.to_string()),
        },
    }
}

async fn check_redis() -> ServiceStatus {
    let start = Instant::now();
    match RedisClient::new() {
        Ok(redis) => match redis.get_conn().await {
            Ok(mut conn) => {
                let result: Result<String, _> = redis::cmd("PING").query_async(&mut conn).await;
                match result {
                    Ok(_) => ServiceStatus {
                        status: "ok".to_string(),
                        latency_ms: Some(start.elapsed().as_millis() as u64),
                        error: None,
                    },
                    Err(e) => ServiceStatus {
                        status: "error".to_string(),
                        latency_ms: Some(start.elapsed().as_millis() as u64),
                        error: Some(e.to_string()),
                    },
                }
            }
            Err(e) => ServiceStatus {
                status: "error".to_string(),
                latency_ms: None,
                error: Some(e.to_string()),
            },
        },
        Err(e) => ServiceStatus {
            status: "error".to_string(),
            latency_ms: None,
            error: Some(e.to_string()),
        },
    }
}

async fn check_qdrant() -> ServiceStatus {
    let start = Instant::now();
    let base_url =
        std::env::var("QDRANT_URL").unwrap_or_else(|_| "http://localhost:6333".to_string());
    let api_key =
        std::env::var("QDRANT_API_KEY").unwrap_or_else(|_| "Test3200Test3200Test3200".to_string());
    let url = format!("{}/collections", base_url);

    let client = reqwest::Client::new();
    match client
        .get(&url)
        .header("api-key", api_key)
        .timeout(std::time::Duration::from_secs(5))
        .send()
        .await
    {
        Ok(resp) if resp.status().is_success() => ServiceStatus {
            status: "ok".to_string(),
            latency_ms: Some(start.elapsed().as_millis() as u64),
            error: None,
        },
        Ok(resp) => ServiceStatus {
            status: "error".to_string(),
            latency_ms: Some(start.elapsed().as_millis() as u64),
            error: Some(format!("HTTP {}", resp.status())),
        },
        Err(e) => ServiceStatus {
            status: "error".to_string(),
            latency_ms: None,
            error: Some(e.to_string()),
        },
    }
}

async fn check_mongodb(cache: &MongoCache) -> ServiceStatus {
    let start = Instant::now();
    match cache.health_check().await {
        Ok(_) => ServiceStatus {
            status: "ok".to_string(),
            latency_ms: Some(start.elapsed().as_millis() as u64),
            error: None,
        },
        Err(e) => ServiceStatus {
            status: "error".to_string(),
            latency_ms: Some(start.elapsed().as_millis() as u64),
            error: Some(e.to_string()),
        },
    }
}

fn generate_query_hash(query: &str, uuid: Option<&str>, limit: usize) -> String {
    let data = serde_json::json!({
        "query": query,
        "uuid": uuid,
        "limit": limit,
    });
    let mut hasher = Sha256::new();
    hasher.update(data.to_string().as_bytes());
    format!("{:x}", hasher.finalize())[..16].to_string()
}

fn generate_visual_search_hash(
    uuid: &str,
    criteria: &visual_chunk_search::VisualChunkSearchCriteria,
) -> String {
    let data = serde_json::json!({
        "uuid": uuid,
        "criteria": criteria,
    });
    let mut hasher = Sha256::new();
    hasher.update(data.to_string().as_bytes());
    format!("{:x}", hasher.finalize())[..16].to_string()
}

async fn register(
    State(state): State<AppState>,
    Json(req): Json<RegisterRequest>,
) -> Result<Json<RegisterResponse>, StatusCode> {
    let path = req.path;

    // Support both relative and absolute paths
    // Relative: ./demo/video.mp4 or demo/video.mp4
    // Absolute: /Users/.../sftpgo/data/demo/video.mp4
    let (relative_path, canonical_path) = if path.starts_with("./") || path.starts_with("../") {
        // Relative path - keep as is for UUID, resolve to absolute for storage
        let rel = path.clone();
        let abs = std::path::Path::new(&path)
            .canonicalize()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_else(|_| path.clone());
        (rel, abs)
    } else if std::path::Path::new(&path).is_absolute() {
        // Absolute path - use as is
        (path.clone(), path.clone())
    } else {
        // Assume relative path without ./
        let rel = format!("./{}", path);
        let abs = std::path::Path::new(&rel)
            .canonicalize()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_else(|_| path.clone());
        (rel, abs)
    };

    // Compute UUID from relative path (username/filepath)
    // Extract: ./demo/video.mp4 -> username="demo", filepath="video.mp4"
    let uuid = crate::core::storage::uuid::compute_uuid_from_relative_path(&relative_path);

    // Extract username and filepath for logging
    let (username, filepath) =
        crate::core::storage::uuid::extract_user_from_relative_path(&relative_path);
    tracing::info!(
        "Registering video: uuid={}, username={}, filepath={}, canonical={}",
        uuid,
        username,
        filepath,
        canonical_path
    );

    let db = PostgresDb::init()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // Check if video already exists
    if let Some(existing_video) = db
        .get_video_by_uuid(&uuid)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    {
        tracing::info!(
            "Video already registered: uuid={}, video_id={}",
            uuid,
            existing_video.id
        );

        // Get the job_id if exists (i64 -> i32)
        let job_id: i32 = existing_video.job_id.map(|id| id as i32).unwrap_or(0);

        return Ok(Json(RegisterResponse {
            file_uuid: existing_video.file_uuid,
            file_id: existing_video.id,
            job_id,
            file_name: existing_video.file_name,
            duration: existing_video.duration,
            width: existing_video.width,
            height: existing_video.height,
            already_exists: true,
        }));
    }

    let probe_result = {
        let probe_path = format!(
            "{}/{}.probe.json",
            crate::core::config::OUTPUT_DIR.as_str(),
            uuid
        );

        if let Ok(content) = std::fs::read_to_string(&probe_path) {
            tracing::info!("Using existing probe.json: {}", probe_path);
            serde_json::from_str(&content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        } else {
            tracing::info!("Running ffprobe for: {}", canonical_path);
            crate::core::probe::probe_video(&canonical_path)
                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        }
    };

    let duration = probe_result
        .format
        .duration
        .as_ref()
        .and_then(|s: &String| s.parse::<f64>().ok())
        .unwrap_or(0.0);

    let mut width = 0u32;
    let mut height = 0u32;

    let mut fps = 0.0;
    for stream in &probe_result.streams {
        if stream.codec_type.as_deref() == Some("video") {
            width = stream.width.unwrap_or(0);
            height = stream.height.unwrap_or(0);

            // Parse FPS from r_frame_rate (e.g., "60000/1001")
            if let Some(frame_rate_str) = &stream.r_frame_rate {
                if let Some((num_str, den_str)) = frame_rate_str.split_once('/') {
                    if let (Ok(num), Ok(den)) = (num_str.parse::<f64>(), den_str.parse::<f64>()) {
                        if den != 0.0 {
                            fps = num / den;
                        }
                    }
                }
            }
        }
    }

    let file_manager = FileManager::new(std::path::PathBuf::from(
        crate::core::config::OUTPUT_DIR.as_str(),
    ));
    let json_str =
        serde_json::to_string(&probe_result).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let _json_path = file_manager
        .save_json(&uuid, "probe", &json_str)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let file_name = std::path::Path::new(&canonical_path)
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_default();

    // Calculate total_frames: prefer nb_frames (exact) over duration * fps (approximate)
    let total_frames = {
        let video_stream = probe_result
            .streams
            .iter()
            .find(|s| s.codec_type.as_deref() == Some("video"));

        if let Some(stream) = video_stream {
            if let Some(nb_frames_str) = &stream.nb_frames {
                if let Ok(nb_frames) = nb_frames_str.parse::<u64>() {
                    tracing::info!(
                        "Using nb_frames from ffprobe: {} frames for {}",
                        nb_frames,
                        file_name
                    );
                    Some(nb_frames)
                } else {
                    tracing::warn!(
                        "Failed to parse nb_frames, using duration * fps fallback for {}",
                        file_name
                    );
                    Some((duration * fps).floor() as u64)
                }
            } else {
                tracing::warn!(
                    "nb_frames not available, using duration * fps fallback for {}",
                    file_name
                );
                Some((duration * fps).floor() as u64)
            }
        } else {
            tracing::warn!("No video stream found for {}", file_name);
            Some(0)
        }
    };

    // Determine file type
    let file_type = Some(detect_file_type(
        std::path::Path::new(&canonical_path),
        Some(&probe_result),
    ));

    let probe_json_str =
        serde_json::to_string(&probe_result).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let file_manager = FileManager::new(std::path::PathBuf::from(
        crate::core::config::OUTPUT_DIR.as_str(),
    ));

    if let Err(e) = file_manager.save_json(&uuid, "probe", &probe_json_str) {
        tracing::warn!("[REGISTER] Failed to save probe JSON: {}", e);
    }

    let record = VideoRecord {
        id: 0,
        file_uuid: uuid.clone(),
        file_path: canonical_path.clone(),
        file_name: file_name.clone(),
        file_type,
        duration,
        width,
        height,
        fps,
        probe_json: Some(probe_json_str),
        storage: Default::default(),
        status: VideoStatus::Pending,
        processing_status: Some(serde_json::json!({"phase": "REGISTERED"})),
        birth_registration: None,
        user_id: None,
        job_id: None,
        created_at: String::new(),
        registration_time: None,
        total_frames: total_frames.unwrap_or(0),
        parent_uuid: None,
    };

    let video_id = db.register_video(&record).await.map_err(|e| {
        tracing::error!("[REGISTER] Failed to register video: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    let job = db
        .create_monitor_job(&uuid, Some(&canonical_path))
        .await
        .map_err(|e| {
            tracing::error!("Failed to create monitor job for {}: {}", uuid, e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    db.update_video_job_id(video_id, job.id)
        .await
        .map_err(|e| {
            tracing::error!(
                "Failed to update video job_id for video {}: {}",
                video_id,
                e
            );
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    db.update_monitor_job_video_id(job.id, video_id)
        .await
        .map_err(|e| {
            tracing::error!(
                "Failed to update monitor job video_id for job {}: {}",
                job.id,
                e
            );
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let _ = state.mongo_cache.invalidate_videos_list().await;

    Ok(Json(RegisterResponse {
        file_uuid: uuid,
        file_id: video_id,
        job_id: job.id,
        file_name,
        duration,
        width,
        height,
        already_exists: false,
    }))
}

async fn register_file(
    State(state): State<AppState>,
    Json(req): Json<RegisterFileRequest>,
) -> Result<Json<RegisterFileResponse>, StatusCode> {
    let file_path = req.file_path;
    tracing::info!("[REGISTER] Starting registration for: {}", file_path);

    let path = std::path::Path::new(&file_path);
    if !path.exists() {
        return Ok(Json(RegisterFileResponse {
            success: false,
            file_uuid: String::new(),
            file_name: String::new(),
            file_path: file_path.clone(),
            file_type: None,
            duration: 0.0,
            width: 0,
            height: 0,
            fps: 0.0,
            total_frames: 0,
            registration_time: None,
            already_exists: false,
            message: format!("File not found: {}", file_path),
        }));
    }

    let canonical_path = path
        .canonicalize()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|_| file_path.clone());

    let file_name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_default();

    // Step 1: Initialize DB
    let db = PostgresDb::init().await.map_err(|e| {
        tracing::error!("[REGISTER] DB init failed: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    // Step 2: Determine Birthday (Identity Anchor)
    // Check if this file (by name) has already been born.
    // If yes, retrieve its original birth time. If no, it's a new birth.
    let birthday = sqlx::query_scalar::<_, chrono::DateTime<chrono::Utc>>(
        "SELECT registration_time FROM dev.videos WHERE file_name = $1 AND registration_time IS NOT NULL LIMIT 1"
    )
    .bind(&file_name)
    .fetch_optional(db.pool())
    .await
    .map_err(|e| {
        tracing::error!("[REGISTER] Birthday lookup failed: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?
    .map(|t| t.to_rfc3339())
    .unwrap_or_else(|| chrono::Utc::now().to_rfc3339());

    // Step 3: Generate Birth UUID based on MAC, Birthday, Path, and Filename.
    // Path IS part of the identity (file-centric design).
    let mac_address = crate::core::storage::uuid::get_mac_address();

    let file_uuid = crate::core::storage::uuid::compute_birth_uuid(
        &mac_address,
        &birthday,
        &canonical_path,
        &file_name,
    );
    tracing::info!("[REGISTER] 3. Calculated file_uuid: {}", file_uuid);

    // Check if identity already exists (Move scenario)
    if let Some(existing) = db.get_video_by_uuid(&file_uuid).await.map_err(|e| {
        tracing::error!("[REGISTER] DB query failed: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })? {
        // Identity exists -> Just update the address (file_path)
        if existing.file_path != canonical_path {
            tracing::info!(
                "[REGISTER] Move detected! Updating path: {}",
                canonical_path
            );
            db.update_file_path(&file_uuid, &canonical_path).await.ok();
        }

        return Ok(Json(RegisterFileResponse {
            success: true,
            file_uuid: existing.file_uuid,
            file_name: existing.file_name,
            file_path: existing.file_path, // Return current path
            file_type: existing.file_type,
            duration: existing.duration,
            width: existing.width,
            height: existing.height,
            fps: existing.fps,
            total_frames: existing.total_frames,
            registration_time: existing.registration_time,
            already_exists: true,
            message: "Identity exists, path updated.".to_string(),
        }));
    }

    // --- New Registration Flow ---

    // Step 2: Determine file_type (Fast check)
    let file_type = match path.extension().and_then(|e| e.to_str()) {
        Some(ext) => match ext.to_lowercase().as_str() {
            "mp4" | "mov" | "mkv" | "avi" | "webm" => Some("video".to_string()),
            "mp3" | "wav" | "flac" | "aac" => Some("audio".to_string()),
            "jpg" | "jpeg" | "png" | "gif" | "bmp" => Some("image".to_string()),
            _ => Some("unknown".to_string()),
        },
        None => Some("unknown".to_string()),
    };
    tracing::info!("[REGISTER] 2. Initial file_type: {:?}", file_type);

    // Step 3: Probe
    tracing::info!("[REGISTER] 3. Probing file...");
    let probe_result = crate::core::probe::probe_video(&canonical_path).map_err(|e| {
        tracing::error!("[REGISTER] Probe failed: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    // Refine file_type
    let has_video = probe_result
        .streams
        .iter()
        .any(|s| s.codec_type.as_deref() == Some("video"));
    let has_audio = probe_result
        .streams
        .iter()
        .any(|s| s.codec_type.as_deref() == Some("audio"));

    let final_file_type = if has_video {
        Some("video".to_string())
    } else if has_audio {
        Some("audio".to_string())
    } else {
        file_type
    };
    tracing::info!("[REGISTER] 3. Refined file_type: {:?}", final_file_type);

    // Metadata extraction
    let duration = probe_result
        .format
        .duration
        .as_ref()
        .and_then(|s| s.parse::<f64>().ok())
        .unwrap_or(0.0);
    let mut width = 0u32;
    let mut height = 0u32;
    let mut fps = 0.0;

    if let Some(s) = probe_result
        .streams
        .iter()
        .find(|s| s.codec_type.as_deref() == Some("video"))
    {
        width = s.width.unwrap_or(0);
        height = s.height.unwrap_or(0);
        if let Some(rate) = &s.r_frame_rate {
            if let Some((n, d)) = rate.split_once('/') {
                if let (Ok(n), Ok(d)) = (n.parse::<f64>(), d.parse::<f64>()) {
                    if d != 0.0 {
                        fps = n / d;
                    }
                }
            }
        }
    }

    let total_frames = if let Some(s) = probe_result
        .streams
        .iter()
        .find(|s| s.codec_type.as_deref() == Some("video"))
    {
        s.nb_frames
            .as_ref()
            .and_then(|n| n.parse::<u64>().ok())
            .unwrap_or((duration * fps).floor() as u64)
    } else {
        0
    };

    let probe_json_str =
        serde_json::to_string(&probe_result).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let file_manager = FileManager::new(std::path::PathBuf::from(
        crate::core::config::OUTPUT_DIR.as_str(),
    ));

    if let Err(e) = file_manager.save_json(&file_uuid, "probe", &probe_json_str) {
        tracing::warn!("[REGISTER] Failed to save probe JSON: {}", e);
    }

    // Step 4: Wait for processing (DB Insert)
    let record = VideoRecord {
        id: 0,
        file_uuid: file_uuid.clone(),
        file_path: canonical_path.clone(),
        file_name: file_name.clone(),
        file_type: final_file_type.clone(),
        duration,
        width,
        height,
        fps,
        probe_json: Some(probe_json_str),
        storage: Default::default(),
        status: VideoStatus::Pending,
        processing_status: Some(serde_json::json!({"phase": "REGISTERED"})),
        birth_registration: None,
        user_id: None,
        job_id: None,
        created_at: String::new(),
        registration_time: None,
        total_frames,
        parent_uuid: None,
    };

    let video_id = db.register_video(&record).await.map_err(|e| {
        tracing::error!("[REGISTER] Failed to register video: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    let job = db
        .create_monitor_job(&file_uuid, Some(&canonical_path))
        .await
        .map_err(|e| {
            tracing::error!("[REGISTER] Failed to create monitor job: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    db.update_video_job_id(video_id, job.id)
        .await
        .map_err(|e| {
            tracing::error!("[REGISTER] Failed to update video job_id: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    db.update_monitor_job_video_id(job.id, video_id)
        .await
        .map_err(|e| {
            tracing::error!("[REGISTER] Failed to update monitor job video_id: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let _ = state.mongo_cache.invalidate_videos_list().await;

    Ok(Json(RegisterFileResponse {
        success: true,
        file_uuid,
        file_name,
        file_path: canonical_path,
        file_type: final_file_type,
        duration,
        width,
        height,
        fps,
        total_frames,
        registration_time: None,
        already_exists: false,
        message: "File registered successfully".to_string(),
    }))
}

async fn probe(
    State(_state): State<AppState>,
    Json(req): Json<ProbeRequest>,
) -> Result<Json<ProbeResponse>, StatusCode> {
    let path = req.path;

    // Support both relative and absolute paths
    let (relative_path, canonical_path) = if path.starts_with("./") || path.starts_with("../") {
        let rel = path.clone();
        let abs = std::path::Path::new(&path)
            .canonicalize()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_else(|_| path.clone());
        (rel, abs)
    } else if std::path::Path::new(&path).is_absolute() {
        (path.clone(), path.clone())
    } else {
        let rel = format!("./{}", path);
        let abs = std::path::Path::new(&rel)
            .canonicalize()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_else(|_| path.clone());
        (rel, abs)
    };

    // Compute UUID from relative path
    let uuid = crate::core::storage::uuid::compute_uuid_from_relative_path(&relative_path);

    let (username, filepath) =
        crate::core::storage::uuid::extract_user_from_relative_path(&relative_path);
    tracing::info!(
        "Probing video: uuid={}, username={}, filepath={}, canonical={}",
        uuid,
        username,
        filepath,
        canonical_path
    );

    // Check for cached probe.json
    let probe_path = format!(
        "{}/{}.probe.json",
        crate::core::config::OUTPUT_DIR.as_str(),
        uuid
    );

    let (probe_result, cached) = if let Ok(content) = std::fs::read_to_string(&probe_path) {
        tracing::info!("Using cached probe.json: {}", probe_path);
        let result: crate::core::probe::ProbeResult =
            serde_json::from_str(&content).map_err(|e| {
                tracing::error!("Failed to parse cached probe.json: {}", e);
                StatusCode::INTERNAL_SERVER_ERROR
            })?;
        (result, true)
    } else {
        tracing::info!("Running ffprobe for: {}", canonical_path);
        let result = crate::core::probe::probe_video(&canonical_path).map_err(|e| {
            tracing::error!("ffprobe failed: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

        // Save probe.json to OUTPUT_DIR
        let file_manager = FileManager::new(std::path::PathBuf::from(
            crate::core::config::OUTPUT_DIR.as_str(),
        ));
        let json_str = serde_json::to_string(&result).map_err(|e| {
            tracing::error!("Failed to serialize probe result: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;
        file_manager
            .save_json(&uuid, "probe", &json_str)
            .map_err(|e| {
                tracing::error!("Failed to save probe.json: {}", e);
                StatusCode::INTERNAL_SERVER_ERROR
            })?;

        (result, false)
    };

    // Extract video info
    let duration = probe_result
        .format
        .duration
        .as_ref()
        .and_then(|s| s.parse::<f64>().ok())
        .unwrap_or(0.0);

    let mut width = 0u32;
    let mut height = 0u32;
    let mut fps = 0.0;

    for stream in &probe_result.streams {
        if stream.codec_type.as_deref() == Some("video") {
            width = stream.width.unwrap_or(0);
            height = stream.height.unwrap_or(0);
            // Parse fps from r_frame_rate (e.g., "30/1" or "29.97")
            if let Some(fps_str) = &stream.r_frame_rate {
                fps = if fps_str.contains('/') {
                    let parts: Vec<&str> = fps_str.split('/').collect();
                    if parts.len() == 2 {
                        let num: f64 = parts[0].parse().unwrap_or(0.0);
                        let den: f64 = parts[1].parse().unwrap_or(1.0);
                        if den > 0.0 {
                            num / den
                        } else {
                            0.0
                        }
                    } else {
                        0.0
                    }
                } else {
                    fps_str.parse().unwrap_or(0.0)
                };
            }
        }
    }

    let file_name = std::path::Path::new(&canonical_path)
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_default();

    Ok(Json(ProbeResponse {
        uuid,
        file_name,
        duration,
        width,
        height,
        fps,
        cached,
        format: probe_result.format,
        streams: probe_result.streams,
    }))
}

async fn probe_by_uuid(
    State(state): State<AppState>,
    Path(uuid): Path<String>,
) -> Result<Json<ProbeResponse>, StatusCode> {
    let table = schema::table_name("videos");
    let row: Option<(String, String)> = sqlx::query_as(&format!(
        "SELECT file_name, file_path FROM {} WHERE file_uuid = $1",
        table
    ))
    .bind(&uuid)
    .fetch_optional(state.db.pool())
    .await
    .map_err(|e| {
        tracing::error!("DB error fetching video: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    let (file_name, path) = row.ok_or_else(|| {
        tracing::warn!("Video not found: {}", uuid);
        StatusCode::NOT_FOUND
    })?;

    // 2. Check for cached probe.json
    let probe_path = format!(
        "{}/{}.probe.json",
        crate::core::config::OUTPUT_DIR.as_str(),
        uuid
    );

    let (probe_result, cached) = if let Ok(content) = std::fs::read_to_string(&probe_path) {
        tracing::info!("Using cached probe.json: {}", probe_path);
        let result: crate::core::probe::ProbeResult =
            serde_json::from_str(&content).map_err(|e| {
                tracing::error!("Failed to parse cached probe.json: {}", e);
                StatusCode::INTERNAL_SERVER_ERROR
            })?;
        (result, true)
    } else {
        tracing::info!("Running ffprobe for: {}", path);
        let result = crate::core::probe::probe_video(&path).map_err(|e| {
            tracing::error!("ffprobe failed: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

        // Save probe.json to OUTPUT_DIR
        let file_manager = FileManager::new(std::path::PathBuf::from(
            crate::core::config::OUTPUT_DIR.as_str(),
        ));
        let json_str = serde_json::to_string(&result).map_err(|e| {
            tracing::error!("Failed to serialize probe result: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;
        file_manager
            .save_json(&uuid, "probe", &json_str)
            .map_err(|e| {
                tracing::error!("Failed to save probe.json: {}", e);
                StatusCode::INTERNAL_SERVER_ERROR
            })?;

        (result, false)
    };

    // 3. Extract video info
    let duration = probe_result
        .format
        .duration
        .as_ref()
        .and_then(|s| s.parse::<f64>().ok())
        .unwrap_or(0.0);

    let mut width = 0u32;
    let mut height = 0u32;
    let mut fps = 0.0;

    for stream in &probe_result.streams {
        if stream.codec_type.as_deref() == Some("video") {
            width = stream.width.unwrap_or(0);
            height = stream.height.unwrap_or(0);
            if let Some(fps_str) = &stream.r_frame_rate {
                fps = if fps_str.contains('/') {
                    let parts: Vec<&str> = fps_str.split('/').collect();
                    if parts.len() == 2 {
                        let num: f64 = parts[0].parse().unwrap_or(0.0);
                        let den: f64 = parts[1].parse().unwrap_or(1.0);
                        if den > 0.0 {
                            num / den
                        } else {
                            0.0
                        }
                    } else {
                        0.0
                    }
                } else {
                    fps_str.parse().unwrap_or(0.0)
                };
            }
        }
    }

    Ok(Json(ProbeResponse {
        uuid,
        file_name,
        duration,
        width,
        height,
        fps,
        cached,
        format: probe_result.format,
        streams: probe_result.streams,
    }))
}

// --- P0 Core API Handlers ---

async fn trigger_processing(
    State(state): State<AppState>,
    Path(uuid): Path<String>,
    Json(req): Json<ProcessRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
    let table = schema::table_name("videos");
    let asset: Option<(String, i64)> = sqlx::query_as(&format!(
        "SELECT file_name, COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1",
        table
    ))
    .bind(&uuid)
    .fetch_optional(state.db.pool())
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("DB Error: {}", e),
        )
    })?;

    let (file_name, total_frames) =
        asset.ok_or((StatusCode::NOT_FOUND, "Asset not found".to_string()))?;
    if total_frames == 0 {
        return Err((
            StatusCode::BAD_REQUEST,
            "Total frames unknown. Run probe first.".to_string(),
        ));
    }

    // Get video file_path
    let video_path: Option<String> = sqlx::query_scalar(&format!(
        "SELECT file_path FROM {} WHERE file_uuid = $1",
        table
    ))
    .bind(&uuid)
    .fetch_optional(state.db.pool())
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Failed to get video path: {}", e),
        )
    })?;

    // 2. Create Monitor Job (Worker polls this table)
    let monitor_job = state
        .db
        .create_monitor_job(&uuid, video_path.as_deref())
        .await
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Failed to create monitor job: {}", e),
            )
        })?;

    // Update processors if specified
    let processors_to_run: Vec<&str> = if let Some(procs) = &req.processors {
        let table = crate::core::db::schema::table_name("monitor_jobs");
        sqlx::query(&format!(
            "UPDATE {} SET processors = $1 WHERE id = $2",
            table
        ))
        .bind(procs)
        .bind(monitor_job.id)
        .execute(state.db.pool())
        .await
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Failed to update processors: {}", e),
            )
        })?;
        procs.iter().map(|s| s.as_str()).collect()
    } else {
        // Empty means all processors
        vec![
            "asr",
            "cut",
            "yolo",
            "ocr",
            "face",
            "pose",
            "asrx",
            "visual_chunk",
        ]
    };

    // 3. Update Asset Status
    let table = schema::table_name("videos");
    sqlx::query(&format!(
        "UPDATE {} SET status = 'processing' WHERE file_uuid = $1",
        table
    ))
    .bind(&uuid)
    .execute(state.db.pool())
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Failed to update asset status: {}", e),
        )
    })?;

    // Initialize processing_status with all processors and total_frames
    state
        .db
        .init_processing_status(&uuid, processors_to_run, total_frames as u64)
        .await
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Failed to init processing status: {}", e),
            )
        })?;

    tracing::info!("Job {} created for asset {}", monitor_job.id, uuid);

    Ok(Json(serde_json::json!({
        "job_id": monitor_job.id,
        "asset_uuid": uuid,
        "status": "PENDING",
        "message": format!("Processing triggered for {}", file_name)
    })))
}

async fn get_asset_status(
    State(state): State<AppState>,
    Path(uuid): Path<String>,
) -> Result<Json<AssetStatusResponse>, StatusCode> {
    let videos_table = schema::table_name("videos");
    let row: Option<(String, String, chrono::DateTime<chrono::Utc>, String, i64)> = sqlx::query_as(
        &format!(
            "SELECT file_uuid, file_name, created_at AT TIME ZONE 'UTC', COALESCE(processing_status, 'REGISTERED'), COALESCE(total_frames, 0) FROM {} WHERE file_uuid = $1",
            videos_table
        )
    )
    .bind(&uuid)
    .fetch_optional(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let (uuid, file_name, time, status, total) = row.ok_or(StatusCode::NOT_FOUND)?;

    let jobs_table = schema::table_name("monitor_jobs");
    let job: Option<(String, String, i64, i64)> = sqlx::query_as(
        &format!(
            "SELECT id::text, COALESCE(status, 'QUEUED'), COALESCE(processed_frames, 0), COALESCE(total_frames, 0) FROM {} WHERE asset_uuid = $1 ORDER BY created_at DESC LIMIT 1",
            jobs_table
        )
    )
    .bind(&uuid)
    .fetch_optional(state.db.pool())
    .await
    .ok()
    .flatten();

    let progress = if let Some((jid, jstatus, pf, tf)) = job {
        if tf > 0 && (jstatus == "RUNNING" || jstatus == "QUEUED") {
            Some((
                jid,
                FrameProgress {
                    total_frames: tf,
                    processed_frames: pf,
                    progress_percent: (pf as f64 / tf as f64) * 100.0,
                },
            ))
        } else {
            None
        }
    } else {
        None
    };

    Ok(Json(AssetStatusResponse {
        uuid,
        file_name,
        registration_time: time.to_rfc3339(),
        processing_status: status,
        current_job_id: progress.as_ref().map(|(id, _)| id.clone()),
        frame_progress: progress.map(|(_, p)| p),
    }))
}

async fn get_job_status(
    State(state): State<AppState>,
    Path(job_id): Path<String>,
) -> Result<Json<JobStatusResponse>, StatusCode> {
    let jobs_table = schema::table_name("monitor_jobs");
    let row: Option<(String, String, String, String, Option<String>, i64, i64)> = sqlx::query_as(
        &format!(
            "SELECT j.id::text, j.asset_uuid, COALESCE(j.rule, 'unknown'), COALESCE(j.status, 'QUEUED'), j.assigned_processor_id::text, j.processed_frames, j.total_frames FROM {} j WHERE j.id = $1::uuid",
            jobs_table
        )
    )
    .bind(&job_id)
    .fetch_optional(state.db.pool())
    .await
    .map_err(|e| {
        eprintln!("DB Error in get_job_status: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    let (id, asset, rule, status, proc_id, pf, tf) = row.ok_or(StatusCode::NOT_FOUND)?;

    Ok(Json(JobStatusResponse {
        job_id: id,
        asset_uuid: asset,
        rule,
        status,
        current_processor_id: proc_id,
        frame_progress: FrameProgress {
            total_frames: tf,
            processed_frames: pf,
            progress_percent: if tf > 0 {
                (pf as f64 / tf as f64) * 100.0
            } else {
                0.0
            },
        },
    }))
}

async fn get_rule_status(
    State(state): State<AppState>,
    Path(rule): Path<String>,
) -> Result<Json<RuleStatusResponse>, StatusCode> {
    let procs: Vec<String> = sqlx::query_scalar(
        "SELECT id::text FROM processors WHERE supported_rules @> ARRAY[$1]::TEXT[]",
    )
    .bind(&rule)
    .fetch_all(state.db.pool())
    .await
    .unwrap_or_default();

    let jobs_table = schema::table_name("monitor_jobs");
    let jobs: Vec<(String, String, String, String, Option<String>, i64, i64)> = sqlx::query_as(
        &format!(
            "SELECT id::text, asset_uuid, COALESCE(rule, 'unknown'), status, assigned_processor_id::text, processed_frames, total_frames FROM {} WHERE rule = $1 AND status IN ('QUEUED','RUNNING')",
            jobs_table
        )
    )
    .bind(&rule)
    .fetch_all(state.db.pool())
    .await
    .unwrap_or_default();

    let active = jobs
        .into_iter()
        .map(|(id, asset, r, s, p, pf, tf)| JobStatusResponse {
            job_id: id,
            asset_uuid: asset,
            rule: r,
            status: s,
            current_processor_id: p,
            frame_progress: FrameProgress {
                total_frames: tf,
                processed_frames: pf,
                progress_percent: if tf > 0 {
                    (pf as f64 / tf as f64) * 100.0
                } else {
                    0.0
                },
            },
        })
        .collect();

    Ok(Json(RuleStatusResponse {
        rule,
        supported_processor_ids: procs,
        active_jobs: active,
    }))
}

// --- End P0 Core API Handlers ---

async fn search(
    State(state): State<AppState>,
    Json(req): Json<SearchRequest>,
) -> Result<Json<SearchResponse>, StatusCode> {
    let mode = req.mode.unwrap_or(SearchMode::Smart);
    let limit = req.limit.unwrap_or(10);
    let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit);
    let cache_key = keys::search(&format!("{:?}", mode));
    let ttl = state.mongo_cache.ttl_search();

    let response = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_SEARCH, || async {
            let pg = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            let results: Vec<SearchResult> = match mode {
                SearchMode::Vector => {
                    let query_vector = state
                        .embedder
                        .embed_query(&req.query)
                        .await
                        .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?;

                    let qdrant = QdrantDb::new();
                    let search_results = if let Some(ref uuid) = req.uuid {
                        qdrant.search_in_uuid(&query_vector, uuid, limit).await?
                    } else {
                        qdrant.search(&query_vector, limit).await?
                    };

                    let mut results = Vec::new();
                    for r in search_results {
                        if let Some(chunk) = pg
                            .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid)
                            .await
                            .ok()
                            .flatten()
                        {
                            let text = extract_text_from_content(&chunk.content);
                            results.push(SearchResult {
                                uuid: r.uuid,
                                chunk_id: r.chunk_id,
                                chunk_type: chunk.chunk_type.as_str().to_string(),
                                start_time: chunk.start_time().seconds(),
                                end_time: chunk.end_time().seconds(),
                                text,
                                score: r.score,
                            });
                        }
                    }
                    results
                }
                SearchMode::Smart => {
                    // Vector search + BM25 reranking
                    let query_vector = state
                        .embedder
                        .embed_query(&req.query)
                        .await
                        .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?;

                    let qdrant = QdrantDb::new();
                    let search_results = if let Some(ref uuid) = req.uuid {
                        qdrant
                            .search_in_uuid(&query_vector, uuid, limit * 2)
                            .await?
                    } else {
                        qdrant.search(&query_vector, limit * 2).await?
                    };

                    // 取得所有 chunk 並用 BM25 重新排序
                    let mut results_with_bm25: Vec<(SearchResult, f32)> = Vec::new();
                    for r in search_results {
                        if let Some(chunk) = pg
                            .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid)
                            .await
                            .ok()
                            .flatten()
                        {
                            let text = extract_text_from_content(&chunk.content);
                            let vector_score = r.score;
                            results_with_bm25.push((
                                SearchResult {
                                    uuid: r.uuid,
                                    chunk_id: r.chunk_id,
                                    chunk_type: chunk.chunk_type.as_str().to_string(),
                                    start_time: chunk.start_time().seconds(),
                                    end_time: chunk.end_time().seconds(),
                                    text,
                                    score: vector_score,
                                },
                                vector_score,
                            ));
                        }
                    }

                    // 依 vector score 排序後取前 limit 個
                    results_with_bm25
                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
                    results_with_bm25
                        .into_iter()
                        .take(limit)
                        .map(|(r, _)| r)
                        .collect()
                }
            };

            Ok::<SearchResponse, anyhow::Error>(SearchResponse {
                results,
                query: req.query.clone(),
            })
        })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(response))
}

async fn n8n_search(
    State(state): State<AppState>,
    Json(req): Json<SearchRequest>,
) -> Result<Json<N8nSearchResponse>, StatusCode> {
    let limit = req.limit.unwrap_or(10);
    let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit);
    let cache_key = keys::n8n_search(&query_hash);
    let ttl = state.mongo_cache.ttl_search();

    let response = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_N8N_SEARCH, || async {
            let query_vector = state
                .embedder
                .embed_query(&req.query)
                .await
                .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?;

            let qdrant = QdrantDb::new();
            let pg = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            let search_results = if let Some(ref uuid) = req.uuid {
                qdrant.search_in_uuid(&query_vector, uuid, limit).await?
            } else {
                qdrant.search(&query_vector, limit).await?
            };

            let mut hits = Vec::new();

            for r in search_results {
                if let Some(chunk) = pg
                    .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid)
                    .await
                    .ok()
                    .flatten()
                {
                    let text = extract_text_from_content(&chunk.content);

                    let title = extract_title_from_content(&chunk.content);

                    let file_path = if chunk.uuid.is_empty() {
                        None
                    } else {
                        let video = pg.get_video_by_uuid(&chunk.uuid).await.ok().flatten();
                        video.map(|v| v.file_path)
                    };

                    hits.push(N8nSearchHit {
                        id: chunk.chunk_id.clone(),
                        vid: chunk.uuid.clone(),
                        start_frame: chunk.start_frame,
                        end_frame: chunk.end_frame,
                        fps: chunk.fps,
                        start: chunk.start_time().seconds(),
                        end: chunk.end_time().seconds(),
                        title: if title.is_empty() {
                            format!("Chunk {}", chunk.chunk_id)
                        } else {
                            title
                        },
                        text,
                        score: r.score,
                        file_path,
                        has_visual_stats: chunk.visual_stats.as_ref().map(|v| {
                            !v.is_null() && v.as_object().map_or(false, |o| !o.is_empty())
                        }),
                        parent_id: chunk.parent_chunk_id.clone(),
                    });
                }
            }

            Ok::<N8nSearchResponse, anyhow::Error>(N8nSearchResponse {
                query: req.query.clone(),
                count: hits.len(),
                hits,
            })
        })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(response))
}

async fn search_bm25(
    State(state): State<AppState>,
    Json(req): Json<SearchRequest>,
) -> Result<Json<SearchResponse>, StatusCode> {
    let limit = req.limit.unwrap_or(10);
    let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit);
    let cache_key = keys::bm25_search(&query_hash);
    let ttl = state.mongo_cache.ttl_search();

    let response = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_SEARCH, || async {
            let pg = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            let bm25_results = pg
                .search_bm25(&req.query, req.uuid.as_deref(), limit)
                .await?;

            let results: Vec<SearchResult> = bm25_results
                .into_iter()
                .map(|r| SearchResult {
                    uuid: r.uuid,
                    chunk_id: r.chunk_id,
                    chunk_type: r.chunk_type,
                    start_time: r.start_time,
                    end_time: r.end_time,
                    text: r.text,
                    score: r.bm25_score,
                })
                .collect();

            Ok::<SearchResponse, anyhow::Error>(SearchResponse {
                results,
                query: req.query.clone(),
            })
        })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(response))
}

async fn search_smart(
    State(state): State<AppState>,
    Json(req): Json<SearchRequest>,
) -> Result<Json<SearchResponse>, StatusCode> {
    let limit = req.limit.unwrap_or(10);
    let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit);
    let cache_key = keys::search(&format!("{}smart", query_hash));
    let ttl = state.mongo_cache.ttl_search();

    let response = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_SEARCH, || async {
            let pg = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            let keywords = vec![req.query.clone()];

            let search_terms = keywords.join(" ");

            let bm25_results = pg
                .search_bm25(&search_terms, req.uuid.as_deref(), limit)
                .await?;

            let results: Vec<SearchResult> = bm25_results
                .into_iter()
                .map(|r| SearchResult {
                    uuid: r.uuid,
                    chunk_id: r.chunk_id,
                    chunk_type: r.chunk_type,
                    start_time: r.start_time,
                    end_time: r.end_time,
                    text: r.text,
                    score: r.bm25_score,
                })
                .collect();

            Ok::<SearchResponse, anyhow::Error>(SearchResponse {
                results,
                query: req.query.clone(),
            })
        })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(response))
}

async fn n8n_search_bm25(
    State(state): State<AppState>,
    Json(req): Json<SearchRequest>,
) -> Result<Json<N8nSearchResponse>, StatusCode> {
    let limit = req.limit.unwrap_or(10);
    let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit);
    let cache_key = keys::n8n_bm25_search(&query_hash);
    let ttl = state.mongo_cache.ttl_search();

    let response = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_N8N_SEARCH, || async {
            let pg = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            let bm25_results = pg
                .search_bm25(&req.query, req.uuid.as_deref(), limit)
                .await?;

            let mut hits = Vec::new();

            for r in bm25_results {
                if let Some(chunk) = pg
                    .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid)
                    .await
                    .ok()
                    .flatten()
                {
                    let text = r.text; // Use text from BM25 result
                    let title = extract_title_from_content(&chunk.content);

                    let file_path = if chunk.uuid.is_empty() {
                        None
                    } else {
                        let video = pg.get_video_by_uuid(&chunk.uuid).await.ok().flatten();
                        video.map(|v| v.file_path)
                    };

                    hits.push(N8nSearchHit {
                        id: chunk.chunk_id.clone(),
                        vid: chunk.uuid.clone(),
                        start_frame: chunk.start_frame,
                        end_frame: chunk.end_frame,
                        fps: chunk.fps,
                        start: chunk.start_time().seconds(),
                        end: chunk.end_time().seconds(),
                        title: if title.is_empty() {
                            format!("Chunk {}", chunk.chunk_id)
                        } else {
                            title
                        },
                        text,
                        score: r.bm25_score,
                        file_path,
                        has_visual_stats: chunk.visual_stats.as_ref().map(|v| {
                            !v.is_null() && v.as_object().map_or(false, |o| !o.is_empty())
                        }),
                        parent_id: chunk.parent_chunk_id.clone(),
                    });
                }
            }

            Ok::<N8nSearchResponse, anyhow::Error>(N8nSearchResponse {
                query: req.query.clone(),
                count: hits.len(),
                hits,
            })
        })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(response))
}

async fn n8n_search_hybrid(
    State(state): State<AppState>,
    Json(req): Json<HybridSearchRequest>,
) -> Result<Json<N8nSearchResponse>, StatusCode> {
    let limit = req.limit.unwrap_or(10);
    let vector_weight = req.vector_weight.unwrap_or(0.7);
    let bm25_weight = req.bm25_weight.unwrap_or(0.3);

    let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit);
    let cache_key = keys::hybrid_search(&query_hash);
    let ttl = state.mongo_cache.ttl_hybrid_search();

    let response = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_HYBRID_SEARCH, || async {
            let query_vector = state
                .embedder
                .embed_query(&req.query)
                .await
                .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?;

            let pg = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            let results = pg
                .hybrid_search(
                    &req.query,
                    &query_vector,
                    req.uuid.as_deref(),
                    limit,
                    vector_weight,
                    bm25_weight,
                )
                .await?;

            let mut hits = Vec::new();

            for r in results {
                if let Some(chunk) = pg
                    .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid)
                    .await
                    .ok()
                    .flatten()
                {
                    let text = r.text;
                    let title = extract_title_from_content(&chunk.content);

                    let file_path = if chunk.uuid.is_empty() {
                        None
                    } else {
                        let video = pg.get_video_by_uuid(&chunk.uuid).await.ok().flatten();
                        video.map(|v| v.file_path)
                    };

                    hits.push(N8nSearchHit {
                        id: chunk.chunk_id.clone(),
                        vid: chunk.uuid.clone(),
                        start_frame: chunk.start_frame,
                        end_frame: chunk.end_frame,
                        fps: chunk.fps,
                        start: chunk.start_time().seconds(),
                        end: chunk.end_time().seconds(),
                        title: if title.is_empty() {
                            format!("Chunk {}", chunk.chunk_id)
                        } else {
                            title
                        },
                        text,
                        score: r.combined_score as f32,
                        file_path,
                        has_visual_stats: chunk.visual_stats.as_ref().map(|v| {
                            !v.is_null() && v.as_object().map_or(false, |o| !o.is_empty())
                        }),
                        parent_id: chunk.parent_chunk_id.clone(),
                    });
                }
            }

            Ok::<N8nSearchResponse, anyhow::Error>(N8nSearchResponse {
                query: req.query.clone(),
                count: hits.len(),
                hits,
            })
        })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(response))
}

async fn n8n_search_smart(
    State(state): State<AppState>,
    Json(req): Json<SearchRequest>,
) -> Result<Json<N8nSearchResponse>, StatusCode> {
    let limit = req.limit.unwrap_or(10);
    let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit);
    let cache_key = keys::search(&format!("{}smart", query_hash));
    let ttl = state.mongo_cache.ttl_search();

    let response = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_SEARCH, || async {
            let pg = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            let keywords = vec![req.query.clone()];
            let search_terms = keywords.join(" ");
            let file_uuid = req.uuid.clone();

            let bm25_results = pg
                .search_bm25(&search_terms, file_uuid.as_deref(), limit)
                .await?;

            let mut hits = Vec::new();

            for r in bm25_results {
                if let Some(chunk) = pg
                    .get_chunk_by_chunk_id_and_uuid(&r.chunk_id, &r.uuid)
                    .await
                    .ok()
                    .flatten()
                {
                    let text = r.text;
                    let title = extract_title_from_content(&chunk.content);

                    let file_path = if chunk.uuid.is_empty() {
                        None
                    } else {
                        let video = pg.get_video_by_uuid(&chunk.uuid).await.ok().flatten();
                        video.map(|v| v.file_path)
                    };

                    hits.push(N8nSearchHit {
                        id: chunk.chunk_id.clone(),
                        vid: chunk.uuid.clone(),
                        start_frame: chunk.start_frame,
                        end_frame: chunk.end_frame,
                        fps: chunk.fps,
                        start: chunk.start_time().seconds(),
                        end: chunk.end_time().seconds(),
                        title: if title.is_empty() {
                            format!("Chunk {}", chunk.chunk_id)
                        } else {
                            title
                        },
                        text,
                        score: r.bm25_score,
                        file_path,
                        has_visual_stats: chunk.visual_stats.as_ref().map(|v| {
                            !v.is_null() && v.as_object().map_or(false, |o| !o.is_empty())
                        }),
                        parent_id: chunk.parent_chunk_id.clone(),
                    });
                }
            }

            Ok::<N8nSearchResponse, anyhow::Error>(N8nSearchResponse {
                query: req.query.clone(),
                count: hits.len(),
                hits,
            })
        })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(response))
}

async fn hybrid_search(
    State(state): State<AppState>,
    Json(req): Json<HybridSearchRequest>,
) -> Result<Json<HybridSearchResponse>, StatusCode> {
    let limit = req.limit.unwrap_or(10);
    let vector_weight = req.vector_weight.unwrap_or(0.7);
    let bm25_weight = req.bm25_weight.unwrap_or(0.3);

    let query_hash = generate_query_hash(&req.query, req.uuid.as_deref(), limit);
    let cache_key = keys::hybrid_search(&query_hash);
    let ttl = state.mongo_cache.ttl_hybrid_search();

    let response = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_HYBRID_SEARCH, || async {
            let query_vector = state
                .embedder
                .embed_query(&req.query)
                .await
                .map_err(|e| anyhow::anyhow!("Embedding failed: {}", e))?;

            let pg = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            let results = pg
                .hybrid_search(
                    &req.query,
                    &query_vector,
                    req.uuid.as_deref(),
                    limit,
                    vector_weight,
                    bm25_weight,
                )
                .await?;

            let search_results: Vec<HybridSearchResult> = results
                .into_iter()
                .map(|r| HybridSearchResult {
                    uuid: r.uuid,
                    chunk_id: r.chunk_id,
                    chunk_type: r.chunk_type,
                    start_time: r.start_time,
                    end_time: r.end_time,
                    text: r.text,
                    vector_score: r.vector_score,
                    bm25_score: r.bm25_score,
                    combined_score: r.combined_score,
                })
                .collect();

            Ok::<HybridSearchResponse, anyhow::Error>(HybridSearchResponse {
                results: search_results,
                query: req.query.clone(),
            })
        })
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(response))
}

async fn lookup(
    State(state): State<AppState>,
    Query(query): Query<LookupQuery>,
) -> Result<Json<LookupResponse>, StatusCode> {
    if let Some(path) = query.path {
        let uuid = crate::uuid::compute_uuid_from_path(&path);
        return Ok(Json(LookupResponse {
            file_uuid: uuid,
            file_path: None,
            file_name: None,
            duration: None,
        }));
    }

    if let Some(uuid) = query.uuid {
        let cache_key = keys::video_meta(&uuid);
        let ttl = state.mongo_cache.ttl_video_meta();

        let video = state
            .mongo_cache
            .get_or_fetch(&cache_key, ttl, keys::CATEGORY_VIDEO_META, || async {
                let db = PostgresDb::init()
                    .await
                    .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;
                db.get_video_by_uuid(&uuid)
                    .await
                    .map_err(|e| anyhow::anyhow!("{}", e))
            })
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

        if let Some(v) = video {
            return Ok(Json(LookupResponse {
                file_uuid: v.file_uuid,
                file_path: Some(v.file_path),
                file_name: Some(v.file_name),
                duration: Some(v.duration),
            }));
        }
    }

    Err(StatusCode::NOT_FOUND)
}

#[derive(Debug, Serialize, Deserialize)]
struct ScannedFileInfo {
    file_name: String,
    relative_path: String,
    file_path: String,
    file_size: u64,
    modified_time: String,
    // Registration info
    is_registered: bool,
    file_uuid: Option<String>,
    status: Option<String>,
    registration_time: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
struct ScanFilesResponse {
    files: Vec<ScannedFileInfo>,
    total: usize,
    registered_count: usize,
    unregistered_count: usize,
}

fn scan_directory_recursive(
    dir: &std::path::Path,
    root: &std::path::Path,
    allowed_extensions: &[&str],
    registered_paths: &std::collections::HashMap<String, (String, String, Option<String>)>,
    files: &mut Vec<ScannedFileInfo>,
) {
    if let Ok(entries) = std::fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                    if name.starts_with('.') {
                        return;
                    }
                }
                scan_directory_recursive(&path, root, allowed_extensions, registered_paths, files);
            } else if path.is_file() {
                if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
                    if allowed_extensions.contains(&ext.to_lowercase().as_str()) {
                        if let Ok(meta) = entry.metadata() {
                            let abs_path = path.to_string_lossy().to_string();
                            let rel_path = path
                                .strip_prefix(root)
                                .map(|p| p.to_string_lossy().to_string())
                                .unwrap_or_else(|_| abs_path.clone());

                            let file_name = path
                                .file_name()
                                .map(|n| n.to_string_lossy().to_string())
                                .unwrap_or_default();

                            let modified_time = meta
                                .modified()
                                .ok()
                                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                                .map(|d| {
                                    chrono::DateTime::from_timestamp(d.as_secs() as i64, 0)
                                        .map(|dt| dt.to_rfc3339())
                                        .unwrap_or_default()
                                })
                                .unwrap_or_default();

                            // Check registration
                            if let Some((uuid, status, reg_time)) = registered_paths.get(&abs_path)
                            {
                                files.push(ScannedFileInfo {
                                    file_name,
                                    relative_path: rel_path,
                                    file_path: abs_path,
                                    file_size: meta.len(),
                                    modified_time,
                                    is_registered: true,
                                    file_uuid: Some(uuid.clone()),
                                    status: Some(status.clone()),
                                    registration_time: reg_time.clone(),
                                });
                            } else {
                                files.push(ScannedFileInfo {
                                    file_name,
                                    relative_path: rel_path,
                                    file_path: abs_path,
                                    file_size: meta.len(),
                                    modified_time,
                                    is_registered: false,
                                    file_uuid: None,
                                    status: None,
                                    registration_time: None,
                                });
                            }
                        }
                    }
                }
            }
        }
    }
}

async fn scan_files(State(state): State<AppState>) -> Result<Json<ScanFilesResponse>, StatusCode> {
    let demo_dir_str = std::env::var("MOMENTRY_SFTP_ROOT")
        .unwrap_or_else(|_| "/Users/accusys/momentry/var/sftpgo/data/demo".to_string());
    let demo_dir = std::path::Path::new(&demo_dir_str);

    let allowed_extensions = vec!["mp4", "mov", "mkv", "avi", "webm"];

    // 1. Get registered files from DB (Map key: absolute file_path)
    let table = schema::table_name("videos");
    let registered_db: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(&format!(
        "SELECT file_path, file_name, file_uuid, status, registration_time::text FROM {} ORDER BY id",
        table
    ))
    .fetch_all(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let registered_paths: std::collections::HashMap<String, (String, String, Option<String>)> =
        registered_db
            .into_iter()
            .map(|(path, _name, uuid, status, reg_time)| (path, (uuid, status, reg_time)))
            .collect();

    // 2. Scan filesystem recursively
    let mut result_files = Vec::new();

    if demo_dir.exists() {
        scan_directory_recursive(
            demo_dir,
            demo_dir,
            &allowed_extensions,
            &registered_paths,
            &mut result_files,
        );
    }

    // 3. Sort: registered first, then by name
    result_files.sort_by(|a, b| {
        b.is_registered
            .cmp(&a.is_registered)
            .then(a.relative_path.cmp(&b.relative_path))
    });

    let registered_count = result_files.iter().filter(|f| f.is_registered).count();
    let unregistered_count = result_files.iter().filter(|f| !f.is_registered).count();

    Ok(Json(ScanFilesResponse {
        files: result_files,
        total: registered_count + unregistered_count,
        registered_count,
        unregistered_count,
    }))
}

async fn list_files(
    State(state): State<AppState>,
    Query(params): Query<FilesQuery>,
) -> Result<Json<FilesResponse>, StatusCode> {
    let page = params.page.unwrap_or(1);
    let page_size = params.page_size.unwrap_or(20);
    let offset = ((page - 1) as i64) * (page_size as i64);
    let status_filter = params.status.clone();
    let query_filter = params.q.clone();

    // Include query and status in cache key
    let cache_key = keys::videos_list(page, page_size);
    let cache_key = if let Some(ref q) = query_filter {
        format!("{}:q:{}", cache_key, q)
    } else {
        cache_key
    };
    let ttl = state.mongo_cache.ttl_videos();

    // If uuid is provided, fetch single video directly
    if let Some(ref uuid) = params.uuid {
        let db = PostgresDb::init()
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

        let video = db
            .get_video_by_uuid(uuid)
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

        if let Some(v) = video {
            return Ok(Json(FilesResponse {
                files: vec![FileInfoResponse {
                    file_uuid: v.file_uuid,
                    file_path: v.file_path,
                    file_name: v.file_name,
                    file_type: v.file_type,
                    duration: v.duration,
                    width: v.width,
                    height: v.height,
                    status: v.status.as_str().to_string(),
                    processing_status: v.processing_status,
                    birth_registration: v.birth_registration,
                    created_at: Some(v.created_at),
                    registration_time: v.registration_time,
                    file_size: None,
                    probe_json: v.probe_json,
                    total_frames: v.total_frames,
                }],
                count: 1,
                page,
                page_size,
            }));
        } else {
            return Err(StatusCode::NOT_FOUND);
        }
    }

    tracing::info!(
        "list_files called: page={}, page_size={}, cache_key={}",
        page,
        page_size,
        cache_key
    );

    let file_infos = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_VIDEOS, || async {
            tracing::info!("Fetching videos from database...");
            let db = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            // Map status parameter to is_processed filter
            let is_processed = match status_filter.as_deref() {
                Some("pending") | Some("unprocessed") => Some(false),
                Some("completed") | Some("ready") | Some("processed") => Some(true),
                _ => None, // no filter
            };

            // Search by query if provided
            let (files, count) = if let Some(ref q) = query_filter {
                db.search_videos(Some(q.as_str()), is_processed, page_size as i32, offset)
                    .await?
            } else if let Some(processed) = is_processed {
                db.search_videos(None, Some(processed), page_size as i32, offset)
                    .await?
            } else {
                db.list_videos(page_size as i32, offset).await?
            };
            tracing::info!("Got {} files from DB", files.len());

            let file_infos: Vec<FileInfoResponse> = files
                .into_iter()
                .map(|v| FileInfoResponse {
                    file_uuid: v.file_uuid,
                    file_path: v.file_path,
                    file_name: v.file_name,
                    file_type: v.file_type,
                    duration: v.duration,
                    width: v.width,
                    height: v.height,
                    status: v.status.as_str().to_string(),
                    processing_status: v.processing_status,
                    birth_registration: v.birth_registration,
                    created_at: Some(v.created_at),
                    registration_time: v.registration_time,
                    file_size: None,
                    probe_json: v.probe_json,
                    total_frames: v.total_frames,
                })
                .collect();

            tracing::info!("Mapped to {} file infos", file_infos.len());

            Ok::<FilesResponse, anyhow::Error>(FilesResponse {
                files: file_infos,
                count,
                page,
                page_size,
            })
        })
        .await
        .map_err(|e| {
            tracing::error!("Error in list_files: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    Ok(Json(file_infos))
}

#[derive(Debug, Serialize)]
struct ProgressResponse {
    uuid: String,
    user: Option<String>,
    group: Option<String>,
    file_name: Option<String>,
    duration: Option<f64>,
    overall_progress: u32,
    cpu_percent: Option<f64>,
    gpu_percent: Option<f64>,
    memory_percent: Option<f64>,
    memory_mb: Option<u64>,
    processors: Vec<ProcessorProgressInfo>,
}

#[derive(Debug, Serialize)]
struct ProcessorProgressInfo {
    name: String,
    status: String,
    current: u32,
    total: u32,
    progress: u32,
    message: String,
}

fn get_system_stats() -> (Option<f64>, Option<f64>, Option<f64>, Option<u64>) {
    use std::process::Command;

    let pid = std::process::id().to_string();

    let cpu = Command::new("ps")
        .args(["-p", &pid, "-o", "%cpu="])
        .output()
        .ok()
        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok());

    let (mem_percent, mem_rss) = Command::new("ps")
        .args(["-p", &pid, "-o", "%mem=,rss="])
        .output()
        .ok()
        .map(|o| {
            let output = String::from_utf8_lossy(&o.stdout);
            let parts: Vec<&str> = output.split_whitespace().collect();
            let percent = parts.first().and_then(|s| s.parse().ok());
            let rss = parts.get(1).and_then(|s| s.parse().ok());
            (percent, rss)
        })
        .unwrap_or((None, None));

    let gpu = Command::new("nvidia-smi")
        .args([
            "--query-gpu=utilization.gpu",
            "--format=csv,noheader,nounits",
        ])
        .output()
        .ok()
        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok());

    (cpu, gpu, mem_percent, mem_rss)
}

async fn get_progress(
    axum::extract::Path(uuid): axum::extract::Path<String>,
) -> Result<Json<ProgressResponse>, StatusCode> {
    let redis = RedisClient::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let pg = PostgresDb::init()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let mut conn = redis
        .get_conn()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let processor_names = ["asr", "cut", "asrx", "yolo", "ocr", "face", "pose"];
    let mut processors = Vec::new();
    let mut completed_count = 0u32;

    for name in processor_names {
        let key = format!("momentry:job:{}:processor:{}", uuid, name);

        let status: String = redis::cmd("HGET")
            .arg(&key)
            .arg("status")
            .query_async(&mut conn)
            .await
            .unwrap_or_else(|_| "pending".to_string());

        let current: u32 = redis::cmd("HGET")
            .arg(&key)
            .arg("current")
            .query_async(&mut conn)
            .await
            .unwrap_or_else(|_| "0".to_string())
            .parse()
            .unwrap_or(0);

        let total: u32 = redis::cmd("HGET")
            .arg(&key)
            .arg("total")
            .query_async(&mut conn)
            .await
            .unwrap_or_else(|_| "0".to_string())
            .parse()
            .unwrap_or(0);

        let message: String = redis::cmd("HGET")
            .arg(&key)
            .arg("message")
            .query_async(&mut conn)
            .await
            .unwrap_or_else(|_| "".to_string());

        let progress = if total > 0 {
            ((current as f64 / total as f64) * 100.0) as u32
        } else if status == "complete" {
            100
        } else {
            0
        };

        if status == "complete" {
            completed_count += 1;
        }

        processors.push(ProcessorProgressInfo {
            name: name.to_string(),
            status,
            current,
            total,
            progress,
            message,
        });
    }

    let overall_progress = (completed_count as f64 / processor_names.len() as f64 * 100.0) as u32;

    let job_key = format!("momentry:job:{}", uuid);
    let user: Option<String> = redis::cmd("HGET")
        .arg(&job_key)
        .arg("user")
        .query_async(&mut conn)
        .await
        .ok()
        .filter(|s: &String| !s.is_empty());

    let group: Option<String> = redis::cmd("HGET")
        .arg(&job_key)
        .arg("group")
        .query_async(&mut conn)
        .await
        .ok()
        .filter(|s: &String| !s.is_empty());

    let (file_name, duration) = pg
        .get_video_by_uuid(&uuid)
        .await
        .ok()
        .flatten()
        .map(|v| (Some(v.file_name), Some(v.duration)))
        .unwrap_or((None, None));

    let (cpu_percent, gpu_percent, memory_percent, memory_mb) = get_system_stats();

    Ok(Json(ProgressResponse {
        uuid,
        user,
        group,
        file_name,
        duration,
        overall_progress,
        cpu_percent,
        gpu_percent,
        memory_percent,
        memory_mb,
        processors,
    }))
}

async fn list_jobs(Query(params): Query<JobsQuery>) -> Result<Json<JobListResponse>, StatusCode> {
    let page = params.page.unwrap_or(1);
    let page_size = params.page_size.unwrap_or(20);
    let status_filter = params
        .status
        .unwrap_or_else(|| "pending,running".to_string());
    let offset = ((page - 1) as i64) * (page_size as i64);

    let pg = PostgresDb::init()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // TODO: 需要修改 PostgresDb::get_pending_jobs 以支持分頁和狀態過濾
    // 目前先使用現有方法，獲取所有工作然後手動分頁
    let jobs = pg
        .get_pending_jobs(1000) // 臨時解決方案：獲取較多工作
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // 過濾狀態
    let filtered_jobs: Vec<_> = jobs
        .into_iter()
        .filter(|j| {
            let job_status = j.status.as_str();
            status_filter.split(',').any(|s| s.trim() == job_status)
        })
        .collect();

    let total_count = filtered_jobs.len() as i64;

    // 手動分頁
    let paginated_jobs: Vec<_> = filtered_jobs
        .into_iter()
        .skip(offset as usize)
        .take(page_size)
        .collect();

    let job_infos: Vec<JobInfoResponse> = paginated_jobs
        .into_iter()
        .map(|j| JobInfoResponse {
            id: j.id,
            uuid: j.uuid,
            status: j.status.as_str().to_string(),
            current_processor: j.current_processor,
            progress_current: j.progress_current,
            progress_total: j.progress_total,
            created_at: j.created_at.to_string(),
            started_at: j.started_at.map(|t| t.to_string()),
        })
        .collect();

    Ok(Json(JobListResponse {
        jobs: job_infos,
        count: total_count,
        page,
        page_size,
    }))
}

async fn get_job(
    axum::extract::Path(uuid): axum::extract::Path<String>,
) -> Result<Json<JobDetailResponse>, StatusCode> {
    tracing::info!("[get_job] START - uuid: {}", uuid);

    let pg = PostgresDb::init().await.map_err(|e| {
        tracing::error!("[get_job] ERROR - Failed to init PostgresDb: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;
    tracing::info!("[get_job] PostgresDb initialized");

    let job = pg
        .get_monitor_job_by_uuid(&uuid)
        .await
        .map_err(|e| {
            tracing::error!("[get_job] ERROR - Failed to get monitor job: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?
        .ok_or_else(|| {
            tracing::warn!("[get_job] Job not found: {}", uuid);
            StatusCode::NOT_FOUND
        })?;

    tracing::info!("[get_job] Found job: id={}, uuid={}", job.id, job.uuid);

    let results = pg.get_processor_results_by_job(job.id).await.map_err(|e| {
        tracing::error!("[get_job] ERROR - Failed to get processor results: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    tracing::info!("[get_job] Got {} processor results", results.len());

    let processors: Vec<ProcessorInfoResponse> = results
        .into_iter()
        .map(|r| ProcessorInfoResponse {
            processor_type: r.processor_type.as_str().to_string(),
            status: r.status.as_str().to_string(),
            started_at: r.started_at,
            completed_at: r.completed_at,
            duration_secs: r.duration_secs,
            error_message: r.error_message,
        })
        .collect();

    tracing::info!("[get_job] Mapped {} processors", processors.len());

    let response = JobDetailResponse {
        id: job.id,
        uuid: job.uuid,
        status: job.status.as_str().to_string(),
        current_processor: job.current_processor,
        progress_current: job.progress_current,
        progress_total: job.progress_total,
        processors,
        created_at: job.created_at.to_string(),
        started_at: job.started_at.map(|t| t.to_string()),
        updated_at: job.updated_at.map(|t| t.to_string()),
    };

    tracing::info!("[get_job] SUCCESS - returning response");
    Ok(Json(response))
}

async fn cache_toggle(
    State(_state): State<AppState>,
    Json(req): Json<CacheToggleRequest>,
) -> Result<Json<CacheToggleResponse>, StatusCode> {
    tracing::info!("[cache_toggle] Setting cache enabled to: {}", req.enabled);

    crate::core::config::set_cache_enabled(req.enabled);

    let response = CacheToggleResponse {
        success: true,
        cache_enabled: req.enabled,
        message: if req.enabled {
            "Cache enabled".to_string()
        } else {
            "Cache disabled".to_string()
        },
    };

    tracing::info!("[cache_toggle] SUCCESS");
    Ok(Json(response))
}

async fn unregister(
    State(state): State<AppState>,
    Json(req): Json<UnregisterRequest>,
) -> Result<Json<UnregisterResponse>, StatusCode> {
    tracing::info!("[unregister] Unregistering video: {}", req.uuid);

    let db = &state.api_state.db;

    match db.delete_video(&req.uuid).await {
        Ok(_) => {
            tracing::info!("[unregister] SUCCESS - deleted: {}", req.uuid);
            Ok(Json(UnregisterResponse {
                success: true,
                uuid: req.uuid,
                message: "Video unregistered successfully".to_string(),
            }))
        }
        Err(e) => {
            tracing::error!("[unregister] ERROR - {}", e);
            Ok(Json(UnregisterResponse {
                success: false,
                uuid: req.uuid,
                message: format!("Failed to unregister: {}", e),
            }))
        }
    }
}

pub async fn start_server(host: &str, port: u16) -> anyhow::Result<()> {
    let _ = SERVER_START.set(Instant::now());

    let embedder = std::sync::Arc::new(Embedder::new("nomic-embed-text-v2-moe:latest".to_string()));
    let mongo_cache = MongoCache::init().await?;
    let redis_cache = RedisCache::new()?;
    let db = PostgresDb::init().await?;
    let db = std::sync::Arc::new(db);
    let api_state = super::middleware::ApiState { db: db.clone() };

    let state = AppState {
        db,
        embedder,
        embedder_model: "nomic-embed-text-v2-moe:latest".to_string(),
        mongo_cache,
        redis_cache,
        api_state,
    };

    let protected_routes = Router::new()
        .route("/api/v1/register", post(register))
        .route("/api/v1/files/register", post(register_file))
        .route("/api/v1/files/scan", get(scan_files))
        .route("/api/v1/unregister", post(unregister))
        .route("/api/v1/probe", post(probe))
        .route("/api/v1/assets/:uuid/probe", get(probe_by_uuid))
        .route("/api/v1/assets/:uuid/process", post(trigger_processing))
        .route("/api/v1/assets/:uuid/status", get(get_asset_status))
        .route("/api/v1/jobs/:job_id", get(get_job_status))
        .route("/api/v1/rules/:rule/status", get(get_rule_status))
        .route("/api/v1/search/hybrid", post(hybrid_search))
        .route("/api/v1/search", post(search))
        .route("/api/v1/n8n/search", post(n8n_search))
        .route("/api/v1/search/bm25", post(search_bm25))
        .route("/api/v1/n8n/search/bm25", post(n8n_search_bm25))
        .route("/api/v1/n8n/search/hybrid", post(n8n_search_hybrid))
        .route("/api/v1/n8n/search/smart", post(n8n_search_smart))
        .route("/api/v1/lookup", get(lookup))
        .route("/api/v1/videos", get(list_files))
        .route("/api/v1/videos/:uuid", delete(delete_video))
        .route("/api/v1/videos/:uuid/details", get(video_details))
        .route("/api/v1/videos/:uuid/pre_chunks", get(list_pre_chunks))
        .route("/api/v1/progress/:uuid", get(get_progress))
        .route("/api/v1/jobs", get(list_jobs))
        .route("/api/v1/config/cache", post(cache_toggle))
        .merge(face_recognition::face_recognition_routes())
        // .merge(person_identity::person_identity_routes()) // V4.0: DISABLED (person_identities table removed)
        .merge(identity_binding::identity_binding_routes())
        .merge(identities::identity_routes())
        .merge(super::snapshot_api::snapshot_routes())
        .layer(axum::middleware::from_fn_with_state(
            state.api_state.clone(),
            api_key_validation,
        ))
        .with_state(state.clone());

    let cors = CorsLayer::new()
        .allow_origin(tower_http::cors::AllowOrigin::any())
        .allow_methods(Any)
        .allow_headers(Any);

    let app = Router::new()
        .route("/health", get(health))
        .route("/health/detailed", get(health_detailed))
        .route("/api/v1/auth/login", post(login))
        .route("/api/v1/auth/logout", post(logout))
        .route("/api/v1/stats/ingest", get(get_ingest_stats))
        .route("/api/v1/stats/sftpgo", get(get_sftpgo_status))
        .route("/api/v1/stats/inference", get(get_inference_health))
        .route("/api/v1/search/visual", post(search_visual_chunks))
        .route(
            "/api/v1/search/visual/class",
            post(search_visual_chunks_by_class),
        )
        .route(
            "/api/v1/search/visual/density",
            post(search_visual_chunks_by_density),
        )
        .route("/api/v1/search/visual/stats", post(get_visual_chunk_stats))
        .route(
            "/api/v1/search/visual/combination",
            post(search_visual_chunks_by_combination),
        )
        .merge(identity_api::identity_routes()) // Phase 3 Routes
        .merge(agent_api::agent_routes()) // Phase 6 Routes
        .merge(super::identity_agent_api::identity_agent_routes()) // Phase 5 Routes
        .merge(five_w1h_agent_api::five_w1h_agent_routes()) // Phase 3 Routes (5W1H Agent)
        .merge(protected_routes)
        .layer(cors)
        .with_state(state);

    let addr: std::net::SocketAddr = format!("{}:{}", host, port).parse().unwrap();
    tracing::info!("Starting API server at http://{}", addr);

    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;

    Ok(())
}

#[derive(Debug, Serialize)]
struct IngestStatsResponse {
    total_videos: i64,
    total_chunks: i64,
    sentence_chunks: i64,
    cut_chunks: i64,
    time_chunks: i64,
    searchable_chunks: i64,
    chunks_with_visual: i64,
    chunks_with_summary: i64,
    pending_videos: i64,
}

async fn get_ingest_stats(
    State(state): State<AppState>,
) -> Result<Json<IngestStatsResponse>, StatusCode> {
    let table_videos = schema::table_name("videos");
    let table_chunks = schema::table_name("chunks");

    let total_videos: (i64,) = sqlx::query_as(&format!("SELECT COUNT(*) FROM {}", table_videos))
        .fetch_one(state.db.pool())
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let total_chunks: (i64,) = sqlx::query_as(&format!("SELECT COUNT(*) FROM {}", table_chunks))
        .fetch_one(state.db.pool())
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let sentence_chunks: (i64,) = sqlx::query_as(&format!(
        "SELECT COUNT(*) FROM {} WHERE chunk_type = 'sentence'",
        table_chunks
    ))
    .fetch_one(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let cut_chunks: (i64,) = sqlx::query_as(&format!(
        "SELECT COUNT(*) FROM {} WHERE chunk_type = 'cut'",
        table_chunks
    ))
    .fetch_one(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let time_chunks: (i64,) = sqlx::query_as(&format!(
        "SELECT COUNT(*) FROM {} WHERE chunk_type = 'time'",
        table_chunks
    ))
    .fetch_one(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let searchable_chunks: (i64,) = sqlx::query_as(&format!(
        "SELECT COUNT(*) FROM {} WHERE vector_id IS NOT NULL",
        table_chunks
    ))
    .fetch_one(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let chunks_with_visual: (i64,) = sqlx::query_as(&format!(
        "SELECT COUNT(*) FROM {} WHERE visual_stats IS NOT NULL AND visual_stats != '{}'::jsonb",
        table_chunks, "{}"
    ))
    .fetch_one(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let chunks_with_summary: (i64,) = sqlx::query_as(&format!(
        "SELECT COUNT(*) FROM {} WHERE summary_text IS NOT NULL",
        table_chunks
    ))
    .fetch_one(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let pending_videos: (i64,) = sqlx::query_as(&format!(
        "SELECT COUNT(*) FROM {} WHERE status = 'pending'",
        table_videos
    ))
    .fetch_one(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(IngestStatsResponse {
        total_videos: total_videos.0,
        total_chunks: total_chunks.0,
        sentence_chunks: sentence_chunks.0,
        cut_chunks: cut_chunks.0,
        time_chunks: time_chunks.0,
        searchable_chunks: searchable_chunks.0,
        chunks_with_visual: chunks_with_visual.0,
        chunks_with_summary: chunks_with_summary.0,
        pending_videos: pending_videos.0,
    }))
}

#[derive(Debug, Serialize)]
struct SftpgoStatusResponse {
    username: String,
    home_dir: String,
    files_count: i64,
    registered_videos: Vec<RegisteredVideo>,
    last_login: Option<String>,
}

#[derive(Debug, Serialize)]
struct RegisteredVideo {
    uuid: String,
    file_name: String,
    status: String,
}

async fn get_sftpgo_status(
    State(state): State<AppState>,
) -> Result<Json<SftpgoStatusResponse>, StatusCode> {
    let demo_dir = "/Users/accusys/momentry/var/sftpgo/data/demo";

    let files_count: i64 = std::fs::read_dir(demo_dir)
        .map(|entries| entries.count() as i64)
        .unwrap_or(0);

    let table_videos = schema::table_name("videos");

    let registered_videos: Vec<(String, String, String)> = sqlx::query_as(&format!(
        "SELECT file_uuid, file_name, status FROM {} WHERE file_path LIKE '%demo%' ORDER BY id",
        table_videos
    ))
    .fetch_all(state.db.pool())
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let registered_videos = registered_videos
        .into_iter()
        .map(|(uuid, file_name, status)| RegisteredVideo {
            uuid,
            file_name,
            status,
        })
        .collect();

    Ok(Json(SftpgoStatusResponse {
        username: "demo".to_string(),
        home_dir: demo_dir.to_string(),
        files_count,
        registered_videos,
        last_login: None,
    }))
}

#[derive(Debug, Serialize)]
struct InferenceEngineStatus {
    engine: String,
    model: String,
    status: String,
    latency_ms: Option<u64>,
    error: Option<String>,
}

#[derive(Debug, Serialize)]
struct InferenceHealthResponse {
    ollama: InferenceEngineStatus,
    llama_server: InferenceEngineStatus,
}

async fn get_inference_health() -> Result<Json<InferenceHealthResponse>, StatusCode> {
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(5))
        .build()
        .unwrap();

    let ollama_start = std::time::Instant::now();
    let ollama_status = match client.get("http://localhost:11434/api/tags").send().await {
        Ok(resp) if resp.status().is_success() => {
            let latency = ollama_start.elapsed().as_millis() as u64;
            InferenceEngineStatus {
                engine: "Ollama".to_string(),
                model: "nomic-embed-text".to_string(),
                status: "ok".to_string(),
                latency_ms: Some(latency),
                error: None,
            }
        }
        Ok(resp) => InferenceEngineStatus {
            engine: "Ollama".to_string(),
            model: "nomic-embed-text".to_string(),
            status: "error".to_string(),
            latency_ms: Some(ollama_start.elapsed().as_millis() as u64),
            error: Some(format!("HTTP {}", resp.status())),
        },
        Err(e) => InferenceEngineStatus {
            engine: "Ollama".to_string(),
            model: "nomic-embed-text".to_string(),
            status: "error".to_string(),
            latency_ms: None,
            error: Some(e.to_string()),
        },
    };

    let llama_start = std::time::Instant::now();
    let llama_status = match client.get("http://localhost:8081/v1/models").send().await {
        Ok(resp) if resp.status().is_success() => {
            let latency = llama_start.elapsed().as_millis() as u64;
            InferenceEngineStatus {
                engine: "llama-server".to_string(),
                model: "gemma4_e4b_q5".to_string(),
                status: "ok".to_string(),
                latency_ms: Some(latency),
                error: None,
            }
        }
        Ok(resp) => InferenceEngineStatus {
            engine: "llama-server".to_string(),
            model: "gemma4_e4b_q5".to_string(),
            status: "error".to_string(),
            latency_ms: Some(llama_start.elapsed().as_millis() as u64),
            error: Some(format!("HTTP {}", resp.status())),
        },
        Err(e) => InferenceEngineStatus {
            engine: "llama-server".to_string(),
            model: "gemma4_e4b_q5".to_string(),
            status: "error".to_string(),
            latency_ms: None,
            error: Some(e.to_string()),
        },
    };

    Ok(Json(InferenceHealthResponse {
        ollama: ollama_status,
        llama_server: llama_status,
    }))
}

#[derive(Debug, Deserialize)]
struct VideoDetailsQuery {
    chunk_id: Option<String>,
    parent_id: Option<String>,
}

#[derive(Debug, Serialize)]
struct VideoDetailsResponse {
    uuid: String,
    #[serde(flatten)]
    details: VideoDetailsResult,
}

#[derive(Debug, Serialize)]
#[serde(untagged)]
enum VideoDetailsResult {
    Chunk(ChunkDetailResponse),
    Parent(ParentChunkResponse),
}

#[derive(Debug, Serialize)]
struct FrameRange {
    start_frame: i64,
    end_frame: i64,
    duration_frames: i64,
    fps: f64,
}

#[derive(Debug, Serialize)]
struct ReferenceTime {
    start: f64,
    end: f64,
}

#[derive(Debug, Serialize)]
struct ChunkDetailResponse {
    chunk_id: String,
    chunk_type: String,
    frame_range: FrameRange,
    reference_time: ReferenceTime,
    text_content: Option<String>,
    content: Option<serde_json::Value>,
    parent_id: Option<String>,
    summary_text: Option<String>,
    metadata: Option<serde_json::Value>,
    visual_stats: Option<serde_json::Value>,
    speaker_ids: Option<Vec<String>>,
    person_ids: Option<Vec<String>>,
}

#[derive(Debug, Serialize)]
struct ParentChunkResponse {
    parent_id: i32,
    metadata: Option<serde_json::Value>,
    summary_text: Option<String>,
    frame_range: FrameRange,
    reference_time: ReferenceTime,
}

/// Search visual chunks based on criteria
#[derive(Debug, Deserialize)]
struct VisualChunkSearchRequest {
    uuid: String,
    criteria: visual_chunk_search::VisualChunkSearchCriteria,
}

#[derive(Debug, Serialize)]
struct VisualChunkSearchResponse {
    chunks: Vec<Chunk>,
    total: usize,
}

async fn search_visual_chunks(
    State(state): State<AppState>,
    Json(req): Json<VisualChunkSearchRequest>,
) -> Result<Json<VisualChunkSearchResponse>, StatusCode> {
    let criteria_hash = generate_visual_search_hash(&req.uuid, &req.criteria);
    let cache_key = keys::visual_search(&req.uuid, &criteria_hash);
    let ttl = state.mongo_cache.ttl_visual_search();

    let chunks = state
        .mongo_cache
        .get_or_fetch(&cache_key, ttl, keys::CATEGORY_VISUAL_SEARCH, || async {
            let db = PostgresDb::init()
                .await
                .map_err(|e| anyhow::anyhow!("PG init failed: {}", e))?;

            visual_chunk_search::search_visual_chunks(&db, &req.uuid, &req.criteria)
                .await
                .map_err(|e| anyhow::anyhow!("Visual search failed: {}", e))
        })
        .await
        .map_err(|e| {
            tracing::error!("Visual chunk search failed: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    Ok(Json(VisualChunkSearchResponse {
        total: chunks.len(),
        chunks,
    }))
}

/// Request for searching visual chunks by object class
#[derive(Debug, Deserialize)]
struct VisualChunkSearchByClassRequest {
    uuid: String,
    object_class: String,
    min_count: Option<u32>,
    max_count: Option<u32>,
}

/// Request for searching visual chunks by density
#[derive(Debug, Deserialize)]
struct VisualChunkSearchByDensityRequest {
    uuid: String,
    min_density: f32,
    max_density: Option<f32>,
}

/// Request for getting visual chunk statistics
#[derive(Debug, Deserialize)]
struct VisualChunkStatsRequest {
    uuid: String,
}

async fn search_visual_chunks_by_class(
    State(state): State<AppState>,
    Json(req): Json<VisualChunkSearchByClassRequest>,
) -> Result<Json<VisualChunkSearchResponse>, StatusCode> {
    let db = PostgresDb::init()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let chunks = visual_chunk_search::search_visual_chunks_by_class(
        &db,
        &req.uuid,
        &req.object_class,
        req.min_count,
        req.max_count,
    )
    .await
    .map_err(|e| {
        tracing::error!("Visual chunk search by class failed: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    Ok(Json(VisualChunkSearchResponse {
        total: chunks.len(),
        chunks,
    }))
}

async fn search_visual_chunks_by_density(
    State(state): State<AppState>,
    Json(req): Json<VisualChunkSearchByDensityRequest>,
) -> Result<Json<VisualChunkSearchResponse>, StatusCode> {
    let db = PostgresDb::init()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let chunks = visual_chunk_search::search_visual_chunks_by_density(
        &db,
        &req.uuid,
        req.min_density,
        req.max_density,
    )
    .await
    .map_err(|e| {
        tracing::error!("Visual chunk search by density failed: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    Ok(Json(VisualChunkSearchResponse {
        total: chunks.len(),
        chunks,
    }))
}

#[derive(Debug, Serialize)]
struct VisualChunkStatsResponse {
    uuid: String,
    stats: std::collections::HashMap<String, serde_json::Value>,
}

async fn get_visual_chunk_stats(
    State(state): State<AppState>,
    Json(req): Json<VisualChunkStatsRequest>,
) -> Result<Json<VisualChunkStatsResponse>, StatusCode> {
    let db = PostgresDb::init()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let stats = visual_chunk_search::get_visual_chunk_statistics(&db, &req.uuid)
        .await
        .map_err(|e| {
            tracing::error!("Get visual chunk stats failed: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    Ok(Json(VisualChunkStatsResponse {
        uuid: req.uuid,
        stats,
    }))
}

/// Request for searching visual chunks by object combination
#[derive(Debug, Deserialize)]
struct VisualChunkSearchByCombinationRequest {
    uuid: String,
    combination: Vec<(String, u32)>, // (object_class, min_count)
}

async fn search_visual_chunks_by_combination(
    State(state): State<AppState>,
    Json(req): Json<VisualChunkSearchByCombinationRequest>,
) -> Result<Json<VisualChunkSearchResponse>, StatusCode> {
    let db = PostgresDb::init()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let combination: Vec<(&str, u32)> = req
        .combination
        .iter()
        .map(|(c, n)| (c.as_str(), *n))
        .collect();

    let chunks =
        visual_chunk_search::search_visual_chunks_by_combination(&db, &req.uuid, &combination)
            .await
            .map_err(|e| {
                tracing::error!("Visual chunk search by combination failed: {}", e);
                StatusCode::INTERNAL_SERVER_ERROR
            })?;

    Ok(Json(VisualChunkSearchResponse {
        total: chunks.len(),
        chunks,
    }))
}

async fn video_details(
    Path(uuid): Path<String>,
    Query(query): Query<VideoDetailsQuery>,
    State(state): State<AppState>,
) -> Result<Json<VideoDetailsResponse>, StatusCode> {
    let table = schema::table_name("chunks");

    if let Some(chunk_id) = query.chunk_id {
        let row: Option<(
            i32, String, String, i32, String, f64, i64, i64,
            Option<String>, serde_json::Value, Option<serde_json::Value>,
            Option<String>, i32, Option<String>, Option<serde_json::Value>, Option<String>,
        )> = sqlx::query_as(&format!(
            "SELECT file_id, uuid, chunk_id, chunk_index, chunk_type::text, fps, start_frame, end_frame, 
                    text_content, content, metadata, vector_id, frame_count, 
                    parent_chunk_id, visual_stats, summary_text
             FROM {} WHERE chunk_id = $1 AND uuid = $2",
            table
        ))
        .bind(&chunk_id)
        .bind(&uuid)
        .fetch_optional(state.db.pool())
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

        let speaker_row: Option<(String, String)> = sqlx::query_as(&format!(
            "SELECT COALESCE(speaker_ids, '{{}}'::text[]), COALESCE(face_ids, '{{}}'::integer[])::text[] FROM {} WHERE chunk_id = $1 AND uuid = $2",
            table
        ))
        .bind(&chunk_id)
        .bind(&uuid)
        .fetch_optional(state.db.pool())
        .await
        .ok()
        .flatten();

        let row = row.ok_or(StatusCode::NOT_FOUND)?;

        let fps = if row.5 > 0.0 { row.5 } else { 24.0 };
        let start_frame = row.6;
        let end_frame = row.7;
        let duration_frames = end_frame - start_frame;

        let start_time = start_frame as f64 / fps;
        let end_time = end_frame as f64 / fps;

        let row_metadata = row.10.clone();

        let mut summary_text = row.15.clone();
        let mut metadata = None;

        if let Some(ref pid_str) = row.13 {
            if !pid_str.is_empty() {
                if let Ok(pid) = pid_str.parse::<i32>() {
                    let parent_table = schema::table_name("parent_chunks");
                    let parent: Option<(Option<String>, Option<serde_json::Value>)> =
                        sqlx::query_as(&format!(
                            "SELECT summary_text, metadata FROM {} WHERE id = $1",
                            parent_table
                        ))
                        .bind(pid)
                        .fetch_optional(state.db.pool())
                        .await
                        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

                    if let Some((s, m)) = parent {
                        if summary_text.is_none() {
                            summary_text = s;
                        }

                        let mut merged: serde_json::Value = serde_json::json!({});

                        if let Some(ref cm) = row_metadata {
                            if let Some(obj) = cm.as_object() {
                                for (k, v) in obj {
                                    merged[k] = v.clone();
                                }
                            }
                        }

                        if let Some(pm) = &m {
                            if let Some(obj) = pm.as_object() {
                                for (k, v) in obj {
                                    merged[k] = v.clone();
                                }
                            }
                        }

                        metadata = Some(merged);
                    }
                }
            }
        } else if let Some(ref cm) = row_metadata {
            metadata = Some(cm.clone());
        }

        let parse_pg_array = |s: &str| -> Vec<String> {
            if s.is_empty() || s == "{}" {
                return vec![];
            }
            s.trim_start_matches('{')
                .trim_end_matches('}')
                .split(',')
                .map(|s| s.trim_matches('"').to_string())
                .collect()
        };

        let (speaker_str, face_str) = speaker_row.unwrap_or(("{}".to_string(), "{}".to_string()));
        let speaker_vec: Vec<String> = parse_pg_array(&speaker_str);
        let speaker_ids: Option<Vec<String>> = if speaker_vec.is_empty() {
            None
        } else {
            Some(speaker_vec)
        };
        let face_vec: Vec<String> = parse_pg_array(&face_str);
        let person_ids: Option<Vec<String>> = if face_vec.is_empty() {
            None
        } else {
            Some(face_vec.iter().map(|id| format!("face_{}", id)).collect())
        };

        return Ok(Json(VideoDetailsResponse {
            uuid: row.1.clone(),
            details: VideoDetailsResult::Chunk(ChunkDetailResponse {
                chunk_id: row.2.clone(),
                chunk_type: row.4.clone(),
                frame_range: FrameRange {
                    start_frame,
                    end_frame,
                    duration_frames,
                    fps,
                },
                reference_time: ReferenceTime {
                    start: start_time,
                    end: end_time,
                },
                text_content: row.8.clone(),
                content: Some(row.9.clone()),
                parent_id: row.13.clone(),
                summary_text,
                metadata,
                visual_stats: row.14.clone(),
                speaker_ids,
                person_ids,
            }),
        }));
    }

    Err(StatusCode::BAD_REQUEST)
}

#[derive(Debug, Deserialize)]
struct PreChunksQuery {
    processor_type: Option<String>,
    page: Option<usize>,
    page_size: Option<usize>,
}

#[derive(Debug, Serialize)]
struct PreChunksResponse {
    pre_chunks: Vec<PreChunkItem>,
    count: i64,
    page: usize,
    page_size: usize,
}

#[derive(Debug, Serialize)]
struct PreChunkItem {
    id: i64,
    processor_type: String,
    coordinate_type: String,
    coordinate_index: i64,
    start_frame: Option<i64>,
    end_frame: Option<i64>,
    start_time: Option<f64>,
    end_time: Option<f64>,
    fps: Option<f64>,
    data: serde_json::Value,
    identity_id: Option<String>,
    confidence: Option<f64>,
    created_at: String,
}

async fn list_pre_chunks(
    Path(uuid): Path<String>,
    Query(query): Query<PreChunksQuery>,
    State(state): State<AppState>,
) -> Result<Json<PreChunksResponse>, StatusCode> {
    let table = schema::table_name("pre_chunks");
    let page = query.page.unwrap_or(1);
    let page_size = query.page_size.unwrap_or(20);
    let offset = (page - 1) * page_size;

    let processor_filter = if let Some(pt) = &query.processor_type {
        format!("AND processor_type = '{}'", pt.to_lowercase())
    } else {
        "".to_string()
    };

    let count_query = format!(
        "SELECT COUNT(*) FROM {} WHERE file_uuid = $1 {}",
        table, processor_filter
    );

    let count: i64 = sqlx::query(&count_query)
        .bind(&uuid)
        .fetch_one(state.db.pool())
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .try_get(0)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let data_query = format!(
        "SELECT id, processor_type, coordinate_type, coordinate_index, 
                start_frame, end_frame, start_time, end_time, fps,
                data, identity_id, confidence, created_at
         FROM {} 
         WHERE file_uuid = $1 {}
         ORDER BY coordinate_index ASC
         LIMIT {} OFFSET {}",
        table, processor_filter, page_size, offset
    );

    let rows: Vec<(
        i64,
        String,
        String,
        i64,
        Option<i64>,
        Option<i64>,
        Option<f64>,
        Option<f64>,
        Option<f64>,
        serde_json::Value,
        Option<uuid::Uuid>,
        Option<f64>,
        chrono::DateTime<chrono::Utc>,
    )> = sqlx::query_as(&data_query)
        .bind(&uuid)
        .fetch_all(state.db.pool())
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let pre_chunks = rows
        .iter()
        .map(|row| PreChunkItem {
            id: row.0,
            processor_type: row.1.clone(),
            coordinate_type: row.2.clone(),
            coordinate_index: row.3,
            start_frame: row.4,
            end_frame: row.5,
            start_time: row.6,
            end_time: row.7,
            fps: row.8,
            data: row.9.clone(),
            identity_id: row.10.map(|id| id.to_string()),
            confidence: row.11,
            created_at: row.12.to_rfc3339(),
        })
        .collect();

    Ok(Json(PreChunksResponse {
        pre_chunks,
        count,
        page,
        page_size,
    }))
}

#[derive(Debug, Serialize)]
struct DeleteVideoResponse {
    success: bool,
    message: String,
    deleted_face_detections: u64,
    deleted_processor_results: u64,
}

#[derive(Debug, Serialize)]
struct UnregisterFileResponse {
    success: bool,
    message: String,
    file_uuid: String,
    deleted_face_detections: u64,
    deleted_processor_results: u64,
    deleted_chunks: u64,
}

/// Detects file type using a tiered strategy:
/// 1. ffprobe (if available)
/// 2. `file` command (MIME type)
/// 3. File extension
fn detect_file_type(
    path: &std::path::Path,
    probe_result: Option<&crate::core::probe::ffprobe::ProbeResult>,
) -> String {
    // 1. ffprobe
    if let Some(probe) = probe_result {
        let has_video = probe
            .streams
            .iter()
            .any(|s| s.codec_type.as_deref() == Some("video"));
        let has_audio = probe
            .streams
            .iter()
            .any(|s| s.codec_type.as_deref() == Some("audio"));

        if has_video {
            return "video".to_string();
        }
        if has_audio {
            return "audio".to_string();
        }
        if !probe.streams.is_empty() {
            return "image".to_string();
        }
    }

    // 2. file command
    if let Ok(output) = std::process::Command::new("file")
        .args(["--mime-type", "-b"])
        .arg(path)
        .output()
    {
        let mime = String::from_utf8_lossy(&output.stdout)
            .trim()
            .to_lowercase();
        if mime.starts_with("video/") {
            return "video".to_string();
        }
        if mime.starts_with("audio/") {
            return "audio".to_string();
        }
        if mime.starts_with("image/") {
            return "image".to_string();
        }
    }

    // 3. Extension
    match path
        .extension()
        .and_then(|e| e.to_str())
        .map(|s| s.to_lowercase())
        .as_deref()
    {
        Some("mp4" | "mov" | "mkv" | "avi" | "webm" | "flv" | "wmv") => "video".to_string(),
        Some("mp3" | "wav" | "flac" | "aac" | "ogg" | "m4a") => "audio".to_string(),
        Some("jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" | "svg") => {
            "image".to_string()
        }
        _ => "unknown".to_string(),
    }
}

async fn delete_video(
    Path(uuid): Path<String>,
    State(state): State<AppState>,
) -> Result<Json<UnregisterFileResponse>, StatusCode> {
    tracing::info!("[UNREGISTER] Unregistering file: {}", uuid);

    let videos_table = schema::table_name("videos");
    let face_table = schema::table_name("face_detections");
    let processor_table = schema::table_name("processor_results");
    let chunks_table = schema::table_name("chunks");
    let parent_chunks_table = schema::table_name("parent_chunks");

    // Check if video exists first
    let exists: Option<String> = sqlx::query_scalar(&format!(
        "SELECT file_uuid FROM {} WHERE file_uuid = $1",
        videos_table
    ))
    .bind(&uuid)
    .fetch_optional(state.db.pool())
    .await
    .map_err(|e| {
        tracing::error!("[UNREGISTER] DB check failed: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    // Delete associated data regardless of video existence (cleanup orphans)
    let deleted_faces: i64 =
        sqlx::query(&format!("DELETE FROM {} WHERE file_uuid = $1", face_table))
            .bind(&uuid)
            .execute(state.db.pool())
            .await
            .map(|r| r.rows_affected() as i64)
            .unwrap_or(0);

    tracing::info!("[UNREGISTER] Deleted {} face_detections", deleted_faces);

    let deleted_processors: i64 = sqlx::query(&format!(
        "DELETE FROM {} WHERE file_uuid = $1",
        processor_table
    ))
    .bind(&uuid)
    .execute(state.db.pool())
    .await
    .map(|r| r.rows_affected() as i64)
    .unwrap_or(0);

    tracing::info!(
        "[UNREGISTER] Deleted {} processor_results",
        deleted_processors
    );

    let deleted_parent_chunks: i64 = sqlx::query(&format!(
        "DELETE FROM {} WHERE uuid = $1",
        parent_chunks_table
    ))
    .bind(&uuid)
    .execute(state.db.pool())
    .await
    .map(|r| r.rows_affected() as i64)
    .unwrap_or(0);

    let deleted_chunks: i64 = sqlx::query(&format!("DELETE FROM {} WHERE uuid = $1", chunks_table))
        .bind(&uuid)
        .execute(state.db.pool())
        .await
        .map(|r| r.rows_affected() as i64)
        .unwrap_or(0);

    tracing::info!("[UNREGISTER] Deleted {} chunks", deleted_chunks);

    if exists.is_none() {
        tracing::warn!(
            "[UNREGISTER] Video not found: {}. (Maybe already deleted or UUID mismatch)",
            uuid
        );
        return Err(StatusCode::NOT_FOUND);
    }

    Ok(Json(UnregisterFileResponse {
        success: true,
        message: format!(
            "File {} unregistered successfully. Record deleted from database.",
            uuid
        ),
        file_uuid: uuid,
        deleted_face_detections: deleted_faces as u64,
        deleted_processor_results: deleted_processors as u64,
        deleted_chunks: (deleted_chunks + deleted_parent_chunks) as u64,
    }))
}
