mod commit_writer;
mod error;
mod jti_check;
mod radicle_publish;

use std::path::PathBuf;

use async_nats::jetstream::{self, consumer::PullConsumer};
use bytes::Bytes;
use clap::Parser;
use futures::StreamExt;
use git2::Repository;
use tracing::{error, info, warn};

use commit_writer::write_audit_commit;
use jti_check::already_committed;
use radicle_publish::announce_repo;

#[derive(Parser)]
#[command(name = "audit-mirror", about = "NATS ops.audit.* → Radicle git commit sidecar (ADR-038)")]
struct Cli {
    /// NATS server URL
    #[arg(long, default_value = "nats://127.0.0.1:4222")]
    nats_url: String,

    /// Workspace name (used to derive stream and consumer names)
    #[arg(long)]
    workspace: String,

    /// Path to the local Radicle storage repo for this workspace's state
    /// (e.g. ~/.radicle/storage/<rid>)
    #[arg(long)]
    state_repo_path: PathBuf,

    /// Radicle Repository ID (RID) for gossip announce
    #[arg(long)]
    rid: String,

    /// Author name embedded in git commits
    #[arg(long, default_value = "audit-mirror")]
    author_name: String,

    /// Author email embedded in git commits
    #[arg(long, default_value = "audit-mirror@ops.local")]
    author_email: String,

    /// Radicle local HTTP API base URL
    #[arg(long, default_value = "http://127.0.0.1:8776")]
    radicle_api_url: String,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    let cli = Cli::parse();

    let nats = async_nats::connect(&cli.nats_url).await?;
    let js = jetstream::new(nats);

    let stream_name = format!("OPS_AUDIT_{}", cli.workspace.to_uppercase());
    let consumer_name = format!("{}-audit-mirror", cli.workspace);

    // Ensure stream exists (idempotent — ADR-038 requires the stream be pre-provisioned
    // by ops-controller, so we only access it here, not create it).
    let stream = js.get_stream(&stream_name).await.map_err(|e| {
        format!("stream '{stream_name}' not found — ensure ops-controller has provisioned it: {e}")
    })?;

    let consumer: PullConsumer = stream
        .get_or_create_consumer(
            &consumer_name,
            jetstream::consumer::pull::Config {
                durable_name: Some(consumer_name.clone()),
                ack_policy: jetstream::consumer::AckPolicy::Explicit,
                filter_subject: format!("ops.audit.{}.>", cli.workspace),
                ..Default::default()
            },
        )
        .await?;

    info!(
        stream = stream_name,
        consumer = consumer_name,
        repo = ?cli.state_repo_path,
        "audit-mirror ready"
    );

    let repo = Repository::open(&cli.state_repo_path).map_err(|e| {
        format!("cannot open state repo at {:?}: {e}", cli.state_repo_path)
    })?;

    let mut messages = consumer.messages().await?;

    while let Some(msg_result) = messages.next().await {
        let msg = match msg_result {
            Ok(m) => m,
            Err(e) => {
                error!("NATS message error: {e}");
                continue;
            }
        };

        let jti = match extract_jti(&msg.payload) {
            Some(j) => j,
            None => {
                warn!(subject = %msg.subject, "no jti in audit payload; acking and skipping");
                let _ = msg.ack().await;
                continue;
            }
        };

        match already_committed(&repo, &jti) {
            Ok(true) => {
                info!(jti, "jti already committed — idempotent skip");
                let _ = msg.ack().await;
                continue;
            }
            Ok(false) => {}
            Err(e) => {
                error!(jti, error = %e, "jti_check failed; leaving in queue for retry");
                continue;
            }
        }

        let payload_str = String::from_utf8_lossy(&msg.payload);

        match write_audit_commit(
            &repo,
            &jti,
            &payload_str,
            &cli.author_name,
            &cli.author_email,
        ) {
            Ok(oid) => {
                info!(jti, commit = %oid, "audit commit written");
            }
            Err(e) => {
                error!(jti, error = %e, "commit_writer failed; leaving in queue for retry");
                continue;
            }
        }

        // Best-effort gossip announce — non-fatal, commit is durable.
        if let Err(e) = announce_repo(&cli.rid, &cli.radicle_api_url).await {
            warn!(jti, error = %e, "announce_repo failed (non-fatal)");
        }

        if let Err(e) = msg.ack().await {
            warn!(jti, error = %e, "ack failed (JetStream will redeliver; jti_check will deduplicate)");
        }
    }

    warn!("NATS message stream ended; audit-mirror exiting");
    Ok(())
}

/// Extracts the `jti` field from a JSON payload.
fn extract_jti(payload: &Bytes) -> Option<String> {
    let v: serde_json::Value = serde_json::from_slice(payload).ok()?;
    v.get("jti")?.as_str().map(str::to_owned)
}

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

    #[test]
    fn extracts_jti_from_payload() {
        let payload = Bytes::from(r#"{"jti":"abc-123","event":"deploy"}"#);
        assert_eq!(extract_jti(&payload), Some("abc-123".to_string()));
    }

    #[test]
    fn returns_none_for_missing_jti() {
        let payload = Bytes::from(r#"{"event":"deploy"}"#);
        assert_eq!(extract_jti(&payload), None);
    }

    #[test]
    fn returns_none_for_invalid_json() {
        let payload = Bytes::from(b"not-json" as &[u8]);
        assert_eq!(extract_jti(&payload), None);
    }
}
