use std::collections::{BTreeMap, HashMap};

use chrono::{TimeZone, Utc};
use fabro_store::{RunProjection, SerializableProjection, StageId};
use fabro_types::graph::Graph;
use fabro_types::run::RunSpec;
use fabro_types::{
    BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord,
    ParallelBranchResult, QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan,
    RunSandboxRuntime, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,
    StageOutcome, StartRecord, first_event_seq, fixtures, test_support,
};
use serde_json::json;

fn sample_run_spec() -> RunSpec {
    RunSpec {
        graph: Graph::new("ship"),
        workflow_slug: Some("demo".to_string()),
        source_directory: Some("/tmp/project".to_string()),
        labels: HashMap::from([("team".to_string(), "platform".to_string())]),
        git: Some(fabro_types::GitContext {
            origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
            branch:     "main".to_string(),
            sha:        None,
            dirty:      fabro_types::DirtyStatus::Clean,
        }),
        ..test_support::test_run_spec()
    }
}

fn sample_checkpoint() -> Checkpoint {
    Checkpoint {
        timestamp:                  Utc
            .with_ymd_and_hms(2026, 4, 20, 12, 0, 0)
            .single()
            .expect("timestamp should be representable"),
        current_node:               "build".to_string(),
        completed_nodes:            vec!["build".to_string()],
        node_retries:               HashMap::new(),
        context_values:             HashMap::new(),
        node_outcomes:              HashMap::new(),
        next_node_id:               Some("ship".to_string()),
        git_commit_sha:             Some("abc123".to_string()),
        loop_failure_signatures:    HashMap::new(),
        restart_failure_signatures: HashMap::new(),
        node_visits:                HashMap::from([("build".to_string(), 2usize)]),
    }
}

fn sample_usage() -> BilledModelUsage {
    serde_json::from_value(json!({
        "input": {
            "usage": {
                "model": {
                    "provider": "openai",
                    "model_id": "gpt-5.2"
                },
                "tokens": {
                    "input_tokens": 123,
                    "output_tokens": 45
                }
            },
            "facts": { "algorithm": "openai" }
        },
        "total_usd_micros": 168
    }))
    .expect("sample usage should deserialize")
}

#[test]
fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
    let stage_id = StageId::new("build", 2);
    let mut projection = RunProjection::new(
        "Demo".to_string(),
        sample_run_spec(),
        Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0)
            .single()
            .unwrap(),
    );
    projection.start = Some(StartRecord {
        start_time: Utc
            .with_ymd_and_hms(2026, 4, 20, 12, 0, 0)
            .single()
            .expect("start_time should be representable"),
        run_branch: Some("fabro/run/demo".to_string()),
        base_sha:   Some("deadbeef".to_string()),
    });
    projection.status = RunStatus::Running;
    projection.checkpoints.push(CheckpointRecord {
        seq:        7,
        checkpoint: sample_checkpoint(),
        diff:       RunDiff::default(),
    });
    let sandbox_plan = RunSandboxPlan {
        provider: SandboxProviderKind::Local,
        image:    None,
        snapshot: None,
    };
    projection.sandbox = Some(RunSandbox::ready(sandbox_plan, RunSandboxInstance {
        provider: SandboxProviderKind::Local,
        image:    None,
        snapshot: None,
        runtime:  RunSandboxRuntime {
            id:                "sandbox-1".to_string(),
            working_directory: "/tmp/project".to_string(),
            repo_cloned:       None,
            clone_origin_url:  None,
            clone_branch:      None,
            workspace_root:    None,
            repos_root:        None,
            primary_repo_path: None,
            primary_repo_link: None,
        },
    }));
    projection.pending_interviews = BTreeMap::new();
    let stage = projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2));
    stage.prompt = Some("plan the work".to_string());
    stage.response = Some("done".to_string());
    stage.completion = Some(StageCompletion {
        outcome:        StageOutcome::Succeeded,
        notes:          Some("ok".to_string()),
        failure_reason: None,
        timestamp:      Utc
            .with_ymd_and_hms(2026, 4, 20, 12, 1, 0)
            .single()
            .expect("timestamp should be representable"),
    });
    stage.provider_used = Some(StageModelUsage {
        mode:             StageModelUsage::MODE_PROMPT.to_string(),
        provider:         Some("openai".to_string()),
        model:            Some("gpt-5.4".to_string()),
        reasoning_effort: None,
        speed:            None,
    });
    stage.diff = Some("diff --git a/a b/a".to_string());
    stage.script_invocation = Some(json!({ "command": "cargo test" }));
    stage.script_timing = Some(json!({ "duration_ms": 10 }));
    let parallel_results = vec![ParallelBranchResult {
        id:              "review".to_string(),
        index:           Some(0),
        item_label:      None,
        status:          StageOutcome::Succeeded,
        context_updates: BTreeMap::from([("response.review".to_string(), json!("looks good"))]),
    }];
    stage.parallel_results = Some(parallel_results.clone());
    stage.timing = Some(fabro_types::StageTiming::wall_only(1234));
    let usage = sample_usage();
    let usage_counts = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&usage));
    stage.usage = usage_counts.clone();
    stage.model = Some(usage.model().clone());
    stage.output = Some("output".to_string());

    let serialized = serde_json::to_value(SerializableProjection(&projection))
        .expect("projection should serialize");
    assert_eq!(
        serialized["stages"]["build@2"]["usage"]["input_tokens"],
        json!(123)
    );
    assert_eq!(
        serialized["stages"]["build@2"]["model"]["model_id"],
        json!("gpt-5.2")
    );
    let round_tripped: RunProjection =
        serde_json::from_value(serialized).expect("serialized projection should deserialize");
    let node = round_tripped.stage(&stage_id).expect("node should remain");

    assert_eq!(round_tripped.spec().id(), fixtures::RUN_1);
    assert_eq!(
        round_tripped
            .current_checkpoint()
            .expect("checkpoint should remain")
            .current_node,
        "build"
    );
    assert_eq!(round_tripped.status(), RunStatus::Running);
    assert!(!round_tripped.is_terminal());
    assert_eq!(node.prompt, None);
    assert_eq!(node.response, None);
    assert_eq!(node.diff, None);
    assert_eq!(node.output, None);
    assert_eq!(node.first_event_seq, first_event_seq(2));
    assert_eq!(
        node.completion
            .as_ref()
            .map(|completion| completion.outcome),
        Some(StageOutcome::Succeeded)
    );
    assert_eq!(
        node.provider_used
            .as_ref()
            .and_then(|usage| usage.provider.as_deref()),
        Some("openai")
    );
    assert_eq!(
        node.provider_used
            .as_ref()
            .and_then(|usage| usage.model.as_deref()),
        Some("gpt-5.4")
    );
    assert_eq!(
        node.script_invocation,
        Some(json!({ "command": "cargo test" }))
    );
    assert_eq!(node.script_timing, Some(json!({ "duration_ms": 10 })));
    assert_eq!(node.parallel_results, Some(parallel_results));
    assert_eq!(node.timing.map(|t| t.wall_time_ms), Some(1234));
    assert_eq!(node.usage, usage_counts);
    assert_eq!(node.model.as_ref(), Some(usage.model()));
}

#[test]
fn projection_query_methods_expose_common_state() {
    let mut projection = RunProjection::new("Demo".to_string(), sample_run_spec(), Utc::now());
    projection.status = RunStatus::Dead;
    projection.archived_at = Some(Utc::now());
    projection.checkpoints.push(CheckpointRecord {
        seq:        7,
        checkpoint: sample_checkpoint(),
        diff:       RunDiff::default(),
    });
    projection.pending_interviews =
        BTreeMap::from([("q-1".to_string(), fabro_store::PendingInterviewRecord {
            question:   InterviewQuestionRecord {
                id:              "q-1".to_string(),
                text:            "Approve?".to_string(),
                stage:           "build".to_string(),
                question_type:   QuestionType::Freeform,
                options:         Vec::new(),
                allow_freeform:  true,
                timeout_seconds: None,
                context_display: None,
                review_target:   None,
            },
            started_at: Utc::now(),
        })]);

    assert_eq!(projection.spec().workflow_slug(), Some("demo"));
    assert_eq!(projection.status(), RunStatus::Dead);
    assert!(projection.is_archived());
    assert!(projection.is_terminal());
    assert_eq!(
        projection
            .current_checkpoint()
            .map(|checkpoint| checkpoint.current_node.as_str()),
        Some("build")
    );
    assert!(projection.pending_interviews().contains_key("q-1"));
}
