use std::path::PathBuf;
use std::sync::Arc;

use axum::{
    Json, Router,
    extract::{Path, Query, State},
    http::{HeaderMap, StatusCode, header},
    response::{IntoResponse, Redirect, Response},
    routing::{get, post},
};
use clap::{Args, Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use tokio::signal::unix::{SignalKind, signal};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

mod db;
mod error;
mod negotiate;

use error::AppError;
use negotiate::NegotiateResult;

// ── CLI ──────────────────────────────────────────────────────────────────────

#[derive(Parser)]
#[command(name = "dragoman", about = "PID redirection and content negotiation server")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Start the server (runs in the foreground).
    Start(StartArgs),
    /// Stop a running server by sending SIGTERM to its PID file.
    Stop(StopArgs),
}

#[derive(Args)]
struct StartArgs {
    /// TCP port to listen on.
    #[arg(short, long, env = "PORT", default_value_t = 3456)]
    port: u16,

    /// Path to a local commonmeta SQLite3 database. Falls back to the
    /// COMMONMETA_DB environment variable, then the platform default
    /// (~/.local/share/commonmeta/commonmeta.sqlite3 on Linux).
    #[arg(short, long, env = "COMMONMETA_DB")]
    db: Option<PathBuf>,

    /// Write the server PID to this file on startup so that `dragoman stop`
    /// can find and terminate the process.
    #[arg(long, env = "DRAGOMAN_PID_FILE")]
    pid_file: Option<PathBuf>,
}

#[derive(Args)]
struct StopArgs {
    /// PID file written by `dragoman start --pid-file`.
    #[arg(long, env = "DRAGOMAN_PID_FILE", default_value = "/tmp/dragoman.pid")]
    pid_file: PathBuf,
}

// ── Entry point ───────────────────────────────────────────────────────────────

#[tokio::main]
async fn main() {
    tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "dragoman=info".into()),
        )
        .with(tracing_subscriber::fmt::layer())
        .init();

    let cli = Cli::parse();
    match cli.command {
        Command::Start(args) => cmd_start(args).await,
        Command::Stop(args) => cmd_stop(args),
    }
}

// ── `start` ──────────────────────────────────────────────────────────────────

fn platform_default_db_path() -> PathBuf {
    #[cfg(target_os = "macos")]
    {
        let home = std::env::var("HOME").unwrap_or_default();
        PathBuf::from(format!(
            "{}/Library/Application Support/commonmeta/commonmeta.sqlite3",
            home
        ))
    }
    #[cfg(target_os = "linux")]
    {
        PathBuf::from("/var/lib/commonmeta/commonmeta.sqlite3")
    }
    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    {
        PathBuf::from("commonmeta.sqlite3")
    }
}

async fn cmd_start(args: StartArgs) {
    // Precedence: --db flag > COMMONMETA_DB env (resolved by clap) > platform default.
    let db_path = args.db.unwrap_or_else(platform_default_db_path);

    let database = {
        let path = db_path.clone();
        match tokio::task::spawn_blocking(move || db::open(&path))
            .await
            .expect("spawn_blocking panicked")
        {
            Ok(Some(p)) => {
                tracing::info!(path = %p.display(), "using local sqlite database");
                Some(Arc::new(p))
            }
            Ok(None) => None,
            Err(e) => {
                tracing::error!(error = %e, "failed to open database");
                std::process::exit(1);
            }
        }
    };

    if let Some(ref path) = args.pid_file {
        if let Err(e) = std::fs::write(path, std::process::id().to_string()) {
            tracing::error!(path = %path.display(), error = %e, "failed to write PID file");
            std::process::exit(1);
        }
        tracing::info!(path = %path.display(), "wrote PID file");
    }

    let app = build_app(AppState { db: database });

    let addr = SocketAddr::from(([0, 0, 0, 0], args.port));
    let listener = match tokio::net::TcpListener::bind(addr).await {
        Ok(l) => l,
        Err(e) => {
            tracing::error!(port = args.port, error = %e, "failed to bind");
            if let Some(ref path) = args.pid_file {
                std::fs::remove_file(path).ok();
            }
            std::process::exit(1);
        }
    };
    tracing::info!("listening on {addr}");

    let pid_file = args.pid_file.clone();
    let shutdown = async move {
        let mut sigterm = match signal(SignalKind::terminate()) {
            Ok(s) => s,
            Err(e) => {
                tracing::error!(error = %e, "failed to register SIGTERM handler");
                std::process::exit(1);
            }
        };
        tokio::select! {
            _ = tokio::signal::ctrl_c() => {}
            _ = sigterm.recv() => {}
        }
        if let Some(ref path) = pid_file {
            std::fs::remove_file(path).ok();
            tracing::info!(path = %path.display(), "removed PID file");
        }
    };

    if let Err(e) = axum::serve(listener, app)
        .with_graceful_shutdown(shutdown)
        .await
    {
        tracing::error!(error = %e, "server error");
        std::process::exit(1);
    }
}

// ── `stop` ────────────────────────────────────────────────────────────────────

fn cmd_stop(args: StopArgs) {
    let pid_str = match std::fs::read_to_string(&args.pid_file) {
        Ok(s) => s,
        Err(e) => {
            eprintln!(
                "error: cannot read PID file '{}': {e}",
                args.pid_file.display()
            );
            std::process::exit(1);
        }
    };

    let pid: u32 = match pid_str.trim().parse() {
        Ok(p) => p,
        Err(_) => {
            eprintln!(
                "error: PID file '{}' does not contain a valid PID",
                args.pid_file.display()
            );
            std::process::exit(1);
        }
    };

    let status = std::process::Command::new("kill")
        .arg(pid.to_string())
        .status();

    match status {
        Ok(s) if s.success() => {
            println!("sent SIGTERM to process {pid}");
            std::fs::remove_file(&args.pid_file).ok();
        }
        Ok(_) => {
            eprintln!("error: kill({pid}) failed — process may have already exited");
            std::process::exit(1);
        }
        Err(e) => {
            eprintln!("error: failed to run kill: {e}");
            std::process::exit(1);
        }
    }
}

// ── App state & query params ─────────────────────────────────────────────────

#[derive(Clone)]
struct AppState {
    db: Option<Arc<PathBuf>>,
}

#[derive(Deserialize)]
struct PidQuery {
    /// Override content negotiation: named commonmeta format (e.g. `bibtex`, `ris`, `csl`).
    format: Option<String>,
    /// Override registration agency: `crossref` or `datacite`.
    source: Option<String>,
    /// Citation style name (CSL style repo, e.g. `apa`). Only for `citation` format.
    style: Option<String>,
    /// Citation locale (e.g. `fr-FR`). Only for `citation` format.
    locale: Option<String>,
}

// ── HTTP handlers ─────────────────────────────────────────────────────────────

async fn index() -> impl IntoResponse {
    (
        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
        include_str!("../ui/dist/index.html"),
    )
}

async fn handle_pid(
    State(state): State<AppState>,
    Path(path): Path<String>,
    Query(query): Query<PidQuery>,
    headers: HeaderMap,
) -> Result<Response, AppError> {
    let doi = commonmeta::doi_utils::validate_doi(&path)
        .ok_or_else(|| AppError::NotFound(path.clone()))?;

    tracing::info!(doi = %doi, "resolving PID");

    let accept = headers
        .get(header::ACCEPT)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("text/html");

    let (format, content_type, style, locale): (
        String,
        &'static str,
        Option<String>,
        Option<String>,
    ) = if let Some(fmt) = &query.format {
        let ct = negotiate::content_type_for_format(fmt)
            .ok_or_else(|| AppError::UnsupportedFormat(fmt.clone()))?;
        (fmt.clone(), ct, query.style.clone(), query.locale.clone())
    } else {
        match negotiate::negotiate(accept) {
            NegotiateResult::Format(n) => (
                n.format.to_string(),
                n.content_type,
                n.style.or_else(|| query.style.clone()),
                n.locale.or_else(|| query.locale.clone()),
            ),
            NegotiateResult::Redirect => {
                let url = resolve_url(&doi, query.source.as_deref(), state.db.as_deref()).await?;
                return Ok(Redirect::temporary(&url).into_response());
            }
            NegotiateResult::NotAcceptable => {
                return Err(AppError::UnsupportedFormat(accept.to_string()));
            }
        }
    };

    let bytes = fetch_and_convert(
        &doi,
        &format,
        query.source.as_deref(),
        style.as_deref(),
        locale.as_deref(),
        state.db.as_deref(),
    )
    .await?;

    Ok((StatusCode::OK, [(header::CONTENT_TYPE, content_type)], bytes).into_response())
}

// ── Business logic ────────────────────────────────────────────────────────────

async fn fetch_and_convert(
    doi: &str,
    format: &str,
    source: Option<&str>,
    style: Option<&str>,
    locale: Option<&str>,
    db: Option<&PathBuf>,
) -> Result<Vec<u8>, AppError> {
    let doi = doi.to_string();
    let format = format.to_string();
    let source = source.map(str::to_string);
    let style = style.map(str::to_string);
    let locale = locale.map(str::to_string);
    let db = db.cloned();

    tokio::task::spawn_blocking(move || {
        if let Some(ref path) = db {
            if let Some(data) = db::lookup(path, &doi)? {
                tracing::debug!(doi = %doi, "served from local sqlite");
                return commonmeta::write_with_style(
                    &format,
                    &data,
                    style.as_deref(),
                    locale.as_deref(),
                )
                .map_err(|e| AppError::FetchError(e.to_string()));
            }
        }
        let from = resolve_source(&doi, source.as_deref());
        if format == "citation" {
            commonmeta::convert_citation(&from, &doi, style.as_deref(), locale.as_deref())
                .map_err(|e| AppError::FetchError(e.to_string()))
        } else {
            commonmeta::convert(&from, &format, &doi)
                .map_err(|e| AppError::FetchError(e.to_string()))
        }
    })
    .await
    .map_err(|e| AppError::Internal(e.to_string()))?
}

async fn resolve_url(
    doi: &str,
    source: Option<&str>,
    db: Option<&PathBuf>,
) -> Result<String, AppError> {
    let doi = doi.to_string();
    let source = source.map(str::to_string);
    let db = db.cloned();

    tokio::task::spawn_blocking(move || {
        if let Some(ref path) = db {
            if let Some(data) = db::lookup(path, &doi)? {
                if !data.url.is_empty() {
                    tracing::debug!(doi = %doi, "url served from local sqlite");
                    return Ok(data.url);
                }
            }
        }
        let from = resolve_source(&doi, source.as_deref());
        let data = commonmeta::read(&from, &doi)
            .map_err(|e| AppError::NotFound(e.to_string()))?;
        if data.url.is_empty() {
            Err(AppError::NotFound(doi))
        } else {
            Ok(data.url)
        }
    })
    .await
    .map_err(|e| AppError::Internal(e.to_string()))?
}

// ── /bibliography ─────────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct BibliographyRequest {
    items: Vec<BibRequestItem>,
    style: Option<String>,
    locale: Option<String>,
}

#[derive(Deserialize)]
struct BibRequestItem {
    id: String,
    data: String, // commonmeta JSON string
}

#[derive(Serialize)]
struct BibliographyResponse {
    items: Vec<BibResponseItem>,
}

#[derive(Serialize)]
struct BibResponseItem {
    id: String,
    html: String,
}

async fn handle_bibliography(
    Json(req): Json<BibliographyRequest>,
) -> Result<Json<BibliographyResponse>, AppError> {
    let style = req.style;
    let locale = req.locale;
    let items = req.items;

    let results = tokio::task::spawn_blocking(move || {
        items
            .into_iter()
            .map(|item| {
                let data = commonmeta::read("commonmeta", &item.data)
                    .map_err(|e| AppError::FetchError(e.to_string()))?;
                let bytes = commonmeta::write_with_style(
                    "citation",
                    &data,
                    style.as_deref(),
                    locale.as_deref(),
                )
                .map_err(|e| AppError::FetchError(e.to_string()))?;
                Ok(BibResponseItem {
                    id: item.id,
                    html: String::from_utf8_lossy(&bytes).into_owned(),
                })
            })
            .collect::<Result<Vec<_>, AppError>>()
    })
    .await
    .map_err(|e| AppError::Internal(e.to_string()))??;

    Ok(Json(BibliographyResponse { items: results }))
}

fn build_app(state: AppState) -> Router {
    Router::new()
        .route("/", get(index))
        .route("/commonmeta_v1.0.json", get(schema_json))
        .route("/status", get(handle_status))
        .route("/random", get(handle_random))
        .route("/bibliography", post(handle_bibliography))
        .route("/{*path}", get(handle_pid))
        .with_state(state)
}

async fn schema_json() -> impl axum::response::IntoResponse {
    (
        [(axum::http::header::CONTENT_TYPE, "application/json")],
        commonmeta::SCHEMA_JSON,
    )
}

async fn handle_status(
    State(state): State<AppState>,
) -> Json<serde_json::Value> {
    Json(serde_json::json!({ "db": state.db.is_some() }))
}

async fn handle_random(
    State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
    let Some(db) = state.db else {
        return Err(AppError::NotFound("no local database".to_string()));
    };
    let id = tokio::task::spawn_blocking(move || db::random_doi(&db))
        .await
        .map_err(|e| AppError::Internal(e.to_string()))??;
    match id {
        Some(doi) => Ok(Json(serde_json::json!({ "id": doi }))),
        None => Err(AppError::NotFound("works table is empty".to_string())),
    }
}

fn resolve_source(doi: &str, source: Option<&str>) -> String {
    if let Some(s) = source {
        return s.to_lowercase();
    }
    match commonmeta::doi_utils::get_doi_ra_sync(doi)
        .as_deref()
        .map(str::to_lowercase)
        .as_deref()
    {
        Some("crossref") => "crossref".to_string(),
        Some("datacite") => "datacite".to_string(),
        _ => "crossref".to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{body::Body, http::Request};
    use tower::ServiceExt; // for .oneshot()

    fn make_test_db() -> (tempfile::TempDir, Arc<PathBuf>) {
        let dir = tempfile::tempdir().unwrap();
        let path = db::tests::make_test_db(&dir.path().join("test.sqlite3"));
        (dir, Arc::new(path))
    }

    fn app_no_db() -> Router {
        build_app(AppState { db: None })
    }

    fn app_with_db() -> (tempfile::TempDir, Router) {
        let (dir, db) = make_test_db();
        let app = build_app(AppState { db: Some(db) });
        (dir, app)
    }

    // ── Index ─────────────────────────────────────────────────────────────────

    #[tokio::test]
    async fn index_returns_200() {
        let response = app_no_db()
            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
    }

    // ── 404 for non-DOI paths ─────────────────────────────────────────────────

    #[tokio::test]
    async fn schema_json_returns_json() {
        let response = app_no_db()
            .oneshot(
                Request::builder()
                    .uri("/commonmeta_v1.0.json")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let ct = response.headers().get("content-type").unwrap().to_str().unwrap();
        assert!(ct.contains("application/json"));
    }

    #[tokio::test]
    async fn non_doi_path_returns_404() {
        let response = app_no_db()
            .oneshot(
                Request::builder()
                    .uri("/not-a-doi")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn arbitrary_text_path_returns_404() {
        let response = app_no_db()
            .oneshot(
                Request::builder()
                    .uri("/hello/world")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    // ── 406 for unsupported Accept types ──────────────────────────────────────

    #[tokio::test]
    async fn unsupported_accept_returns_406() {
        let response = app_no_db()
            .oneshot(
                Request::builder()
                    .uri("/10.1234/test")
                    .header("Accept", "application/rdf+xml")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE);
    }

    #[tokio::test]
    async fn multiple_unsupported_accept_returns_406() {
        let response = app_no_db()
            .oneshot(
                Request::builder()
                    .uri("/10.1234/test")
                    .header("Accept", "application/rdf+xml, image/png")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE);
    }

    #[tokio::test]
    async fn invalid_format_query_param_returns_406() {
        let response = app_no_db()
            .oneshot(
                Request::builder()
                    .uri("/10.1234/test?format=nonsense")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE);
    }

    // ── DB-backed responses (no live API call) ────────────────────────────────

    #[tokio::test]
    async fn html_accept_with_db_returns_redirect() {
        let (_dir, app) = app_with_db();
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/10.1234/test")
                    .header("Accept", "text/html")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
        let location = response.headers().get("location").unwrap().to_str().unwrap();
        assert_eq!(location, "https://example.com/test-article");
    }

    #[tokio::test]
    async fn bibtex_accept_with_db_returns_200() {
        let (_dir, app) = app_with_db();
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/10.1234/test")
                    .header("Accept", "application/x-bibtex")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let ct = response.headers().get("content-type").unwrap().to_str().unwrap();
        assert!(ct.contains("application/x-bibtex"), "unexpected content-type: {ct}");
        let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
            .await
            .unwrap();
        let text = std::str::from_utf8(&body).unwrap();
        assert!(text.starts_with('@'), "bibtex should start with '@': {text}");
    }

    #[tokio::test]
    async fn format_query_param_with_db_returns_bibtex() {
        let (_dir, app) = app_with_db();
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/10.1234/test?format=bibtex")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let ct = response.headers().get("content-type").unwrap().to_str().unwrap();
        assert!(ct.contains("application/x-bibtex"), "unexpected content-type: {ct}");
    }

    #[tokio::test]
    async fn csl_json_accept_with_db_returns_200() {
        let (_dir, app) = app_with_db();
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/10.1234/test")
                    .header("Accept", "application/vnd.citationstyles.csl+json")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let ct = response.headers().get("content-type").unwrap().to_str().unwrap();
        assert!(ct.contains("citationstyles.csl+json"), "unexpected content-type: {ct}");
    }
}
