use std::fmt;
use std::str::FromStr;

use serde::Deserialize;

use super::plane_entity_identifiers::{
    CycleId, LabelId, MemberId, ModuleId, ProjectId, SequenceNumber, StateId, WorkItemId,
};

/// Relation type between two work items in Plane's issue relation system.
/// Canonical types (blocked_by, duplicate, relates_to, start_before, finish_before,
/// implemented_by) are stored directly. Inverse types (blocking, start_after,
/// finish_after, implements) cause the API to swap issue/related_issue before storage.
/// Used as both map keys in the list response and as values in create/remove requests.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RelationKind {
    /// This issue is blocked by the related issue (canonical)
    BlockedBy,
    /// This issue is blocking the related issue (inverse of blocked_by)
    Blocking,
    /// This issue is a duplicate of the related issue (symmetric)
    Duplicate,
    /// This issue relates to the related issue (symmetric)
    RelatesTo,
    /// This issue must start before the related issue (canonical)
    StartBefore,
    /// This issue must start after the related issue (inverse of start_before)
    StartAfter,
    /// This issue must finish before the related issue (canonical)
    FinishBefore,
    /// This issue must finish after the related issue (inverse of finish_before)
    FinishAfter,
    /// This issue is implemented by the related issue (canonical)
    ImplementedBy,
    /// This issue implements the related issue (inverse of implemented_by)
    Implements,
}

/// Display formatting for human-readable relation descriptions in terminal output
impl fmt::Display for RelationKind {
    fn fmt(&self, relation_output: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::BlockedBy => relation_output.write_str("blocked by"),
            Self::Blocking => relation_output.write_str("blocking"),
            Self::Duplicate => relation_output.write_str("duplicate of"),
            Self::RelatesTo => relation_output.write_str("relates to"),
            Self::StartBefore => relation_output.write_str("starts before"),
            Self::StartAfter => relation_output.write_str("starts after"),
            Self::FinishBefore => relation_output.write_str("finishes before"),
            Self::FinishAfter => relation_output.write_str("finishes after"),
            Self::ImplementedBy => relation_output.write_str("implemented by"),
            Self::Implements => relation_output.write_str("implements"),
        }
    }
}

/// Parsing from CLI input strings (kebab-case) into typed relation variants.
/// Accepts both kebab-case ("blocked-by") and snake_case ("blocked_by") for flexibility.
impl FromStr for RelationKind {
    type Err = anyhow::Error;

    fn from_str(text: &str) -> anyhow::Result<Self> {
        match text {
            "blocked-by" | "blocked_by" => Ok(Self::BlockedBy),
            "blocking" => Ok(Self::Blocking),
            "duplicate" => Ok(Self::Duplicate),
            "relates-to" | "relates_to" => Ok(Self::RelatesTo),
            "start-before" | "start_before" => Ok(Self::StartBefore),
            "start-after" | "start_after" => Ok(Self::StartAfter),
            "finish-before" | "finish_before" => Ok(Self::FinishBefore),
            "finish-after" | "finish_after" => Ok(Self::FinishAfter),
            "implemented-by" | "implemented_by" => Ok(Self::ImplementedBy),
            "implements" => Ok(Self::Implements),
            unknown => anyhow::bail!(
                "unknown relation type '{}' — valid: blocked-by, blocking, duplicate, \
                 relates-to, start-before, start-after, finish-before, finish-after, \
                 implemented-by, implements",
                unknown
            ),
        }
    }
}

/// API wire format conversion for relation types
impl RelationKind {
    /// Returns the snake_case string the Plane API expects in JSON request bodies
    pub fn api_wire_name(self) -> &'static str {
        match self {
            Self::BlockedBy => "blocked_by",
            Self::Blocking => "blocking",
            Self::Duplicate => "duplicate",
            Self::RelatesTo => "relates_to",
            Self::StartBefore => "start_before",
            Self::StartAfter => "start_after",
            Self::FinishBefore => "finish_before",
            Self::FinishAfter => "finish_after",
            Self::ImplementedBy => "implemented_by",
            Self::Implements => "implements",
        }
    }
}

/// Summary of a related work item returned by the Plane issue-relation list endpoint.
/// Contains the minimum metadata needed to display a human-readable relation listing
/// with identifiers like "NRO-123 — Fix authentication".
#[derive(Debug, Deserialize)]
pub struct RelatedIssueSummary {
    id: WorkItemId,
    name: String,
    sequence_id: SequenceNumber,
    project_id: ProjectId,
}

/// Read-only access to related issue summary fields for display rendering
impl RelatedIssueSummary {
    /// Returns the work item's UUID for cross-referencing with other API responses
    pub fn id(&self) -> &WorkItemId {
        &self.id
    }

    /// Returns the work item title for display in relation listings
    pub fn title(&self) -> &str {
        &self.name
    }

    /// Returns the numeric sequence (e.g. 123 in NRO-123) for constructing
    /// human-readable identifiers by combining with the project code
    pub fn sequence_number(&self) -> SequenceNumber {
        self.sequence_id
    }

    /// Returns the project UUID this related item belongs to, used to resolve
    /// the project identifier code for display formatting
    pub fn project_id(&self) -> &ProjectId {
        &self.project_id
    }
}

/// Workflow state group classification used by Plane to categorize states
/// across projects regardless of their custom state names.
/// Controls board column grouping and burndown calculations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StateGroup {
    /// Triaged but not yet committed to — excluded from sprint metrics
    Backlog,
    /// Committed but work has not begun — counts toward sprint scope
    Unstarted,
    /// Actively being worked on — drives "in progress" dashboard metrics
    Started,
    /// Work finished and verified — drives completion percentage
    Completed,
    /// Intentionally abandoned — excluded from completion metrics
    Cancelled,
}

/// Priority level assigned to a work item, controlling sort order in lists
/// and enabling priority-based filtering. Mapped from Plane's string representation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WorkItemPriority {
    /// Blocks other work and must be addressed immediately
    Urgent,
    /// Important work that should be prioritized in the current cycle
    High,
    /// Standard priority for regular planned work
    Medium,
    /// Can be deferred without impacting deliverables
    Low,
    /// No priority assigned — typically newly created items or null in API
    None,
}

/// Whether a work item is still a draft or has been published for team visibility.
/// Deserialized from the `is_draft` boolean field in Plane API responses.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DraftStatus {
    /// Work item is a draft — not visible to the broader team
    Draft,
    /// Work item is published and visible in project boards and queries
    Published,
}

/// Whether a work item has description content in a given format.
/// Absent means no description was provided; Present contains the text
/// (HTML or plain text depending on which API field sourced it).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkItemDescription {
    /// No description was provided — the work item body is empty
    Absent,
    /// Description content exists with Plane's formatting preserved
    Present(String),
}

/// Accessor for extracting the contained text when present
impl WorkItemDescription {
    /// Returns the description text if present, or None for items without descriptions
    pub fn as_description_text(&self) -> Option<&str> {
        match self {
            Self::Absent => Option::None,
            Self::Present(description_text) => Option::Some(description_text.as_str()),
        }
    }
}

/// Whether a date-based scheduling constraint has been set on a work item.
/// Plane work items optionally have start and target dates as ISO 8601 strings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScheduledDate {
    /// No date constraint set — the work item is unscheduled
    Unscheduled,
    /// A date has been assigned as an ISO 8601 date string (e.g. "2026-03-15")
    Scheduled(String),
}

/// Accessor for extracting the date string when scheduled
impl ScheduledDate {
    /// Returns the ISO 8601 date string if scheduled, or None for unscheduled items
    pub fn as_date_string(&self) -> Option<&str> {
        match self {
            Self::Unscheduled => Option::None,
            Self::Scheduled(date_text) => Option::Some(date_text.as_str()),
        }
    }
}

/// Whether a work item has been marked as completed, with the completion timestamp.
/// Drives the "done" status in dashboards and the `plane done` command flow.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CompletionTimestamp {
    /// Work item has not been completed yet
    Incomplete,
    /// Work item was completed at the given ISO 8601 timestamp
    CompletedAt(String),
}

/// Accessor for extracting the completion timestamp when present
impl CompletionTimestamp {
    /// Returns the ISO 8601 completion timestamp if completed, or None if still in progress
    pub fn as_completion_string(&self) -> Option<&str> {
        match self {
            Self::Incomplete => Option::None,
            Self::CompletedAt(timestamp_text) => Option::Some(timestamp_text.as_str()),
        }
    }
}

/// Whether a work item is a top-level item or a child of another work item.
/// Plane supports one level of parent-child hierarchy for sub-tasks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParentWorkItem {
    /// This is a top-level work item with no parent
    TopLevel,
    /// This work item is a sub-task of the referenced parent
    ChildOf(WorkItemId),
}

/// Accessor for extracting the parent reference when present
impl ParentWorkItem {
    /// Returns the parent work item UUID if this is a child, or None for top-level items
    pub fn parent_id(&self) -> Option<&WorkItemId> {
        match self {
            Self::TopLevel => Option::None,
            Self::ChildOf(parent_work_item_id) => Option::Some(parent_work_item_id),
        }
    }
}

/// Human-readable title of a Plane work item, displayed in boards, lists, and detail views.
/// Validated to be non-empty since every visible work item must have a title.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(transparent)]
pub struct WorkItemTitle(String);

/// String access for display and search operations on work item titles
impl WorkItemTitle {
    /// Returns the title text for rendering in tables, detail views, and search results
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl FromStr for WorkItemTitle {
    type Err = anyhow::Error;

    fn from_str(raw_title: &str) -> anyhow::Result<Self> {
        if raw_title.is_empty() {
            anyhow::bail!("work item title cannot be empty");
        }
        Ok(Self(raw_title.to_owned()))
    }
}

impl fmt::Display for WorkItemTitle {
    fn fmt(&self, title_display: &mut fmt::Formatter<'_>) -> fmt::Result {
        title_display.write_str(&self.0)
    }
}

impl<'de> Deserialize<'de> for WorkItemTitle {
    fn deserialize<D: serde::Deserializer<'de>>(serde_input: D) -> Result<Self, D::Error> {
        let raw_text = <String as Deserialize>::deserialize(serde_input)?;
        if raw_text.is_empty() {
            return Err(serde::de::Error::custom("work item title cannot be empty"));
        }
        Ok(Self(raw_text))
    }
}

/// Work item as returned by the Plane API list and retrieve endpoints.
/// All nullable JSON fields are represented as domain-specific enums that name
/// the absent case rather than using raw Option types.
#[derive(Debug, Clone)]
pub struct PlaneWorkItem {
    id: WorkItemId,
    title: WorkItemTitle,
    description_html: WorkItemDescription,
    description_stripped: WorkItemDescription,
    priority: WorkItemPriority,
    workflow_state: StateId,
    project: ProjectId,
    assignees: Vec<MemberId>,
    labels: Vec<LabelId>,
    sequence_id: SequenceNumber,
    start_date: ScheduledDate,
    target_date: ScheduledDate,
    completed_at: CompletionTimestamp,
    created_at: String,
    updated_at: String,
    parent_reference: ParentWorkItem,
    draft_status: DraftStatus,
}

/// Serde deserialization: parses JSON into a generic object, then extracts typed fields
/// converting nullable values and booleans to domain enums without intermediate raw structs
impl<'de> Deserialize<'de> for PlaneWorkItem {
    fn deserialize<D: serde::Deserializer<'de>>(serde_input: D) -> Result<Self, D::Error> {
        let raw_json = serde_json::Value::deserialize(serde_input)?;
        let mut json_fields = match raw_json {
            serde_json::Value::Object(field_entries) => field_entries,
            _ => {
                return Err(serde::de::Error::custom(
                    "expected JSON object for work item",
                ));
            }
        };

        let work_item_id: WorkItemId = serde_json::from_value(
            json_fields
                .remove("id")
                .ok_or_else(|| serde::de::Error::missing_field("id"))?,
        )
        .map_err(serde::de::Error::custom)?;

        let work_item_title: WorkItemTitle = serde_json::from_value(
            json_fields
                .remove("name")
                .ok_or_else(|| serde::de::Error::missing_field("name"))?,
        )
        .map_err(serde::de::Error::custom)?;

        let workflow_state_id: StateId = serde_json::from_value(
            json_fields
                .remove("state")
                .ok_or_else(|| serde::de::Error::missing_field("state"))?,
        )
        .map_err(serde::de::Error::custom)?;

        let project_id: ProjectId = serde_json::from_value(
            json_fields
                .remove("project")
                .ok_or_else(|| serde::de::Error::missing_field("project"))?,
        )
        .map_err(serde::de::Error::custom)?;

        let sequence_id: SequenceNumber = serde_json::from_value(
            json_fields
                .remove("sequence_id")
                .ok_or_else(|| serde::de::Error::missing_field("sequence_id"))?,
        )
        .map_err(serde::de::Error::custom)?;

        let created_at_timestamp: String = serde_json::from_value(
            json_fields
                .remove("created_at")
                .ok_or_else(|| serde::de::Error::missing_field("created_at"))?,
        )
        .map_err(serde::de::Error::custom)?;

        let updated_at_timestamp: String = serde_json::from_value(
            json_fields
                .remove("updated_at")
                .ok_or_else(|| serde::de::Error::missing_field("updated_at"))?,
        )
        .map_err(serde::de::Error::custom)?;

        let description_html = match json_fields.remove("description_html") {
            Option::Some(serde_json::Value::String(html_text)) => {
                WorkItemDescription::Present(html_text)
            }
            _ => WorkItemDescription::Absent,
        };

        let description_stripped = match json_fields.remove("description_stripped") {
            Option::Some(serde_json::Value::String(stripped_text)) => {
                WorkItemDescription::Present(stripped_text)
            }
            _ => WorkItemDescription::Absent,
        };

        let work_item_priority = match json_fields.remove("priority") {
            Option::Some(serde_json::Value::Null) | Option::None => WorkItemPriority::None,
            Option::Some(priority_json) => {
                serde_json::from_value(priority_json).map_err(serde::de::Error::custom)?
            }
        };

        let assigned_members: Vec<MemberId> = match json_fields.remove("assignees") {
            Option::Some(assignees_json) => {
                serde_json::from_value(assignees_json).map_err(serde::de::Error::custom)?
            }
            Option::None => Vec::new(),
        };

        let attached_labels: Vec<LabelId> = match json_fields.remove("labels") {
            Option::Some(labels_json) => {
                serde_json::from_value(labels_json).map_err(serde::de::Error::custom)?
            }
            Option::None => Vec::new(),
        };

        let scheduled_start = match json_fields.remove("start_date") {
            Option::Some(serde_json::Value::String(date_text)) => {
                ScheduledDate::Scheduled(date_text)
            }
            _ => ScheduledDate::Unscheduled,
        };

        let scheduled_target = match json_fields.remove("target_date") {
            Option::Some(serde_json::Value::String(date_text)) => {
                ScheduledDate::Scheduled(date_text)
            }
            _ => ScheduledDate::Unscheduled,
        };

        let completion_timestamp = match json_fields.remove("completed_at") {
            Option::Some(serde_json::Value::String(timestamp_text)) => {
                CompletionTimestamp::CompletedAt(timestamp_text)
            }
            _ => CompletionTimestamp::Incomplete,
        };

        let parent_work_item = match json_fields.remove("parent") {
            Option::Some(serde_json::Value::Null) | Option::None => ParentWorkItem::TopLevel,
            Option::Some(parent_json) => {
                let parent_work_item_id: WorkItemId =
                    serde_json::from_value(parent_json).map_err(serde::de::Error::custom)?;
                ParentWorkItem::ChildOf(parent_work_item_id)
            }
        };

        let draft_status = match json_fields.remove("is_draft") {
            Option::Some(serde_json::Value::Bool(true)) => DraftStatus::Draft,
            _ => DraftStatus::Published,
        };

        Ok(PlaneWorkItem {
            id: work_item_id,
            title: work_item_title,
            description_html,
            description_stripped,
            priority: work_item_priority,
            workflow_state: workflow_state_id,
            project: project_id,
            assignees: assigned_members,
            labels: attached_labels,
            sequence_id,
            start_date: scheduled_start,
            target_date: scheduled_target,
            completed_at: completion_timestamp,
            created_at: created_at_timestamp,
            updated_at: updated_at_timestamp,
            parent_reference: parent_work_item,
            draft_status,
        })
    }
}

/// Read-only access to deserialized work item fields for display and business logic
impl PlaneWorkItem {
    /// Returns the work item's unique API identifier for use in update and detail endpoints
    pub fn id(&self) -> &WorkItemId {
        &self.id
    }

    /// Consumes the work item to extract the owned WorkItemId, transferring ownership
    /// without cloning. Used when only the UUID is needed and the work item data
    /// can be discarded (e.g. after resolving a target for a relation operation).
    pub fn into_id(self) -> WorkItemId {
        self.id
    }

    /// Returns the work item title as displayed in Plane boards and lists
    pub fn title(&self) -> &WorkItemTitle {
        &self.title
    }

    /// Returns the HTML-formatted description, which may be absent for items without body content.
    /// When present, contains acceptance criteria as `<ul data-type="taskList">` elements.
    pub fn description_html(&self) -> &WorkItemDescription {
        &self.description_html
    }

    /// Returns the plain-text description with HTML stripped, or Absent if empty.
    /// Acceptance criteria appear as `- [x]` / `- [ ]` markdown-style checkboxes.
    pub fn description_stripped(&self) -> &WorkItemDescription {
        &self.description_stripped
    }

    /// Returns the priority level, defaulting to None for items without explicit priority
    pub fn priority(&self) -> WorkItemPriority {
        self.priority
    }

    /// Returns the state UUID for resolving to a named state via the state lookup
    pub fn workflow_state_id(&self) -> &StateId {
        &self.workflow_state
    }

    /// Returns the project UUID this work item belongs to
    pub fn project_id(&self) -> &ProjectId {
        &self.project
    }

    /// Returns the list of member UUIDs assigned to this work item
    pub fn assignee_ids(&self) -> &[MemberId] {
        &self.assignees
    }

    /// Returns the list of label UUIDs attached to this work item for filtering
    /// and display against the project's label definitions
    pub fn label_ids(&self) -> &[LabelId] {
        &self.labels
    }

    /// Returns the sequence number for constructing human-readable identifiers like "NRO-123"
    pub fn sequence_id(&self) -> SequenceNumber {
        self.sequence_id
    }

    /// Returns the target completion date scheduling constraint
    pub fn target_date(&self) -> &ScheduledDate {
        &self.target_date
    }

    /// Returns the ISO 8601 timestamp when the work item was created
    pub fn created_at(&self) -> &str {
        &self.created_at
    }

    /// Returns whether the work item is a draft or published
    pub fn draft_status(&self) -> DraftStatus {
        self.draft_status
    }
}

/// Workflow state definition as returned by the Plane API list-states endpoint.
/// Each project has its own set of states grouped into the five canonical `StateGroup` categories.
#[derive(Debug, Clone, Deserialize)]
pub struct PlaneState {
    id: StateId,
    #[serde(rename = "name")]
    display_name: String,
    group: StateGroup,
    color: String,
}

/// Read-only access to deserialized state fields for display and state resolution
impl PlaneState {
    /// Returns the state's unique API identifier for matching against work item state fields
    pub fn id(&self) -> &StateId {
        &self.id
    }

    /// Returns the human-readable state name (e.g. "In Progress", "Done", "Backlog")
    pub fn display_name(&self) -> &str {
        &self.display_name
    }

    /// Returns which canonical workflow group this state belongs to (backlog, started, etc.)
    /// for driving metrics, filtering, and determining whether a work item is "done"
    pub fn state_group(&self) -> StateGroup {
        self.group
    }

    /// Returns the hex color code (e.g. "#16A34A") for terminal-colored state display
    pub fn color_hex(&self) -> &str {
        &self.color
    }
}

/// Project definition as returned by the Plane API list-projects endpoint.
/// Contains the fields needed for project resolution (matching config slugs to API UUIDs)
/// and display in project listings.
#[derive(Debug, Clone, Deserialize)]
pub struct PlaneProject {
    id: ProjectId,
    #[serde(rename = "name")]
    display_name: String,
    identifier: String,
    #[serde(default, deserialize_with = "deserialize_nullable_text")]
    description: String,
}

/// Read-only access to deserialized project fields for resolution and display
impl PlaneProject {
    /// Returns the project's unique API identifier for use in scoped API endpoints
    pub fn id(&self) -> &ProjectId {
        &self.id
    }

    /// Returns the project display name as shown in Plane's UI
    pub fn display_name(&self) -> &str {
        &self.display_name
    }

    /// Returns the short uppercase code (e.g. "NRO") used as the prefix in work item
    /// identifiers like "NRO-123". Matched case-insensitively against config project slugs.
    pub fn identifier(&self) -> &str {
        &self.identifier
    }
}

/// Label definition as returned by the Plane API list-labels endpoint.
/// Used for displaying label names on work items instead of raw UUIDs.
#[derive(Debug, Clone, Deserialize)]
pub struct PlaneLabel {
    id: LabelId,
    #[serde(rename = "name")]
    display_name: String,
    #[serde(default, deserialize_with = "deserialize_nullable_text")]
    color: String,
}

/// Read-only access to deserialized label fields for display
impl PlaneLabel {
    /// Returns the label's unique API identifier for matching against work item label lists
    pub fn id(&self) -> &LabelId {
        &self.id
    }

    /// Returns the label display name for showing on work items in tables and detail views
    pub fn display_name(&self) -> &str {
        &self.display_name
    }

    /// Returns the hex color code for this label (e.g. "#ff0000")
    pub fn color_hex(&self) -> &str {
        &self.color
    }
}

/// Whether a cycle has an assigned owner in the Plane workspace
#[derive(Debug, Clone)]
pub enum CycleOwnership {
    /// The cycle is owned by a specific workspace member
    Owned(MemberId),
    /// The cycle has no designated owner
    Unowned,
}

impl Default for CycleOwnership {
    fn default() -> Self {
        CycleOwnership::Unowned
    }
}

/// Time-boxed iteration within a project, grouping work items into sprints or releases.
/// Returned by the Plane API list-cycles endpoint with progress metadata.
#[derive(Debug, Clone, Deserialize)]
pub struct PlaneCycle {
    id: CycleId,
    #[serde(rename = "name")]
    display_name: String,
    #[serde(default, deserialize_with = "deserialize_nullable_text")]
    start_date: String,
    #[serde(default, deserialize_with = "deserialize_nullable_text")]
    end_date: String,
    #[serde(default, rename = "owned_by", deserialize_with = "deserialize_cycle_ownership")]
    ownership: CycleOwnership,
}

/// Converts the nullable `owned_by` UUID from the Plane API into a typed CycleOwnership enum
fn deserialize_cycle_ownership<'de, D>(serde_input: D) -> Result<CycleOwnership, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let nullable: Option<MemberId> = Option::deserialize(serde_input)?;
    match nullable {
        Option::Some(member_id) => Ok(CycleOwnership::Owned(member_id)),
        Option::None => Ok(CycleOwnership::Unowned),
    }
}

/// Read-only access to deserialized cycle fields for display and resolution
impl PlaneCycle {
    /// Returns the cycle's unique API identifier for URL construction and filtering
    pub fn id(&self) -> &CycleId {
        &self.id
    }

    /// Returns the human-readable cycle name (e.g. "Sprint 4", "March Release")
    pub fn display_name(&self) -> &str {
        &self.display_name
    }

    /// Returns the cycle start date as an ISO 8601 string, or empty if unscheduled
    pub fn start_date(&self) -> &str {
        &self.start_date
    }

    /// Returns the cycle end date as an ISO 8601 string, or empty if unscheduled
    pub fn end_date(&self) -> &str {
        &self.end_date
    }

    /// Returns the cycle ownership — either Owned(member_id) or Unowned
    pub fn ownership(&self) -> &CycleOwnership {
        &self.ownership
    }
}

/// Whether a module has a designated lead in the Plane workspace
#[derive(Debug, Clone)]
pub enum ModuleLeadership {
    /// The module is led by a specific workspace member
    Led(MemberId),
    /// The module has no designated lead
    Unleaded,
}

impl Default for ModuleLeadership {
    fn default() -> Self {
        ModuleLeadership::Unleaded
    }
}

/// Workflow phase of a Plane module, tracking its lifecycle from planning to completion
#[derive(Debug, Clone, Deserialize)]
pub enum ModuleWorkflowPhase {
    /// Module is in the backlog, not yet planned
    #[serde(rename = "backlog")]
    Backlog,
    /// Module is planned but work has not started
    #[serde(rename = "planned")]
    Planned,
    /// Module is actively being worked on
    #[serde(rename = "in-progress")]
    InProgress,
    /// Module work is temporarily suspended
    #[serde(rename = "paused")]
    Paused,
    /// Module work is finished
    #[serde(rename = "completed")]
    Completed,
    /// Module was abandoned or removed from scope
    #[serde(rename = "cancelled")]
    Cancelled,
}

impl Default for ModuleWorkflowPhase {
    fn default() -> Self {
        ModuleWorkflowPhase::Backlog
    }
}

/// Feature group or functional area within a project, organizing work items by goal.
/// Returned by the Plane API list-modules endpoint.
#[derive(Debug, Clone, Deserialize)]
pub struct PlaneModule {
    id: ModuleId,
    #[serde(rename = "name")]
    display_name: String,
    #[serde(default, deserialize_with = "deserialize_nullable_text")]
    description: String,
    #[serde(default, rename = "lead", deserialize_with = "deserialize_module_leadership")]
    leadership: ModuleLeadership,
    #[serde(default)]
    members: Vec<MemberId>,
    #[serde(default, rename = "status")]
    workflow_phase: ModuleWorkflowPhase,
    #[serde(default, deserialize_with = "deserialize_nullable_text")]
    start_date: String,
    #[serde(default, deserialize_with = "deserialize_nullable_text")]
    target_date: String,
}

/// Converts the nullable `lead` UUID from the Plane API into a typed ModuleLeadership enum
fn deserialize_module_leadership<'de, D>(serde_input: D) -> Result<ModuleLeadership, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let nullable: Option<MemberId> = Option::deserialize(serde_input)?;
    match nullable {
        Option::Some(member_id) => Ok(ModuleLeadership::Led(member_id)),
        Option::None => Ok(ModuleLeadership::Unleaded),
    }
}

/// Read-only access to deserialized module fields for display and resolution
impl PlaneModule {
    /// Returns the module's unique API identifier for URL construction and filtering
    pub fn id(&self) -> &ModuleId {
        &self.id
    }

    /// Returns the human-readable module name (e.g. "Auth System", "Onboarding")
    pub fn display_name(&self) -> &str {
        &self.display_name
    }

    /// Returns the module description text, or empty if none was provided
    pub fn module_description(&self) -> &str {
        &self.description
    }

    /// Returns the module leadership — either Led(member_id) or Unleaded
    pub fn leadership(&self) -> &ModuleLeadership {
        &self.leadership
    }

    /// Returns the list of member UUIDs assigned to this module
    pub fn member_ids(&self) -> &[MemberId] {
        &self.members
    }

    /// Returns the module's workflow phase (backlog, planned, in-progress, etc.)
    pub fn workflow_phase(&self) -> &ModuleWorkflowPhase {
        &self.workflow_phase
    }

    /// Returns the module start date as YYYY-MM-DD, or empty if unscheduled
    pub fn start_date(&self) -> &str {
        &self.start_date
    }

    /// Returns the module target date as YYYY-MM-DD, or empty if unscheduled
    pub fn target_date(&self) -> &str {
        &self.target_date
    }
}

/// Deserializes a JSON field that may be either a string or null into a `String`,
/// collapsing null values to empty strings for fields where absence and empty are equivalent.
fn deserialize_nullable_text<'de, D>(serde_input: D) -> Result<String, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let nullable: Option<String> = Option::deserialize(serde_input)?;
    match nullable {
        Option::Some(text_content) => Ok(text_content),
        Option::None => Ok(String::new()),
    }
}

/// Plane API list response envelope using the "results" key (plural).
/// Most list endpoints return this format for paginated collections.
#[derive(Deserialize)]
struct PluralResultsEnvelope<T> {
    results: Vec<T>,
}

/// Consuming extraction for the plural-key response envelope
impl<T> PluralResultsEnvelope<T> {
    /// Unwraps the envelope, returning the contained collection of API items
    fn into_items(self) -> Vec<T> {
        self.results
    }
}

/// Plane API list response envelope using the "result" key (singular).
/// Some endpoints (notably list-projects) use this format instead of the plural form.
#[derive(Deserialize)]
struct SingularResultEnvelope<T> {
    result: Vec<T>,
}

/// Consuming extraction for the singular-key response envelope
impl<T> SingularResultEnvelope<T> {
    /// Unwraps the envelope, returning the contained collection of API items
    fn into_items(self) -> Vec<T> {
        self.result
    }
}

/// Attempts to parse a Plane API list response body into a vector of typed items.
/// Handles the three response formats the API uses: `{"results": [...]}`, `{"result": [...]}`,
/// and bare `[...]` arrays. Tries each format in order and returns the first successful parse.
pub fn parse_list_response<T: serde::de::DeserializeOwned>(
    response_body: &str,
) -> anyhow::Result<Vec<T>> {
    let plural_error =
        match serde_json::from_str::<PluralResultsEnvelope<T>>(response_body) {
            Ok(envelope) => return Ok(envelope.into_items()),
            Err(parse_error) => parse_error,
        };

    let singular_error =
        match serde_json::from_str::<SingularResultEnvelope<T>>(response_body) {
            Ok(envelope) => return Ok(envelope.into_items()),
            Err(parse_error) => parse_error,
        };

    let array_error = match serde_json::from_str::<Vec<T>>(response_body) {
        Ok(items) => return Ok(items),
        Err(parse_error) => parse_error,
    };

    anyhow::bail!(
        "failed to parse Plane API list response in any known format:\n  \
         as {{results:[...]}}: {}\n  \
         as {{result:[...]}}: {}\n  \
         as [...]: {}",
        plural_error,
        singular_error,
        array_error
    )
}
