#![expect(
    clippy::disallowed_methods,
    reason = "These git integration tests intentionally exercise the real git CLI to validate repository helper behavior."
)]

use std::collections::HashMap;
use std::path::Path;
use std::process::{Command, Output};
use std::sync::Arc;

use fabro_agent::Sandbox;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_types::{RunEvent, WorkflowSettings, fixtures};
use fabro_workflow::event::Emitter;
use fabro_workflow::git::{branch_needs_push, push_branch, push_ref};
use fabro_workflow::handler::HandlerRegistry;
use fabro_workflow::handler::exit::ExitHandler;
use fabro_workflow::handler::start::StartHandler;
use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions};
use fabro_workflow::test_support::run_graph;
use tokio_util::sync::CancellationToken;

fn assert_success(output: &Output, context: &str) {
    assert!(
        output.status.success(),
        "{context} failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

fn init_repo(dir: &Path) {
    std::fs::create_dir_all(dir).expect("failed to create repo dir");
    let init = Command::new("git")
        .args(["init"])
        .current_dir(dir)
        .output()
        .expect("git init should run");
    assert_success(&init, "git init");
    let commit = Command::new("git")
        .args([
            "-c",
            "user.name=test",
            "-c",
            "user.email=test@test",
            "commit",
            "--allow-empty",
            "-m",
            "init",
        ])
        .current_dir(dir)
        .output()
        .expect("git commit --allow-empty should run");
    assert_success(&commit, "git commit --allow-empty");
}

fn init_bare_remote(dir: &Path) {
    std::fs::create_dir_all(
        dir.parent()
            .expect("bare remote path should have a parent directory"),
    )
    .expect("failed to create bare remote parent dir");
    let init = Command::new("git")
        .args(["init", "--bare"])
        .arg(dir)
        .output()
        .expect("git init --bare should run");
    assert_success(&init, "git init --bare");
}

fn add_origin(repo_dir: &Path, remote_dir: &Path) {
    let output = Command::new("git")
        .args(["remote", "add", "origin"])
        .arg(remote_dir)
        .current_dir(repo_dir)
        .output()
        .expect("git remote add origin should run");
    assert_success(&output, "git remote add origin");
}

fn rename_branch(repo_dir: &Path, branch: &str) {
    let output = Command::new("git")
        .args(["branch", "-M", branch])
        .current_dir(repo_dir)
        .output()
        .expect("git branch -M should run");
    assert_success(&output, "git branch -M");
}

fn empty_commit(repo_dir: &Path, message: &str) {
    let output = Command::new("git")
        .args([
            "-c",
            "user.name=test",
            "-c",
            "user.email=test@test",
            "commit",
            "--allow-empty",
            "-m",
            message,
        ])
        .current_dir(repo_dir)
        .output()
        .expect("git commit --allow-empty should run");
    assert_success(&output, "git commit --allow-empty");
}

fn list_branch(repo_dir: &Path, branch: &str) -> String {
    let output = Command::new("git")
        .args(["branch", "--list", branch])
        .current_dir(repo_dir)
        .output()
        .expect("git branch --list should run");
    assert_success(&output, "git branch --list");
    String::from_utf8(output.stdout).expect("git branch --list output should be UTF-8")
}

fn local_env(repo: &Path) -> Arc<dyn Sandbox> {
    Arc::new(fabro_agent::LocalSandbox::new(repo.to_path_buf()))
}

fn simple_graph() -> Graph {
    let mut g = Graph::new("git_checkpoint");
    g.attrs.insert(
        "goal".to_string(),
        AttrValue::String("Create git checkpoints".to_string()),
    );

    let mut start = Node::new("start");
    start.attrs.insert(
        "shape".to_string(),
        AttrValue::String("Mdiamond".to_string()),
    );
    g.nodes.insert("start".to_string(), start);

    let mut exit = Node::new("exit");
    exit.attrs.insert(
        "shape".to_string(),
        AttrValue::String("Msquare".to_string()),
    );
    g.nodes.insert("exit".to_string(), exit);

    g
}

fn make_registry() -> HandlerRegistry {
    let mut registry = HandlerRegistry::new(Box::new(StartHandler));
    registry.register("start", Box::new(StartHandler));
    registry.register("exit", Box::new(ExitHandler));
    registry
}

fn test_run_options(run_dir: &Path) -> RunOptions {
    RunOptions {
        run_dir:          run_dir.to_path_buf(),
        cancel_token:     CancellationToken::new(),
        run_id:           fixtures::RUN_2,
        settings:         WorkflowSettings::default(),
        git:              None,
        pre_run_git:      None,
        fork_source_ref:  None,
        labels:           HashMap::new(),
        github_app:       None,
        base_branch:      None,
        display_base_sha: None,
        workflow_slug:    None,
    }
}

#[test]
fn push_ref_to_bare_remote() {
    let dir = tempfile::tempdir().unwrap();
    let repo_dir = dir.path().join("repo");
    let remote_dir = dir.path().join("remote.git");

    init_bare_remote(&remote_dir);
    init_repo(&repo_dir);
    add_origin(&repo_dir, &remote_dir);

    rename_branch(&repo_dir, "test-push");
    let url = format!("file://{}", remote_dir.display());
    push_ref(&repo_dir, &url, "refs/heads/test-push").unwrap();

    assert!(list_branch(&remote_dir, "test-push").contains("test-push"));
}

#[test]
fn push_branch_to_remote() {
    let dir = tempfile::tempdir().unwrap();
    let repo_dir = dir.path().join("repo");
    let remote_dir = dir.path().join("remote.git");

    init_bare_remote(&remote_dir);
    init_repo(&repo_dir);
    add_origin(&repo_dir, &remote_dir);
    rename_branch(&repo_dir, "main");

    push_branch(&repo_dir, "origin", "main").unwrap();

    assert!(list_branch(&remote_dir, "main").contains("main"));
}

#[test]
fn branch_needs_push_when_ahead() {
    let dir = tempfile::tempdir().unwrap();
    let repo_dir = dir.path().join("repo");
    let remote_dir = dir.path().join("remote.git");

    init_bare_remote(&remote_dir);
    init_repo(&repo_dir);
    add_origin(&repo_dir, &remote_dir);
    rename_branch(&repo_dir, "main");

    push_branch(&repo_dir, "origin", "main").unwrap();
    empty_commit(&repo_dir, "second");

    assert!(branch_needs_push(&repo_dir, "origin", "main"));
}

#[test]
fn branch_needs_push_when_in_sync() {
    let dir = tempfile::tempdir().unwrap();
    let repo_dir = dir.path().join("repo");
    let remote_dir = dir.path().join("remote.git");

    init_bare_remote(&remote_dir);
    init_repo(&repo_dir);
    add_origin(&repo_dir, &remote_dir);
    rename_branch(&repo_dir, "main");

    push_branch(&repo_dir, "origin", "main").unwrap();

    assert!(!branch_needs_push(&repo_dir, "origin", "main"));
}

#[tokio::test]
async fn git_checkpoint_skips_start_node() {
    let repo_dir = tempfile::tempdir().unwrap();
    let repo = repo_dir.path();
    init_repo(repo);

    let base_sha = String::from_utf8(
        Command::new("git")
            .args(["rev-parse", "HEAD"])
            .current_dir(repo)
            .output()
            .unwrap()
            .stdout,
    )
    .unwrap()
    .trim()
    .to_string();

    let run_tmp = tempfile::tempdir().unwrap();
    let mut g = simple_graph();
    g.nodes.insert("work".to_string(), Node::new("work"));
    g.edges.clear();
    g.edges.push(Edge::new("start", "work"));
    g.edges.push(Edge::new("work", "exit"));

    let events = Arc::new(std::sync::Mutex::new(Vec::<RunEvent>::new()));
    let events_clone = Arc::clone(&events);
    let emitter = Emitter::new(fixtures::RUN_2);
    emitter.on_event(move |event| {
        events_clone.lock().unwrap().push(event.clone());
    });

    let mut run_options = test_run_options(run_tmp.path());
    run_options.git = Some(GitCheckpointOptions {
        base_sha:    Some(base_sha),
        run_branch:  None,
        meta_branch: Some(format!("fabro/meta/{}", fixtures::RUN_2)),
    });

    Box::pin(run_graph(
        make_registry(),
        Arc::new(emitter),
        local_env(repo),
        &g,
        &run_options,
    ))
    .await
    .unwrap();

    let collected = events.lock().unwrap();
    let checkpoint_node_ids: Vec<&str> = collected
        .iter()
        .filter(|event| {
            event.event_name() == "checkpoint.completed"
                && event.properties().is_ok_and(|properties| {
                    properties
                        .get("git_commit_sha")
                        .and_then(|value| value.as_str())
                        .is_some()
                })
        })
        .filter_map(|event| event.node_id.as_deref())
        .collect();
    assert!(!checkpoint_node_ids.contains(&"start"));
    assert!(checkpoint_node_ids.contains(&"work"));
}

/// Sandbox double for remote-style runs: commands and files operate on a real
/// local checkout, but the workflow engine's run directory is reported as
/// inaccessible (as it is for Docker/Daytona) and the sandbox exposes a
/// runtime directory outside the checkout.
struct RemoteRuntimeSandbox {
    inner:             fabro_agent::LocalSandbox,
    hidden_path:       String,
    runtime_directory: String,
}

#[async_trait::async_trait]
impl Sandbox for RemoteRuntimeSandbox {
    async fn read_file_bytes(&self, path: &str) -> fabro_sandbox::Result<Vec<u8>> {
        self.inner.read_file_bytes(path).await
    }

    async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> {
        self.inner.write_file(path, content).await
    }

    async fn delete_file(&self, path: &str) -> fabro_sandbox::Result<()> {
        self.inner.delete_file(path).await
    }

    async fn file_exists(&self, path: &str) -> fabro_sandbox::Result<bool> {
        if path == self.hidden_path {
            return Ok(false);
        }
        self.inner.file_exists(path).await
    }

    async fn list_directory(
        &self,
        path: &str,
        depth: Option<usize>,
    ) -> fabro_sandbox::Result<Vec<fabro_agent::DirEntry>> {
        self.inner.list_directory(path, depth).await
    }

    async fn exec_command(
        &self,
        command: &str,
        timeout_ms: u64,
        working_dir: Option<&str>,
        env_vars: Option<&HashMap<String, String>>,
        cancel_token: Option<CancellationToken>,
    ) -> fabro_sandbox::Result<fabro_agent::ExecResult> {
        self.inner
            .exec_command(command, timeout_ms, working_dir, env_vars, cancel_token)
            .await
    }

    async fn grep(
        &self,
        pattern: &str,
        path: &str,
        options: &fabro_sandbox::GrepOptions,
    ) -> fabro_sandbox::Result<Vec<String>> {
        self.inner.grep(pattern, path, options).await
    }

    async fn download_file_to_local(
        &self,
        remote_path: &str,
        local_path: &Path,
    ) -> fabro_sandbox::Result<()> {
        self.inner
            .download_file_to_local(remote_path, local_path)
            .await
    }

    async fn upload_file_from_local(
        &self,
        local_path: &Path,
        remote_path: &str,
    ) -> fabro_sandbox::Result<()> {
        self.inner
            .upload_file_from_local(local_path, remote_path)
            .await
    }

    async fn initialize(&self) -> fabro_sandbox::Result<()> {
        Ok(())
    }

    async fn cleanup(&self) -> fabro_sandbox::Result<()> {
        Ok(())
    }

    fn working_directory(&self) -> &str {
        self.inner.working_directory()
    }

    fn runtime_directory(&self) -> Option<&str> {
        Some(&self.runtime_directory)
    }

    fn platform(&self) -> &str {
        self.inner.platform()
    }

    fn os_version(&self) -> String {
        self.inner.os_version()
    }
}

fn git_status_porcelain(repo_dir: &Path) -> String {
    let output = Command::new("git")
        .args(["status", "--porcelain"])
        .current_dir(repo_dir)
        .output()
        .expect("git status --porcelain should run");
    assert_success(&output, "git status --porcelain");
    String::from_utf8(output.stdout).expect("git status output should be UTF-8")
}

fn git_committed_files(repo_dir: &Path, sha: &str) -> String {
    let output = Command::new("git")
        .args(["show", "--name-only", "--format=", sha])
        .current_dir(repo_dir)
        .output()
        .expect("git show --name-only should run");
    assert_success(&output, "git show --name-only");
    String::from_utf8(output.stdout).expect("git show output should be UTF-8")
}

/// Remote-style prompt demotion must materialize blobs in the sandbox runtime
/// directory, outside the checkout, so a real checkpoint commit can never pick
/// them up, and re-resolution must recreate a deleted materialized file from
/// the durable blob store. Regression test for issue #798.
#[tokio::test]
async fn remote_prompt_demotion_stays_outside_checkout_and_survives_checkpoint() {
    use std::time::Duration;

    use fabro_store::test_support as store_test_support;
    use fabro_types::settings::run::RunCheckpointSettings;
    use fabro_workflow::context::Context;
    use fabro_workflow::git::GitAuthor;
    use fabro_workflow::runtime_store::RunStoreHandle;
    use fabro_workflow::{artifact, sandbox_git};
    use object_store::memory::InMemory;

    let dir = tempfile::tempdir().unwrap();
    let repo_dir = dir.path().join("repo");
    init_repo(&repo_dir);
    let runtime_dir = dir.path().join("fabro").join("runtime");
    let run_dir = dir.path().join("run");
    std::fs::create_dir_all(&run_dir).unwrap();

    let sandbox = RemoteRuntimeSandbox {
        inner:             fabro_agent::LocalSandbox::new(repo_dir.clone()),
        hidden_path:       run_dir.to_string_lossy().to_string(),
        runtime_directory: runtime_dir.to_string_lossy().to_string(),
    };

    let store = store_test_support::test_database(
        Arc::new(InMemory::new()),
        "runs/",
        Duration::from_millis(1),
        None,
    );
    let run_store: RunStoreHandle = store.create_run(&fixtures::RUN_2).await.unwrap().into();

    let oversized = serde_json::json!("x".repeat(64 * 1024));
    let oversized_bytes = serde_json::to_vec(&oversized).unwrap();
    let mut values = HashMap::from([("dataset".to_string(), oversized.clone())]);
    artifact::demote_large_values_for_prompt(
        &mut values,
        &mut HashMap::new(),
        &run_store,
        &sandbox,
        &run_dir,
    )
    .await;

    let marker = values["dataset"]
        .get("fabroLargeValue")
        .expect("oversized value should demote to a marker");
    let blob_path = marker["path"].as_str().unwrap().to_string();
    assert!(
        blob_path.starts_with(&runtime_dir.to_string_lossy().to_string()),
        "materialized blob {blob_path} should live under the sandbox runtime directory"
    );
    assert!(
        !blob_path.starts_with(&repo_dir.to_string_lossy().to_string()),
        "materialized blob {blob_path} must not live inside the checkout"
    );

    // The agent-facing path is readable through the sandbox.
    let contents = sandbox.read_file_bytes(&blob_path).await.unwrap();
    assert_eq!(contents, oversized_bytes);

    // Materialization leaves the checkout clean, and a real checkpoint commit
    // stages no runtime blob file.
    assert_eq!(git_status_porcelain(&repo_dir), "");
    let sha = sandbox_git::git_checkpoint(
        &sandbox,
        &fixtures::RUN_2.to_string(),
        "work",
        "succeeded",
        1,
        None,
        &RunCheckpointSettings::default(),
        &GitAuthor::default(),
    )
    .await
    .expect("checkpoint commit should succeed");
    assert_eq!(git_committed_files(&repo_dir, &sha).trim(), "");
    assert_eq!(git_status_porcelain(&repo_dir), "");

    // Removing the materialized file and resolving the value again recreates
    // it from the durable blob store.
    std::fs::remove_file(&blob_path).unwrap();
    let blob_hash = fabro_types::BlobHash::new(&oversized_bytes);
    let context = Context::new();
    context.set(
        "report",
        serde_json::json!(fabro_types::format_blob_ref(&blob_hash)),
    );
    let resolved = artifact::resolved_context_snapshot(&context, &run_store, &sandbox, &run_dir)
        .await
        .unwrap();
    assert_eq!(
        resolved["report"],
        serde_json::json!(format!("file://{blob_path}"))
    );
    assert_eq!(
        sandbox.read_file_bytes(&blob_path).await.unwrap(),
        oversized_bytes
    );
}
