//! `AbandonedExecutionsRepo` — forensic table written by the
//! dispatcher when it tries to resolve a sync-HTTP inbox channel
//! that's already been dropped (orchestrator timed out and gave up).
//!
//! Schema: see `migrations/0011_abandoned_executions.sql`.
//!
//! Tiny surface: insert + GC. Reading happens via direct SQL when
//! correlating the metric counter spike.

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, ScriptId};
use sqlx::PgPool;
use uuid::Uuid;

#[derive(Debug, thiserror::Error)]
pub enum AbandonedRepoError {
    #[error("database error: {0}")]
    Db(#[from] sqlx::Error),
}

#[derive(Debug, Clone)]
pub struct NewAbandonedExecution {
    pub app_id: AppId,
    pub outbox_id: Uuid,
    pub script_id: Option<ScriptId>,
    pub inbox_id: Uuid,
    pub status_code: u16,
    pub result_summary: Option<String>,
}

#[async_trait]
pub trait AbandonedRepo: Send + Sync {
    async fn insert(&self, row: NewAbandonedExecution) -> Result<Uuid, AbandonedRepoError>;

    /// Retention sweep — deletes rows older than `older_than` up to
    /// `limit` at a time.
    async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, AbandonedRepoError>;
}

pub struct PostgresAbandonedRepo {
    pool: PgPool,
}

impl PostgresAbandonedRepo {
    #[must_use]
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

const SUMMARY_CAP_BYTES: usize = 4096;

#[async_trait]
impl AbandonedRepo for PostgresAbandonedRepo {
    async fn insert(&self, row: NewAbandonedExecution) -> Result<Uuid, AbandonedRepoError> {
        // Truncate the summary at write-time. The forensic table
        // doesn't need megabytes; the original outbox row may have
        // been arbitrary size but we lose nothing useful by clipping.
        let summary = row.result_summary.map(|s| truncate(s, SUMMARY_CAP_BYTES));
        let (id,): (Uuid,) = sqlx::query_as(
            "INSERT INTO abandoned_executions ( \
                app_id, outbox_id, script_id, inbox_id, status_code, result_summary \
             ) VALUES ($1, $2, $3, $4, $5, $6) \
             RETURNING id",
        )
        .bind(row.app_id.into_inner())
        .bind(row.outbox_id)
        .bind(row.script_id.map(ScriptId::into_inner))
        .bind(row.inbox_id)
        .bind(i32::from(row.status_code))
        .bind(summary)
        .fetch_one(&self.pool)
        .await?;
        Ok(id)
    }

    async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, AbandonedRepoError> {
        let res = sqlx::query(
            "DELETE FROM abandoned_executions \
                WHERE id IN ( \
                    SELECT id FROM abandoned_executions \
                    WHERE created_at < $1 \
                    FOR UPDATE SKIP LOCKED \
                    LIMIT $2 \
                )",
        )
        .bind(older_than)
        .bind(limit)
        .execute(&self.pool)
        .await?;
        Ok(res.rows_affected())
    }
}

fn truncate(mut s: String, max_bytes: usize) -> String {
    if s.len() <= max_bytes {
        return s;
    }
    // Walk back from `max_bytes` to a UTF-8 char boundary so we never
    // panic on `truncate` mid-codepoint.
    let mut cut = max_bytes;
    while cut > 0 && !s.is_char_boundary(cut) {
        cut -= 1;
    }
    s.truncate(cut);
    s
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn truncate_respects_char_boundaries() {
        // 3-byte UTF-8 chars; cap inside the middle char should walk
        // back to the start.
        let s = "héllo".to_string();
        let t = truncate(s, 2);
        assert!(t.is_char_boundary(t.len()));
        assert_eq!(t, "h");
    }

    #[test]
    fn truncate_passthrough_for_short_strings() {
        assert_eq!(truncate("ok".into(), 100), "ok");
    }
}
