//! Error-path tests for the shiplog-engine orchestration pipeline.
//!
//! Verifies that errors from renderers, clusterers, redactors, and filesystem
//! operations propagate correctly through `run()`, `import()`, and `refresh()`.

use anyhow::Result;
use chrono::{NaiveDate, TimeZone, Utc};
use shiplog_engine::Engine;
use shiplog_ids::RunId;
use shiplog_ports::{IngestOutput, Redactor, Renderer, WorkstreamClusterer};
use shiplog_render_md::MarkdownRenderer;
use shiplog_schema::bundle::BundleProfile;
use shiplog_schema::coverage::{Completeness, CoverageManifest, TimeWindow};
use shiplog_schema::event::*;
use shiplog_schema::workstream::{Workstream, WorkstreamStats, WorkstreamsFile};
use shiplog_workstreams::RepoClusterer;
use shiplog_workstreams::WorkstreamManager;

// ---------------------------------------------------------------------------
// Stubs
// ---------------------------------------------------------------------------

struct FailingRenderer;
impl Renderer for FailingRenderer {
    fn render_packet_markdown(
        &self,
        _user: &str,
        _window_label: &str,
        _events: &[EventEnvelope],
        _workstreams: &WorkstreamsFile,
        _coverage: &CoverageManifest,
    ) -> Result<String> {
        anyhow::bail!("renderer exploded")
    }
}

struct FailingClusterer;
impl WorkstreamClusterer for FailingClusterer {
    fn cluster(&self, _events: &[EventEnvelope]) -> Result<WorkstreamsFile> {
        anyhow::bail!("clusterer exploded")
    }
}

struct FailingRedactor;
impl Redactor for FailingRedactor {
    fn redact_events(
        &self,
        _events: &[EventEnvelope],
        _profile: &str,
    ) -> Result<Vec<EventEnvelope>> {
        anyhow::bail!("redactor exploded")
    }
    fn redact_workstreams(&self, _ws: &WorkstreamsFile, _profile: &str) -> Result<WorkstreamsFile> {
        anyhow::bail!("redactor exploded")
    }
}

struct NoopRedactor;
impl Redactor for NoopRedactor {
    fn redact_events(
        &self,
        events: &[EventEnvelope],
        _profile: &str,
    ) -> Result<Vec<EventEnvelope>> {
        Ok(events.to_vec())
    }
    fn redact_workstreams(&self, ws: &WorkstreamsFile, _profile: &str) -> Result<WorkstreamsFile> {
        Ok(ws.clone())
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn make_coverage() -> CoverageManifest {
    CoverageManifest {
        run_id: RunId("error_test".into()),
        generated_at: Utc.timestamp_opt(0, 0).unwrap(),
        user: "tester".into(),
        window: TimeWindow {
            since: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
            until: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
        },
        mode: "merged".into(),
        sources: vec!["github".into()],
        slices: vec![],
        warnings: vec![],
        completeness: Completeness::Complete,
    }
}

fn make_ingest(events: Vec<EventEnvelope>) -> IngestOutput {
    IngestOutput {
        events,
        coverage: make_coverage(),
        freshness: Vec::new(),
    }
}

fn one_event() -> Vec<EventEnvelope> {
    vec![shiplog_testkit::pr_event("acme/foo", 1, "test PR")]
}

fn real_engine() -> Engine<'static> {
    let renderer: &'static dyn Renderer = Box::leak(Box::new(MarkdownRenderer::default()));
    let clusterer: &'static dyn WorkstreamClusterer = Box::leak(Box::new(RepoClusterer));
    let redactor: &'static dyn Redactor = Box::leak(Box::new(NoopRedactor));
    Engine::new(renderer, clusterer, redactor)
}

fn empty_workstreams() -> WorkstreamsFile {
    WorkstreamsFile {
        version: 1,
        generated_at: Utc::now(),
        workstreams: vec![],
    }
}

// ---------------------------------------------------------------------------
// import() error propagation
// ---------------------------------------------------------------------------

#[test]
fn import_propagates_renderer_error() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("import_fail_render");

    let renderer: &'static dyn Renderer = Box::leak(Box::new(FailingRenderer));
    let clusterer: &'static dyn WorkstreamClusterer = Box::leak(Box::new(RepoClusterer));
    let redactor: &'static dyn Redactor = Box::leak(Box::new(NoopRedactor));
    let engine = Engine::new(renderer, clusterer, redactor);

    let result = engine.import(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        None,
        &BundleProfile::Internal,
    );

    let err = match result {
        Err(e) => e,
        Ok(_) => panic!("expected renderer error"),
    };
    let msg = format!("{err:#}");
    assert!(msg.contains("renderer exploded"), "unexpected: {msg}");
}

#[test]
fn import_propagates_redactor_error() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("import_fail_redact");

    let renderer: &'static dyn Renderer = Box::leak(Box::new(MarkdownRenderer::default()));
    let clusterer: &'static dyn WorkstreamClusterer = Box::leak(Box::new(RepoClusterer));
    let redactor: &'static dyn Redactor = Box::leak(Box::new(FailingRedactor));
    let engine = Engine::new(renderer, clusterer, redactor);

    let result = engine.import(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        None,
        &BundleProfile::Internal,
    );

    let err = match result {
        Err(e) => e,
        Ok(_) => panic!("expected redactor error"),
    };
    let msg = format!("{err:#}");
    assert!(msg.contains("redactor exploded"), "unexpected: {msg}");
}

#[test]
fn import_propagates_clusterer_error_when_no_workstreams() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("import_fail_cluster");

    let renderer: &'static dyn Renderer = Box::leak(Box::new(MarkdownRenderer::default()));
    let clusterer: &'static dyn WorkstreamClusterer = Box::leak(Box::new(FailingClusterer));
    let redactor: &'static dyn Redactor = Box::leak(Box::new(NoopRedactor));
    let engine = Engine::new(renderer, clusterer, redactor);

    // No pre-existing workstreams and None passed → must cluster → error
    let result = engine.import(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        None,
        &BundleProfile::Internal,
    );

    let err = match result {
        Err(e) => e,
        Ok(_) => panic!("expected clusterer error"),
    };
    let msg = format!("{err:#}");
    assert!(msg.contains("clusterer exploded"), "unexpected: {msg}");
}

// ---------------------------------------------------------------------------
// refresh() error propagation
// ---------------------------------------------------------------------------

#[test]
fn refresh_propagates_renderer_error() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("refresh_fail_render");
    std::fs::create_dir_all(&out).unwrap();

    // Pre-write workstreams so refresh passes the "no workstreams" check
    shiplog_workstreams::write_workstreams(
        &WorkstreamManager::curated_path(&out),
        &empty_workstreams(),
    )
    .unwrap();

    let renderer: &'static dyn Renderer = Box::leak(Box::new(FailingRenderer));
    let clusterer: &'static dyn WorkstreamClusterer = Box::leak(Box::new(RepoClusterer));
    let redactor: &'static dyn Redactor = Box::leak(Box::new(NoopRedactor));
    let engine = Engine::new(renderer, clusterer, redactor);

    let result = engine.refresh(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        &BundleProfile::Internal,
    );

    let err = match result {
        Err(e) => e,
        Ok(_) => panic!("expected renderer error"),
    };
    let msg = format!("{err:#}");
    assert!(msg.contains("renderer exploded"), "unexpected: {msg}");
}

#[test]
fn refresh_propagates_redactor_error() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("refresh_fail_redact");
    std::fs::create_dir_all(&out).unwrap();

    shiplog_workstreams::write_workstreams(
        &WorkstreamManager::curated_path(&out),
        &empty_workstreams(),
    )
    .unwrap();

    let renderer: &'static dyn Renderer = Box::leak(Box::new(MarkdownRenderer::default()));
    let clusterer: &'static dyn WorkstreamClusterer = Box::leak(Box::new(RepoClusterer));
    let redactor: &'static dyn Redactor = Box::leak(Box::new(FailingRedactor));
    let engine = Engine::new(renderer, clusterer, redactor);

    let result = engine.refresh(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        &BundleProfile::Internal,
    );

    let err = match result {
        Err(e) => e,
        Ok(_) => panic!("expected redactor error"),
    };
    let msg = format!("{err:#}");
    assert!(msg.contains("redactor exploded"), "unexpected: {msg}");
}

#[test]
fn refresh_errors_on_corrupted_workstreams_yaml() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("refresh_corrupted_yaml");
    std::fs::create_dir_all(&out).unwrap();

    // Write invalid YAML to the curated path
    std::fs::write(WorkstreamManager::curated_path(&out), "{{{{not yaml").unwrap();

    let engine = real_engine();
    let result = engine.refresh(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        &BundleProfile::Internal,
    );

    assert!(result.is_err(), "corrupted YAML should cause an error");
    let err = match result {
        Err(e) => e,
        Ok(_) => unreachable!(),
    };
    assert!(
        err.to_string().contains("parse curated workstreams yaml"),
        "unexpected: {err}"
    );
}

#[test]
fn refresh_errors_on_corrupted_suggested_yaml_when_no_curated() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("refresh_corrupted_suggested");
    std::fs::create_dir_all(&out).unwrap();

    // Write invalid YAML to the suggested path (no curated exists)
    std::fs::write(
        WorkstreamManager::suggested_path(&out),
        "not: [valid: yaml: {",
    )
    .unwrap();

    let engine = real_engine();
    let result = engine.refresh(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        &BundleProfile::Internal,
    );

    assert!(result.is_err(), "corrupted suggested YAML should error");
    let err = match result {
        Err(e) => e,
        Ok(_) => unreachable!(),
    };
    assert!(
        err.to_string().contains("parse suggested workstreams yaml"),
        "unexpected: {err}"
    );
}

// ---------------------------------------------------------------------------
// Empty event list edge cases across all commands
// ---------------------------------------------------------------------------

#[test]
fn run_empty_events_produces_zero_line_ledger() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("empty_ledger");

    let engine = real_engine();
    let (outputs, _) = engine
        .run(
            make_ingest(vec![]),
            "nobody",
            "2025-01-01..2025-02-01",
            &out,
            false,
            &BundleProfile::Internal,
        )
        .unwrap();

    let ledger = std::fs::read_to_string(&outputs.ledger_events_jsonl).unwrap();
    assert!(
        ledger.trim().is_empty(),
        "empty events should produce empty ledger"
    );
}

#[test]
fn import_empty_events_with_provided_workstreams_succeeds() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("import_empty_ws");

    let ws = WorkstreamsFile {
        version: 1,
        generated_at: Utc::now(),
        workstreams: vec![Workstream {
            id: shiplog_ids::WorkstreamId::from_parts(["ws", "orphan"]),
            title: "Orphan Workstream".into(),
            summary: None,
            tags: vec![],
            stats: WorkstreamStats::zero(),
            events: vec![],
            receipts: vec![],
        }],
    };

    let engine = real_engine();
    let (outputs, _) = engine
        .import(
            make_ingest(vec![]),
            "tester",
            "2025-01-01..2025-02-01",
            &out,
            false,
            Some(ws),
            &BundleProfile::Internal,
        )
        .unwrap();

    assert!(outputs.packet_md.exists());
    let content = std::fs::read_to_string(&outputs.packet_md).unwrap();
    assert!(
        content.contains("Orphan Workstream"),
        "workstream title should appear in packet"
    );
}

// ---------------------------------------------------------------------------
// merge() error paths
// ---------------------------------------------------------------------------

#[test]
fn merge_single_input_succeeds() {
    let engine = real_engine();
    let result = engine.merge(
        vec![make_ingest(one_event())],
        shiplog_engine::ConflictResolution::PreferFirst,
    );
    assert!(result.is_ok());
    assert_eq!(result.unwrap().events.len(), 1);
}

// ---------------------------------------------------------------------------
// Error context message quality
// ---------------------------------------------------------------------------

#[test]
fn run_to_invalid_dir_includes_path_in_error() {
    let engine = real_engine();
    let temp = tempfile::tempdir().unwrap();
    let invalid = temp
        .path()
        .join("does")
        .join("not")
        .join("exist")
        .join("nested")
        .join("deep");
    drop(temp);
    let result = engine.run(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &invalid,
        false,
        &BundleProfile::Internal,
    );
    // Should fail creating the directory, and the error context should mention the path
    // The tempdir was dropped so the parent is gone
    if let Err(e) = result {
        let msg = format!("{e:#}");
        assert!(
            msg.contains("does") || msg.contains("not") || msg.contains("exist"),
            "error should include the path: {msg}"
        );
    }
}

#[test]
fn import_renderer_error_chain_preserves_cause() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("chain_test");

    let renderer: &'static dyn Renderer = Box::leak(Box::new(FailingRenderer));
    let clusterer: &'static dyn WorkstreamClusterer = Box::leak(Box::new(RepoClusterer));
    let redactor: &'static dyn Redactor = Box::leak(Box::new(NoopRedactor));
    let engine = Engine::new(renderer, clusterer, redactor);

    let err = match engine.import(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        None,
        &BundleProfile::Internal,
    ) {
        Err(e) => e,
        Ok(_) => panic!("expected renderer error for chain test"),
    };

    // The original cause "renderer exploded" should be reachable via the chain
    let chain: Vec<String> = err.chain().map(|e| e.to_string()).collect();
    assert!(
        chain.iter().any(|msg| msg.contains("renderer exploded")),
        "error chain should contain original cause: {chain:?}"
    );
}

#[test]
fn refresh_no_workstreams_error_message_is_actionable() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("no_ws");
    std::fs::create_dir_all(&out).unwrap();

    let engine = real_engine();
    let err = match engine.refresh(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        &BundleProfile::Internal,
    ) {
        Err(e) => e,
        Ok(_) => panic!("expected no-workstreams error"),
    };

    let msg = err.to_string();
    assert!(
        msg.contains("collect"),
        "error should suggest running collect: {msg}"
    );
}

#[test]
fn run_with_failing_renderer_still_writes_ledger_before_error() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("partial_write");

    let renderer: &'static dyn Renderer = Box::leak(Box::new(FailingRenderer));
    let clusterer: &'static dyn WorkstreamClusterer = Box::leak(Box::new(RepoClusterer));
    let redactor: &'static dyn Redactor = Box::leak(Box::new(NoopRedactor));
    let engine = Engine::new(renderer, clusterer, redactor);

    let _ = engine.run(
        make_ingest(one_event()),
        "tester",
        "2025-01-01..2025-02-01",
        &out,
        false,
        &BundleProfile::Internal,
    );

    // Ledger is written before rendering, so it should exist even on render failure
    let ledger = out.join("ledger.events.jsonl");
    assert!(
        ledger.exists(),
        "ledger should be written before render step"
    );
}

#[test]
fn merge_empty_inputs_returns_error() {
    let engine = real_engine();
    let result = engine.merge(vec![], shiplog_engine::ConflictResolution::PreferFirst);
    // Merging zero sources is an error condition
    assert!(result.is_err(), "merging zero inputs should error");
    let msg = result.unwrap_err().to_string();
    assert!(!msg.is_empty(), "merge error should have a message");
}

#[test]
fn run_empty_user_does_not_panic() {
    let dir = tempfile::tempdir().unwrap();
    let out = dir.path().join("empty_user");

    let engine = real_engine();
    let result = engine.run(
        make_ingest(one_event()),
        "",
        "2025-01-01..2025-02-01",
        &out,
        false,
        &BundleProfile::Internal,
    );
    // Should succeed (empty user is valid, just unusual)
    assert!(result.is_ok());
}
