Skip to content
use actix_web::{HttpRequest, HttpResponse, Responder, get, post, web};
use maud::{DOCTYPE, Markup, html};
use serde::{Deserialize, Serialize};

use crate::config::Config;
use crate::db::Database;
use crate::session::SessionStore;

pub mod debug;
pub mod export;
pub mod guard;
pub mod icons;
pub mod install;
pub mod notify;

pub use export::export_config;
pub use install::{
    download_file, install_page, linux_installer_script, mac_installer_script,
    omarchy_installer_script, omarchy_plugin_config, omarchy_plugin_file,
};

pub fn is_htmx(req: &HttpRequest) -> bool {
    req.headers().contains_key("hx-request")
}

#[derive(Deserialize, Serialize)]
pub struct PinForm {
    pub pin: String,
}

#[get("/")]
pub async fn index() -> Markup {
    main()
}

#[post("/pin")]
pub async fn verify_pin(
    form: web::Form<PinForm>,
    config: web::Data<Config>,
    session_store: web::Data<SessionStore>,
) -> impl Responder {
    if config.is_valid_pin(&form.pin) {
        let token = session_store.create_session().await;
        let cookie = actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token)
            .path("/")
            .http_only(true)
            .same_site(actix_web::cookie::SameSite::Lax)
            .max_age(actix_web::cookie::time::Duration::seconds(
                crate::session::SESSION_TTL_SECS as i64,
            ))
            .finish();

        HttpResponse::Ok()
            .cookie(cookie)
            .content_type("text/html; charset=utf-8")
            .body(notify::notify_content().into_string())
    } else {
        HttpResponse::Ok()
            .content_type("text/html; charset=utf-8")
            .body(guard::guard_view(Some("Invalid PIN. Please try again.")).into_string())
    }
}

#[derive(Deserialize, Serialize, Default)]
pub struct NotifyQuery {
    pub message: Option<String>,
}

#[derive(Deserialize, Serialize, Default, Debug, Clone, PartialEq, Eq)]
pub struct CreateNotificationPayload {
    #[serde(default)]
    pub kind: Option<String>,
    #[serde(default, alias = "text", alias = "body", alias = "msg")]
    pub message: Option<String>,
}

pub async fn post_notification_json(
    _auth: crate::auth::PinAuth,
    body: Option<web::Json<CreateNotificationPayload>>,
    db: web::Data<Database>,
) -> impl Responder {
    let payload = body.map(|b| b.into_inner()).unwrap_or_default();
    let raw_kind = payload.kind.as_deref().unwrap_or("notification").trim();
    let kind = if raw_kind.is_empty() {
        "notification"
    } else {
        raw_kind
    };

    let default_message = match kind {
        "heart" => "Look at your phone",
        "cat" => "Missing you",
        "emergency" => "Emergency",
        _ => "Notification",
    };

    let raw_msg = payload.message.as_deref().unwrap_or(default_message).trim();
    let message = if raw_msg.is_empty() {
        default_message
    } else {
        raw_msg
    };

    match db.save_notification(kind, message, false).await {
        Ok(notification) => HttpResponse::Created().json(notification),
        Err(e) => {
            log::error!("Failed to save notification: {e}");
            HttpResponse::InternalServerError().json(serde_json::json!({
                "error": "Failed to save notification",
                "details": e,
            }))
        }
    }
}

#[derive(Deserialize, Serialize, Default, Debug)]
pub struct ScanRequest {
    pub limit: Option<usize>,
}

async fn handle_notify_action(
    req: HttpRequest,
    kind: String,
    query: NotifyQuery,
    config: web::Data<Config>,
    session_store: web::Data<SessionStore>,
    db: web::Data<Database>,
) -> impl Responder {
    let is_session_auth = session_store.is_authenticated(&req);
    let is_pin_auth = crate::auth::extract_pin(&req)
        .map(|k| config.is_valid_pin(&k))
        .unwrap_or(false);

    if !is_session_auth && !is_pin_auth {
        return HttpResponse::Unauthorized().finish();
    }

    let default_message = match kind.as_str() {
        "heart" => "Look at your phone",
        "cat" => "Missing you",
        "emergency" => "Emergency",
        _ => "Notification",
    };

    let message = query
        .message
        .as_deref()
        .filter(|m| !m.trim().is_empty())
        .unwrap_or(default_message);

    // Save initial row in database with sent = false (ready for scanning)
    if let Err(e) = db.save_notification(&kind, message, false).await {
        log::error!("Failed to save notification: {e}");
        return HttpResponse::InternalServerError().finish();
    }

    // 204 No Content: HTMX request finishes once saved in DB, resetting the pending state
    HttpResponse::NoContent().finish()
}

#[post("/notify/{kind}")]
pub async fn notify_action(
    req: HttpRequest,
    path: web::Path<String>,
    query: web::Query<NotifyQuery>,
    config: web::Data<Config>,
    session_store: web::Data<SessionStore>,
    db: web::Data<Database>,
) -> impl Responder {
    handle_notify_action(
        req,
        path.into_inner(),
        query.into_inner(),
        config,
        session_store,
        db,
    )
    .await
}

#[post("/api/notify/{kind}")]
pub async fn api_notify_action(
    req: HttpRequest,
    path: web::Path<String>,
    query: web::Query<NotifyQuery>,
    config: web::Data<Config>,
    session_store: web::Data<SessionStore>,
    db: web::Data<Database>,
) -> impl Responder {
    handle_notify_action(
        req,
        path.into_inner(),
        query.into_inner(),
        config,
        session_store,
        db,
    )
    .await
}

pub async fn scan_notifications(
    _auth: crate::auth::PinAuth,
    body: Option<web::Json<ScanRequest>>,
    db: web::Data<Database>,
) -> impl Responder {
    let limit = body.and_then(|b| b.limit);
    log::debug!("Scanning pending notifications (limit: {limit:?})");
    let notifications = match db.get_pending_notifications(limit).await {
        Ok(items) => items,
        Err(e) => {
            log::error!("Failed to scan notifications: {e}");
            return HttpResponse::InternalServerError().json(serde_json::json!({
                "error": "Failed to scan notifications",
                "details": e,
            }));
        }
    };

    if notifications.is_empty() {
        // Return 204 No Content (2xx indicating nothing changed)
        return HttpResponse::NoContent().finish();
    }

    HttpResponse::Ok().json(notifications)
}

#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(untagged)]
pub enum MarkSentRequest {
    Object {
        #[serde(default)]
        ids: Option<Vec<i64>>,
        #[serde(default)]
        id: Option<i64>,
    },
    List(Vec<i64>),
}

impl MarkSentRequest {
    pub fn into_ids(self) -> Vec<i64> {
        match self {
            MarkSentRequest::Object { ids, id } => {
                if let Some(ids) = ids {
                    ids
                } else if let Some(id) = id {
                    vec![id]
                } else {
                    Vec::new()
                }
            }
            MarkSentRequest::List(ids) => ids,
        }
    }
}

pub async fn mark_notifications_sent(
    _auth: crate::auth::PinAuth,
    body: Option<web::Json<MarkSentRequest>>,
    db: web::Data<Database>,
) -> impl Responder {
    let ids = body.map(|b| b.into_inner().into_ids()).unwrap_or_default();
    match db.mark_as_sent(&ids).await {
        Ok(updated) => HttpResponse::Ok().json(serde_json::json!({
            "updated": updated,
        })),
        Err(e) => {
            log::error!("Failed to mark notifications as sent: {e}");
            HttpResponse::InternalServerError().json(serde_json::json!({
                "error": "Failed to mark notifications as sent",
                "details": e,
            }))
        }
    }
}

pub async fn mark_single_notification_sent(
    _auth: crate::auth::PinAuth,
    path: web::Path<i64>,
    db: web::Data<Database>,
) -> impl Responder {
    let id = path.into_inner();
    match db.mark_as_sent(&[id]).await {
        Ok(updated) => HttpResponse::Ok().json(serde_json::json!({
            "updated": updated,
        })),
        Err(e) => {
            log::error!("Failed to mark notification as sent: {e}");
            HttpResponse::InternalServerError().json(serde_json::json!({
                "error": "Failed to mark notification as sent",
                "details": e,
            }))
        }
    }
}

pub fn main() -> Markup {
    html! {
        (DOCTYPE)
        html lang="en" {
            head {
                meta charset="utf-8";
                meta name="viewport" content="width=device-width, initial-scale=1.0";
                title { "Notify" }
                link rel="stylesheet" href="/assets/daisyui.css";
                link rel="stylesheet" href="/assets/app.css";
                script src="/assets/h.js" {}
            }
            body class="min-h-screen bg-base-100 text-base-content"  {
                div id="main" class="min-h-screen w-full" hx-get="/notify" hx-trigger="load" {
                    // fetch main body content
                    // by default /notify
                }
            }
        }
    }
}

pub fn main_with_content(content: Markup) -> Markup {
    html! {
        (DOCTYPE)
        html lang="en" {
            head {
                meta charset="utf-8";
                meta name="viewport" content="width=device-width, initial-scale=1.0";
                title { "Notify" }
                link rel="stylesheet" href="/assets/daisyui.css";
                link rel="stylesheet" href="/assets/app.css";
                script src="/assets/h.js" {}
            }
            body class="min-h-screen bg-base-100 text-base-content"  {
                div id="main" class="min-h-screen w-full" {
                    (content)
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::http::header::HeaderValue;
    use actix_web::{App, test as aw_test};

    fn test_config() -> Config {
        Config::new(
            8080,
            HeaderValue::from_static(crate::config::DEFAULT_CACHE_CONTROL),
            "notify.db".to_string(),
            false,
            "123456".to_string(),
        )
    }

    #[test]
    fn test_main_view_contains_daisyui_and_app_css() {
        let html_str = main().into_string();

        assert!(html_str.contains("/assets/daisyui.css"));
        assert!(html_str.contains("/assets/app.css"));
        assert!(html_str.contains("/assets/h.js"));
        assert!(html_str.contains("hx-get=\"/notify\""));
    }

    #[actix_web::test]
    async fn test_index_endpoint() {
        let app = aw_test::init_service(App::new().service(index)).await;
        let req = aw_test::TestRequest::get().uri("/").to_request();
        let resp = aw_test::call_service(&app, req).await;

        assert!(resp.status().is_success());
        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("hx-get=\"/notify\""));
        assert!(body_str.contains("id=\"main\""));
    }

    #[actix_web::test]
    async fn test_verify_pin_success() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());
        let app = aw_test::init_service(
            App::new()
                .app_data(config.clone())
                .app_data(session_store.clone())
                .service(verify_pin),
        )
        .await;

        let req = aw_test::TestRequest::post()
            .uri("/pin")
            .set_form(PinForm {
                pin: "123456".to_string(),
            })
            .to_request();
        let resp = aw_test::call_service(&app, req).await;

        assert!(resp.status().is_success());
        let cookie = resp
            .response()
            .cookies()
            .find(|c| c.name() == crate::session::SESSION_COOKIE_NAME);
        assert!(cookie.is_some(), "Should set session cookie");
        let cookie = cookie.unwrap();
        assert_eq!(
            cookie.max_age(),
            Some(actix_web::cookie::time::Duration::days(365)),
            "Session cookie should last a year"
        );

        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("Look at your phone"));
    }

    #[actix_web::test]
    async fn test_verify_pin_failure() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());
        let app = aw_test::init_service(
            App::new()
                .app_data(config.clone())
                .app_data(session_store.clone())
                .service(verify_pin),
        )
        .await;

        let req = aw_test::TestRequest::post()
            .uri("/pin")
            .set_form(PinForm {
                pin: "9999".to_string(),
            })
            .to_request();
        let resp = aw_test::call_service(&app, req).await;

        assert!(resp.status().is_success());
        let cookie = resp
            .response()
            .cookies()
            .find(|c| c.name() == crate::session::SESSION_COOKIE_NAME);
        assert!(cookie.is_none(), "Should NOT set session cookie on failure");

        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("Invalid PIN"));
        assert!(body_str.contains("class=\"otp\""));
    }

    #[actix_web::test]
    async fn test_notify_endpoints_unauthorized() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_notify_unauth_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config)
                .app_data(session_store.clone())
                .app_data(db_data)
                .service(notify_action),
        )
        .await;

        let req = aw_test::TestRequest::post()
            .uri("/notify/heart")
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);

        // Verify no notification was saved
        let list = db.list_notifications().await.unwrap();
        assert!(list.is_empty());
        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_notify_endpoints_authorized() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());
        let token = session_store.create_session().await;
        let cookie =
            actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token).finish();

        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_notify_auth_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config)
                .app_data(session_store.clone())
                .app_data(db_data)
                .service(notify_action),
        )
        .await;

        for kind in ["heart", "cat", "emergency"] {
            let req = aw_test::TestRequest::post()
                .uri(&format!("/notify/{kind}"))
                .cookie(cookie.clone())
                .to_request();
            let resp = aw_test::call_service(&app, req).await;
            // 204 No Content tells HTMX that saving is complete without replacing button content with alerts
            assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);
            let body = aw_test::read_body(resp).await;
            let body_str = String::from_utf8(body.to_vec()).unwrap();

            // Must NOT say "notification sent"
            assert!(!body_str.contains("notification sent"));
            assert!(!body_str.contains("Notification sent"));
            assert!(body_str.is_empty());
        }

        // Verify that 3 rows were saved into the database ready to be scanned (sent == false)
        let list = db.list_notifications().await.unwrap();
        assert_eq!(list.len(), 3);
        for item in &list {
            assert!(
                !item.sent,
                "Notification {} should initially be ready/unsent",
                item.id
            );
        }

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_scan_notifications_when_empty_returns_204_no_content() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_scan_empty_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data)
                .route("/notifications/scan", web::post().to(scan_notifications)),
        )
        .await;

        let req = aw_test::TestRequest::post()
            .uri("/notifications/scan")
            .insert_header(("X-PIN", "123456"))
            .set_json(ScanRequest::default())
            .to_request();
        let resp = aw_test::call_service(&app, req).await;

        // 204 No Content indicates nothing changed
        assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);
        assert!(resp.status().is_success());

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_scan_notifications_does_not_flag_them_as_sent() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_scan_no_flag_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        // Save 2 notifications that are ready
        db.save_notification("heart", "Look at your phone", false)
            .await
            .unwrap();
        db.save_notification("cat", "Missing you", false)
            .await
            .unwrap();

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data.clone())
                .route("/notifications/scan", web::post().to(scan_notifications)),
        )
        .await;

        // Call scan endpoint
        let req = aw_test::TestRequest::post()
            .uri("/notifications/scan")
            .insert_header(("X-PIN", "123456"))
            .set_json(ScanRequest { limit: Some(10) })
            .to_request();
        let resp = aw_test::call_service(&app, req).await;

        assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
        let body = aw_test::read_body(resp).await;
        let notifications: Vec<crate::db::Notification> =
            serde_json::from_slice(&body).expect("Should deserialize JSON response");

        assert_eq!(notifications.len(), 2);
        assert_eq!(notifications[0].kind, "heart");
        assert_eq!(notifications[1].kind, "cat");

        // The endpoint should NOT flag them as sent!
        assert!(!notifications[0].sent);
        assert!(!notifications[1].sent);

        // Verify in DB directly that sent is still false (0)
        let in_db = db.list_notifications().await.unwrap();
        for item in in_db {
            assert!(
                !item.sent,
                "Notification {} should still NOT be flagged as sent",
                item.id
            );
        }

        // Subsequent scan still returns both notifications (not 204) because they weren't flagged as sent
        let req2 = aw_test::TestRequest::post()
            .uri("/notifications/scan")
            .insert_header(("X-PIN", "123456"))
            .set_json(ScanRequest::default())
            .to_request();
        let resp2 = aw_test::call_service(&app, req2).await;
        assert_eq!(resp2.status(), actix_web::http::StatusCode::OK);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_mark_notifications_sent_endpoint() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_mark_sent_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let n1 = db
            .save_notification("heart", "Look at your phone", false)
            .await
            .unwrap();
        let n2 = db
            .save_notification("cat", "Missing you", false)
            .await
            .unwrap();

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data.clone())
                .route("/notifications/scan", web::post().to(scan_notifications))
                .route(
                    "/notifications/sent",
                    web::post().to(mark_notifications_sent),
                ),
        )
        .await;

        // Mark only n1 as sent via JSON body { "ids": [n1.id] }
        let req = aw_test::TestRequest::post()
            .uri("/notifications/sent")
            .insert_header(("X-PIN", "123456"))
            .set_json(MarkSentRequest::Object {
                ids: Some(vec![n1.id]),
                id: None,
            })
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::OK);

        let body = aw_test::read_body(resp).await;
        let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(result["updated"], serde_json::json!([n1.id]));

        // Now scan only returns n2
        let scan_req = aw_test::TestRequest::post()
            .uri("/notifications/scan")
            .insert_header(("X-PIN", "123456"))
            .set_json(ScanRequest::default())
            .to_request();
        let scan_resp = aw_test::call_service(&app, scan_req).await;
        assert_eq!(scan_resp.status(), actix_web::http::StatusCode::OK);
        let body = aw_test::read_body(scan_resp).await;
        let ready: Vec<crate::db::Notification> = serde_json::from_slice(&body).unwrap();
        assert_eq!(ready.len(), 1);
        assert_eq!(ready[0].id, n2.id);

        // Mark remaining sent with empty payload (marks all pending as sent)
        let req2 = aw_test::TestRequest::post()
            .uri("/notifications/sent")
            .insert_header(("X-PIN", "123456"))
            .set_json(MarkSentRequest::Object {
                ids: None,
                id: None,
            })
            .to_request();
        let resp2 = aw_test::call_service(&app, req2).await;
        assert_eq!(resp2.status(), actix_web::http::StatusCode::OK);

        // Now scan returns 204 No Content
        let scan_req2 = aw_test::TestRequest::post()
            .uri("/notifications/scan")
            .insert_header(("X-PIN", "123456"))
            .set_json(ScanRequest::default())
            .to_request();
        let scan_resp2 = aw_test::call_service(&app, scan_req2).await;
        assert_eq!(scan_resp2.status(), actix_web::http::StatusCode::NO_CONTENT);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_mark_single_notification_sent_endpoint() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_mark_single_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let n1 = db
            .save_notification("emergency", "Emergency", false)
            .await
            .unwrap();

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data.clone())
                .route("/notifications/scan", web::get().to(scan_notifications))
                .route(
                    "/notifications/{id}/sent",
                    web::post().to(mark_single_notification_sent),
                ),
        )
        .await;

        // Mark single notification sent
        let req = aw_test::TestRequest::post()
            .uri(&format!("/notifications/{}/sent", n1.id))
            .insert_header(("X-PIN", "123456"))
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::OK);

        // Scan now returns 204 No Content
        let scan_req = aw_test::TestRequest::get()
            .uri("/notifications/scan")
            .insert_header(("X-PIN", "123456"))
            .to_request();
        let scan_resp = aw_test::call_service(&app, scan_req).await;
        assert_eq!(scan_resp.status(), actix_web::http::StatusCode::NO_CONTENT);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_notify_with_custom_message_query() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());
        let token = session_store.create_session().await;
        let cookie =
            actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token).finish();

        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_notify_custom_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config)
                .app_data(session_store.clone())
                .app_data(db_data)
                .service(notify_action),
        )
        .await;

        let req = aw_test::TestRequest::post()
            .uri("/notify/custom_kind?message=Please%20pick%20up%20milk")
            .cookie(cookie.clone())
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert!(resp.status().is_success());

        let list = db.list_notifications().await.unwrap();
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].kind, "custom_kind");
        assert_eq!(list[0].message, "Please pick up milk");
        assert!(!list[0].sent);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_mark_notification_completed_endpoint() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_mark_completed_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let n1 = db
            .save_notification("cat", "Missing you", false)
            .await
            .unwrap();

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data.clone())
                .route("/notifications/scan", web::get().to(scan_notifications))
                .route(
                    "/notifications/{id}/completed",
                    web::post().to(mark_single_notification_sent),
                ),
        )
        .await;

        // Mark completed
        let req = aw_test::TestRequest::post()
            .uri(&format!("/notifications/{}/completed", n1.id))
            .insert_header(("X-PIN", "123456"))
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::OK);

        // Scan now returns 204
        let scan_req = aw_test::TestRequest::get()
            .uri("/notifications/scan")
            .insert_header(("X-PIN", "123456"))
            .to_request();
        let scan_resp = aw_test::call_service(&app, scan_req).await;
        assert_eq!(scan_resp.status(), actix_web::http::StatusCode::NO_CONTENT);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_scan_notifications_unauthorized_without_pin() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_scan_no_pin_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data)
                .route("/notifications/scan", web::get().to(scan_notifications)),
        )
        .await;

        let req = aw_test::TestRequest::get()
            .uri("/notifications/scan")
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_scan_notifications_unauthorized_with_wrong_pin() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_scan_wrong_pin_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data)
                .route("/notifications/scan", web::get().to(scan_notifications)),
        )
        .await;

        let req = aw_test::TestRequest::get()
            .uri("/notifications/scan")
            .insert_header(("X-PIN", "9999"))
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_scan_notifications_authorized_with_bearer_token() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_scan_bearer_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data)
                .route("/notifications/scan", web::get().to(scan_notifications)),
        )
        .await;

        let req = aw_test::TestRequest::get()
            .uri("/notifications/scan")
            .insert_header(("Authorization", "Bearer 123456"))
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        // 204 No Content because there are no pending notifications, but it is authenticated!
        assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_scan_notifications_authorized_with_query_param() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_scan_query_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data)
                .route("/notifications/scan", web::get().to(scan_notifications)),
        )
        .await;

        let req = aw_test::TestRequest::get()
            .uri("/notifications/scan?pin=123456")
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_notify_action_authorized_with_pin() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_notify_pin_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config)
                .app_data(session_store)
                .app_data(db_data.clone())
                .service(notify_action),
        )
        .await;

        // No session cookie, but passes X-PIN
        let req = aw_test::TestRequest::post()
            .uri("/notify/emergency")
            .insert_header(("X-PIN", "123456"))
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);

        let list = db.list_notifications().await.unwrap();
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].kind, "emergency");

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_api_notify_action_authorized_with_pin() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_api_notify_pin_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config)
                .app_data(session_store)
                .app_data(db_data.clone())
                .service(api_notify_action),
        )
        .await;

        let req = aw_test::TestRequest::post()
            .uri("/api/notify/cat")
            .insert_header(("X-PIN", "123456"))
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::NO_CONTENT);

        let list = db.list_notifications().await.unwrap();
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].kind, "cat");

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_api_notify_action_unauthorized() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_api_notify_unauth_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config)
                .app_data(session_store)
                .app_data(db_data.clone())
                .service(api_notify_action),
        )
        .await;

        let req = aw_test::TestRequest::post()
            .uri("/api/notify/cat")
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_export_config_routes_and_plugin_compatibility() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());

        let app = aw_test::init_service(
            App::new()
                .app_data(config)
                .app_data(session_store)
                .route("/config/export", web::get().to(export_config))
                .route("/export", web::get().to(export_config))
                .route("/api/config/export", web::get().to(export_config)),
        )
        .await;

        for uri in ["/config/export", "/export", "/api/config/export"] {
            let req = aw_test::TestRequest::get()
                .uri(uri)
                .insert_header(("X-PIN", "123456"))
                .insert_header(("Host", "192.168.1.50:8080"))
                .to_request();
            let resp = aw_test::call_service(&app, req).await;
            assert_eq!(resp.status(), actix_web::http::StatusCode::OK);

            let body = aw_test::read_body(resp).await;
            let val: serde_json::Value = serde_json::from_slice(&body).unwrap();

            // Validate that exported config has all fields needed by plugin
            assert_eq!(val["name"], "notify");
            assert_eq!(val["url"], "http://192.168.1.50:8080");
            assert_eq!(val["pin"], "123456");
            assert_eq!(val["api_key"], "123456");
            assert_eq!(val["active"], "notify");
        }
    }

    #[actix_web::test]
    async fn test_install_page_routes_and_plugin_download() {
        let config = web::Data::new(test_config());
        let session_store = web::Data::new(SessionStore::in_memory());
        let token = session_store.create_session().await;
        let cookie =
            actix_web::cookie::Cookie::build(crate::session::SESSION_COOKIE_NAME, token).finish();

        let app = aw_test::init_service(
            App::new()
                .app_data(config.clone())
                .app_data(session_store.clone())
                .service(install_page)
                .service(
                    web::resource("/install/omarchy.sh")
                        .route(web::get().to(omarchy_installer_script)),
                )
                .service(
                    web::resource("/install/omarchy")
                        .route(web::get().to(omarchy_installer_script)),
                )
                .service(
                    web::resource("/api/install/omarchy.sh")
                        .route(web::get().to(omarchy_installer_script)),
                )
                .service(
                    web::resource("/install/omarchy/download")
                        .route(web::get().to(omarchy_installer_script)),
                )
                .service(
                    web::resource("/install/omarchy/plugin/{filename:.*}")
                        .route(web::get().to(omarchy_plugin_file)),
                )
                .service(
                    web::resource("/install/omarchy/config")
                        .route(web::get().to(omarchy_plugin_config)),
                ),
        )
        .await;

        // 1. Test GET /install via HTMX
        let req = aw_test::TestRequest::get()
            .uri("/install")
            .cookie(cookie.clone())
            .insert_header(("HX-Request", "true"))
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("Omarchy Plugin"));
        assert!(body_str.contains("macOS App"));
        assert!(body_str.contains("Linux App"));
        assert!(body_str.contains("Ready to install"));
        assert!(body_str.contains("Notify.app"));
        assert!(body_str.contains("curl -fsSL"));
        assert!(body_str.contains("/install/omarchy.sh?pin=123456"));
        assert!(!body_str.contains("<!DOCTYPE html>")); // HTMX partial
        assert!(!body_str.contains("download=\"notify.json\""));
        assert!(!body_str.contains("omarchy-notify-plugin.tar.gz"));

        // 2. Test GET /install directly (non-HTMX browser request)
        let req = aw_test::TestRequest::get()
            .uri("/install")
            .cookie(cookie.clone())
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("<!DOCTYPE html>")); // Full page wrapper
        assert!(body_str.contains("/assets/daisyui.css"));
        assert!(body_str.contains("Omarchy Plugin"));
        assert!(body_str.contains("macOS App"));
        assert!(body_str.contains("Linux App"));
        assert!(body_str.contains("Ready to install"));

        // 3. Test downloading installer script across endpoints
        for uri in [
            "/install/omarchy.sh",
            "/install/omarchy",
            "/api/install/omarchy.sh",
            "/install/omarchy/download",
        ] {
            let req = aw_test::TestRequest::get()
                .uri(uri)
                .insert_header(("X-PIN", "123456"))
                .insert_header(("Host", "my-notify-server.local:8080"))
                .to_request();
            let resp = aw_test::call_service(&app, req).await;
            assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
            assert_eq!(
                resp.headers()
                    .get("content-type")
                    .unwrap()
                    .to_str()
                    .unwrap(),
                "text/x-shellscript; charset=utf-8"
            );

            let body = aw_test::read_body(resp).await;
            let script_str = String::from_utf8(body.to_vec()).unwrap();
            assert!(script_str.starts_with("#!/usr/bin/env bash"));
            assert!(script_str.contains(
                "SERVER_URL=\"${NOTIFY_SERVER_URL:-\"http://my-notify-server.local:8080\"}\""
            ));
            assert!(script_str.contains("PIN=\"${NOTIFY_PIN:-\"123456\"}\""));
            assert!(script_str.contains("manifest.json"));
            assert!(script_str.contains("Service.qml"));
            assert!(script_str.contains("/install/omarchy/config?pin=${PIN}"));
            assert!(script_str.contains("omarchy plugin enable notify.desktop"));
        }

        // 4. Test plugin file serving and config endpoint
        let req = aw_test::TestRequest::get()
            .uri("/install/omarchy/plugin/manifest.json?pin=123456")
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
        let body = aw_test::read_body(resp).await;
        let body_str = String::from_utf8(body.to_vec()).unwrap();
        assert!(body_str.contains("\"id\": \"notify.desktop\""));

        let req = aw_test::TestRequest::get()
            .uri("/install/omarchy/config?pin=123456")
            .insert_header(("Host", "my-notify-server.local:8080"))
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::OK);
        let body = aw_test::read_body(resp).await;
        let parsed_config: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(parsed_config["url"], "http://my-notify-server.local:8080");
        assert_eq!(parsed_config["pin"], "123456");
        assert_eq!(parsed_config["active"], "notify");
    }

    #[actix_web::test]
    async fn test_post_notification_json_unauthorized() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_post_json_unauth_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data)
                .route("/api/notify", web::post().to(post_notification_json)),
        )
        .await;

        let req = aw_test::TestRequest::post()
            .uri("/api/notify")
            .set_json(CreateNotificationPayload {
                kind: Some("heart".to_string()),
                message: Some("hello".to_string()),
            })
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::UNAUTHORIZED);

        let _ = std::fs::remove_file(&db_path);
    }

    #[actix_web::test]
    async fn test_post_notification_json_authorized_success() {
        let config_data = web::Data::new(test_config());
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let db_path = format!("/tmp/test_post_json_auth_{now}.db");
        let db = Database::new(&db_path);
        db.init().await.unwrap();
        let db_data = web::Data::new(db.clone());

        let app = aw_test::init_service(
            App::new()
                .app_data(config_data)
                .app_data(db_data.clone())
                .route("/api/notify", web::post().to(post_notification_json))
                .route("/api/notifications", web::post().to(post_notification_json))
                .route("/notifications", web::post().to(post_notification_json)),
        )
        .await;

        // 1. Full payload on /api/notify
        let req = aw_test::TestRequest::post()
            .uri("/api/notify")
            .insert_header(("X-PIN", "123456"))
            .set_json(serde_json::json!({
                "kind": "heart",
                "message": "Custom heart message"
            }))
            .to_request();
        let resp = aw_test::call_service(&app, req).await;
        assert_eq!(resp.status(), actix_web::http::StatusCode::CREATED);
        let body = aw_test::read_body(resp).await;
        let created: crate::db::Notification = serde_json::from_slice(&body).unwrap();
        assert_eq!(created.kind, "heart");
        assert_eq!(created.message, "Custom heart message");
        assert!(!created.sent);

        // 2. Kind only (should use default message) on /api/notifications
        let req2 = aw_test::TestRequest::post()
            .uri("/api/notifications")
            .insert_header(("Authorization", "Bearer 123456"))
            .set_json(serde_json::json!({
                "kind": "cat"
            }))
            .to_request();
        let resp2 = aw_test::call_service(&app, req2).await;
        assert_eq!(resp2.status(), actix_web::http::StatusCode::CREATED);
        let body2 = aw_test::read_body(resp2).await;
        let created2: crate::db::Notification = serde_json::from_slice(&body2).unwrap();
        assert_eq!(created2.kind, "cat");
        assert_eq!(created2.message, "Missing you");

        // 3. Message only (should default kind to "notification") on /notifications
        let req3 = aw_test::TestRequest::post()
            .uri("/notifications")
            .insert_header(("X-PIN", "123456"))
            .set_json(serde_json::json!({
                "message": "Custom text from mac app"
            }))
            .to_request();
        let resp3 = aw_test::call_service(&app, req3).await;
        assert_eq!(resp3.status(), actix_web::http::StatusCode::CREATED);
        let body3 = aw_test::read_body(resp3).await;
        let created3: crate::db::Notification = serde_json::from_slice(&body3).unwrap();
        assert_eq!(created3.kind, "notification");
        assert_eq!(created3.message, "Custom text from mac app");

        // 4. Empty JSON body
        let req4 = aw_test::TestRequest::post()
            .uri("/api/notify")
            .insert_header(("X-PIN", "123456"))
            .set_json(serde_json::json!({}))
            .to_request();
        let resp4 = aw_test::call_service(&app, req4).await;
        assert_eq!(resp4.status(), actix_web::http::StatusCode::CREATED);
        let body4 = aw_test::read_body(resp4).await;
        let created4: crate::db::Notification = serde_json::from_slice(&body4).unwrap();
        assert_eq!(created4.kind, "notification");
        assert_eq!(created4.message, "Notification");

        // Verify all 4 saved in DB
        let list = db.list_notifications().await.unwrap();
        assert_eq!(list.len(), 4);

        let _ = std::fs::remove_file(&db_path);
    }
}