//! Axum endpoints for activity PDF download, external-questionnaire callback,
//! and static Slidev slide serving.
//!
//! # Environment variables
//!
//! | Variable | Description |
//! |----------|-------------|
//! | `ACTIVITY_ASSETS_PATH` | Root directory for PDF files |
//! | `ACTIVITY_SLIDES_PATH` | Root directory for Slidev static builds |
//! | `ACTIVITY_CALLBACK_SECRET` | Hex-encoded 32-byte secret for HMAC tokens |

use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use axum::{
    body::Body,
    extract::{Path as AxumPath, Query},
    http::{header, StatusCode},
    response::{IntoResponse, Response},
    routing::get,
    Extension, Router,
};
#[cfg(feature = "leptos-hydration")]
use rustelo_components_leptos::auth::otp_service::server::OtpServiceExtension;
use tower_http::services::ServeDir;

// ── Configuration
// ─────────────────────────────────────────────────────────────

fn assets_path() -> &'static Path {
    static PATH: OnceLock<PathBuf> = OnceLock::new();
    PATH.get_or_init(|| {
        std::env::var("ACTIVITY_ASSETS_PATH")
            .map(PathBuf::from)
            .unwrap_or_else(|_| PathBuf::from("site/assets/activities"))
    })
    .as_path()
}

fn slides_path() -> &'static Path {
    static PATH: OnceLock<PathBuf> = OnceLock::new();
    PATH.get_or_init(|| {
        std::env::var("ACTIVITY_SLIDES_PATH")
            .map(PathBuf::from)
            .unwrap_or_else(|_| PathBuf::from("site/slides"))
    })
    .as_path()
}

fn callback_secret() -> &'static [u8] {
    static SECRET: OnceLock<Vec<u8>> = OnceLock::new();
    SECRET.get_or_init(|| {
        std::env::var("ACTIVITY_CALLBACK_SECRET")
            .ok()
            .and_then(|s| hex::decode(s).ok())
            .unwrap_or_else(|| b"default_dev_secret_change_in_production".to_vec())
    })
}

// ── HMAC-SHA256 signing
// ───────────────────────────────────────────────────────

/// Compute a keyed hash token for external questionnaire callbacks.
///
/// Construction: `hex(SHA256(secret || user_id || activity_id || timestamp))`
/// with a long secret this is practically unforgeable.
fn sign_callback_token(user_id: &str, activity_id: &str, timestamp_secs: u64) -> String {
    use sha2::{Digest, Sha256};

    let secret = callback_secret();
    let msg = format!("{user_id}:{activity_id}:{timestamp_secs}");

    let mut hasher = Sha256::new();
    hasher.update(secret);
    hasher.update(msg.as_bytes());
    let result = hasher.finalize();
    hex::encode(result)
}

fn verify_callback_token(
    user_id: &str,
    activity_id: &str,
    timestamp_secs: u64,
    token: &str,
) -> bool {
    let expected = sign_callback_token(user_id, activity_id, timestamp_secs);
    // Constant-time comparison to prevent timing attacks.
    if expected.len() != token.len() {
        return false;
    }
    expected
        .bytes()
        .zip(token.bytes())
        .fold(0u8, |acc, (a, b)| acc | (a ^ b))
        == 0
}

// ── Filename sanitization
// ─────────────────────────────────────────────────────

/// Validate that `filename` contains only safe characters and the resolved
/// canonical path stays under `root`.  Returns the full safe path or an error.
fn safe_file_path(root: &Path, filename: &str) -> Result<PathBuf, &'static str> {
    // Allow: alphanumeric, `-`, `_`, `.`
    if !filename
        .chars()
        .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
    {
        return Err("Invalid filename characters");
    }

    let candidate = root.join(filename);

    // Canonicalize both to resolve `..` traversal attempts.
    let canon_root = root
        .canonicalize()
        .map_err(|_| "Assets path not accessible")?;
    let canon_candidate = candidate.canonicalize().map_err(|_| "File not found")?;

    if !canon_candidate.starts_with(&canon_root) {
        return Err("Path traversal denied");
    }

    Ok(canon_candidate)
}

// ── Handlers
// ──────────────────────────────────────────────────────────────────

/// Session cookie extraction helper.
fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
    headers
        .get(header::COOKIE)
        .and_then(|v| v.to_str().ok())
        .and_then(|cookies| {
            cookies
                .split(';')
                .find_map(|c| c.trim().strip_prefix("session_token=").map(str::to_string))
        })
}

/// `GET /api/activities/download/:activity_id?pdf=<filename>`
///
/// Verifies auth + questionnaire completion, then streams the PDF.
pub async fn download_activity_pdf(
    AxumPath(activity_id): AxumPath<String>,
    Query(params): Query<std::collections::HashMap<String, String>>,
    Extension(otp_svc): Extension<OtpServiceExtension>,
    headers: axum::http::HeaderMap,
) -> Response {
    let session_token = match extract_session_cookie(&headers) {
        Some(t) => t,
        None => {
            return (StatusCode::UNAUTHORIZED, "Authentication required").into_response();
        }
    };

    // Verify completion.
    let completed = otp_svc
        .0
        .check_activity_completion(&session_token, activity_id.clone())
        .await
        .unwrap_or(false);

    if !completed {
        return (
            StatusCode::FORBIDDEN,
            "Complete the activity questionnaire first",
        )
            .into_response();
    }

    // Resolve the PDF filename from query param `?pdf=`.
    let pdf_filename = match params.get("pdf") {
        Some(f) => f.clone(),
        None => format!("{activity_id}.pdf"),
    };

    let file_path = match safe_file_path(assets_path(), &pdf_filename) {
        Ok(p) => p,
        Err(e) => return (StatusCode::BAD_REQUEST, e).into_response(),
    };

    let file = match tokio::fs::File::open(&file_path).await {
        Ok(f) => f,
        Err(_) => return (StatusCode::NOT_FOUND, "PDF not found").into_response(),
    };

    let stream = tokio_util::io::ReaderStream::new(file);
    let body = Body::from_stream(stream);
    let content_disposition = format!("attachment; filename=\"{pdf_filename}\"");

    Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "application/pdf")
        .header(header::CONTENT_DISPOSITION, content_disposition)
        .header(header::CACHE_CONTROL, "no-store")
        .body(body)
        .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Stream error").into_response())
}

/// `GET /api/activities/callback?activity_id=X&user_id=Y&ts=Z&token=T`
///
/// Verifies the HMAC token (24-hour expiry) and records the completion.
/// Redirects to the activity page on success.
pub async fn activity_callback(
    Query(params): Query<std::collections::HashMap<String, String>>,
    Extension(otp_svc): Extension<OtpServiceExtension>,
) -> Response {
    let get_param = |key: &str| -> Result<String, Response> {
        params.get(key).cloned().ok_or_else(|| {
            (StatusCode::BAD_REQUEST, format!("Missing param: {key}")).into_response()
        })
    };

    let activity_id = match get_param("activity_id") {
        Ok(v) => v,
        Err(r) => return r,
    };
    let user_id = match get_param("user_id") {
        Ok(v) => v,
        Err(r) => return r,
    };
    let ts_str = match get_param("ts") {
        Ok(v) => v,
        Err(r) => return r,
    };
    let token = match get_param("token") {
        Ok(v) => v,
        Err(r) => return r,
    };

    let timestamp: u64 = match ts_str.parse() {
        Ok(t) => t,
        Err(_) => return (StatusCode::BAD_REQUEST, "Invalid timestamp").into_response(),
    };

    // Check expiry: 24 hours.
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    if now.saturating_sub(timestamp) > 86_400 {
        return (StatusCode::GONE, "Callback token expired").into_response();
    }

    if !verify_callback_token(&user_id, &activity_id, timestamp, &token) {
        return (StatusCode::FORBIDDEN, "Invalid callback token").into_response();
    }

    // We verified the user identity from the HMAC — record the completion.
    // The `session_token` isn't available here (this is a GET from an external
    // form); we use the signed `user_id` to call through a bypass path.
    let _ = otp_svc
        .0
        .record_activity_completion_by_user_id(&user_id, activity_id.clone(), "external")
        .await;

    // Redirect to the activity page with a success indicator.
    let redirect_url = format!("/activities/{activity_id}?download=ready");
    Response::builder()
        .status(StatusCode::FOUND)
        .header(header::LOCATION, redirect_url)
        .body(Body::empty())
        .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Redirect failed").into_response())
}

// ── Router builder
// ────────────────────────────────────────────────────────────

/// Build and mount all activity-related routes.
///
/// - `GET /api/activities/download/:activity_id` — gated PDF download
/// - `GET /api/activities/callback` — external questionnaire HMAC callback
/// - `GET /slides/*path` — static Slidev builds served from
///   `ACTIVITY_SLIDES_PATH`
pub fn activity_routes() -> Router {
    let slides_dir = slides_path().to_path_buf();

    Router::new()
        .route(
            "/api/activities/download/{activity_id}",
            get(download_activity_pdf),
        )
        .route("/api/activities/callback", get(activity_callback))
        .nest_service("/slides", ServeDir::new(slides_dir))
}

/// Generate a signed callback URL for external questionnaire providers.
///
/// Exposed for use in server functions that generate the questionnaire link.
pub fn generate_callback_url(user_id: &str, activity_id: &str, base_url: &str) -> String {
    let timestamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    let token = sign_callback_token(user_id, activity_id, timestamp);

    format!(
        "{base_url}/api/activities/callback?activity_id={activity_id}&user_id={user_id}&\
         ts={timestamp}&token={token}"
    )
}
