//! Dashboard: the Jobs pages (`/dashboard/jobs`, dashboard plan §14.4).
//!
//! The catalogue as cards, the `jobs` table, `POST /dashboard/jobs/{name}`
//! (insert `requested`, ask the runner to start the unit) and the job page
//! with the live unit status and the journal tail. The server never runs the
//! pipeline in-process: `job run` inside the unit does the work (§14.2).

use askama::Template;
use axum::Router;
use axum::body::Bytes;
use axum::extract::{Extension, Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::get;
use axum_login::tower_sessions::Session;
use jiff::Timestamp;

use crate::config::Config;
use crate::jobs::{self, EXITED_BEFORE_START, Job, JobRow};
use crate::server::AppState;
use crate::web::session::{AuthSession, Viewer};
use crate::web::{Flash, Html, Page, UnitStatus, WebError, take_flash};

use super::{db_err, duration_between, fmt_duration, fmt_stored_time};

/// Routes contributed by this page group (merged by `dashboard::router`).
pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/dashboard/jobs", get(index))
        .route("/dashboard/jobs/{key}", get(show).post(start))
}

/// How many rows the jobs table shows.
const TABLE_ROWS: i64 = 200;

/// A `requested` row whose unit already stopped with a failure this long after
/// the request is marked failed (§14.4).
const EXIT_GRACE_SECS: i64 = 30;

#[derive(Debug, Clone)]
struct JobCard {
    name: String,
    description: &'static str,
    dangerous: bool,
    /// The `generate` card carries the date input for `generate-YYYY-MM-DD`.
    dated: bool,
    lock: Option<&'static str>,
    /// Status of the most recent job started from this card, when there is one.
    last_status: Option<String>,
    /// When that job was requested, for the card's "last run" line.
    last_requested: Option<String>,
    /// Its id, so the card links straight to the job page.
    last_id: Option<i64>,
}

#[derive(Debug, Clone)]
struct JobLine {
    id: i64,
    name: String,
    requested_by: String,
    requested: String,
    started: String,
    finished: String,
    duration: String,
    status: String,
    message: Option<String>,
    run_id: Option<i64>,
}

#[derive(Template)]
#[template(path = "dashboard/jobs.html")]
struct JobsTemplate {
    page: Page,
    jobs_enabled: bool,
    cards: Vec<JobCard>,
    jobs: Vec<JobLine>,
    today: String,
}

#[derive(Template)]
#[template(path = "dashboard/job.html")]
struct JobTemplate {
    page: Page,
    job: JobLine,
    unit: String,
    description: &'static str,
    refresh: bool,
    status: Option<UnitStatus>,
    status_error: Option<String>,
    log: String,
    log_error: Option<String>,
    log_lines: u32,
}

fn cards() -> Vec<JobCard> {
    Job::CATALOGUE
        .iter()
        .map(|job| JobCard {
            name: job.name(),
            description: job.description(),
            dangerous: job.dangerous(),
            dated: matches!(job, Job::Generate { .. }),
            lock: job.takes_lock(),
            last_status: None,
            last_requested: None,
            last_id: None,
        })
        .collect()
}

/// `rows` is newest first, so the first row whose name is the card's name (or
/// `<name>-<date>` for the dated `generate` card) is that card's last run.
fn attach_last_runs(cards: &mut [JobCard], rows: &[JobLine]) {
    for card in cards.iter_mut() {
        let prefix = format!("{}-", card.name);
        if let Some(row) = rows
            .iter()
            .find(|row| row.name == card.name || row.name.starts_with(&prefix))
        {
            card.last_status = Some(row.status.clone());
            card.last_requested = Some(row.requested.clone());
            card.last_id = Some(row.id);
        }
    }
}

fn job_line(row: &JobRow, config: &Config) -> JobLine {
    JobLine {
        id: row.id,
        name: row.name.clone(),
        requested_by: match (&row.requested_by_name, row.requested_by) {
            (Some(name), _) => name.clone(),
            (None, Some(id)) => format!("user {id}"),
            (None, None) => "by hand".into(),
        },
        requested: fmt_stored_time(Some(&row.requested_at), config),
        started: fmt_stored_time(row.started_at.as_deref(), config),
        finished: fmt_stored_time(row.finished_at.as_deref(), config),
        duration: fmt_duration(
            row.started_at
                .as_deref()
                .and_then(|started| duration_between(started, row.finished_at.as_deref())),
        ),
        status: row.status.clone(),
        message: row.message.clone(),
        run_id: row.run_id,
    }
}

async fn jobs_template(
    state: &AppState,
    viewer: Option<Viewer>,
    flash: Option<Flash>,
) -> Result<JobsTemplate, WebError> {
    let config = state.config();
    let rows = jobs::list(&state.db, TABLE_ROWS).await.map_err(db_err)?;
    let mut page = Page::new("Jobs", viewer, "jobs");
    page.flash = flash;
    let today = config
        .tz()
        .map(|tz| Timestamp::now().to_zoned(tz).date().to_string())
        .unwrap_or_else(|_| {
            Timestamp::now()
                .to_zoned(jiff::tz::TimeZone::UTC)
                .date()
                .to_string()
        });
    let jobs: Vec<JobLine> = rows.iter().map(|row| job_line(row, &config)).collect();
    let mut cards = cards();
    attach_last_runs(&mut cards, &jobs);
    Ok(JobsTemplate {
        page,
        jobs_enabled: config.server.jobs_enabled,
        cards,
        jobs,
        today,
    })
}

/// `GET /dashboard/jobs`: the catalogue cards and the jobs table.
async fn index(
    State(state): State<AppState>,
    auth: AuthSession,
    Extension(session): Extension<Session>,
) -> Result<Response, WebError> {
    let viewer = auth.user().await.map(Viewer::from);
    let flash = take_flash(&session).await?;
    Ok(Html(jobs_template(&state, viewer, flash).await?).into_response())
}

/// The `date` field of the start form, when present and non-empty.
fn form_date(body: &[u8]) -> Option<String> {
    url::form_urlencoded::parse(body)
        .find(|(key, _)| key == "date")
        .map(|(_, value)| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

async fn set_flash(session: &Session, kind: &str, text: String) -> Result<(), WebError> {
    session
        .insert(
            "flash",
            Flash {
                kind: kind.into(),
                text,
            },
        )
        .await
        .map_err(|error| WebError::Internal(error.into()))
}

/// `POST /dashboard/jobs/{name}` (admin, origin-checked): parse the name,
/// refuse a duplicate `requested`/`running` unit (409), insert `requested`,
/// start the unit; a failed start marks the row `failed`.
async fn start(
    State(state): State<AppState>,
    auth: AuthSession,
    Extension(session): Extension<Session>,
    Path(name): Path<String>,
    body: Bytes,
) -> Result<Response, WebError> {
    let viewer = auth
        .user()
        .await
        .map(Viewer::from)
        .ok_or_else(|| WebError::Unauthenticated {
            next: "/dashboard/jobs".into(),
        })?;
    let mut job = Job::parse(&name).ok_or(WebError::NotFound)?;
    if let (Job::Generate { date: None }, Some(date)) = (job, form_date(&body)) {
        let parsed = date
            .parse::<jiff::civil::Date>()
            .map_err(|_| WebError::BadRequest(format!("invalid date {date:?}")))?;
        job = Job::Generate { date: Some(parsed) };
    }
    let config = state.config();
    if !config.server.jobs_enabled {
        set_flash(
            &session,
            "error",
            "Jobs are disabled on this server (server.jobs_enabled = false).".into(),
        )
        .await?;
        return Ok(Redirect::to("/dashboard/jobs").into_response());
    }
    let unit = job.unit();
    if let Some(active) = jobs::active_for_unit(&state.db, &unit)
        .await
        .map_err(db_err)?
    {
        let flash = Flash {
            kind: "error".into(),
            text: format!(
                "{} is already requested or running (job {active}).",
                job.name()
            ),
        };
        let template = jobs_template(&state, Some(viewer), Some(flash)).await?;
        return Ok((StatusCode::CONFLICT, Html(template)).into_response());
    }

    // Pick up a hand-edited config.toml before the unit starts (§4.2); a file
    // that no longer loads keeps the previous config live and is only logged.
    if let Err(error) = crate::web::WebState::reload_if_changed(&state) {
        tracing::warn!(%error, "config.toml on disk does not load; keeping the previous config");
    }
    let now = Timestamp::now();
    let id = jobs::insert_requested(&state.db, &job, Some(viewer.id), now)
        .await
        .map_err(db_err)?;
    match state.web.jobs.start(&unit).await {
        Ok(()) => {
            tracing::info!(user = %viewer.username, job = %job.name(), job_id = id, %unit, "job requested");
            set_flash(&session, "success", format!("Started {}.", job.name())).await?;
        }
        Err(error) => {
            tracing::warn!(user = %viewer.username, job = %job.name(), job_id = id, %unit, %error, "job start failed");
            jobs::finish(
                &state.db,
                id,
                jobs::Outcome::Failed,
                &format!("could not start {unit}: {error}"),
                None,
                Timestamp::now(),
            )
            .await
            .map_err(db_err)?;
            set_flash(
                &session,
                "error",
                format!("Could not start {}: {error}", job.name()),
            )
            .await?;
        }
    }
    Ok(Redirect::to(&format!("/dashboard/jobs/{id}")).into_response())
}

/// `GET /dashboard/jobs/{id}`: the row, the live unit status and the journal
/// tail; applies the 30-second "unit exited before the job started" rule.
async fn show(
    State(state): State<AppState>,
    auth: AuthSession,
    Extension(session): Extension<Session>,
    Path(key): Path<String>,
) -> Result<Response, WebError> {
    let viewer = auth.user().await.map(Viewer::from);
    let id: i64 = key.parse().map_err(|_| WebError::NotFound)?;
    let config = state.config();
    let db = &state.db;
    let mut row = jobs::get(db, id)
        .await
        .map_err(db_err)?
        .ok_or(WebError::NotFound)?;
    let job = Job::parse(&row.name);
    let (status, status_error) = match state.web.jobs.status(&row.unit).await {
        Ok(status) => (Some(status), None),
        Err(error) => (None, Some(error)),
    };
    if let Some(status) = &status
        && row.status == "requested"
        && status.exited_unsuccessfully()
        && requested_secs_ago(&row, Timestamp::now()) >= EXIT_GRACE_SECS
    {
        jobs::finish(
            db,
            id,
            jobs::Outcome::Failed,
            EXITED_BEFORE_START,
            None,
            Timestamp::now(),
        )
        .await
        .map_err(db_err)?;
        row = jobs::get(db, id)
            .await
            .map_err(db_err)?
            .ok_or(WebError::NotFound)?;
    }
    let lines = config.server.journal_lines;
    let (log, log_error) = match state.web.jobs.log(&row.unit, lines as usize).await {
        Ok(log) => (log, None),
        Err(error) => (String::new(), Some(error)),
    };
    let mut page = Page::new(format!("Job {id} · {}", row.name), viewer, "jobs");
    page.flash = take_flash(&session).await?;
    Ok(Html(JobTemplate {
        page,
        refresh: row.is_active(),
        unit: row.unit.clone(),
        description: job.map(|job| job.description()).unwrap_or(""),
        job: job_line(&row, &config),
        status,
        status_error,
        log,
        log_error,
        log_lines: lines,
    })
    .into_response())
}

fn requested_secs_ago(row: &JobRow, now: Timestamp) -> i64 {
    row.requested_at
        .parse::<Timestamp>()
        .map(|requested| now.as_second() - requested.as_second())
        .unwrap_or(i64::MAX)
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use axum::body::Body;
    use axum::http::{Method, Request, header};
    use tower::ServiceExt;

    use super::*;
    use crate::db::Db;
    use crate::server::router;
    use crate::web::MockRunner;
    use crate::web::dashboard::tests::{assert_admin_only, get, login_cookie, response_text};

    fn job_line_named(id: i64, name: &str, status: &str) -> JobLine {
        JobLine {
            id,
            name: name.into(),
            requested_by: "admin".into(),
            requested: "2026-09-03 01:00".into(),
            started: String::new(),
            finished: String::new(),
            duration: String::new(),
            status: status.into(),
            message: None,
            run_id: None,
        }
    }

    #[test]
    fn cards_take_their_last_run_from_the_newest_matching_job() {
        let mut cards = cards();
        // Newest first, the way `jobs::list` returns them.
        let rows = vec![
            job_line_named(4, "generate-2026-09-03", "running"),
            job_line_named(3, "profile-rebuild", "ok"),
            job_line_named(2, "generate-2026-09-02", "ok"),
        ];
        attach_last_runs(&mut cards, &rows);

        let generate = cards.iter().find(|card| card.name == "generate").unwrap();
        assert_eq!(generate.last_id, Some(4), "the dated job matches its card");
        assert_eq!(generate.last_status.as_deref(), Some("running"));
        let rebuild = cards
            .iter()
            .find(|card| card.name == "profile-rebuild")
            .unwrap();
        assert_eq!(rebuild.last_id, Some(3));
        let never = cards
            .iter()
            .find(|card| card.last_id.is_none())
            .expect("a catalogue job with no recorded run");
        assert!(never.last_status.is_none());
    }

    async fn app_with_runner(
        config: Config,
        runner: Arc<dyn crate::web::JobRunner>,
    ) -> (tempfile::TempDir, Db, axum::Router) {
        let dir = tempfile::tempdir().unwrap();
        let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
            .await
            .unwrap();
        crate::web::users::add(&db, "reader", "correct horse battery", false)
            .await
            .unwrap();
        crate::web::users::add(&db, "admin", "correct horse battery", true)
            .await
            .unwrap();
        let app = router(AppState::with_jobs(db.clone(), config, None, runner));
        (dir, db, app)
    }

    async fn post(app: &axum::Router, uri: &str, body: &str, cookie: &str) -> Response {
        app.clone()
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri(uri)
                    .header(header::COOKIE, cookie)
                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
                    .header("sec-fetch-site", "same-origin")
                    .body(Body::from(body.to_string()))
                    .unwrap(),
            )
            .await
            .unwrap()
    }

    fn location(response: &Response) -> String {
        response
            .headers()
            .get(header::LOCATION)
            .unwrap()
            .to_str()
            .unwrap()
            .to_string()
    }

    #[test]
    fn form_date_reads_the_optional_field() {
        assert_eq!(form_date(b"date=2026-09-03"), Some("2026-09-03".into()));
        assert_eq!(form_date(b"date=+2026-09-03+"), Some("2026-09-03".into()));
        assert_eq!(form_date(b"date="), None);
        assert_eq!(form_date(b""), None);
        assert_eq!(form_date(b"other=1"), None);
    }

    #[tokio::test]
    async fn jobs_page_lists_the_catalogue_and_the_table() {
        let runner = Arc::new(MockRunner::default());
        let (_dir, db, app) = app_with_runner(Config::default(), runner).await;
        let now: Timestamp = "2026-09-03T10:00:00Z".parse().unwrap();
        let id = jobs::insert_requested(&db, &Job::FeaturesPrune, None, now)
            .await
            .unwrap();
        jobs::finish(&db, id, jobs::Outcome::Ok, "pruned 0 embeddings", None, now)
            .await
            .unwrap();
        let body = assert_admin_only(&app, "/dashboard/jobs").await;
        for job in Job::CATALOGUE {
            assert!(
                body.contains(&format!("action=\"/dashboard/jobs/{}\"", job.name())),
                "{}: {body}",
                job.name()
            );
            let escaped_description = job.description().replace('\'', "&#39;");
            assert!(body.contains(&escaped_description), "{}", job.name());
        }
        assert!(body.contains("data-confirm"), "generate needs confirmation");
        assert!(body.contains("type=\"date\""), "the dated generate input");
        assert!(body.contains("pruned 0 embeddings"), "{body}");
        assert!(body.contains(&format!("/dashboard/jobs/{id}")), "{body}");
        assert!(!body.contains("style=\""), "no inline styles under the CSP");
    }

    #[tokio::test]
    async fn starting_a_job_inserts_a_row_and_starts_the_unit() {
        let runner = Arc::new(MockRunner::default());
        let (_dir, db, app) = app_with_runner(
            Config::default(),
            runner.clone() as Arc<dyn crate::web::JobRunner>,
        )
        .await;
        let admin = login_cookie(&app, "admin", "correct horse battery").await;

        let response = post(&app, "/dashboard/jobs/features-prune", "", &admin).await;
        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        let target = location(&response);
        assert!(target.starts_with("/dashboard/jobs/"), "{target}");
        let row = jobs::list(&db, 10).await.unwrap().remove(0);
        assert_eq!(target, format!("/dashboard/jobs/{}", row.id));
        assert_eq!(row.name, "features-prune");
        assert_eq!(row.unit, "daily-epub-job@features-prune.service");
        assert_eq!(row.status, "requested");
        assert_eq!(row.requested_by_name.as_deref(), Some("admin"));
        assert_eq!(
            runner.calls(),
            vec!["start daily-epub-job@features-prune.service".to_string()]
        );

        // The job page shows the row, the unit and refreshes while active.
        let page = get(&app, &target, Some(&admin)).await;
        assert_eq!(page.status(), StatusCode::OK);
        let body = response_text(page).await;
        assert!(
            body.contains("daily-epub-job@features-prune.service"),
            "{body}"
        );
        assert!(body.contains("data-refresh=\"5\""), "{body}");
        assert!(body.contains("badge requested"), "{body}");
        assert!(body.contains("Started features-prune."), "the flash");

        // Simulate the unit claiming the request, then prove a running
        // duplicate is refused with 409 and no second row.
        assert_eq!(
            jobs::claim(&db, &Job::FeaturesPrune, Timestamp::now())
                .await
                .unwrap(),
            row.id
        );
        let duplicate = post(&app, "/dashboard/jobs/features-prune", "", &admin).await;
        assert_eq!(duplicate.status(), StatusCode::CONFLICT);
        let body = response_text(duplicate).await;
        assert!(body.contains("already requested or running"), "{body}");
        assert_eq!(jobs::list(&db, 10).await.unwrap().len(), 1);
        assert_eq!(
            runner
                .calls()
                .iter()
                .filter(|call| call.starts_with("start "))
                .count(),
            1,
            "no second start"
        );

        // The dated generate form starts the dated unit.
        let dated = post(&app, "/dashboard/jobs/generate", "date=2026-09-03", &admin).await;
        assert_eq!(dated.status(), StatusCode::SEE_OTHER);
        let row = jobs::list(&db, 10).await.unwrap().remove(0);
        assert_eq!(row.name, "generate-2026-09-03");
        assert_eq!(
            runner.calls().last().unwrap(),
            "start daily-epub-job@generate-2026-09-03.service"
        );
        let bad_date = post(&app, "/dashboard/jobs/generate", "date=soon", &admin).await;
        assert_eq!(bad_date.status(), StatusCode::BAD_REQUEST);

        // Unknown names never reach the runner.
        for name in ["../x", "Generate", "backup"] {
            let unknown = post(&app, &format!("/dashboard/jobs/{name}"), "", &admin).await;
            assert_eq!(unknown.status(), StatusCode::NOT_FOUND, "{name}");
        }
        assert_eq!(
            runner
                .calls()
                .iter()
                .filter(|call| call.starts_with("start "))
                .count(),
            2
        );

        // Readers cannot start jobs.
        let reader = login_cookie(&app, "reader", "correct horse battery").await;
        let forbidden = post(&app, "/dashboard/jobs/features-prune", "", &reader).await;
        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
    }

    #[tokio::test]
    async fn a_failed_start_marks_the_row_failed() {
        let runner = Arc::new(MockRunner::default());
        runner.fail_starts(Some("polkit: access denied"));
        let (_dir, db, app) = app_with_runner(
            Config::default(),
            runner.clone() as Arc<dyn crate::web::JobRunner>,
        )
        .await;
        let admin = login_cookie(&app, "admin", "correct horse battery").await;
        let response = post(&app, "/dashboard/jobs/dry-run", "", &admin).await;
        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        let row = jobs::list(&db, 10).await.unwrap().remove(0);
        assert_eq!(row.status, "failed");
        assert!(row.finished_at.is_some());
        assert_eq!(
            row.message.as_deref(),
            Some("could not start daily-epub-job@dry-run.service: polkit: access denied")
        );
        let body = response_text(get(&app, &location(&response), Some(&admin)).await).await;
        assert!(body.contains("polkit: access denied"), "{body}");
        assert!(
            !body.contains("data-refresh"),
            "a finished job does not refresh"
        );
        // The unit is free again.
        runner.fail_starts(None);
        let again = post(&app, "/dashboard/jobs/dry-run", "", &admin).await;
        assert_eq!(again.status(), StatusCode::SEE_OTHER);
        assert_eq!(jobs::list(&db, 10).await.unwrap()[0].status, "requested");
    }

    #[tokio::test]
    async fn job_page_marks_a_unit_that_exited_before_the_job_started() {
        let runner = Arc::new(MockRunner::default());
        let unit = Job::ProfileRebuild.unit();
        runner.set_status(
            &unit,
            UnitStatus {
                active_state: "inactive".into(),
                sub_state: "dead".into(),
                result: "exit-code".into(),
                exit_status: Some(1),
                started: Some("Thu 2026-09-03 05:30:01 EDT".into()),
                exited: Some("Thu 2026-09-03 05:30:02 EDT".into()),
            },
        );
        runner.set_log("Sep 03 05:30:01 daily-epub[1]: Error: profile rebuild is already running");
        let (_dir, db, app) = app_with_runner(
            Config::default(),
            runner.clone() as Arc<dyn crate::web::JobRunner>,
        )
        .await;
        let admin = login_cookie(&app, "admin", "correct horse battery").await;

        // Requested a minute ago and never claimed.
        let requested_at = Timestamp::now() - jiff::Span::new().seconds(60);
        let id = jobs::insert_requested(&db, &Job::ProfileRebuild, None, requested_at)
            .await
            .unwrap();
        let body =
            response_text(get(&app, &format!("/dashboard/jobs/{id}"), Some(&admin)).await).await;
        assert!(body.contains(EXITED_BEFORE_START), "{body}");
        assert!(body.contains("badge failed"), "{body}");
        assert!(body.contains("exit-code"), "the live unit status");
        assert!(body.contains("is already running"), "the journal tail");
        assert_eq!(jobs::get(&db, id).await.unwrap().unwrap().status, "failed");
        assert!(runner.calls().contains(&format!(
            "log {unit} {}",
            Config::default().server.journal_lines
        )));

        // A fresh request whose unit has not run yet is left alone.
        runner.set_status(
            &Job::FeaturesPrune.unit(),
            UnitStatus {
                active_state: "inactive".into(),
                sub_state: "dead".into(),
                result: "success".into(),
                ..UnitStatus::default()
            },
        );
        let fresh = jobs::insert_requested(&db, &Job::FeaturesPrune, None, Timestamp::now())
            .await
            .unwrap();
        let body =
            response_text(get(&app, &format!("/dashboard/jobs/{fresh}"), Some(&admin)).await).await;
        assert!(body.contains("badge requested"), "{body}");
        assert_eq!(
            jobs::get(&db, fresh).await.unwrap().unwrap().status,
            "requested"
        );

        let missing = get(&app, "/dashboard/jobs/999999", Some(&admin)).await;
        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn disabled_jobs_refuse_starts_without_a_row() {
        let mut config = Config::default();
        config.server.jobs_enabled = false;
        let (_dir, db, app) = app_with_runner(config, Arc::new(crate::web::DisabledRunner)).await;
        let admin = login_cookie(&app, "admin", "correct horse battery").await;
        let body = response_text(get(&app, "/dashboard/jobs", Some(&admin)).await).await;
        assert!(body.contains("Jobs are disabled"), "{body}");
        let response = post(&app, "/dashboard/jobs/features-prune", "", &admin).await;
        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        assert_eq!(location(&response), "/dashboard/jobs");
        assert!(jobs::list(&db, 10).await.unwrap().is_empty());
    }
}
