#![expect(
    clippy::unwrap_used,
    reason = "SQLite automation-store integration tests use panic-on-failure fixture setup"
)]

use std::path::Path;

use fabro_automation::{
    ApiTrigger, AutomationDraft, AutomationId, AutomationReplace, AutomationStore,
    AutomationStoreError, AutomationTarget, AutomationTrigger, AutomationTriggerId,
    ScheduleTrigger,
};
use fabro_db::Database;
use tokio::fs;

async fn test_database() -> (tempfile::TempDir, Database) {
    let dir = tempfile::tempdir().unwrap();
    let database = Database::connect(dir.path().join("fabro.sqlite3"))
        .await
        .unwrap();
    database.migrate().await.unwrap();
    (dir, database)
}

fn target() -> AutomationTarget {
    AutomationTarget {
        repository:   "fabro-sh/fabro".to_string(),
        ref_selector: "main".to_string(),
        workflow:     "release".to_string(),
    }
}

fn schedule(id: &str, expression: &str, enabled: bool) -> AutomationTrigger {
    AutomationTrigger::Schedule(ScheduleTrigger {
        id: AutomationTriggerId::new(id).unwrap(),
        enabled,
        expression: expression.to_string(),
    })
}

fn draft(id: &str, api_enabled: bool) -> AutomationDraft {
    AutomationDraft {
        id:          AutomationId::new(id).unwrap(),
        name:        "Nightly".to_string(),
        description: Some("Runs every night".to_string()),
        target:      target(),
        triggers:    vec![
            schedule("z-last", "0 2 * * *", false),
            AutomationTrigger::Api(ApiTrigger {
                id:      AutomationTriggerId::new("custom-api-id").unwrap(),
                enabled: api_enabled,
            }),
            schedule("a-first", "0 1 * * *", true),
        ],
    }
}

fn replacement(name: &str, expression: &str) -> AutomationReplace {
    AutomationReplace {
        name:        name.to_string(),
        description: None,
        target:      target(),
        triggers:    vec![
            schedule("nightly", expression, true),
            AutomationTrigger::Api(ApiTrigger {
                id:      AutomationTriggerId::new("api").unwrap(),
                enabled: true,
            }),
        ],
    }
}

fn trigger_ids(automation: &fabro_automation::Automation) -> Vec<&str> {
    automation
        .triggers
        .iter()
        .map(|trigger| trigger.id().as_str())
        .collect()
}

#[tokio::test]
async fn crud_normalizes_api_and_schedule_order() {
    let (_dir, database) = test_database().await;
    let store = AutomationStore::new(database.clone_pool());

    let created = store.create(draft("nightly", true)).await.unwrap();
    assert_eq!(trigger_ids(&created), vec!["manual", "a-first", "z-last"]);
    assert!(created.enabled_api_trigger().is_some());

    let fetched = store.get(&created.id).await.unwrap().unwrap();
    assert_eq!(fetched, created);
    assert_eq!(store.list().await.unwrap(), vec![created.clone()]);

    let replaced = store
        .replace(
            &created.id,
            &created.revision,
            replacement("Updated", "30 4 * * *"),
        )
        .await
        .unwrap();
    assert_ne!(replaced.revision, created.revision);
    assert_eq!(replaced.name, "Updated");

    store
        .delete(&replaced.id, &replaced.revision)
        .await
        .unwrap();
    assert!(store.get(&replaced.id).await.unwrap().is_none());
}

#[tokio::test]
async fn disabled_api_trigger_normalizes_to_absent() {
    let (_dir, database) = test_database().await;
    let store = AutomationStore::new(database.clone_pool());

    let created = store.create(draft("nightly", false)).await.unwrap();

    assert!(created.enabled_api_trigger().is_none());
    assert_eq!(trigger_ids(&created), vec!["a-first", "z-last"]);
    assert_eq!(store.get(&created.id).await.unwrap().unwrap(), created);
}

#[tokio::test]
async fn equivalent_trigger_orders_have_the_same_revision() {
    let (_dir, database) = test_database().await;
    let store = AutomationStore::new(database.clone_pool());
    let first = store.create(draft("first", true)).await.unwrap();
    let mut reordered = draft("second", true);
    reordered.triggers.reverse();

    let second = store.create(reordered).await.unwrap();

    assert_eq!(first.revision, second.revision);
}

#[tokio::test]
async fn create_conflict_and_conditional_delete_errors_are_typed() {
    let (_dir, database) = test_database().await;
    let store = AutomationStore::new(database.clone_pool());
    let created = store.create(draft("nightly", true)).await.unwrap();

    let duplicate = store.create(draft("nightly", true)).await.unwrap_err();
    assert!(matches!(
        duplicate,
        AutomationStoreError::AlreadyExists { .. }
    ));

    let mut revision_source = draft("revision-source", true);
    revision_source.name = "Different revision".to_string();
    let stale_revision = store.create(revision_source).await.unwrap().revision;
    let stale = store
        .delete(&created.id, &stale_revision)
        .await
        .unwrap_err();
    assert!(matches!(stale, AutomationStoreError::StaleRevision { .. }));

    let missing = AutomationId::new("missing").unwrap();
    let not_found = store.delete(&missing, &stale_revision).await.unwrap_err();
    assert!(matches!(not_found, AutomationStoreError::NotFound { .. }));
}

#[tokio::test]
async fn independent_pools_observe_writes_and_revision_conflicts() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("fabro.sqlite3");
    let first_database = Database::connect(&path).await.unwrap();
    first_database.migrate().await.unwrap();
    let second_database = Database::connect(&path).await.unwrap();
    second_database.migrate().await.unwrap();
    let first = AutomationStore::new(first_database.clone_pool());
    let second = AutomationStore::new(second_database.clone_pool());

    let created = first.create(draft("nightly", true)).await.unwrap();
    assert_eq!(second.get(&created.id).await.unwrap().unwrap(), created);

    let replaced = second
        .replace(
            &created.id,
            &created.revision,
            replacement("Winner", "0 5 * * *"),
        )
        .await
        .unwrap();
    let err = first
        .replace(
            &created.id,
            &created.revision,
            replacement("Loser", "0 6 * * *"),
        )
        .await
        .unwrap_err();
    assert!(matches!(err, AutomationStoreError::StaleRevision {
        actual,
        ..
    } if actual == replaced.revision));
}

#[tokio::test]
async fn failed_schedule_insert_rolls_back_parent_replace() {
    let (_dir, database) = test_database().await;
    let store = AutomationStore::new(database.clone_pool());
    let created = store.create(draft("nightly", true)).await.unwrap();
    sqlx::query(
        r"
        CREATE TRIGGER reject_blocked_schedule
        BEFORE INSERT ON automation_triggers
        WHEN NEW.id = 'blocked'
        BEGIN
            SELECT RAISE(ABORT, 'blocked schedule');
        END
        ",
    )
    .execute(database.pool())
    .await
    .unwrap();
    let replacement = AutomationReplace {
        name:        "Should roll back".to_string(),
        description: None,
        target:      target(),
        triggers:    vec![schedule("blocked", "0 7 * * *", true)],
    };

    let err = store
        .replace(&created.id, &created.revision, replacement)
        .await
        .unwrap_err();

    assert!(matches!(err, AutomationStoreError::Db { .. }));
    assert_eq!(store.get(&created.id).await.unwrap().unwrap(), created);
}

#[tokio::test]
async fn invalid_stored_schedule_is_rejected_on_read() {
    let (_dir, database) = test_database().await;
    let store = AutomationStore::new(database.clone_pool());
    let created = store.create(draft("nightly", true)).await.unwrap();
    sqlx::query("UPDATE automation_triggers SET expression = 'not cron' WHERE automation_id = ?")
        .bind(created.id.as_str())
        .execute(database.pool())
        .await
        .unwrap();

    let err = store.get(&created.id).await.unwrap_err();

    assert!(matches!(err, AutomationStoreError::StoredValidation { .. }));
}

#[tokio::test]
async fn legacy_import_is_transactional_and_sql_wins() {
    let (dir, database) = test_database().await;
    let store = AutomationStore::new(database.clone_pool());
    store.create(draft("existing", true)).await.unwrap();
    let source_dir = dir.path().join("automations");
    fs::create_dir_all(&source_dir).await.unwrap();
    write_legacy_automation(&source_dir, "existing", "Legacy existing").await;
    write_legacy_automation(&source_dir, "imported", "Imported").await;
    fs::write(source_dir.join("notes.txt"), "ignored")
        .await
        .unwrap();

    let report = fabro_automation::import_legacy_directory_once(database.pool(), &source_dir)
        .await
        .unwrap()
        .unwrap();

    assert_eq!(report.imported_rows, 1);
    assert_eq!(report.skipped_rows, 1);
    assert_eq!(report.names, vec!["imported"]);
    assert!(!source_dir.exists());
    assert!(report.backup_path.exists());
    assert_eq!(
        store
            .get(&AutomationId::new("existing").unwrap())
            .await
            .unwrap()
            .unwrap()
            .name,
        "Nightly"
    );
    assert_eq!(
        store
            .get(&AutomationId::new("imported").unwrap())
            .await
            .unwrap()
            .unwrap()
            .name,
        "Imported"
    );

    fs::create_dir_all(&source_dir).await.unwrap();
    write_legacy_automation(&source_dir, "existing", "Legacy existing").await;
    write_legacy_automation(&source_dir, "imported", "Imported again").await;
    let retry = fabro_automation::import_legacy_directory_once(database.pool(), &source_dir)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(retry.imported_rows, 0);
    assert_eq!(retry.skipped_rows, 2);
    assert!(retry.backup_path.exists());
    assert_eq!(
        store
            .get(&AutomationId::new("imported").unwrap())
            .await
            .unwrap()
            .unwrap()
            .name,
        "Imported"
    );
    assert!(
        fabro_automation::import_legacy_directory_once(database.pool(), &source_dir)
            .await
            .unwrap()
            .is_none()
    );
}

#[tokio::test]
async fn invalid_legacy_file_leaves_directory_and_database_unchanged() {
    let (dir, database) = test_database().await;
    let source_dir = dir.path().join("automations");
    fs::create_dir_all(&source_dir).await.unwrap();
    write_legacy_automation(&source_dir, "valid", "Valid").await;
    fs::write(source_dir.join("broken.toml"), "not valid toml =")
        .await
        .unwrap();

    let err = fabro_automation::import_legacy_directory_once(database.pool(), &source_dir)
        .await
        .unwrap_err();

    assert!(matches!(err, AutomationStoreError::Parse { .. }));
    assert!(source_dir.exists());
    assert!(
        AutomationStore::new(database.clone_pool())
            .list()
            .await
            .unwrap()
            .is_empty()
    );
}

async fn write_legacy_automation(dir: &Path, id: &str, name: &str) {
    fs::write(
        dir.join(format!("{id}.toml")),
        format!(
            r#"name = "{name}"

[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "release"

[[triggers]]
id = "manual"
type = "api"
enabled = true

[[triggers]]
id = "nightly"
type = "schedule"
enabled = true
expression = "0 3 * * *"
"#
        ),
    )
    .await
    .unwrap();
}
