//! Projection layer — converts loose CRDT views into typed `PipelineItem` enums.
//!
//! Story 944: the view layer (`PipelineItemView`) now carries a typed
//! [`Stage`] directly, so this projection is mechanical — no more stage-string
//! parsing or payload synthesis happens here.  [`TryFrom`] is kept for
//! backwards-compatible callers (apply.rs threads `ProjectionError` through
//! `ApplyError`), but the impl is infallible in practice.

use std::fmt;

use crate::crdt_state::PipelineItemView;

use super::{PipelineItem, StoryId};

/// Errors from projecting loose CRDT data into typed enums.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectionError {
    /// The stage string from the CRDT doesn't map to any known Stage variant.
    UnknownStage(String),
    /// A required field is missing from the CRDT data.
    MissingField(&'static str),
    /// A field has an invalid value.
    InvalidField { field: &'static str, detail: String },
}

impl fmt::Display for ProjectionError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnknownStage(s) => write!(f, "unknown stage: {s:?}"),
            Self::MissingField(field) => write!(f, "missing required field: {field}"),
            Self::InvalidField { field, detail } => {
                write!(f, "invalid field {field}: {detail}")
            }
        }
    }
}

impl std::error::Error for ProjectionError {}

// ── Projection: WorkItem → PipelineItem ─────────────────────────────────────

impl TryFrom<&PipelineItemView> for PipelineItem {
    type Error = ProjectionError;

    fn try_from(view: &PipelineItemView) -> Result<Self, ProjectionError> {
        let story_id = StoryId(view.story_id().to_string());
        let name = view.name().to_string();

        let depends_on: Vec<StoryId> = view
            .depends_on()
            .iter()
            .map(|d| StoryId(d.to_string()))
            .collect();

        Ok(PipelineItem {
            story_id,
            name,
            stage: view.stage().clone(),
            depends_on,
        })
    }
}

// ── Bridge to existing CRDT reads ───────────────────────────────────────────

/// Read all pipeline items from the CRDT and project them into typed enums.
///
/// Items that fail projection (e.g. unknown stage strings from a future
/// version) are logged and skipped — they don't poison the entire read.
pub fn read_all_typed() -> Vec<PipelineItem> {
    let Some(views) = crate::crdt_state::read_all_items() else {
        return Vec::new();
    };
    views
        .iter()
        .filter_map(|v| match PipelineItem::try_from(v) {
            Ok(item) => Some(item),
            Err(e) => {
                crate::slog!(
                    "[pipeline_state] projection error for '{}': {e}",
                    v.story_id()
                );
                None
            }
        })
        .collect()
}

/// Read a single pipeline item by story_id and project it into the typed enum.
pub fn read_typed(story_id: &str) -> Result<Option<PipelineItem>, ProjectionError> {
    let Some(view) = crate::crdt_state::read_item(story_id) else {
        return Ok(None);
    };
    PipelineItem::try_from(&view).map(Some)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pipeline_state::{ArchiveReason, BranchName, GitSha, PlanState, Stage};
    use chrono::Utc;
    use std::num::NonZeroU32;

    fn nz(n: u32) -> NonZeroU32 {
        NonZeroU32::new(n).unwrap()
    }
    fn fb(name: &str) -> BranchName {
        BranchName(name.to_string())
    }

    fn make_view(story_id: &str, stage: Stage, name: Option<&str>) -> PipelineItemView {
        PipelineItemView::for_test(
            story_id,
            stage,
            name.unwrap_or("(unnamed)"),
            None,
            vec![],
            None,
            None,
            None,
        )
    }

    #[test]
    fn project_upcoming_item() {
        let view = make_view("42_story_test", Stage::Upcoming, Some("Test Story"));
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(item.stage, Stage::Upcoming));
    }

    #[test]
    fn project_backlog_item() {
        let view = PipelineItemView::for_test(
            "42_story_test",
            Stage::Backlog,
            "Test Story",
            None,
            vec![10, 20],
            None,
            None,
            None,
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
        assert_eq!(item.name, "Test Story");
        assert!(matches!(item.stage, Stage::Backlog));
        assert_eq!(item.depends_on.len(), 2);
    }

    #[test]
    fn project_current_item() {
        let view = PipelineItemView::for_test(
            "42_story_test",
            Stage::Coding {
                claim: None,
                plan: PlanState::Missing,
                retries: 2,
            },
            "Test",
            Some(crate::config::AgentName::Coder1),
            vec![],
            None,
            None,
            None,
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(item.stage, Stage::Coding { .. }));
        assert_eq!(item.retry_count(), 2);
    }

    #[test]
    fn project_merge_item() {
        let view = make_view(
            "42_story_test",
            Stage::Merge {
                feature_branch: fb("feature/story-42_story_test"),
                commits_ahead: nz(1),
                claim: None,
                retries: 0,
                server_start_time: None,
            },
            Some("Test"),
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(item.stage, Stage::Merge { .. }));
        if let Stage::Merge {
            feature_branch,
            commits_ahead,
            ..
        } = &item.stage
        {
            assert_eq!(feature_branch.0, "feature/story-42_story_test");
            assert_eq!(commits_ahead.get(), 1);
        }
    }

    #[test]
    fn project_blocked_item() {
        let view = make_view(
            "42_story_test",
            Stage::Blocked {
                reason: String::new(),
            },
            Some("Test"),
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(item.stage, Stage::Blocked { .. }));
    }

    #[test]
    fn project_archived_blocked_item() {
        let view = PipelineItemView::for_test(
            "42_story_test",
            Stage::Archived {
                archived_at: Utc::now(),
                reason: ArchiveReason::Blocked {
                    reason: "migrated from legacy blocked field".to_string(),
                },
            },
            "Test",
            None,
            vec![],
            None,
            None,
            None,
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(
            item.stage,
            Stage::Archived {
                reason: ArchiveReason::Blocked { .. },
                ..
            }
        ));
    }

    #[test]
    fn project_archived_completed_item() {
        let view = PipelineItemView::for_test(
            "42_story_test",
            Stage::Archived {
                archived_at: Utc::now(),
                reason: ArchiveReason::Completed,
            },
            "Test",
            None,
            vec![],
            None,
            None,
            None,
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(
            item.stage,
            Stage::Archived {
                reason: ArchiveReason::Completed,
                ..
            }
        ));
    }

    #[test]
    fn project_frozen_item() {
        let view = make_view(
            "42_story_test",
            Stage::Frozen {
                resume_to: Box::new(Stage::Coding {
                    claim: None,
                    plan: PlanState::Missing,
                    retries: 0,
                }),
            },
            Some("Frozen Story"),
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(item.stage, Stage::Frozen { .. }));
    }

    // ── Event bus tests ─────────────────────────────────────────────────

    #[test]
    fn projection_error_display() {
        let err = ProjectionError::UnknownStage("9_invalid".into());
        assert_eq!(err.to_string(), "unknown stage: \"9_invalid\"");

        let err = ProjectionError::MissingField("story_id");
        assert_eq!(err.to_string(), "missing required field: story_id");
    }

    // Compile-time check that GitSha is reachable from the test imports
    // (mirrors the previous reverse_projection test that used it).
    #[test]
    fn git_sha_constructs() {
        let _ = GitSha("abc".to_string());
    }

    // ── PlanState projection ────────────────────────────────────────────

    #[test]
    fn project_coding_plan_missing() {
        let view = make_view(
            "42_story_test",
            Stage::Coding {
                claim: None,
                plan: PlanState::Missing,
                retries: 0,
            },
            Some("Test"),
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(
            item.stage,
            Stage::Coding {
                plan: PlanState::Missing,
                ..
            }
        ));
    }

    #[test]
    fn project_coding_plan_drafted() {
        let view = make_view(
            "42_story_test",
            Stage::Coding {
                claim: None,
                plan: PlanState::Drafted,
                retries: 0,
            },
            Some("Test"),
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(
            item.stage,
            Stage::Coding {
                plan: PlanState::Drafted,
                ..
            }
        ));
    }

    #[test]
    fn project_coding_plan_confirmed() {
        let view = make_view(
            "42_story_test",
            Stage::Coding {
                claim: None,
                plan: PlanState::Confirmed,
                retries: 0,
            },
            Some("Test"),
        );
        let item = PipelineItem::try_from(&view).unwrap();
        assert!(matches!(
            item.stage,
            Stage::Coding {
                plan: PlanState::Confirmed,
                ..
            }
        ));
    }
}
