use runledger_core::jobs::{
    JobFailureKind, JobType, StepKey, WorkflowRunEnqueueBuilder, WorkflowStepEnqueueBuilder,
    WorkflowType,
};
use runledger_postgres::jobs::{
    JobDefinitionUpsert, JobEnqueue, JobFailureUpdate, JobPayloadUuidArrayFieldUpdate,
    JobPayloadUuidArrayFieldUpdateRejection, cancel_job, claim_jobs_for_types,
    complete_job_failure, complete_job_success, enqueue_job, enqueue_workflow_run,
    list_workflow_steps, update_job_payload_uuid_array_field, upsert_job_definition_tx,
};
use runledger_postgres::{Error, QueryErrorCategory};
use runledger_test_support::{setup_ephemeral_pool, teardown_ephemeral_pool};
use serde_json::{Value, json};
use sqlx::types::Uuid;
use sqlx::{PgPool, Row};
use tokio::time::{Duration, timeout};

const PAYLOAD_FIELD: &str = "target_ids";

fn uuid(value: &str) -> Uuid {
    Uuid::parse_str(value).expect("valid uuid")
}

fn org_id() -> Uuid {
    uuid("00000000-0000-0000-0000-000000000001")
}

fn original_payload() -> Value {
    json!({
        PAYLOAD_FIELD: [uuid("00000000-0000-0000-0000-000000000010").to_string()],
        "stable": "keep"
    })
}

fn payload_with_ids(ids: &[Uuid]) -> Value {
    let ids = ids.iter().map(Uuid::to_string).collect::<Vec<_>>();
    json!({
        PAYLOAD_FIELD: ids,
        "stable": "keep"
    })
}

fn replacement_ids() -> [Uuid; 2] {
    [
        uuid("00000000-0000-0000-0000-000000000101"),
        uuid("00000000-0000-0000-0000-000000000102"),
    ]
}

fn assert_lock_timeout_error(error: Error) {
    match error {
        Error::QueryError(query_error) => {
            assert_eq!(query_error.category(), QueryErrorCategory::Internal);
            assert_eq!(query_error.sqlstate(), Some("55P03"));
            assert!(
                query_error.source_arc().is_some(),
                "lock timeout should preserve the source sqlx error"
            );
        }
        other => panic!("expected query error, got {other:?}"),
    }
}

async fn register_job_definition(pool: &PgPool, job_type: JobType<'_>) {
    let mut tx = pool.begin().await.expect("begin setup tx");
    upsert_job_definition_tx(
        &mut tx,
        &JobDefinitionUpsert {
            job_type,
            version: 1,
            max_attempts: 3,
            default_timeout_seconds: 60,
            default_priority: 100,
            is_enabled: true,
        },
    )
    .await
    .expect("upsert job definition");
    tx.commit().await.expect("commit setup tx");
}

async fn enqueue_direct_job(
    pool: &PgPool,
    organization_id: Uuid,
    job_type: JobType<'_>,
    payload: &Value,
    idempotency_key: Option<&str>,
) -> Uuid {
    enqueue_job(
        pool,
        &JobEnqueue {
            job_type,
            organization_id: Some(organization_id),
            payload,
            priority: None,
            max_attempts: None,
            timeout_seconds: None,
            next_run_at: None,
            idempotency_key,
            stage: None,
        },
    )
    .await
    .expect("enqueue direct job")
}

async fn load_job_payload(pool: &PgPool, job_id: Uuid) -> Value {
    sqlx::query_scalar::<_, Value>("SELECT payload FROM job_queue WHERE id = $1")
        .bind(job_id)
        .fetch_one(pool)
        .await
        .expect("load job payload")
}

async fn load_job_payload_and_enqueue_request(
    pool: &PgPool,
    job_id: Uuid,
) -> (Value, Option<Value>) {
    let row = sqlx::query("SELECT payload, enqueue_request FROM job_queue WHERE id = $1")
        .bind(job_id)
        .fetch_one(pool)
        .await
        .expect("load job payload and enqueue request");

    (
        row.try_get("payload").expect("payload column"),
        row.try_get("enqueue_request")
            .expect("enqueue_request column"),
    )
}

async fn assert_rejected_update_preserves_payload(
    pool: &PgPool,
    organization_id: Uuid,
    job_id: Uuid,
    job_type: JobType<'_>,
    expected_payload: &Value,
    expected_reason: JobPayloadUuidArrayFieldUpdateRejection,
) {
    let replacement = replacement_ids();
    let updated = update_job_payload_uuid_array_field(
        pool,
        organization_id,
        job_id,
        job_type,
        PAYLOAD_FIELD,
        &replacement,
    )
    .await
    .expect("update payload field");

    assert_eq!(
        updated,
        JobPayloadUuidArrayFieldUpdate::Rejected {
            reason: expected_reason
        },
        "guarded job should not be mutated"
    );
    assert_eq!(
        load_job_payload(pool, job_id).await,
        expected_payload.clone()
    );
}

#[tokio::test]
async fn admin_payload_update_allows_pending_non_idempotent_direct_job() {
    let (pool, database) = setup_ephemeral_pool("admin_payload_pending", 4).await;
    let job_type = JobType::new("jobs.test.admin_payload_pending");
    let organization_id = org_id();
    register_job_definition(&pool, job_type).await;

    let payload = original_payload();
    let job_id = enqueue_direct_job(&pool, organization_id, job_type, &payload, None).await;
    let replacement = replacement_ids();

    let updated = update_job_payload_uuid_array_field(
        &pool,
        organization_id,
        job_id,
        job_type,
        PAYLOAD_FIELD,
        &replacement,
    )
    .await
    .expect("update pending non-idempotent payload");

    assert_eq!(updated, JobPayloadUuidArrayFieldUpdate::Updated);
    assert_eq!(
        load_job_payload(&pool, job_id).await,
        payload_with_ids(&replacement)
    );

    teardown_ephemeral_pool(pool, database).await;
}

#[tokio::test]
async fn admin_payload_update_row_lock_wait_is_bounded_without_mutation() {
    let (pool, database) = setup_ephemeral_pool("admin_payload_lock_timeout", 4).await;
    let job_type = JobType::new("jobs.test.admin_payload_lock_timeout");
    let organization_id = org_id();
    register_job_definition(&pool, job_type).await;

    let payload = original_payload();
    let job_id = enqueue_direct_job(&pool, organization_id, job_type, &payload, None).await;

    let mut blocker = pool.begin().await.expect("begin blocker tx");
    sqlx::query("SELECT id FROM job_queue WHERE id = $1 FOR UPDATE")
        .bind(job_id)
        .fetch_one(&mut *blocker)
        .await
        .expect("hold job row lock");

    let replacement = replacement_ids();
    let error = timeout(
        Duration::from_secs(2),
        update_job_payload_uuid_array_field(
            &pool,
            organization_id,
            job_id,
            job_type,
            PAYLOAD_FIELD,
            &replacement,
        ),
    )
    .await
    .expect("row lock acquisition should be bounded")
    .expect_err("conflicting row lock should time out");

    assert_lock_timeout_error(error);
    assert_eq!(load_job_payload(&pool, job_id).await, payload);

    blocker.rollback().await.expect("release blocker tx");
    teardown_ephemeral_pool(pool, database).await;
}

#[tokio::test]
async fn admin_payload_update_reports_not_found_for_missing_job() {
    let (pool, database) = setup_ephemeral_pool("admin_payload_missing", 4).await;
    let job_type = JobType::new("jobs.test.admin_payload_missing");
    let organization_id = org_id();
    register_job_definition(&pool, job_type).await;

    let replacement = replacement_ids();
    let updated = update_job_payload_uuid_array_field(
        &pool,
        organization_id,
        uuid("00000000-0000-0000-0000-00000000ffff"),
        job_type,
        PAYLOAD_FIELD,
        &replacement,
    )
    .await
    .expect("update missing payload field");

    assert_eq!(updated, JobPayloadUuidArrayFieldUpdate::NotFound);

    teardown_ephemeral_pool(pool, database).await;
}

#[tokio::test]
async fn admin_payload_update_rejects_leased_and_terminal_jobs() {
    let (pool, database) = setup_ephemeral_pool("admin_payload_state_guards", 4).await;
    let job_type = JobType::new("jobs.test.admin_payload_state_guards");
    let organization_id = org_id();
    register_job_definition(&pool, job_type).await;
    let payload = original_payload();

    let leased_job_id = enqueue_direct_job(&pool, organization_id, job_type, &payload, None).await;
    let leased_job = claim_jobs_for_types(&pool, "worker-leased", 30, 1, &[job_type])
        .await
        .expect("claim leased job")
        .pop()
        .expect("leased job claimed");
    assert_eq!(leased_job.id, leased_job_id);
    assert_rejected_update_preserves_payload(
        &pool,
        organization_id,
        leased_job_id,
        job_type,
        &payload,
        JobPayloadUuidArrayFieldUpdateRejection::NotPendingOrClaimed,
    )
    .await;

    let succeeded_job_id =
        enqueue_direct_job(&pool, organization_id, job_type, &payload, None).await;
    let succeeded_job = claim_jobs_for_types(&pool, "worker-succeeded", 30, 1, &[job_type])
        .await
        .expect("claim succeeded job")
        .pop()
        .expect("succeeded job claimed");
    assert_eq!(succeeded_job.id, succeeded_job_id);
    let succeeded_worker = succeeded_job
        .worker_id
        .as_deref()
        .expect("claimed job worker id");
    complete_job_success(
        &pool,
        succeeded_job.id,
        succeeded_job.run_number,
        succeeded_job.attempt,
        succeeded_worker,
        None,
    )
    .await
    .expect("complete succeeded job");
    assert_rejected_update_preserves_payload(
        &pool,
        organization_id,
        succeeded_job_id,
        job_type,
        &payload,
        JobPayloadUuidArrayFieldUpdateRejection::NotPendingOrClaimed,
    )
    .await;

    let canceled_job_id =
        enqueue_direct_job(&pool, organization_id, job_type, &payload, None).await;
    cancel_job(
        &pool,
        Some(organization_id),
        canceled_job_id,
        Some("test cancel"),
    )
    .await
    .expect("cancel pending job");
    assert_rejected_update_preserves_payload(
        &pool,
        organization_id,
        canceled_job_id,
        job_type,
        &payload,
        JobPayloadUuidArrayFieldUpdateRejection::NotPendingOrClaimed,
    )
    .await;

    let dead_lettered_job_id =
        enqueue_direct_job(&pool, organization_id, job_type, &payload, None).await;
    let dead_lettered_job = claim_jobs_for_types(&pool, "worker-dead-lettered", 30, 1, &[job_type])
        .await
        .expect("claim dead-lettered job")
        .pop()
        .expect("dead-lettered job claimed");
    assert_eq!(dead_lettered_job.id, dead_lettered_job_id);
    let dead_lettered_worker = dead_lettered_job
        .worker_id
        .as_deref()
        .expect("claimed job worker id");
    complete_job_failure(
        &pool,
        dead_lettered_job.id,
        dead_lettered_job.run_number,
        dead_lettered_job.attempt,
        dead_lettered_worker,
        &JobFailureUpdate {
            kind: JobFailureKind::Terminal,
            code: "terminal",
            message: "terminal failure",
            retry_delay_ms: None,
        },
    )
    .await
    .expect("complete dead-lettered job");
    assert_rejected_update_preserves_payload(
        &pool,
        organization_id,
        dead_lettered_job_id,
        job_type,
        &payload,
        JobPayloadUuidArrayFieldUpdateRejection::NotPendingOrClaimed,
    )
    .await;

    teardown_ephemeral_pool(pool, database).await;
}

#[tokio::test]
async fn admin_payload_update_rejects_idempotent_pending_job_without_snapshot_drift() {
    let (pool, database) = setup_ephemeral_pool("admin_payload_idempotent", 4).await;
    let job_type = JobType::new("jobs.test.admin_payload_idempotent");
    let organization_id = org_id();
    register_job_definition(&pool, job_type).await;

    let payload = original_payload();
    let job_id = enqueue_direct_job(
        &pool,
        organization_id,
        job_type,
        &payload,
        Some("admin-payload-idempotent"),
    )
    .await;
    let (before_payload, before_enqueue_request) =
        load_job_payload_and_enqueue_request(&pool, job_id).await;
    assert_eq!(before_payload, payload);
    assert!(
        before_enqueue_request.is_some(),
        "keyed job should store an enqueue request snapshot"
    );

    assert_rejected_update_preserves_payload(
        &pool,
        organization_id,
        job_id,
        job_type,
        &payload,
        JobPayloadUuidArrayFieldUpdateRejection::IdempotentRequestSnapshot,
    )
    .await;

    let (after_payload, after_enqueue_request) =
        load_job_payload_and_enqueue_request(&pool, job_id).await;
    assert_eq!(after_payload, before_payload);
    assert_eq!(after_enqueue_request, before_enqueue_request);

    let retry_id = enqueue_direct_job(
        &pool,
        organization_id,
        job_type,
        &payload,
        Some("admin-payload-idempotent"),
    )
    .await;
    assert_eq!(retry_id, job_id);

    teardown_ephemeral_pool(pool, database).await;
}

#[tokio::test]
async fn admin_payload_update_rejects_workflow_managed_pending_job() {
    let (pool, database) = setup_ephemeral_pool("admin_payload_workflow", 4).await;
    let job_type = JobType::new("jobs.test.admin_payload_workflow");
    let organization_id = org_id();
    register_job_definition(&pool, job_type).await;

    let payload = original_payload();
    let metadata = json!({"test": "admin_payload_update_rejects_workflow_managed_pending_job"});
    let step = WorkflowStepEnqueueBuilder::new(StepKey::new("step"), job_type, &payload)
        .try_build()
        .expect("build workflow step");
    let workflow =
        WorkflowRunEnqueueBuilder::new(WorkflowType::new("workflow.test.admin_payload"), &metadata)
            .organization_id(organization_id)
            .step(step)
            .try_build()
            .expect("build workflow");
    let workflow_run = enqueue_workflow_run(&pool, &workflow)
        .await
        .expect("enqueue workflow");
    let steps = list_workflow_steps(&pool, Some(organization_id), workflow_run.id)
        .await
        .expect("list workflow steps");
    let workflow_step = steps.first().expect("workflow step exists");
    let job_id = workflow_step.job_id.expect("workflow step released a job");

    assert_rejected_update_preserves_payload(
        &pool,
        organization_id,
        job_id,
        job_type,
        &payload,
        JobPayloadUuidArrayFieldUpdateRejection::WorkflowManaged,
    )
    .await;
    let steps_after = list_workflow_steps(&pool, Some(organization_id), workflow_run.id)
        .await
        .expect("list workflow steps after rejected update");
    assert_eq!(steps_after[0].payload, payload);

    teardown_ephemeral_pool(pool, database).await;
}
