use axum::{
    extract::{ConnectInfo, State},
    http::{header, StatusCode},
    response::{IntoResponse, Response},
    routing::{get, post, put},
    Json, Router,
};
use include_dir::{include_dir, Dir};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;

static UI_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../apm-ui/dist");

mod agents;
mod handlers;
mod tls;
mod auth;
mod log;
mod models;
mod queue;
mod util;
mod work;
mod workers;

use handlers::tickets::{
    batch_priority, batch_transition, create_ticket, get_ticket, get_ticket_prompt,
    list_tickets, patch_ticket, put_body, transition_ticket,
};
#[cfg(test)]
use handlers::tickets::extract_section;

#[allow(dead_code)] // InMemory is constructed in tests but matched in shared code
pub(crate) enum TicketSource {
    Git(PathBuf, PathBuf),
    InMemory(Vec<apm_core::ticket::Ticket>),
}

pub(crate) struct AppState {
    pub(crate) source: TicketSource,
    work_engine: work::WorkEngineState,
    log_file: Option<std::path::PathBuf>,
    max_concurrent_override: Arc<tokio::sync::Mutex<Option<usize>>>,
    otp_store: auth::OtpStore,
    session_store: auth::SessionStore,
    webauthn_state: Arc<auth::WebauthnState>,
    credential_store: auth::CredentialStore,
}

pub(crate) fn is_localhost(connect_info: Option<ConnectInfo<SocketAddr>>) -> bool {
    connect_info
        .map(|ConnectInfo(addr)| addr.ip().is_loopback())
        .unwrap_or(false)
}

impl AppState {
    fn git_root(&self) -> Option<&PathBuf> {
        match &self.source {
            TicketSource::Git(root, _) => Some(root),
            TicketSource::InMemory(_) => None,
        }
    }
}

struct AppError(anyhow::Error);

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
    }
}

impl From<anyhow::Error> for AppError {
    fn from(e: anyhow::Error) -> Self {
        AppError(e)
    }
}

impl From<tokio::task::JoinError> for AppError {
    fn from(e: tokio::task::JoinError) -> Self {
        AppError(anyhow::anyhow!(e))
    }
}


async fn health_handler() -> Json<serde_json::Value> {
    Json(serde_json::json!({"ok": true}))
}

async fn version_handler() -> Json<serde_json::Value> {
    Json(serde_json::json!({"version": env!("CARGO_PKG_VERSION"), "build": env!("APM_GIT_DESCRIBE")}))
}

async fn collaborators_handler(
    State(state): State<Arc<AppState>>,
) -> Response {
    let root = match state.git_root() {
        Some(r) => r,
        None => return Json(Vec::<String>::new()).into_response(),
    };
    let Ok(config) = apm_core::config::Config::load(root) else {
        return Json(Vec::<String>::new()).into_response();
    };
    let local = apm_core::config::LocalConfig::load(root);
    let (collaborators, _warnings) = apm_core::config::resolve_collaborators(&config, &local);
    Json(collaborators).into_response()
}

async fn me_handler(
    State(state): State<Arc<AppState>>,
    connect_info: Option<ConnectInfo<SocketAddr>>,
    headers: axum::http::HeaderMap,
) -> Json<serde_json::Value> {
    let repo_name = state.git_root()
        .and_then(|root| apm_core::config::Config::load(root).ok())
        .map(|cfg| cfg.project.name)
        .unwrap_or_default();
    if is_localhost(connect_info) {
        let username = match state.git_root() {
            Some(root) => apm_core::config::resolve_identity(root),
            None => "unassigned".to_string(),
        };
        return Json(serde_json::json!({"username": username, "repo_name": repo_name}));
    }
    let username = auth::find_session_username(&headers, &state.session_store)
        .unwrap_or_else(|| "unassigned".to_string());
    Json(serde_json::json!({"username": username, "repo_name": repo_name}))
}

async fn serve_ui(uri: axum::http::Uri) -> Response {
    let path = uri.path().trim_start_matches('/');
    if let Some(file) = UI_DIR.get_file(path) {
        let mime = mime_guess::from_path(path).first_or_octet_stream();
        (
            [(header::CONTENT_TYPE, mime.as_ref())],
            file.contents(),
        )
            .into_response()
    } else {
        let index = UI_DIR.get_file("index.html").expect("index.html missing from embedded UI");
        (
            [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
            index.contents(),
        )
            .into_response()
    }
}

fn build_app(root: PathBuf, origin_override: Option<&str>) -> Router {
    let config = apm_core::config::Config::load(&root).expect("cannot load apm config");
    let tickets_dir = config.tickets.dir;
    let log_file = config.logging.file.map(|p| {
        if p.is_absolute() { p } else { root.join(&p) }
    });
    let sessions_path = root.join(".apm/sessions.json");
    let credentials_path = root.join(".apm/credentials.json");
    let origin = origin_override
        .map(String::from)
        .unwrap_or(config.server.origin);
    let wa_state = auth::WebauthnState::new(&origin)
        .expect("cannot initialise WebAuthn state");
    let state = Arc::new(AppState {
        source: TicketSource::Git(root, tickets_dir),
        work_engine: work::new_engine_state(),
        log_file,
        max_concurrent_override: Arc::new(tokio::sync::Mutex::new(None)),
        otp_store: auth::OtpStore::new(),
        session_store: auth::SessionStore::load(sessions_path),
        webauthn_state: Arc::new(wa_state),
        credential_store: auth::CredentialStore::load(credentials_path),
    });
    let protected = Router::new()
        .route("/api/sync", post(handlers::maintenance::sync_handler))
        .route("/api/clean", post(handlers::maintenance::clean_handler))
        .route("/api/tickets", get(list_tickets).post(create_ticket))
        .route("/api/tickets/:id", get(get_ticket).patch(patch_ticket))
        .route("/api/tickets/:id/body", put(put_body))
        .route("/api/tickets/:id/prompt", get(get_ticket_prompt))
        .route("/api/tickets/:id/transition", post(transition_ticket))
        .route("/api/tickets/batch/transition", post(batch_transition))
        .route("/api/tickets/batch/priority", post(batch_priority))
        .route("/api/queue", get(queue::queue_handler))
        .route("/api/workers", get(workers::workers_handler))
        .route("/api/workers/:pid", axum::routing::delete(workers::delete_worker))
        .route("/api/work/status", get(work::get_work_status))
        .route("/api/work/start", post(work::post_work_start))
        .route("/api/work/stop", post(work::post_work_stop))
        .route("/api/work/dry-run", get(work::get_work_dry_run))
        .route("/api/agents/config", get(agents::get_agents_config).patch(agents::patch_agents_config))
        .route("/api/log/stream", get(log::stream_handler))
        .route("/api/epics", get(handlers::epics::list_epics).post(handlers::epics::create_epic))
        .route("/api/epics/:id", get(handlers::epics::get_epic))
        .route("/api/workflow", get(handlers::workflow::workflow_handler))
        .route("/api/me", get(me_handler))
        .route("/api/collaborators", get(collaborators_handler))
        .route("/api/auth/otp", post(auth::otp_handler))
        .route("/api/auth/sessions", get(auth::list_sessions_handler).delete(auth::revoke_sessions_handler))
        .route_layer(axum::middleware::from_fn_with_state(state.clone(), auth::require_auth));
    let open = Router::new()
        .route("/api/version", get(version_handler))
        .route("/health", get(health_handler))
        .route("/register", get(auth::register_page_handler))
        .route("/api/auth/register/challenge", post(auth::register_challenge_handler))
        .route("/api/auth/register/complete", post(auth::register_complete_handler))
        .route("/login", get(auth::login_page_handler))
        .route("/api/auth/login/challenge", post(auth::login_challenge_handler))
        .route("/api/auth/login/complete", post(auth::login_complete_handler));
    let fallback_state = state.clone();
    Router::new()
        .merge(protected)
        .merge(open)
        .fallback(move |req: axum::extract::Request| {
            let state = fallback_state.clone();
            async move {
                let connect_info = req.extensions().get::<ConnectInfo<SocketAddr>>().copied();
                if connect_info.is_none()
                    || is_localhost(connect_info)
                    || auth::find_session_username(req.headers(), &state.session_store).is_some()
                {
                    serve_ui(req.uri().clone()).await
                } else {
                    axum::response::Redirect::temporary("/login").into_response()
                }
            }
        })
        .with_state(state)
}

#[cfg(test)]
fn default_webauthn_state() -> Arc<auth::WebauthnState> {
    Arc::new(
        auth::WebauthnState::new("http://localhost:3000")
            .expect("test webauthn state"),
    )
}

#[cfg(test)]
pub(crate) fn build_app_with_tickets(tickets: Vec<apm_core::ticket::Ticket>) -> Router {
    let state = Arc::new(AppState {
        source: TicketSource::InMemory(tickets),
        work_engine: work::new_engine_state(),
        log_file: None,
        max_concurrent_override: Arc::new(tokio::sync::Mutex::new(None)),
        otp_store: auth::OtpStore::new(),
        session_store: auth::SessionStore::load(PathBuf::new()),
        webauthn_state: default_webauthn_state(),
        credential_store: auth::CredentialStore::load(PathBuf::new()),
    });
    Router::new()
        .route("/api/sync", post(handlers::maintenance::sync_handler))
        .route("/api/clean", post(handlers::maintenance::clean_handler))
        .route("/api/tickets", get(list_tickets).post(create_ticket))
        .route("/api/tickets/:id", get(get_ticket).patch(patch_ticket))
        .route("/api/tickets/:id/body", put(put_body))
        .route("/api/tickets/:id/prompt", get(get_ticket_prompt))
        .route("/api/tickets/:id/transition", post(transition_ticket))
        .route("/api/epics", get(handlers::epics::list_epics).post(handlers::epics::create_epic))
        .route("/api/epics/:id", get(handlers::epics::get_epic))
        .route("/api/workflow", get(handlers::workflow::workflow_handler))
        .route("/api/me", get(me_handler))
        .with_state(state)
}

#[cfg(test)]
pub fn build_app_in_memory_with_workers(tickets: Vec<apm_core::ticket::Ticket>) -> Router {
    let state = Arc::new(AppState {
        source: TicketSource::InMemory(tickets),
        work_engine: work::new_engine_state(),
        log_file: None,
        max_concurrent_override: Arc::new(tokio::sync::Mutex::new(None)),
        otp_store: auth::OtpStore::new(),
        session_store: auth::SessionStore::load(PathBuf::new()),
        webauthn_state: default_webauthn_state(),
        credential_store: auth::CredentialStore::load(PathBuf::new()),
    });
    Router::new()
        .route("/api/workers", get(workers::workers_handler))
        .with_state(state)
}

#[cfg(test)]
pub fn build_app_in_memory_with_queue(tickets: Vec<apm_core::ticket::Ticket>) -> Router {
    let state = Arc::new(AppState {
        source: TicketSource::InMemory(tickets),
        work_engine: work::new_engine_state(),
        log_file: None,
        max_concurrent_override: Arc::new(tokio::sync::Mutex::new(None)),
        otp_store: auth::OtpStore::new(),
        session_store: auth::SessionStore::load(PathBuf::new()),
        webauthn_state: default_webauthn_state(),
        credential_store: auth::CredentialStore::load(PathBuf::new()),
    });
    Router::new()
        .route("/api/queue", get(queue::queue_handler))
        .with_state(state)
}

#[cfg(test)]
pub fn build_app_in_memory_for_work() -> Router {
    let state = Arc::new(AppState {
        source: TicketSource::InMemory(vec![]),
        work_engine: work::new_engine_state(),
        log_file: None,
        max_concurrent_override: Arc::new(tokio::sync::Mutex::new(None)),
        otp_store: auth::OtpStore::new(),
        session_store: auth::SessionStore::load(PathBuf::new()),
        webauthn_state: default_webauthn_state(),
        credential_store: auth::CredentialStore::load(PathBuf::new()),
    });
    Router::new()
        .route("/api/work/status", get(work::get_work_status))
        .route("/api/work/start", post(work::post_work_start))
        .route("/api/work/stop", post(work::post_work_stop))
        .route("/api/work/dry-run", get(work::get_work_dry_run))
        .route("/api/agents/config", get(agents::get_agents_config).patch(agents::patch_agents_config))
        .with_state(state)
}

#[cfg(test)]
fn build_app_for_auth_tests(
    root: PathBuf,
    otp_store: auth::OtpStore,
    session_store: auth::SessionStore,
) -> Router {
    let state = Arc::new(AppState {
        source: TicketSource::Git(root.clone(), root.join("tickets")),
        work_engine: work::new_engine_state(),
        log_file: None,
        max_concurrent_override: Arc::new(tokio::sync::Mutex::new(None)),
        otp_store,
        session_store,
        webauthn_state: default_webauthn_state(),
        credential_store: auth::CredentialStore::load(PathBuf::new()),
    });
    Router::new()
        .route("/api/me", get(me_handler))
        .route("/api/auth/otp", post(auth::otp_handler))
        .route("/api/auth/sessions", get(auth::list_sessions_handler).delete(auth::revoke_sessions_handler))
        .with_state(state)
}

#[derive(clap::Parser)]
struct Cli {
    #[arg(long, value_name = "MODE", num_args = 0..=1, default_missing_value = "acme")]
    tls: Option<TlsMode>,
    #[arg(long, value_name = "DOMAIN")]
    tls_domain: Option<String>,
    #[arg(long, value_name = "EMAIL")]
    tls_email: Option<String>,
    #[arg(long, value_name = "DIR")]
    tls_cert_dir: Option<PathBuf>,
    #[arg(long, value_name = "PATH")]
    tls_cert: Option<PathBuf>,
    #[arg(long, value_name = "PATH")]
    tls_key: Option<PathBuf>,
    #[arg(long, value_name = "PORT")]
    port: Option<u16>,
    #[arg(long, value_name = "ADDR")]
    bind: Option<String>,
}

#[derive(clap::ValueEnum, Clone)]
enum TlsMode {
    Acme,
    #[value(name = "self-signed")]
    SelfSigned,
}

fn add_hsts(app: Router) -> Router {
    use axum::http::HeaderValue;
    use axum::http::header::STRICT_TRANSPORT_SECURITY;
    use tower_http::set_header::SetResponseHeaderLayer;
    app.layer(SetResponseHeaderLayer::if_not_present(
        STRICT_TRANSPORT_SECURITY,
        HeaderValue::from_static("max-age=63072000; includeSubDomains"),
    ))
}

/// Spawn a plain HTTP listener on 127.0.0.1:3000 for localhost CLI access
/// (e.g. `apm register`) when the main server is running TLS.
fn spawn_localhost_http(app: Router, _tls_port: u16) {
    tokio::spawn(async move {
        let addr: SocketAddr = ([127, 0, 0, 1], 3000).into();
        let listener = tokio::net::TcpListener::bind(addr).await.expect("bind localhost HTTP listener");
        println!("Listening on http://{addr} (localhost CLI access)");
        axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
            .await
            .unwrap();
    });
}

#[tokio::main]
async fn main() {
    use clap::Parser;
    let cli = Cli::parse();

    let root = std::env::current_dir().unwrap();
    let origin_override = cli.tls_domain.as_deref().map(|d| format!("https://{d}"));
    let app = build_app(root, origin_override.as_deref());

    let is_tls = cli.tls.is_some() || cli.tls_cert.is_some();
    let default_port: u16 = if is_tls { 443 } else { 3000 };
    let port = cli.port.unwrap_or(default_port);
    let bind = cli.bind.as_deref().unwrap_or("0.0.0.0");
    let addr: SocketAddr = format!("{bind}:{port}").parse().expect("invalid bind address");

    match (cli.tls, &cli.tls_cert) {
        (None, None) => {
            let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
            println!("Listening on http://{addr}");
            axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
                .await
                .unwrap();
        }
        (None, Some(cert)) => {
            let key = cli.tls_key.as_ref().expect("--tls-key is required when using --tls-cert");
            let config = tls::custom_cert_config(cert, key)
                .expect("failed to load TLS certificate");
            spawn_localhost_http(app.clone(), port);
            tls::serve_tls(addr, add_hsts(app), config).await;
        }
        (Some(TlsMode::SelfSigned), _) => {
            let domain = cli.tls_domain.as_deref().unwrap_or("localhost");
            eprintln!("Warning: self-signed certificate for '{domain}' — not trusted by browsers");
            let config = tls::self_signed_config(domain)
                .expect("failed to generate self-signed certificate");
            spawn_localhost_http(app.clone(), port);
            tls::serve_tls(addr, add_hsts(app), config).await;
        }
        (Some(TlsMode::Acme), _) => {
            let domain = cli.tls_domain.clone()
                .expect("--tls-domain is required with --tls (ACME mode)");
            let email = cli.tls_email.clone()
                .expect("--tls-email is required with --tls (ACME mode)");
            let cache_dir = cli.tls_cert_dir.clone().unwrap_or_else(|| {
                let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
                PathBuf::from(home).join(".apm/certs")
            });
            spawn_localhost_http(app.clone(), port);
            tls::serve_acme(addr, add_hsts(app), domain, email, cache_dir).await;
        }
    }
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use apm_core::ticket::{Frontmatter, Ticket};
    use axum::body::Body;
    use axum::http::{Request, StatusCode};
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    #[tokio::test]
    async fn hsts_header_present_when_tls_enabled() {
        let app = add_hsts(build_app_with_tickets(vec![]));
        let response = app
            .oneshot(Request::builder().uri("/api/tickets").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(
            response.headers().get("strict-transport-security").map(|v| v.to_str().unwrap()),
            Some("max-age=63072000; includeSubDomains"),
        );
    }

    #[tokio::test]
    async fn no_hsts_header_without_tls_wrapper() {
        let app = build_app_with_tickets(vec![]);
        let response = app
            .oneshot(Request::builder().uri("/api/tickets").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert!(response.headers().get("strict-transport-security").is_none());
    }

    fn fake_ticket(id: &str, title: &str) -> Ticket {
        Ticket {
            frontmatter: Frontmatter {
                id: id.to_string(),
                title: title.to_string(),
                state: "ready".to_string(),
                priority: 0,
                effort: 0,
                risk: 0,
                author: None,
                owner: None,
                branch: None,
                created_at: None,
                updated_at: None,
                focus_section: None,
                epic: None,
                target_branch: None,
                depends_on: None,
                agent: None,
                agent_overrides: std::collections::HashMap::new(),
            },
            body: String::new(),
            path: PathBuf::from(format!("{}.md", id)),
            local_stale: false,
            local_diverged: false,
        }
    }

    pub(crate) fn test_tickets() -> Vec<Ticket> {
        vec![
            fake_ticket("aaaabbbb-fake-ticket-one", "Fake ticket one"),
            fake_ticket("ccccdddd-fake-ticket-two", "Fake ticket two"),
        ]
    }

    #[tokio::test]
    async fn list_tickets_returns_200_json_array() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        assert!(response
            .headers()
            .get("content-type")
            .unwrap()
            .to_str()
            .unwrap()
            .contains("application/json"));
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(json["tickets"].is_array());
        assert_eq!(json["tickets"].as_array().unwrap().len(), 2);
    }

    #[tokio::test]
    async fn list_tickets_envelope_has_supervisor_states() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let supervisor_states = json["supervisor_states"].as_array().unwrap();
        assert!(supervisor_states.iter().any(|s| s == "new"), "new must be in supervisor_states");
        assert!(!supervisor_states.iter().any(|s| s == "closed"), "closed must not be in supervisor_states");
        assert!(!supervisor_states.iter().any(|s| s == "ammend"), "ammend must not be in supervisor_states (agent-only)");
    }

    #[tokio::test]
    async fn list_tickets_excludes_closed_by_default() {
        let mut closed_ticket = fake_ticket("aaaabbbb-closed-one", "Closed ticket");
        closed_ticket.frontmatter.state = "closed".to_string();
        let open_ticket = fake_ticket("ccccdddd-open-one", "Open ticket");
        let app = build_app_with_tickets(vec![closed_ticket, open_ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["id"].as_str().unwrap(), "ccccdddd-open-one");
    }

    #[tokio::test]
    async fn list_tickets_includes_closed_when_requested() {
        let mut closed_ticket = fake_ticket("aaaabbbb-closed-two", "Closed ticket");
        closed_ticket.frontmatter.state = "closed".to_string();
        let open_ticket = fake_ticket("ccccdddd-open-two", "Open ticket");
        let app = build_app_with_tickets(vec![closed_ticket, open_ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets?include_closed=true")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["tickets"].as_array().unwrap().len(), 2);
    }

    #[test]
    fn extract_section_finds_content() {
        let body = "## Spec\n\n### Open questions\n\nIs this real?\n\n### Amendment requests\n\n- [ ] Fix it\n";
        assert_eq!(extract_section(body, "Open questions").trim(), "Is this real?");
        assert_eq!(extract_section(body, "Amendment requests").trim(), "- [ ] Fix it");
    }

    #[test]
    fn extract_section_missing_returns_empty() {
        let body = "## Spec\n\nNo sections here.\n";
        assert_eq!(extract_section(body, "Open questions"), "");
    }

    #[tokio::test]
    async fn list_tickets_includes_badge_fields() {
        let mut ticket_with_question = fake_ticket("11112222-badge-test-q", "Question ticket");
        ticket_with_question.body = "### Open questions\n\nWhat is this?\n".to_string();

        let mut ticket_with_amendment = fake_ticket("33334444-badge-test-a", "Amendment ticket");
        ticket_with_amendment.body = "### Amendment requests\n\n- [ ] Fix the thing\n".to_string();

        let mut ticket_clean = fake_ticket("55556666-badge-test-c", "Clean ticket");
        ticket_clean.body = "### Open questions\n\n\n### Amendment requests\n\n- [x] Done\n".to_string();

        let app = build_app_with_tickets(vec![ticket_with_question, ticket_with_amendment, ticket_clean]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();

        let q = arr.iter().find(|t| t["id"] == "11112222-badge-test-q").unwrap();
        assert_eq!(q["has_open_questions"], true);
        assert_eq!(q["has_pending_amendments"], false);

        let a = arr.iter().find(|t| t["id"] == "33334444-badge-test-a").unwrap();
        assert_eq!(a["has_open_questions"], false);
        assert_eq!(a["has_pending_amendments"], true);

        let c = arr.iter().find(|t| t["id"] == "55556666-badge-test-c").unwrap();
        assert_eq!(c["has_open_questions"], false);
        assert_eq!(c["has_pending_amendments"], false);
    }

    #[tokio::test]
    async fn list_tickets_blocking_deps() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();

        // git init
        for args in [
            vec!["init", "-q", "-b", "main"],
            vec!["config", "user.email", "test@test.com"],
            vec!["config", "user.name", "test"],
        ] {
            std::process::Command::new("git")
                .args(&args)
                .current_dir(&p)
                .status()
                .unwrap();
        }

        // config with `implemented` satisfying deps
        std::fs::create_dir_all(p.join(".apm")).unwrap();
        std::fs::write(
            p.join(".apm/config.toml"),
            r#"[project]
name = "test"

[tickets]
dir = "tickets"

[[workflow.states]]
id             = "in_progress"
label          = "In Progress"

[[workflow.states]]
id             = "implemented"
label          = "Implemented"
satisfies_deps = true
"#,
        )
        .unwrap();

        std::fs::create_dir_all(p.join("tickets")).unwrap();

        for args in [
            vec!["add", ".apm/config.toml"],
            vec!["-c", "commit.gpgsign=false", "commit", "-m", "init"],
        ] {
            std::process::Command::new("git")
                .args(&args)
                .current_dir(&p)
                .env("GIT_AUTHOR_NAME", "test")
                .env("GIT_AUTHOR_EMAIL", "test@test.com")
                .env("GIT_COMMITTER_NAME", "test")
                .env("GIT_COMMITTER_EMAIL", "test@test.com")
                .status()
                .unwrap();
        }

        // helper: commit a ticket file to its own branch
        let commit_ticket = |slug: &str, content: &str| {
            let branch = format!("ticket/{slug}");
            let filename = format!("tickets/{slug}.md");
            let file_path = p.join(&filename);
            std::fs::create_dir_all(p.join("tickets")).unwrap();
            std::fs::write(&file_path, content).unwrap();
            for args in [
                vec!["checkout", "-b", branch.as_str()],
                vec!["add", filename.as_str()],
                vec!["-c", "commit.gpgsign=false", "commit", "-m", "add ticket"],
            ] {
                std::process::Command::new("git")
                    .args(&args)
                    .current_dir(&p)
                    .env("GIT_AUTHOR_NAME", "test")
                    .env("GIT_AUTHOR_EMAIL", "test@test.com")
                    .env("GIT_COMMITTER_NAME", "test")
                    .env("GIT_COMMITTER_EMAIL", "test@test.com")
                    .status()
                    .unwrap();
            }
            std::process::Command::new("git")
                .args(["checkout", "main"])
                .current_dir(&p)
                .status()
                .unwrap();
        };

        // dep-satisfied: in `implemented` state (satisfies_deps=true)
        commit_ticket(
            "aabbccdd-dep-satisfied",
            "+++\nid = \"aabbccdd-dep-satisfied\"\ntitle = \"Dep Satisfied\"\nstate = \"implemented\"\n+++\n\n",
        );

        // dep-blocking: in `in_progress` state (not satisfying)
        commit_ticket(
            "11223344-dep-blocking",
            "+++\nid = \"11223344-dep-blocking\"\ntitle = \"Dep Blocking\"\nstate = \"in_progress\"\n+++\n\n",
        );

        // ticket with no depends_on
        commit_ticket(
            "aaaaaaaa-no-deps",
            "+++\nid = \"aaaaaaaa-no-deps\"\ntitle = \"No Deps\"\nstate = \"ready\"\n+++\n\n",
        );

        // ticket depending on satisfied dep → blocking_deps should be []
        commit_ticket(
            "bbbbbbbb-dep-on-satisfied",
            "+++\nid = \"bbbbbbbb-dep-on-satisfied\"\ntitle = \"Dep On Satisfied\"\nstate = \"ready\"\ndepends_on = [\"aabbccdd-dep-satisfied\"]\n+++\n\n",
        );

        // ticket depending on blocking dep → blocking_deps should be non-empty
        commit_ticket(
            "cccccccc-dep-on-blocking",
            "+++\nid = \"cccccccc-dep-on-blocking\"\ntitle = \"Dep On Blocking\"\nstate = \"ready\"\ndepends_on = [\"11223344-dep-blocking\"]\n+++\n\n",
        );

        let app = build_app(p.clone(), None);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();

        let no_deps = arr.iter().find(|t| t["id"] == "aaaaaaaa-no-deps").unwrap();
        assert_eq!(no_deps["blocking_deps"], serde_json::json!([]));

        let on_satisfied = arr.iter().find(|t| t["id"] == "bbbbbbbb-dep-on-satisfied").unwrap();
        assert_eq!(on_satisfied["blocking_deps"], serde_json::json!([]));

        let on_blocking = arr.iter().find(|t| t["id"] == "cccccccc-dep-on-blocking").unwrap();
        let blocking = on_blocking["blocking_deps"].as_array().unwrap();
        assert_eq!(blocking.len(), 1);
        assert_eq!(blocking[0]["id"], "11223344-dep-blocking");
        assert_eq!(blocking[0]["state"], "in_progress");
    }

    #[tokio::test]
    async fn get_ticket_unknown_id_returns_404() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/00000000")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn get_ticket_valid_prefix_returns_200_object() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/aaaabbbb")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(json.is_object());
        assert_eq!(json["id"], "aaaabbbb-fake-ticket-one");
    }

    #[tokio::test]
    async fn get_ticket_invalid_id_format_returns_400() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/ab")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn get_ticket_includes_valid_transitions_field() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/aaaabbbb")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(json["valid_transitions"].is_array());
    }

    #[tokio::test]
    async fn put_body_in_memory_returns_501() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("PUT")
                    .uri("/api/tickets/aaaabbbb/body")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"spec":"just a spec"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    }

    #[tokio::test]
    async fn put_body_rejects_spec_with_front_matter_delimiter() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("PUT")
                    .uri("/api/tickets/aaaabbbb/body")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"spec":"contains +++ delimiter"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn put_body_rejects_spec_with_history_section() {
        let app = build_app_with_tickets(test_tickets());
        let body = serde_json::json!({"spec": "## Spec\n\nfoo\n\n## History\n\n| row |"}).to_string();
        let response = app
            .oneshot(
                Request::builder()
                    .method("PUT")
                    .uri("/api/tickets/aaaabbbb/body")
                    .header("content-type", "application/json")
                    .body(Body::from(body))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn get_ticket_includes_raw_field() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/aaaabbbb")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(json["raw"].is_string());
        let raw = json["raw"].as_str().unwrap();
        assert!(raw.starts_with("+++\n"), "raw should start with +++");
    }

    #[tokio::test]
    async fn get_ticket_includes_spec_field() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/aaaabbbb")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(json["spec"].is_string(), "spec field should be present");
        let spec = json["spec"].as_str().unwrap();
        assert!(!spec.contains("+++"), "spec should not contain front matter");
        assert!(!spec.contains("## History"), "spec should not contain history");
    }

    #[tokio::test]
    async fn create_ticket_missing_title_returns_400() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/tickets")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"problem":"something"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn create_ticket_empty_title_returns_400() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/tickets")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"title":"   "}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn create_ticket_in_memory_returns_501() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/tickets")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"title":"New ticket"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    }

    #[tokio::test]
    async fn create_ticket_with_epic_and_depends_on_in_memory_returns_501() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/tickets")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        r#"{"title":"t","epic":"ab12cd34","depends_on":["cd56ef78"]}"#,
                    ))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    }

    #[tokio::test]
    async fn patch_ticket_in_memory_returns_501() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri("/api/tickets/aaaabbbb")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"effort":5}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    }

    #[tokio::test]
    async fn patch_ticket_priority_out_of_range_returns_422() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri("/api/tickets/aaaabbbb")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"priority":256}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        // 256 exceeds u8::MAX → JSON deserialization fails → 422
        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
    }

    pub(crate) fn git_setup(p: &std::path::Path) {
        for args in [
            vec!["init", "-q", "-b", "main"],
            vec!["config", "user.email", "test@test.com"],
            vec!["config", "user.name", "test"],
        ] {
            std::process::Command::new("git")
                .args(&args)
                .current_dir(p)
                .status()
                .unwrap();
        }
        std::fs::create_dir_all(p.join(".apm")).unwrap();
        std::fs::write(
            p.join(".apm/config.toml"),
            r#"[project]
name = "test"

[tickets]
dir = "tickets"

[agents]
max_concurrent = 3

[workflow.prioritization]
priority_weight = 10.0
effort_weight = -2.0
risk_weight = -1.0

[[workflow.states]]
id         = "ready"
label      = "Ready"
actionable = ["agent"]

[[workflow.states]]
id    = "in_progress"
label = "In Progress"
"#,
        )
        .unwrap();
        for args in [
            vec!["add", ".apm/config.toml"],
            vec!["-c", "commit.gpgsign=false", "commit", "-m", "init"],
        ] {
            std::process::Command::new("git")
                .args(&args)
                .current_dir(p)
                .env("GIT_AUTHOR_NAME", "test")
                .env("GIT_AUTHOR_EMAIL", "test@test.com")
                .env("GIT_COMMITTER_NAME", "test")
                .env("GIT_COMMITTER_EMAIL", "test@test.com")
                .status()
                .unwrap();
        }
        std::fs::create_dir_all(p.join("tickets")).unwrap();
    }

    #[tokio::test]
    async fn patch_ticket_priority_persists_to_git() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        git_setup(&p);

        let config = apm_core::config::Config::load(&p).unwrap();
        let mut _warnings = Vec::new();
        let ticket = apm_core::ticket::create(
            &p,
            &config,
            "test ticket".to_string(),
            "test".to_string(),
            "test".to_string(),
            None,
            None,
            false,
            vec![],
            None,
            None,
            None,
            None,
            &mut _warnings,
        )
        .unwrap();
        let ticket_id = ticket.frontmatter.id.clone();

        let app = build_app(p.clone(), None);
        let response = app
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri(format!("/api/tickets/{}", &ticket_id[..8]))
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"priority":42}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["priority"], 42);

        // Verify the value was committed to the branch
        let branch = ticket.frontmatter.branch.unwrap();
        let rel_path = ticket.path.strip_prefix(&p).unwrap().to_string_lossy().to_string();
        let content = apm_core::git::read_from_branch(&p, &branch, &rel_path).unwrap();
        assert!(content.contains("priority = 42"), "expected priority = 42 in: {content}");
    }

    #[tokio::test]
    async fn patch_ticket_owner_persists_to_git() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        git_setup(&p);

        let config = apm_core::config::Config::load(&p).unwrap();
        let mut _warnings = Vec::new();
        let ticket = apm_core::ticket::create(
            &p, &config, "test ticket".to_string(), "test".to_string(), "test".to_string(),
            None, None, false, vec![], None, None, None, None, &mut _warnings,
        ).unwrap();
        let ticket_id = ticket.frontmatter.id.clone();

        let app = build_app(p.clone(), None);
        let response = app
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri(format!("/api/tickets/{}", &ticket_id[..8]))
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"owner":"alice"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["owner"], "alice");

        let branch = ticket.frontmatter.branch.unwrap();
        let rel_path = ticket.path.strip_prefix(&p).unwrap().to_string_lossy().to_string();
        let file_content = apm_core::git::read_from_branch(&p, &branch, &rel_path).unwrap();
        assert!(file_content.contains(r#"owner = "alice""#), "expected owner in frontmatter: {file_content}");
    }

    #[tokio::test]
    async fn patch_ticket_owner_empty_clears() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        git_setup(&p);

        let config = apm_core::config::Config::load(&p).unwrap();
        let mut _warnings = Vec::new();
        let ticket = apm_core::ticket::create(
            &p, &config, "test ticket".to_string(), "test".to_string(), "test".to_string(),
            None, None, false, vec![], None, None, None, None, &mut _warnings,
        ).unwrap();
        let ticket_id = ticket.frontmatter.id.clone();

        let app1 = build_app(p.clone(), None);
        app1.oneshot(
            Request::builder()
                .method("PATCH")
                .uri(format!("/api/tickets/{}", &ticket_id[..8]))
                .header("content-type", "application/json")
                .body(Body::from(r#"{"owner":"alice"}"#))
                .unwrap(),
        ).await.unwrap();

        let app2 = build_app(p.clone(), None);
        let response = app2
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri(format!("/api/tickets/{}", &ticket_id[..8]))
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"owner":""}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(json["owner"].is_null(), "owner should be absent after clear");

        let branch = ticket.frontmatter.branch.unwrap();
        let rel_path = ticket.path.strip_prefix(&p).unwrap().to_string_lossy().to_string();
        let file_content = apm_core::git::read_from_branch(&p, &branch, &rel_path).unwrap();
        assert!(!file_content.contains("owner ="), "owner should be absent in frontmatter: {file_content}");
    }

    #[tokio::test]
    async fn patch_ticket_owner_omitted_unchanged() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        git_setup(&p);

        let config = apm_core::config::Config::load(&p).unwrap();
        let mut _warnings = Vec::new();
        let ticket = apm_core::ticket::create(
            &p, &config, "test ticket".to_string(), "test".to_string(), "test".to_string(),
            None, None, false, vec![], None, None, None, None, &mut _warnings,
        ).unwrap();
        let ticket_id = ticket.frontmatter.id.clone();

        let app1 = build_app(p.clone(), None);
        app1.oneshot(
            Request::builder()
                .method("PATCH")
                .uri(format!("/api/tickets/{}", &ticket_id[..8]))
                .header("content-type", "application/json")
                .body(Body::from(r#"{"owner":"alice"}"#))
                .unwrap(),
        ).await.unwrap();

        let app2 = build_app(p.clone(), None);
        let response = app2
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri(format!("/api/tickets/{}", &ticket_id[..8]))
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"priority":5}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["owner"], "alice", "owner should be unchanged when field omitted");
    }

    #[tokio::test]
    async fn patch_ticket_unknown_id_returns_not_implemented_for_in_memory() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("PATCH")
                    .uri("/api/tickets/00000000")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"effort":5}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        // In-memory has no git root so returns 501 before ID resolution
        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    }

    #[tokio::test]
    async fn transition_in_memory_returns_not_implemented() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/tickets/aaaabbbb/transition")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"to":"in_progress"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    }

    #[tokio::test]
    async fn transition_unknown_id_returns_not_implemented_for_in_memory() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/tickets/00000000/transition")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"to":"in_progress"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        // In-memory has no git root so returns 501 before ID resolution
        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    }

    #[tokio::test]
    async fn create_ticket_with_depends_on_persists_to_git() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        git_setup(&p);

        let app = build_app(p.clone(), None);
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/tickets")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"title":"dep ticket","depends_on":["ab12cd34"]}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::CREATED);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["depends_on"][0], "ab12cd34");

        let branch = json["branch"].as_str().unwrap().to_string();
        let id = json["id"].as_str().unwrap().to_string();
        let config = apm_core::config::Config::load(&p).unwrap();
        let rel_path = format!("{}/{}-dep-ticket.md", config.tickets.dir.to_string_lossy(), id);
        let content = apm_core::git::read_from_branch(&p, &branch, &rel_path).unwrap();
        assert!(content.contains(r#"depends_on = ["ab12cd34"]"#), "expected depends_on in: {content}");
    }

    #[tokio::test]
    async fn create_ticket_with_unknown_epic_returns_400() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        git_setup(&p);

        let app = build_app(p.clone(), None);
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/tickets")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"title":"orphan","epic":"deadbeef"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn create_ticket_with_epic_resolves_target_branch() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        git_setup(&p);

        // Create an epic branch locally
        std::process::Command::new("git")
            .args(["-c", "commit.gpgsign=false", "branch", "epic/ab12cd34-foo"])
            .current_dir(&p)
            .status()
            .unwrap();

        let app = build_app(p.clone(), None);
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/tickets")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"title":"epic child","epic":"ab12cd34"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::CREATED);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["epic"], "ab12cd34");
        assert_eq!(json["target_branch"], "epic/ab12cd34-foo");
    }

    #[tokio::test]
    async fn list_tickets_author_field_always_present() {
        let ticket = fake_ticket("aaaabbbb-no-author", "No author ticket");
        assert!(ticket.frontmatter.author.is_none());
        let app = build_app_with_tickets(vec![ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["author"], "unassigned");
    }

    #[tokio::test]
    async fn list_tickets_author_filter() {
        let mut alice_ticket = fake_ticket("aaaabbbb-alice-ticket", "Alice ticket");
        alice_ticket.frontmatter.author = Some("alice".to_string());
        let mut bob_ticket = fake_ticket("ccccdddd-bob-ticket", "Bob ticket");
        bob_ticket.frontmatter.author = Some("bob".to_string());
        let app = build_app_with_tickets(vec![alice_ticket, bob_ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets?author=alice")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["id"], "aaaabbbb-alice-ticket");
        assert_eq!(arr[0]["author"], "alice");
    }

    #[tokio::test]
    async fn list_tickets_author_unassigned_filter() {
        let unassigned_ticket = fake_ticket("aaaabbbb-unassigned", "Unassigned ticket");
        assert!(unassigned_ticket.frontmatter.author.is_none());
        let mut alice_ticket = fake_ticket("ccccdddd-alice", "Alice ticket");
        alice_ticket.frontmatter.author = Some("alice".to_string());
        let app = build_app_with_tickets(vec![unassigned_ticket, alice_ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets?author=unassigned")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["id"], "aaaabbbb-unassigned");
        assert_eq!(arr[0]["author"], "unassigned");
    }

    #[tokio::test]
    async fn get_ticket_author_field_always_present() {
        let ticket = fake_ticket("aaaabbbb-no-author-detail", "No author ticket");
        assert!(ticket.frontmatter.author.is_none());
        let app = build_app_with_tickets(vec![ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/aaaabbbb")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["author"], "unassigned");
    }

    #[tokio::test]
    async fn me_handler_returns_unassigned_when_no_local_toml() {
        let app = build_app_with_tickets(vec![]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/me")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["username"], "unassigned");
    }

    #[tokio::test]
    async fn post_otp_from_localhost_returns_otp() {
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let dir = tempfile::tempdir().unwrap();
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let mut req = Request::builder()
            .uri("/api/auth/otp")
            .method("POST")
            .header("content-type", "application/json")
            .body(Body::from(r#"{"username":"alice"}"#))
            .unwrap();
        req.extensions_mut()
            .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 1234))));
        let response = app.oneshot(req).await.unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let otp = json["otp"].as_str().unwrap();
        assert_eq!(otp.len(), 8);
        assert!(otp.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
    }

    #[tokio::test]
    async fn post_otp_from_remote_returns_403() {
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let dir = tempfile::tempdir().unwrap();
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/auth/otp")
                    .method("POST")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"username":"alice"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
    }

    #[tokio::test]
    async fn post_otp_with_malformed_body_returns_400() {
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let dir = tempfile::tempdir().unwrap();
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let mut req = Request::builder()
            .uri("/api/auth/otp")
            .method("POST")
            .header("content-type", "application/json")
            .body(Body::from("not-json"))
            .unwrap();
        req.extensions_mut()
            .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 1234))));
        let response = app.oneshot(req).await.unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn get_me_localhost_with_local_toml() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".apm")).unwrap();
        std::fs::write(dir.path().join(".apm/local.toml"), "username = \"alice\"\n").unwrap();
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let mut req = Request::builder()
            .uri("/api/me")
            .body(Body::empty())
            .unwrap();
        req.extensions_mut()
            .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 1234))));
        let response = app.oneshot(req).await.unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["username"], "alice");
    }

    #[tokio::test]
    async fn get_me_localhost_without_local_toml() {
        let dir = tempfile::tempdir().unwrap();
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let mut req = Request::builder()
            .uri("/api/me")
            .body(Body::empty())
            .unwrap();
        req.extensions_mut()
            .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 1234))));
        let response = app.oneshot(req).await.unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["username"], "unassigned");
    }

    #[tokio::test]
    async fn get_me_remote_with_valid_session() {
        let dir = tempfile::tempdir().unwrap();
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        session_store.insert("testtoken".to_string(), "bob".to_string(), None);
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/me")
                    .header("cookie", "__Host-apm-session=testtoken")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["username"], "bob");
    }

    #[tokio::test]
    async fn get_me_remote_with_expired_session() {
        let dir = tempfile::tempdir().unwrap();
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let expired = chrono::Utc::now() - chrono::Duration::days(10);
        session_store.insert_expiring_at("exptoken".to_string(), "eve".to_string(), expired);
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/me")
                    .header("cookie", "__Host-apm-session=exptoken")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["username"], "unassigned");
    }

    #[tokio::test]
    async fn get_me_remote_with_no_cookie() {
        let dir = tempfile::tempdir().unwrap();
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/me")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["username"], "unassigned");
    }

    fn build_app_for_webauthn_tests(
        otp_store: auth::OtpStore,
        session_store: auth::SessionStore,
    ) -> Router {
        let dir = tempfile::TempDir::new().unwrap();
        let state = Arc::new(AppState {
            source: TicketSource::InMemory(vec![]),
            work_engine: work::new_engine_state(),
            log_file: None,
            max_concurrent_override: Arc::new(tokio::sync::Mutex::new(None)),
            otp_store,
            session_store,
            webauthn_state: default_webauthn_state(),
            credential_store: auth::CredentialStore::load(
                dir.path().join("credentials.json"),
            ),
        });
        // keep dir alive by leaking (tests are short-lived)
        std::mem::forget(dir);
        Router::new()
            .route("/register", get(auth::register_page_handler))
            .route("/api/auth/register/challenge", post(auth::register_challenge_handler))
            .route("/api/auth/register/complete", post(auth::register_complete_handler))
            .route("/api/me", get(me_handler))
            .with_state(state)
    }

    #[tokio::test]
    async fn register_page_returns_200_html() {
        let app = build_app_for_webauthn_tests(
            auth::OtpStore::new(),
            auth::SessionStore::load(PathBuf::new()),
        );
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/register")
                    .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("text/html"), "unexpected content-type: {ct}");
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let html = std::str::from_utf8(&bytes).unwrap();
        assert!(html.contains("username"), "expected username field in HTML");
        assert!(html.contains("otp") || html.contains("one-time"), "expected OTP field in HTML");
    }

    #[tokio::test]
    async fn challenge_missing_fields_returns_400() {
        let app = build_app_for_webauthn_tests(
            auth::OtpStore::new(),
            auth::SessionStore::load(PathBuf::new()),
        );
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/register/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from("{}"))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn challenge_invalid_otp_returns_400() {
        let otp_store = auth::OtpStore::new();
        otp_store.insert("alice", "VALIDOTP".to_string());
        let app = build_app_for_webauthn_tests(
            otp_store,
            auth::SessionStore::load(PathBuf::new()),
        );
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/register/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"username":"alice","otp":"WRONGOTP"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn challenge_no_otp_for_user_returns_400() {
        let app = build_app_for_webauthn_tests(
            auth::OtpStore::new(),
            auth::SessionStore::load(PathBuf::new()),
        );
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/register/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"username":"alice","otp":"ANYTHING"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn challenge_valid_otp_returns_200_with_reg_id_and_public_key() {
        let otp_store = auth::OtpStore::new();
        otp_store.insert("alice", "VALIDOTP".to_string());
        let app = build_app_for_webauthn_tests(
            otp_store,
            auth::SessionStore::load(PathBuf::new()),
        );
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/register/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"username":"alice","otp":"VALIDOTP"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(json["reg_id"].is_string(), "reg_id missing");
        assert!(json["publicKey"].is_object(), "publicKey missing");
        assert!(json["publicKey"]["challenge"].is_string(), "challenge missing");
        assert!(json["publicKey"]["rp"].is_object(), "rp missing");
        assert!(json["publicKey"]["user"].is_object(), "user missing");
    }

    #[tokio::test]
    async fn challenge_otp_consumed_second_use_returns_400() {
        let otp_store = auth::OtpStore::new();
        otp_store.insert("alice", "ONCEONLY".to_string());
        let app = build_app_for_webauthn_tests(
            otp_store,
            auth::SessionStore::load(PathBuf::new()),
        );
        // First call consumes the OTP
        let _ = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/register/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"username":"alice","otp":"ONCEONLY"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        // Second call with same OTP should fail
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/register/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"username":"alice","otp":"ONCEONLY"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn complete_unknown_reg_id_returns_400() {
        let app = build_app_for_webauthn_tests(
            auth::OtpStore::new(),
            auth::SessionStore::load(PathBuf::new()),
        );
        let body = serde_json::json!({
            "reg_id": "nonexistent",
            "response": {
                "id": "dGVzdA",
                "rawId": "dGVzdA",
                "type": "public-key",
                "response": {
                    "clientDataJSON": "dGVzdA",
                    "attestationObject": "dGVzdA"
                }
            }
        });
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/register/complete")
                    .header("content-type", "application/json")
                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    fn build_app_for_login_tests(
        session_store: auth::SessionStore,
        credential_store: auth::CredentialStore,
    ) -> Router {
        let state = Arc::new(AppState {
            source: TicketSource::InMemory(vec![]),
            work_engine: work::new_engine_state(),
            log_file: None,
            max_concurrent_override: Arc::new(tokio::sync::Mutex::new(None)),
            otp_store: auth::OtpStore::new(),
            session_store,
            webauthn_state: default_webauthn_state(),
            credential_store,
        });
        Router::new()
            .route("/login", get(auth::login_page_handler))
            .route("/api/auth/login/challenge", post(auth::login_challenge_handler))
            .route("/api/auth/login/complete", post(auth::login_complete_handler))
            .route("/api/me", get(me_handler))
            .with_state(state)
    }

    #[tokio::test]
    async fn login_page_returns_200_html() {
        let app = build_app_for_login_tests(
            auth::SessionStore::load(PathBuf::new()),
            auth::CredentialStore::load(PathBuf::new()),
        );
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/login")
                    .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("text/html"), "unexpected content-type: {ct}");
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let html = std::str::from_utf8(&bytes).unwrap();
        assert!(html.contains("username"), "expected username field in HTML");
        assert!(html.contains("Sign in"), "expected sign-in button in HTML");
    }

    #[tokio::test]
    async fn login_challenge_malformed_body_returns_400() {
        let app = build_app_for_login_tests(
            auth::SessionStore::load(PathBuf::new()),
            auth::CredentialStore::load(PathBuf::new()),
        );
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/login/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from(b"not json".to_vec()))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn login_challenge_unknown_user_returns_400() {
        let app = build_app_for_login_tests(
            auth::SessionStore::load(PathBuf::new()),
            auth::CredentialStore::load(PathBuf::new()),
        );
        let body = serde_json::json!({"username": "nobody"});
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/login/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn login_complete_unknown_login_id_returns_400() {
        let app = build_app_for_login_tests(
            auth::SessionStore::load(PathBuf::new()),
            auth::CredentialStore::load(PathBuf::new()),
        );
        let body = serde_json::json!({
            "login_id": "nonexistent",
            "response": {
                "id": "dGVzdA",
                "rawId": "dGVzdA",
                "type": "public-key",
                "response": {
                    "clientDataJSON": "dGVzdA",
                    "authenticatorData": "dGVzdA",
                    "signature": "dGVzdA",
                    "userHandle": null
                }
            }
        });
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/login/complete")
                    .header("content-type", "application/json")
                    .body(Body::from(serde_json::to_vec(&body).unwrap()))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn get_sessions_from_remote_returns_403() {
        let dir = tempfile::tempdir().unwrap();
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/auth/sessions")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
    }

    #[tokio::test]
    async fn get_sessions_from_localhost_returns_active_only() {
        let dir = tempfile::tempdir().unwrap();
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let expired = chrono::Utc::now() - chrono::Duration::days(10);
        session_store.insert_expiring_at("exp".to_string(), "eve".to_string(), expired);
        session_store.insert("active".to_string(), "alice".to_string(), None);
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let mut req = Request::builder()
            .uri("/api/auth/sessions")
            .body(Body::empty())
            .unwrap();
        req.extensions_mut()
            .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 1234))));
        let response = app.oneshot(req).await.unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json.as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["username"], "alice");
    }

    #[tokio::test]
    async fn delete_sessions_from_remote_returns_403() {
        let dir = tempfile::tempdir().unwrap();
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let response = app
            .oneshot(
                Request::builder()
                    .method("DELETE")
                    .uri("/api/auth/sessions")
                    .header("content-type", "application/json")
                    .body(Body::from(r#"{"all":true}"#))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::FORBIDDEN);
    }

    #[tokio::test]
    async fn delete_sessions_all_from_localhost() {
        let dir = tempfile::tempdir().unwrap();
        let otp_store = auth::OtpStore::new();
        let session_store = auth::SessionStore::load(PathBuf::new());
        session_store.insert("t1".to_string(), "alice".to_string(), None);
        session_store.insert("t2".to_string(), "bob".to_string(), None);
        let app = build_app_for_auth_tests(dir.path().to_path_buf(), otp_store, session_store);
        let mut req = Request::builder()
            .method("DELETE")
            .uri("/api/auth/sessions")
            .header("content-type", "application/json")
            .body(Body::from(r#"{"all":true}"#))
            .unwrap();
        req.extensions_mut()
            .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 1234))));
        let response = app.oneshot(req).await.unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["revoked"], 2);
    }

    #[tokio::test]
    async fn list_tickets_owner_field_present() {
        let mut ticket = fake_ticket("aaaabbbb-owner-present", "Owner present");
        ticket.frontmatter.owner = Some("alice".to_string());
        let app = build_app_with_tickets(vec![ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();
        assert_eq!(arr[0]["owner"], "alice");
    }

    #[tokio::test]
    async fn list_tickets_owner_field_absent() {
        let ticket = fake_ticket("bbbbcccc-owner-absent", "Owner absent");
        let app = build_app_with_tickets(vec![ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();
        assert!(arr[0]["owner"].is_null());
    }

    #[tokio::test]
    async fn get_ticket_owner_field_absent() {
        let ticket = fake_ticket("cccc1111-owner-absent-detail", "Owner absent detail");
        let app = build_app_with_tickets(vec![ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/cccc1111")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(json["owner"].is_null());
    }

    #[tokio::test]
    async fn get_ticket_owner_field_present() {
        let mut ticket = fake_ticket("dddd2222-owner-present-detail", "Owner present detail");
        ticket.frontmatter.owner = Some("alice".to_string());
        let app = build_app_with_tickets(vec![ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/dddd2222")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["owner"], "alice");
    }

    #[tokio::test]
    async fn list_tickets_owner_filter() {
        let mut alice_ticket = fake_ticket("ccccdddd-owner-alice", "Alice ticket");
        alice_ticket.frontmatter.owner = Some("alice".to_string());
        let mut bob_ticket = fake_ticket("ddddeee0-owner-bob", "Bob ticket");
        bob_ticket.frontmatter.owner = Some("bob".to_string());
        let app = build_app_with_tickets(vec![alice_ticket, bob_ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets?owner=alice")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["id"], "ccccdddd-owner-alice");
    }

    #[tokio::test]
    async fn list_tickets_owner_unassigned_filter() {
        let mut owned_ticket = fake_ticket("eeeeffff-owner-set", "Owned ticket");
        owned_ticket.frontmatter.owner = Some("alice".to_string());
        let unowned_ticket = fake_ticket("ffff0000-owner-none", "Unowned ticket");
        let app = build_app_with_tickets(vec![owned_ticket, unowned_ticket]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets?owner=unassigned")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        let arr = json["tickets"].as_array().unwrap();
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["id"], "ffff0000-owner-none");
    }

    fn build_app_for_middleware_tests(session_store: auth::SessionStore) -> Router {
        let state = Arc::new(AppState {
            source: TicketSource::InMemory(vec![]),
            work_engine: work::new_engine_state(),
            log_file: None,
            max_concurrent_override: Arc::new(tokio::sync::Mutex::new(None)),
            otp_store: auth::OtpStore::new(),
            session_store,
            webauthn_state: default_webauthn_state(),
            credential_store: auth::CredentialStore::load(PathBuf::new()),
        });
        let protected = Router::new()
            .route("/api/tickets", get(list_tickets).post(create_ticket))
            .route("/api/auth/otp", post(auth::otp_handler))
            .route("/api/auth/sessions", get(auth::list_sessions_handler).delete(auth::revoke_sessions_handler))
            .route_layer(axum::middleware::from_fn_with_state(state.clone(), auth::require_auth));
        let open = Router::new()
            .route("/health", get(health_handler))
            .route("/api/auth/register/challenge", post(auth::register_challenge_handler))
            .route("/api/auth/login/challenge", post(auth::login_challenge_handler));
        Router::new()
            .merge(protected)
            .merge(open)
            .with_state(state)
    }

    #[tokio::test]
    async fn require_auth_external_no_session_returns_401() {
        let app = build_app_for_middleware_tests(auth::SessionStore::load(PathBuf::new()));
        let mut req = Request::builder().uri("/api/tickets").body(Body::empty()).unwrap();
        req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 1], 8080))));
        let response = app
            .oneshot(req)
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["error"], "unauthorized");
    }

    #[tokio::test]
    async fn require_auth_external_valid_session_returns_200() {
        let session_store = auth::SessionStore::load(PathBuf::new());
        session_store.insert("validtoken".to_string(), "alice".to_string(), None);
        let app = build_app_for_middleware_tests(session_store);
        let mut req = Request::builder()
            .uri("/api/tickets")
            .header("cookie", "__Host-apm-session=validtoken")
            .body(Body::empty())
            .unwrap();
        req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 1], 8080))));
        let response = app
            .oneshot(req)
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn require_auth_external_invalid_session_returns_401() {
        let app = build_app_for_middleware_tests(auth::SessionStore::load(PathBuf::new()));
        let mut req = Request::builder()
            .uri("/api/tickets")
            .header("cookie", "__Host-apm-session=bogustoken")
            .body(Body::empty())
            .unwrap();
        req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 1], 8080))));
        let response = app
            .oneshot(req)
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn require_auth_external_expired_session_returns_401() {
        let session_store = auth::SessionStore::load(PathBuf::new());
        let expired = chrono::Utc::now() - chrono::Duration::days(10);
        session_store.insert_expiring_at("exptoken".to_string(), "eve".to_string(), expired);
        let app = build_app_for_middleware_tests(session_store);
        let mut req = Request::builder()
            .uri("/api/tickets")
            .header("cookie", "__Host-apm-session=exptoken")
            .body(Body::empty())
            .unwrap();
        req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 1], 8080))));
        let response = app
            .oneshot(req)
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn require_auth_loopback_no_session_returns_200() {
        let app = build_app_for_middleware_tests(auth::SessionStore::load(PathBuf::new()));
        let mut req = Request::builder().uri("/api/tickets").body(Body::empty()).unwrap();
        req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 9999))));
        let response = app.oneshot(req).await.unwrap();
        assert_eq!(response.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn require_auth_health_no_session_returns_200() {
        let app = build_app_for_middleware_tests(auth::SessionStore::load(PathBuf::new()));
        let response = app
            .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
    }

    #[tokio::test]
    async fn require_auth_login_challenge_no_session_not_401() {
        let app = build_app_for_middleware_tests(auth::SessionStore::load(PathBuf::new()));
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/login/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from(br#"{"username":"alice"}"#.to_vec()))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_ne!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn require_auth_register_challenge_no_session_not_401() {
        let app = build_app_for_middleware_tests(auth::SessionStore::load(PathBuf::new()));
        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/auth/register/challenge")
                    .header("content-type", "application/json")
                    .body(Body::from(br#"{"username":"alice","otp":"ABCD1234"}"#.to_vec()))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_ne!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn require_auth_otp_external_no_session_returns_401() {
        let app = build_app_for_middleware_tests(auth::SessionStore::load(PathBuf::new()));
        let mut req = Request::builder()
            .method("POST")
            .uri("/api/auth/otp")
            .header("content-type", "application/json")
            .body(Body::from(br#"{"username":"alice"}"#.to_vec()))
            .unwrap();
        req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 1], 8080))));
        let response = app.oneshot(req).await.unwrap();
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn require_auth_sessions_external_no_session_returns_401() {
        let app = build_app_for_middleware_tests(auth::SessionStore::load(PathBuf::new()));
        let mut req = Request::builder().uri("/api/auth/sessions").body(Body::empty()).unwrap();
        req.extensions_mut().insert(ConnectInfo(SocketAddr::from(([203, 0, 113, 1], 8080))));
        let response = app.oneshot(req).await.unwrap();
        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn workflow_in_memory_returns_empty_states_and_transitions() {
        let app = build_app_with_tickets(vec![]);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/workflow")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(json["states"], serde_json::json!([]));
        assert_eq!(json["transitions"], serde_json::json!([]));
    }

    #[tokio::test]
    async fn get_prompt_in_memory_returns_501() {
        let app = build_app_with_tickets(test_tickets());
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/aaaabbbb/prompt")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
    }

    #[tokio::test]
    async fn get_prompt_unknown_id_returns_404() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        git_setup(&p);

        let app = build_app(p.clone(), None);
        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/tickets/00000000/prompt")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn get_prompt_known_id_returns_200_with_prompt() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().to_path_buf();
        git_setup(&p);

        let config = apm_core::config::Config::load(&p).unwrap();
        let mut _warnings = Vec::new();
        let ticket = apm_core::ticket::create(
            &p,
            &config,
            "test prompt ticket".to_string(),
            "test".to_string(),
            "test".to_string(),
            None,
            None,
            false,
            vec![],
            None,
            None,
            None,
            None,
            &mut _warnings,
        )
        .unwrap();
        let ticket_id = ticket.frontmatter.id.clone();

        let app = build_app(p.clone(), None);
        let response = app
            .oneshot(
                Request::builder()
                    .uri(format!("/api/tickets/{}/prompt", &ticket_id[..8]))
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(json["prompt"].is_string(), "prompt field should be present");
        assert!(!json["prompt"].as_str().unwrap().is_empty(), "prompt should not be empty");
    }
}
