//! Loopback HTTP listener for OAuth / magic-link callbacks (RFC 8252).
//!
//! Used as the preferred desktop redirect target ahead of the `openhuman://`
//! deep link: the frontend asks the shell to bind a one-shot HTTP server on a
//! fixed loopback port, hands the resulting URL to the backend as
//! `redirectUri`, and waits for the `loopback-oauth-callback` Tauri event.
//!
//! Lifecycle is spawn-on-demand: each call to
//! [`start_loopback_oauth_listener`] supersedes any previously-running
//! listener, binds `127.0.0.1:<port>`, accepts connections until either the
//! state-matching `/auth` request arrives or `timeout_secs` elapses, then
//! shuts the listener down. If bind fails (port already in use), the command
//! returns an error and the caller falls back to the deep-link path.
//!
//! Only the `/auth` path is honored — favicons and stray requests get a
//! 404 and keep the loop alive. The state nonce is generated in the shell
//! and returned to the caller; the backend must echo it back as `state=` on
//! the redirect so a hostile page on the same loopback origin cannot fake a
//! callback.

use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;

use rand::RngCore;
use serde::Serialize;
use tauri::Emitter;

use crate::AppRuntime;
type AppHandle = tauri::AppHandle<AppRuntime>;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::time::timeout;

const LOOPBACK_CALLBACK_EVENT: &str = "loopback-oauth-callback";
const READ_BUFFER_BYTES: usize = 8 * 1024;
const PER_CONNECTION_READ_TIMEOUT: Duration = Duration::from_secs(5);

struct ActiveListener {
    id: u64,
    tx: oneshot::Sender<()>,
}

static NEXT_LISTENER_ID: AtomicU64 = AtomicU64::new(1);
static ACTIVE_LISTENER: Mutex<Option<ActiveListener>> = Mutex::new(None);

#[derive(Serialize, Clone)]
pub struct StartResult {
    /// Full redirect URI the backend should redirect to, e.g.
    /// `http://127.0.0.1:53824/auth`. State is appended by the caller.
    pub redirect_uri: String,
    /// State nonce the backend must echo back as `?state=<value>`.
    pub state: String,
}

#[derive(Serialize, Clone)]
struct CallbackPayload {
    /// Full callback URL including query string. Frontend re-uses the existing
    /// `handleAuthDeepLink` parser by converting it to an `openhuman://` URL.
    url: String,
}

fn cancel_active_listener() {
    if let Ok(mut guard) = ACTIVE_LISTENER.lock() {
        if let Some(active) = guard.take() {
            let _ = active.tx.send(());
        }
    }
}

fn install_active_listener(id: u64, tx: oneshot::Sender<()>) {
    if let Ok(mut guard) = ACTIVE_LISTENER.lock() {
        if let Some(old) = guard.replace(ActiveListener { id, tx }) {
            let _ = old.tx.send(());
        }
    }
}

/// Only clear the global slot if it still belongs to this listener's id.
/// A superseded listener's exit must NOT wipe out the newer sender installed
/// by the start that cancelled it.
fn clear_active_listener(id: u64) {
    if let Ok(mut guard) = ACTIVE_LISTENER.lock() {
        if guard.as_ref().map(|active| active.id) == Some(id) {
            *guard = None;
        }
    }
}

fn random_state_nonce() -> String {
    let mut bytes = [0u8; 16];
    rand::rng().fill_bytes(&mut bytes);
    hex::encode(bytes)
}

/// Parse the request target (path + query) out of an HTTP/1.x request head.
fn parse_request_target(head: &str) -> Option<&str> {
    let first_line = head.split("\r\n").next()?;
    let mut parts = first_line.split_whitespace();
    let method = parts.next()?;
    let target = parts.next()?;
    if method.eq_ignore_ascii_case("GET") {
        Some(target)
    } else {
        None
    }
}

/// Return the value of `state=` in a query string, if present.
fn extract_state(query: &str) -> Option<&str> {
    query
        .split('&')
        .filter_map(|pair| pair.split_once('='))
        .find(|(k, _)| *k == "state")
        .map(|(_, v)| v)
}

const SUCCESS_BODY: &str = "<!doctype html><meta charset=utf-8><title>Signed in</title>\
<body style=\"font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;color:#1c1c1e;background:#f5f5f7\">\
<div style=\"text-align:center\"><h2 style=\"margin:0 0 8px\">You're signed in.</h2>\
<p style=\"margin:0;color:#6e6e73\">You can close this tab and return to OpenHuman.</p></div>\
<script>setTimeout(function(){window.close()},250)</script></body>";

fn http_response(status: &str, body: &str) -> Vec<u8> {
    format!(
        "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {len}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}",
        len = body.len(),
    )
    .into_bytes()
}

#[tauri::command]
pub async fn start_loopback_oauth_listener(
    app: AppHandle,
    port: u16,
    timeout_secs: u64,
) -> Result<StartResult, String> {
    cancel_active_listener();

    let bind_addr = format!("127.0.0.1:{port}");
    let listener = TcpListener::bind(&bind_addr)
        .await
        .map_err(|err| format!("bind {bind_addr} failed: {err}"))?;
    // Use the listener's actual bound port for the emitted callback URL so
    // the frontend rewrite (`^https?://127.0.0.1:\d+/auth`) always matches,
    // even if a future change moves to port 0.
    let bound_port = listener
        .local_addr()
        .map(|addr| addr.port())
        .unwrap_or(port);
    log::info!("[loopback-oauth] listening on 127.0.0.1:{bound_port}");

    let state = random_state_nonce();
    let redirect_uri = format!("http://127.0.0.1:{bound_port}/auth");

    let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
    let listener_id = NEXT_LISTENER_ID.fetch_add(1, Ordering::Relaxed);
    install_active_listener(listener_id, cancel_tx);

    let expected_state = state.clone();
    tauri::async_runtime::spawn(async move {
        let lifetime = Duration::from_secs(timeout_secs.max(1));
        let run = run_accept_loop(listener, app, expected_state, bound_port, cancel_rx);
        match timeout(lifetime, run).await {
            Ok(()) => log::info!("[loopback-oauth] listener finished"),
            Err(_) => log::warn!(
                "[loopback-oauth] listener timed out after {}s",
                lifetime.as_secs()
            ),
        }
        clear_active_listener(listener_id);
    });

    Ok(StartResult {
        redirect_uri,
        state,
    })
}

#[tauri::command]
pub async fn stop_loopback_oauth_listener() -> Result<(), String> {
    cancel_active_listener();
    Ok(())
}

async fn run_accept_loop(
    listener: TcpListener,
    app: AppHandle,
    expected_state: String,
    bound_port: u16,
    mut cancel_rx: oneshot::Receiver<()>,
) {
    loop {
        tokio::select! {
            _ = &mut cancel_rx => {
                log::debug!("[loopback-oauth] cancelled by new start or explicit stop");
                return;
            }
            accept = listener.accept() => {
                let (mut socket, peer) = match accept {
                    Ok(pair) => pair,
                    Err(err) => {
                        log::warn!("[loopback-oauth] accept failed: {err}");
                        continue;
                    }
                };
                if !peer.ip().is_loopback() {
                    log::warn!("[loopback-oauth] rejecting non-loopback peer {peer}");
                    let _ = socket.shutdown().await;
                    continue;
                }

                let mut buf = vec![0u8; READ_BUFFER_BYTES];
                let read = match timeout(PER_CONNECTION_READ_TIMEOUT, socket.read(&mut buf)).await {
                    Ok(Ok(n)) => n,
                    Ok(Err(err)) => {
                        log::debug!("[loopback-oauth] read error from {peer}: {err}");
                        continue;
                    }
                    Err(_) => {
                        log::debug!("[loopback-oauth] read timeout from {peer}");
                        continue;
                    }
                };
                if read == 0 {
                    continue;
                }

                let head = String::from_utf8_lossy(&buf[..read]);
                let target = match parse_request_target(&head) {
                    Some(t) => t.to_string(),
                    None => {
                        let _ = socket.write_all(&http_response("405 Method Not Allowed", "method not allowed")).await;
                        continue;
                    }
                };

                let (path, query) = match target.split_once('?') {
                    Some((p, q)) => (p, q),
                    None => (target.as_str(), ""),
                };

                if path != "/auth" {
                    let _ = socket.write_all(&http_response("404 Not Found", "not found")).await;
                    continue;
                }

                match extract_state(query) {
                    Some(s) if s == expected_state => {}
                    _ => {
                        log::warn!("[loopback-oauth] /auth with missing or mismatched state — ignoring");
                        let _ = socket.write_all(&http_response("400 Bad Request", "state mismatch")).await;
                        continue;
                    }
                }

                let _ = socket.write_all(&http_response("200 OK", SUCCESS_BODY)).await;
                let _ = socket.flush().await;

                let callback_url = format!("http://127.0.0.1:{}{}", bound_port, target);
                if let Err(err) = app.emit(LOOPBACK_CALLBACK_EVENT, CallbackPayload { url: callback_url }) {
                    log::warn!("[loopback-oauth] emit callback event failed: {err}");
                }
                return;
            }
        }
    }
}

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

    #[test]
    fn parses_get_request_target() {
        let head = "GET /auth?token=abc&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n";
        assert_eq!(
            parse_request_target(head),
            Some("/auth?token=abc&state=xyz")
        );
    }

    #[test]
    fn rejects_non_get_methods() {
        let head = "POST /auth HTTP/1.1\r\n\r\n";
        assert_eq!(parse_request_target(head), None);
    }

    #[test]
    fn extracts_state_value() {
        assert_eq!(extract_state("token=abc&state=xyz"), Some("xyz"));
        assert_eq!(extract_state("state=only"), Some("only"));
        assert_eq!(extract_state("token=abc"), None);
        assert_eq!(extract_state(""), None);
    }

    #[tokio::test]
    async fn random_state_is_32_hex_chars() {
        let s = random_state_nonce();
        assert_eq!(s.len(), 32);
        assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
    }
}
