//! Magic-link routes — turn an API-key authenticated desktop call into a
//! browser session without an interactive password login.
//!
//! Flow:
//!   1. Desktop client → `POST /api/auth/magic-link` (X-API-Key) with the
//!      desired `return_to` path. Server stores a one-time token (TTL 60s)
//!      and returns it.
//!   2. Desktop client opens the system browser at
//!      `{server_url}/web/magic?token=…`.
//!   3. The browser hits `GET /web/magic`. The server consumes the token
//!      (one-time-use), installs a regular `WebSession`, sets the session
//!      cookie, and 303-redirects to `return_to`.

use std::sync::Arc;
use std::time::{Duration, Instant};

use axum::extract::{Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Redirect, Response};
use axum::{Json, http::StatusCode};
use axum_extra::extract::cookie::CookieJar;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};

use crate::auth::AuthenticatedUser;
use crate::config::Config;
use crate::error::AppError;
use crate::magic_link::{MagicLinkStore, PendingMagicLink};
use crate::web_session::{SessionStore, WebSession, build_session_cookie, generate_token};

/// Magic-link tokens only need to survive click → browser-launch → first
/// request. 60s gives slow systems headroom without lengthening the attack
/// window unnecessarily.
const MAGIC_LINK_TTL_SECS: u64 = 60;

/// Default landing page when the desktop client does not specify one.
const DEFAULT_RETURN_TO: &str = "/web/cases";

#[derive(Deserialize, Default)]
pub struct MagicLinkRequest {
    #[serde(default)]
    pub return_to: Option<String>,
}

#[derive(Serialize)]
pub struct MagicLinkResponse {
    pub token: String,
}

/// Issue a one-time magic-link token. Authenticated by `X-API-Key` —
/// re-uses the existing `AuthenticatedUser` extractor so policy stays
/// in one place.
pub async fn handle_create(
    user: AuthenticatedUser,
    State(store): State<MagicLinkStore>,
    body: Option<Json<MagicLinkRequest>>,
) -> Result<Json<MagicLinkResponse>, AppError> {
    let req = body.map(|Json(r)| r).unwrap_or_default();
    let return_to = req
        .return_to
        .unwrap_or_else(|| DEFAULT_RETURN_TO.to_owned());

    // Open-redirect guard: only accept paths under our own /web/ tree.
    // Reject schemes, hosts, protocol-relative URLs, and traversal.
    if !is_safe_return_to(&return_to) {
        warn!(slug = %user.slug, return_to = %return_to, "magic-link rejected: unsafe return_to");
        return Err(AppError::BadRequest(
            "return_to must be a path under /web/".into(),
        ));
    }

    let token = generate_token();
    let pending = PendingMagicLink {
        slug: user.slug.clone(),
        role: user.role.clone(),
        return_to,
        expires_at: Instant::now() + Duration::from_secs(MAGIC_LINK_TTL_SECS),
    };

    {
        let mut w = store.write().await;
        // Best-effort eviction so the map cannot grow unbounded under load.
        w.retain(|_, v| v.expires_at > Instant::now());
        w.insert(token.clone(), pending);
    }

    info!(slug = %user.slug, "magic-link issued");
    Ok(Json(MagicLinkResponse { token }))
}

#[derive(Deserialize)]
pub struct ConsumeQuery {
    pub token: String,
}

/// Consume a magic-link token: install a session cookie and redirect to
/// the previously-stored `return_to`. One-time use — the token is removed
/// from the store on first lookup, success or expiry alike.
pub async fn handle_consume(
    State(config): State<Arc<Config>>,
    State(magic_store): State<MagicLinkStore>,
    State(session_store): State<SessionStore>,
    jar: CookieJar,
    Query(q): Query<ConsumeQuery>,
) -> Response {
    // Shape-check the token before the store lookup. Fold a malformed
    // token into the same redirect-to-login path used for unknown /
    // expired tokens — any deviation would leak whether a given string
    // could ever be a valid token shape.
    if crate::validate::validate_token(&q.token).is_err() {
        warn!("magic-link consume failed: malformed token");
        return redirect_to_login();
    }

    let pending = {
        let mut w = magic_store.write().await;
        w.remove(&q.token)
    };

    let Some(pending) = pending else {
        warn!("magic-link consume failed: unknown or already-used token");
        return redirect_to_login();
    };

    if pending.expires_at <= Instant::now() {
        warn!(slug = %pending.slug, "magic-link consume failed: expired");
        return redirect_to_login();
    }

    let session_token = generate_token();
    let csrf_token = generate_token();
    let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600);
    {
        let mut w = session_store.write().await;
        w.insert(
            session_token.clone(),
            WebSession {
                slug: pending.slug.clone(),
                role: pending.role.clone(),
                expires_at: Instant::now() + ttl,
                csrf_token,
            },
        );
    }

    info!(slug = %pending.slug, "magic-link consumed → session installed");

    let cookie = build_session_cookie(session_token, ttl, config.cookie_secure);
    let jar = jar.add(cookie);

    // 303 See Other forces the browser to follow with GET. Combined with
    // `Referrer-Policy: no-referrer`, the token does not leak to any
    // resource the redirect target may load.
    let mut response = (jar, Redirect::to(&pending.return_to)).into_response();
    response.headers_mut().insert(
        header::REFERRER_POLICY,
        "no-referrer".parse().expect("static header value"),
    );
    response
}

fn redirect_to_login() -> Response {
    Response::builder()
        .status(StatusCode::SEE_OTHER)
        .header(header::LOCATION, "/web/login")
        .header(header::REFERRER_POLICY, "no-referrer")
        .body(axum::body::Body::empty())
        .expect("static redirect response")
}

/// Allow only same-origin paths under `/web/`. Rejects schemes, host
/// authorities, protocol-relative URLs (`//evil.com/...`), and the
/// trivially malformed empty string.
fn is_safe_return_to(path: &str) -> bool {
    if path.is_empty() {
        return false;
    }
    if path.starts_with("//") {
        return false; // protocol-relative — would jump host
    }
    if !path.starts_with("/web/") {
        return false;
    }
    // Defensive: a `\` anywhere can confuse some legacy parsers into
    // treating the rest as an authority. Reject outright.
    if path.contains('\\') {
        return false;
    }
    true
}

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

    #[test]
    fn safe_return_to_accepts_web_paths() {
        assert!(is_safe_return_to("/web/cases"));
        assert!(is_safe_return_to(
            "/web/cases/123e4567-e89b-12d3-a456-426614174000"
        ));
    }

    #[test]
    fn safe_return_to_rejects_open_redirects() {
        assert!(!is_safe_return_to(""));
        assert!(!is_safe_return_to("/"));
        assert!(!is_safe_return_to("//evil.com/path"));
        assert!(!is_safe_return_to("https://evil.com/"));
        assert!(!is_safe_return_to("/api/upload"));
        assert!(!is_safe_return_to("/web\\evil"));
    }
}
