//! Handlers for source document storage and provenance lookup.
//!
//! These endpoints enable 100% citation recall by storing the source documents
//! that back assertions. Agents store sources first, then reference their hash
//! in assertions.
//!
//! # Flow
//!
//! 1. Agent POSTs source document to `/v1/source`
//! 2. API returns the BLAKE3 hash of the content
//! 3. Agent creates assertion with `source_hash` pointing to that hash
//! 4. Verifier can later GET `/v1/provenance/{hash}` to retrieve the source
//!
//! # Storage Format
//!
//! Sources are stored at `SRC:{hash}` keys with payload:
//! - 4 bytes: content_type length (u32 LE)
//! - N bytes: content_type string (UTF-8)
//! - M bytes: raw content

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use tracing::instrument;

use crate::{
    dto::{ErrorResponse, ProvenanceResponse, StoreSourceRequest, StoreSourceResponse},
    error::{ApiError, Result},
    state::AppState,
};
use stemedb_storage::KVStore;

/// Maximum source document size (10 MB).
const MAX_SOURCE_SIZE: usize = 10 * 1024 * 1024;

/// Store a source document by content hash.
///
/// Computes the BLAKE3 hash of the content and stores at `SRC:{hash}`.
/// The same content always produces the same hash (content-addressed).
/// Re-uploading the same content is idempotent - no error, same hash returned.
#[utoipa::path(
    post,
    path = "/v1/source",
    request_body = StoreSourceRequest,
    responses(
        (status = 201, description = "Source stored successfully", body = StoreSourceResponse),
        (status = 400, description = "Invalid request (bad base64, too large)", body = ErrorResponse),
        (status = 500, description = "Internal server error", body = ErrorResponse)
    ),
    tag = "provenance"
)]
#[instrument(skip(state, req), fields(content_type = %req.content_type))]
pub async fn store_source(
    State(state): State<AppState>,
    Json(req): Json<StoreSourceRequest>,
) -> Result<(StatusCode, Json<StoreSourceResponse>)> {
    // Decode base64 content
    let content = BASE64
        .decode(&req.content)
        .map_err(|e| ApiError::InvalidRequest(format!("Invalid base64 content: {}", e)))?;

    // Validate size
    if content.len() > MAX_SOURCE_SIZE {
        return Err(ApiError::InvalidRequest(format!(
            "Content too large: {} bytes (max {} bytes)",
            content.len(),
            MAX_SOURCE_SIZE
        )));
    }

    // Compute content-addressed hash
    let hash = blake3::hash(&content);
    let hash_hex = hash.to_hex().to_string();

    // Build storage payload: content_type_len (u32 LE) + content_type + content
    let mut payload = Vec::with_capacity(4 + req.content_type.len() + content.len());
    payload.extend_from_slice(&(req.content_type.len() as u32).to_le_bytes());
    payload.extend_from_slice(req.content_type.as_bytes());
    payload.extend_from_slice(&content);

    // Store at SRC:{hash}
    let key = format!("SRC:{}", hash_hex).into_bytes();
    state.store.put(&key, &payload).await?;

    tracing::info!(
        hash = %hash_hex,
        size = content.len(),
        content_type = %req.content_type,
        "Stored source document"
    );

    Ok((
        StatusCode::CREATED,
        Json(StoreSourceResponse {
            hash: hash_hex,
            size: content.len(),
            status: "stored".to_string(),
        }),
    ))
}

/// Retrieve a source document by its hash.
///
/// Looks up the source at `SRC:{hash}` and returns the content with metadata.
/// Returns 404 if the source hash is not found.
#[utoipa::path(
    get,
    path = "/v1/provenance/{hash}",
    params(
        ("hash" = String, Path, description = "BLAKE3 hash of the source document (hex-encoded, 64 chars)")
    ),
    responses(
        (status = 200, description = "Source document found", body = ProvenanceResponse),
        (status = 400, description = "Invalid hash format", body = ErrorResponse),
        (status = 404, description = "Source not found", body = ErrorResponse),
        (status = 500, description = "Internal server error", body = ErrorResponse)
    ),
    tag = "provenance"
)]
#[instrument(skip(state), fields(hash = %hash))]
pub async fn get_provenance(
    State(state): State<AppState>,
    Path(hash): Path<String>,
) -> Result<Json<ProvenanceResponse>> {
    // Validate hash format (64 hex chars = 32 bytes)
    if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
        return Err(ApiError::InvalidRequest(
            "Invalid hash: must be 64 hex characters".to_string(),
        ));
    }

    // Lookup at SRC:{hash}
    let key = format!("SRC:{}", hash).into_bytes();
    let payload = state
        .store
        .get(&key)
        .await?
        .ok_or_else(|| ApiError::NotFound(format!("Source not found: {}", hash)))?;

    // Parse payload: content_type_len (u32 LE) + content_type + content
    if payload.len() < 4 {
        return Err(ApiError::Serialization("Corrupt source record: too short".to_string()));
    }

    let content_type_len =
        u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;

    if payload.len() < 4 + content_type_len {
        return Err(ApiError::Serialization(
            "Corrupt source record: truncated content_type".to_string(),
        ));
    }

    let content_type = String::from_utf8(payload[4..4 + content_type_len].to_vec())
        .map_err(|e| ApiError::Serialization(format!("Invalid content_type UTF-8: {}", e)))?;

    let content = &payload[4 + content_type_len..];

    tracing::debug!(
        hash = %hash,
        size = content.len(),
        content_type = %content_type,
        "Retrieved source document"
    );

    Ok(Json(ProvenanceResponse {
        hash,
        content: BASE64.encode(content),
        content_type,
        size: content.len(),
    }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        body::Body,
        http::{Method, Request},
    };
    use serde_json::json;
    use stemedb_storage::HybridStore;
    use stemedb_wal::Journal;
    use tower::ServiceExt;

    /// Test context that keeps temp dir alive.
    struct TestContext {
        app: axum::Router,
        #[allow(dead_code)]
        temp_dir: tempfile::TempDir,
    }

    /// Create a test app with in-memory storage.
    async fn test_app() -> TestContext {
        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
        let wal_path = temp_dir.path().join("wal");
        let store_path = temp_dir.path().join("store");

        let write_journal = Journal::open(&wal_path).expect("failed to open write journal");
        let read_journal = Journal::open(&wal_path).expect("failed to open read journal");
        let store =
            std::sync::Arc::new(HybridStore::open(&store_path).expect("failed to open store"));

        let state = AppState::new(write_journal, read_journal, store);

        let app = axum::Router::new()
            .route("/v1/source", axum::routing::post(store_source))
            .route("/v1/provenance/:hash", axum::routing::get(get_provenance))
            .with_state(state);

        TestContext { app, temp_dir }
    }

    #[tokio::test]
    async fn test_store_and_retrieve_source() {
        let ctx = test_app().await;

        // Store a source document
        let content = "This is a test document.";
        let content_b64 = BASE64.encode(content);

        let req_body = json!({
            "content": content_b64,
            "content_type": "text/plain"
        });

        let response = ctx
            .app
            .clone()
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/v1/source")
                    .header("content-type", "application/json")
                    .body(Body::from(serde_json::to_string(&req_body).expect("json")))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::CREATED);

        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
        let store_resp: StoreSourceResponse =
            serde_json::from_slice(&body).expect("deserialize store response");

        assert_eq!(store_resp.size, content.len());
        assert_eq!(store_resp.status, "stored");
        assert_eq!(store_resp.hash.len(), 64);

        // Retrieve the source
        let response = ctx
            .app
            .clone()
            .oneshot(
                Request::builder()
                    .method(Method::GET)
                    .uri(format!("/v1/provenance/{}", store_resp.hash))
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);

        let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
        let prov_resp: ProvenanceResponse =
            serde_json::from_slice(&body).expect("deserialize provenance response");

        assert_eq!(prov_resp.hash, store_resp.hash);
        assert_eq!(prov_resp.content_type, "text/plain");
        assert_eq!(prov_resp.size, content.len());

        // Decode and verify content matches
        let decoded = BASE64.decode(&prov_resp.content).expect("decode content");
        assert_eq!(String::from_utf8(decoded).expect("utf8"), content);
    }

    #[tokio::test]
    async fn test_store_source_invalid_base64() {
        let ctx = test_app().await;

        let req_body = json!({
            "content": "not valid base64!!!",
            "content_type": "text/plain"
        });

        let response = ctx
            .app
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/v1/source")
                    .header("content-type", "application/json")
                    .body(Body::from(serde_json::to_string(&req_body).expect("json")))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn test_get_provenance_not_found() {
        let ctx = test_app().await;

        // Use a valid hex hash that doesn't exist
        let fake_hash = "a".repeat(64);

        let response = ctx
            .app
            .oneshot(
                Request::builder()
                    .method(Method::GET)
                    .uri(format!("/v1/provenance/{}", fake_hash))
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn test_get_provenance_invalid_hash() {
        let ctx = test_app().await;

        // Too short
        let response = ctx
            .app
            .clone()
            .oneshot(
                Request::builder()
                    .method(Method::GET)
                    .uri("/v1/provenance/abc123")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);

        // Non-hex characters
        let response = ctx
            .app
            .oneshot(
                Request::builder()
                    .method(Method::GET)
                    .uri(format!("/v1/provenance/{}", "g".repeat(64)))
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn test_store_source_idempotent() {
        let ctx = test_app().await;

        let content = "Same content twice";
        let content_b64 = BASE64.encode(content);

        let req_body = json!({
            "content": content_b64,
            "content_type": "text/plain"
        });

        // Store first time
        let response1 = ctx
            .app
            .clone()
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/v1/source")
                    .header("content-type", "application/json")
                    .body(Body::from(serde_json::to_string(&req_body).expect("json")))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response1.status(), StatusCode::CREATED);

        let body1 = axum::body::to_bytes(response1.into_body(), usize::MAX).await.expect("body");
        let resp1: StoreSourceResponse = serde_json::from_slice(&body1).expect("deserialize");

        // Store second time - same content
        let response2 = ctx
            .app
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/v1/source")
                    .header("content-type", "application/json")
                    .body(Body::from(serde_json::to_string(&req_body).expect("json")))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response2.status(), StatusCode::CREATED);

        let body2 = axum::body::to_bytes(response2.into_body(), usize::MAX).await.expect("body");
        let resp2: StoreSourceResponse = serde_json::from_slice(&body2).expect("deserialize");

        // Same hash returned both times
        assert_eq!(resp1.hash, resp2.hash);
    }
}
