//! CSI frame recording API.
//!
//! Provides REST endpoints for recording CSI frames to `.csi.jsonl` files.
//! When recording is active, each processed CSI frame is appended as a JSON
//! line to the current session file stored under `data/recordings/`.
//!
//! Endpoints:
//! - `POST /api/v1/recording/start`   — start a new recording session
//! - `POST /api/v1/recording/stop`    — stop the active recording
//! - `GET  /api/v1/recording/list`    — list all recording sessions
//! - `GET  /api/v1/recording/download/:id` — download a recording file
//! - `DELETE /api/v1/recording/:id`   — delete a recording

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;

use axum::{
    extract::{Path as AxumPath, State},
    response::{IntoResponse, Json},
    routing::{delete, get, post},
    Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{error, info, warn};

// ── Recording data directory ─────────────────────────────────────────────────

/// Base directory for recording files.
pub const RECORDINGS_DIR: &str = "data/recordings";

// ── Types ────────────────────────────────────────────────────────────────────

/// Request body for `POST /api/v1/recording/start`.
#[derive(Debug, Deserialize)]
pub struct StartRecordingRequest {
    pub session_name: String,
    pub label: Option<String>,
    pub duration_secs: Option<u64>,
}

/// Metadata for a completed or active recording session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingSession {
    pub id: String,
    pub name: String,
    pub label: Option<String>,
    pub started_at: String,
    pub ended_at: Option<String>,
    pub frame_count: u64,
    pub file_size_bytes: u64,
    pub file_path: String,
}

/// A single recorded CSI frame line (JSONL format).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedFrame {
    pub timestamp: f64,
    pub subcarriers: Vec<f64>,
    pub rssi: f64,
    pub noise_floor: f64,
    pub features: serde_json::Value,
}

/// Runtime state for the active recording session.
///
/// Stored inside `AppStateInner` and checked on each CSI frame tick.
pub struct RecordingState {
    /// Whether a recording is currently active.
    pub active: bool,
    /// Session ID of the active recording.
    pub session_id: String,
    /// Session display name.
    pub session_name: String,
    /// Optional label / activity tag.
    pub label: Option<String>,
    /// Path to the JSONL file being written.
    pub file_path: PathBuf,
    /// Number of frames written so far.
    pub frame_count: u64,
    /// When the recording started.
    pub start_time: Instant,
    /// ISO-8601 start timestamp for metadata.
    pub started_at: String,
    /// Optional auto-stop duration.
    pub duration_secs: Option<u64>,
}

impl Default for RecordingState {
    fn default() -> Self {
        Self {
            active: false,
            session_id: String::new(),
            session_name: String::new(),
            label: None,
            file_path: PathBuf::new(),
            frame_count: 0,
            start_time: Instant::now(),
            started_at: String::new(),
            duration_secs: None,
        }
    }
}

/// Shared application state type used across all handlers.
pub type AppState = Arc<RwLock<super::AppStateInner>>;

// ── Public helpers (called from the CSI processing loop in main.rs) ──────────

/// Append a single frame to the active recording file.
///
/// This is designed to be called from the main CSI processing tick.
/// If recording is not active, it returns immediately.
pub async fn maybe_record_frame(
    state: &AppState,
    subcarriers: &[f64],
    rssi: f64,
    noise_floor: f64,
    features: &serde_json::Value,
) {
    let should_write;
    let file_path;
    let auto_stop;
    {
        let s = state.read().await;
        let rec = &s.recording_state;
        if !rec.active {
            return;
        }
        should_write = true;
        file_path = rec.file_path.clone();
        auto_stop = rec.duration_secs.map(|d| rec.start_time.elapsed().as_secs() >= d).unwrap_or(false);
    }

    if auto_stop {
        // Duration exceeded — stop recording.
        stop_recording_inner(state).await;
        return;
    }

    if !should_write {
        return;
    }

    let frame = RecordedFrame {
        timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
        subcarriers: subcarriers.to_vec(),
        rssi,
        noise_floor,
        features: features.clone(),
    };

    let line = match serde_json::to_string(&frame) {
        Ok(l) => l,
        Err(e) => {
            warn!("Failed to serialize recording frame: {e}");
            return;
        }
    };

    // Append line to file (async).
    if let Err(e) = append_line(&file_path, &line).await {
        warn!("Failed to write recording frame: {e}");
        return;
    }

    // Increment frame counter.
    {
        let mut s = state.write().await;
        s.recording_state.frame_count += 1;
    }
}

async fn append_line(path: &Path, line: &str) -> std::io::Result<()> {
    use tokio::io::AsyncWriteExt;
    let mut file = tokio::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .await?;
    file.write_all(line.as_bytes()).await?;
    file.write_all(b"\n").await?;
    Ok(())
}

// ── Internal helpers ─────────────────────────────────────────────────────────

/// Stop the active recording and write session metadata.
async fn stop_recording_inner(state: &AppState) {
    let mut s = state.write().await;
    if !s.recording_state.active {
        return;
    }
    s.recording_state.active = false;

    let ended_at = chrono::Utc::now().to_rfc3339();
    let session = RecordingSession {
        id: s.recording_state.session_id.clone(),
        name: s.recording_state.session_name.clone(),
        label: s.recording_state.label.clone(),
        started_at: s.recording_state.started_at.clone(),
        ended_at: Some(ended_at),
        frame_count: s.recording_state.frame_count,
        file_size_bytes: std::fs::metadata(&s.recording_state.file_path)
            .map(|m| m.len())
            .unwrap_or(0),
        file_path: s.recording_state.file_path.to_string_lossy().to_string(),
    };

    // Write a companion .meta.json alongside the JSONL file.
    let meta_path = s.recording_state.file_path.with_extension("meta.json");
    if let Ok(json) = serde_json::to_string_pretty(&session) {
        if let Err(e) = tokio::fs::write(&meta_path, json).await {
            warn!("Failed to write recording metadata: {e}");
        }
    }

    info!(
        "Recording stopped: {} ({} frames)",
        session.id, session.frame_count
    );
}

/// Scan the recordings directory and return all sessions with metadata.
async fn list_sessions() -> Vec<RecordingSession> {
    let dir = PathBuf::from(RECORDINGS_DIR);
    let mut sessions = Vec::new();

    let mut entries = match tokio::fs::read_dir(&dir).await {
        Ok(e) => e,
        Err(_) => return sessions,
    };

    while let Ok(Some(entry)) = entries.next_entry().await {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) == Some("json")
            && path.to_string_lossy().contains(".meta.")
        {
            if let Ok(data) = tokio::fs::read_to_string(&path).await {
                if let Ok(session) = serde_json::from_str::<RecordingSession>(&data) {
                    sessions.push(session);
                }
            }
        }
    }

    // Sort by started_at descending (newest first).
    sessions.sort_by(|a, b| b.started_at.cmp(&a.started_at));
    sessions
}

// ── Axum handlers ────────────────────────────────────────────────────────────

async fn start_recording(
    State(state): State<AppState>,
    Json(body): Json<StartRecordingRequest>,
) -> Json<serde_json::Value> {
    // Ensure recordings directory exists.
    if let Err(e) = tokio::fs::create_dir_all(RECORDINGS_DIR).await {
        error!("Failed to create recordings directory: {e}");
        return Json(serde_json::json!({
            "status": "error",
            "message": format!("Cannot create recordings directory: {e}"),
        }));
    }

    let mut s = state.write().await;
    if s.recording_state.active {
        return Json(serde_json::json!({
            "status": "error",
            "message": "A recording is already active. Stop it first.",
            "active_session": s.recording_state.session_id,
        }));
    }

    // Validate session_name BEFORE embedding it in a path. The legacy
    // `replace(' ', "_")` only normalised whitespace, not path traversal
    // (#615). Reject any session_name containing path separators or
    // parent-directory references.
    let safe_name = match crate::path_safety::safe_id(&body.session_name) {
        Ok(n) => n,
        Err(e) => {
            return Json(serde_json::json!({
                "status": "error",
                "message": format!("Invalid session_name: {e}"),
            }));
        }
    };
    let session_id = format!(
        "{}-{}",
        safe_name,
        chrono::Utc::now().format("%Y%m%d_%H%M%S")
    );
    let file_name = format!("{session_id}.csi.jsonl");
    let file_path = PathBuf::from(RECORDINGS_DIR).join(&file_name);
    let started_at = chrono::Utc::now().to_rfc3339();

    s.recording_state = RecordingState {
        active: true,
        session_id: session_id.clone(),
        session_name: body.session_name.clone(),
        label: body.label.clone(),
        file_path: file_path.clone(),
        frame_count: 0,
        start_time: Instant::now(),
        started_at: started_at.clone(),
        duration_secs: body.duration_secs,
    };

    info!(
        "Recording started: {session_id} (label={:?}, duration={:?}s)",
        body.label, body.duration_secs
    );

    Json(serde_json::json!({
        "status": "recording",
        "session_id": session_id,
        "session_name": body.session_name,
        "label": body.label,
        "started_at": started_at,
        "file_path": file_path.to_string_lossy(),
        "duration_secs": body.duration_secs,
    }))
}

async fn stop_recording(State(state): State<AppState>) -> Json<serde_json::Value> {
    {
        let s = state.read().await;
        if !s.recording_state.active {
            return Json(serde_json::json!({
                "status": "error",
                "message": "No active recording to stop.",
            }));
        }
    }

    stop_recording_inner(&state).await;

    let s = state.read().await;
    Json(serde_json::json!({
        "status": "stopped",
        "session_id": s.recording_state.session_id,
        "frame_count": s.recording_state.frame_count,
    }))
}

async fn list_recordings(
    State(_state): State<AppState>,
) -> Json<serde_json::Value> {
    let sessions = list_sessions().await;
    Json(serde_json::json!({
        "recordings": sessions,
        "count": sessions.len(),
    }))
}

async fn download_recording(
    State(_state): State<AppState>,
    AxumPath(id): AxumPath<String>,
) -> impl IntoResponse {
    // Path-traversal guard (#615). Reject any id that contains '/', '..',
    // null bytes, or anything outside [A-Za-z0-9._-] BEFORE building the
    // path. Otherwise GET /api/v1/recording/download/../../.env leaks
    // arbitrary files the server process can read.
    let id = match crate::path_safety::safe_id(&id) {
        Ok(s) => s.to_string(),
        Err(e) => {
            return (
                axum::http::StatusCode::BAD_REQUEST,
                Json(serde_json::json!({
                    "status": "error",
                    "message": format!("Invalid recording id: {e}"),
                })),
            )
                .into_response();
        }
    };
    let dir = PathBuf::from(RECORDINGS_DIR);
    // Find the JSONL file matching the ID.
    let file_path = dir.join(format!("{id}.csi.jsonl"));

    if !file_path.exists() {
        return (
            axum::http::StatusCode::NOT_FOUND,
            Json(serde_json::json!({
                "status": "error",
                "message": format!("Recording '{id}' not found"),
            })),
        )
            .into_response();
    }

    match tokio::fs::read(&file_path).await {
        Ok(data) => {
            let headers = [
                (
                    axum::http::header::CONTENT_TYPE,
                    "application/x-ndjson".to_string(),
                ),
                (
                    axum::http::header::CONTENT_DISPOSITION,
                    format!("attachment; filename=\"{id}.csi.jsonl\""),
                ),
            ];
            (headers, data).into_response()
        }
        Err(e) => (
            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({
                "status": "error",
                "message": format!("Failed to read recording: {e}"),
            })),
        )
            .into_response(),
    }
}

async fn delete_recording(
    State(_state): State<AppState>,
    AxumPath(id): AxumPath<String>,
) -> Json<serde_json::Value> {
    // Path-traversal guard (#615). Reject any id that contains '/', '..',
    // null bytes, or anything outside [A-Za-z0-9._-] BEFORE building the
    // paths. Otherwise DELETE /api/v1/recording/delete/../../config/database
    // can remove arbitrary files the server process can write.
    let id = match crate::path_safety::safe_id(&id) {
        Ok(s) => s.to_string(),
        Err(e) => {
            return Json(serde_json::json!({
                "status": "error",
                "message": format!("Invalid recording id: {e}"),
            }));
        }
    };
    let dir = PathBuf::from(RECORDINGS_DIR);
    let jsonl_path = dir.join(format!("{id}.csi.jsonl"));
    let meta_path = dir.join(format!("{id}.csi.meta.json"));

    if !jsonl_path.exists() && !meta_path.exists() {
        return Json(serde_json::json!({
            "status": "error",
            "message": format!("Recording '{id}' not found"),
        }));
    }

    let mut deleted = Vec::new();
    if jsonl_path.exists() {
        if let Err(e) = tokio::fs::remove_file(&jsonl_path).await {
            warn!("Failed to delete {}: {e}", jsonl_path.display());
        } else {
            deleted.push(jsonl_path.to_string_lossy().to_string());
        }
    }
    if meta_path.exists() {
        if let Err(e) = tokio::fs::remove_file(&meta_path).await {
            warn!("Failed to delete {}: {e}", meta_path.display());
        } else {
            deleted.push(meta_path.to_string_lossy().to_string());
        }
    }

    Json(serde_json::json!({
        "status": "deleted",
        "id": id,
        "deleted_files": deleted,
    }))
}

// ── Router factory ───────────────────────────────────────────────────────────

/// Build the recording sub-router.
///
/// Mount this at the top level; all routes are prefixed with `/api/v1/recording`.
pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/api/v1/recording/start", post(start_recording))
        .route("/api/v1/recording/stop", post(stop_recording))
        .route("/api/v1/recording/list", get(list_recordings))
        .route(
            "/api/v1/recording/download/{id}",
            get(download_recording),
        )
        .route("/api/v1/recording/{id}", delete(delete_recording))
}

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

    #[test]
    fn default_recording_state_is_inactive() {
        let rs = RecordingState::default();
        assert!(!rs.active);
        assert_eq!(rs.frame_count, 0);
    }

    #[test]
    fn recorded_frame_serializes_to_json() {
        let frame = RecordedFrame {
            timestamp: 1700000000.0,
            subcarriers: vec![1.0, 2.0, 3.0],
            rssi: -45.0,
            noise_floor: -90.0,
            features: serde_json::json!({"motion": 0.5}),
        };
        let json = serde_json::to_string(&frame).unwrap();
        assert!(json.contains("\"timestamp\""));
        assert!(json.contains("\"subcarriers\""));
    }

    #[test]
    fn recording_session_deserializes() {
        let json = r#"{
            "id": "test-20240101_120000",
            "name": "test",
            "label": "walking",
            "started_at": "2024-01-01T12:00:00Z",
            "ended_at": "2024-01-01T12:05:00Z",
            "frame_count": 3000,
            "file_size_bytes": 1500000,
            "file_path": "data/recordings/test-20240101_120000.csi.jsonl"
        }"#;
        let session: RecordingSession = serde_json::from_str(json).unwrap();
        assert_eq!(session.id, "test-20240101_120000");
        assert_eq!(session.frame_count, 3000);
        assert_eq!(session.label, Some("walking".to_string()));
    }
}
