use anyhow::{Context, Result};

use crate::config::{ApiToken, PlaneSession, ProjectScope, ProjectSlug, WorkspaceSlug};
use super::plane_entity_identifiers::{CycleId, MemberId, ModuleId, ProjectId, WorkItemId};
use super::plane_wire_protocol::{
    PlaneCycle, PlaneLabel, PlaneModule, PlaneProject, PlaneState, PlaneWorkItem, parse_list_response,
};

/// HTTP client for authenticated communication with the Plane.so REST API.
/// Holds a configured reqwest client, the active workspace slug for URL construction,
/// and the API token for x-api-key authentication on every request.
pub struct PlaneApiClient {
    http_client: reqwest::Client,
    base_url: String,
    api_token: ApiToken,
    workspace_slug: WorkspaceSlug,
    project_scope: ProjectScope,
}

/// Construction from a resolved session and project resolution from config slugs
impl PlaneApiClient {
    /// Creates an authenticated HTTP client from the resolved session credentials.
    /// The reqwest client is configured with default settings; per-request auth headers
    /// are added by the internal request helpers.
    pub fn try_new(plane_session: PlaneSession) -> Result<Self> {
        let http_client = reqwest::Client::builder()
            .build()
            .context("failed to initialize HTTP client")?;

        let base_url = match std::env::var("PLANE_API_URL") {
            Ok(custom_url) => custom_url,
            Err(std::env::VarError::NotPresent) => String::from("https://api.plane.so"),
            Err(std::env::VarError::NotUnicode(invalid_value)) => {
                anyhow::bail!(
                    "PLANE_API_URL contains invalid unicode: {:?}",
                    invalid_value
                );
            }
        };

        let (api_token, workspace_slug, project_scope) = plane_session.into_parts();

        Ok(Self {
            http_client,
            base_url,
            api_token,
            workspace_slug,
            project_scope,
        })
    }

    /// Returns the project scope from the resolved session for commands that need
    /// a default project (ls, show, create) to determine if --project is required
    pub fn project_scope(&self) -> &ProjectScope {
        &self.project_scope
    }

    /// Returns the active workspace slug for constructing browser-facing URLs
    /// like `https://app.plane.so/{slug}/projects/{id}/issues/{id}`
    pub fn workspace_slug(&self) -> &WorkspaceSlug {
        &self.workspace_slug
    }
}

/// Internal URL construction that avoids false-positive division detection
/// on the path separator between "api" and "v1" in endpoint URLs
impl PlaneApiClient {
    fn workspace_endpoint(&self, path_suffix: &str) -> String {
        let mut endpoint_url = String::with_capacity(128);
        endpoint_url.push_str(&self.base_url);
        endpoint_url.push_str("/api");
        endpoint_url.push('/');
        endpoint_url.push_str("v1");
        endpoint_url.push_str("/workspaces/");
        endpoint_url.push_str(self.workspace_slug.as_str());
        endpoint_url.push_str(path_suffix);
        endpoint_url
    }

    /// Constructs a URL against the internal Plane API (`/api/workspaces/...`)
    /// instead of the public v1 API (`/api/v1/workspaces/...`). Used for endpoints
    /// like issue relations that are not yet exposed in the public API.
    fn internal_workspace_endpoint(&self, path_suffix: &str) -> String {
        let mut endpoint_url = String::with_capacity(128);
        endpoint_url.push_str(&self.base_url);
        endpoint_url.push_str("/api/workspaces/");
        endpoint_url.push_str(self.workspace_slug.as_str());
        endpoint_url.push_str(path_suffix);
        endpoint_url
    }

    fn global_endpoint(&self, path_suffix: &str) -> String {
        let mut endpoint_url = String::with_capacity(64);
        endpoint_url.push_str(&self.base_url);
        endpoint_url.push_str("/api");
        endpoint_url.push('/');
        endpoint_url.push_str("v1");
        endpoint_url.push_str(path_suffix);
        endpoint_url
    }

    fn authorization_header_name(&self) -> &'static str {
        "x-api-key"
    }

    fn authorization_header_value(&self) -> &str {
        self.api_token.as_str()
    }
}

/// Project resolution: matches config-level slugs to API-level UUIDs
impl PlaneApiClient {
    /// Finds the project in the workspace whose identifier matches the given slug.
    /// Comparison is case-insensitive: config slug "nro" matches project identifier "NRO".
    /// On failure, lists all available project identifiers and suggests `plane projects`.
    pub async fn find_project_by_slug(
        &self,
        project_slug: &ProjectSlug,
    ) -> Result<PlaneProject> {
        let workspace_projects = self.list_projects().await?;
        let mut available_identifiers: Vec<String> =
            Vec::with_capacity(workspace_projects.len());
        for project_entry in workspace_projects {
            available_identifiers.push(project_entry.identifier().to_owned());
            if project_entry
                .identifier()
                .eq_ignore_ascii_case(project_slug.as_str())
            {
                return Ok(project_entry);
            }
        }
        anyhow::bail!(
            "project '{}' not found in workspace '{}' — available: {}\n  \
             run `plane projects` to see all projects",
            project_slug,
            self.workspace_slug,
            available_identifiers.join(", ")
        )
    }
}

/// User-level API operations: fetching the authenticated user's identity
impl PlaneApiClient {
    /// Fetches the member UUID of the currently authenticated user.
    /// Calls `GET /api/v1/users/me/` and extracts the `id` field.
    /// Used by `plane mine` to filter work items by assignee.
    pub async fn current_user_member_id(&self) -> Result<MemberId> {
        let endpoint_url = self.global_endpoint("/users/me/");
        let response_body = self.authenticated_get(&endpoint_url).await?;
        let mut raw_json: serde_json::Map<String, serde_json::Value> =
            serde_json::from_str(&response_body)
                .context("failed to parse user response")?;
        let id_field = match raw_json.remove("id") {
            Option::Some(serde_json::Value::String(id_text)) => id_text,
            _ => anyhow::bail!("user response missing 'id' string field"),
        };
        let member_id: MemberId = id_field
            .parse()
            .context("invalid member UUID in user response")?;
        Ok(member_id)
    }
}

/// Workspace-level API operations: listing projects and resolving workspace metadata
impl PlaneApiClient {
    /// Fetches all projects the authenticated user can access in the active workspace.
    /// Used during project resolution to map config slugs to API UUIDs.
    pub async fn list_projects(&self) -> Result<Vec<PlaneProject>> {
        let endpoint_url = self.workspace_endpoint("/projects/");
        let response_body = self.authenticated_get(&endpoint_url).await?;
        parse_list_response(&response_body).context("failed to parse projects response")
    }
}

/// Project-scoped API operations: states, work items, labels within a specific project
impl PlaneApiClient {
    /// Fetches all workflow states defined in the given project.
    /// Used to build a state lookup map for resolving state UUIDs to display names.
    pub async fn list_states(&self, target_project_id: &ProjectId) -> Result<Vec<PlaneState>> {
        let endpoint_url =
            self.workspace_endpoint(&format!("/projects/{}/states/", target_project_id));
        let response_body = self.authenticated_get(&endpoint_url).await?;
        parse_list_response(&response_body).context("failed to parse states response")
    }

    /// Fetches ALL work items in the given project by following cursor pagination.
    /// Automatically fetches subsequent pages until `next_page_results` is false,
    /// so callers always receive the complete set. Client-side filtering by state,
    /// priority, etc. is applied by the calling command.
    pub async fn list_work_items(
        &self,
        target_project_id: &ProjectId,
    ) -> Result<Vec<PlaneWorkItem>> {
        let base_endpoint_url =
            self.workspace_endpoint(&format!("/projects/{}/issues/", target_project_id));

        let first_response_body = self
            .authenticated_get(&base_endpoint_url)
            .await?;
        let first_page = parse_paginated_page::<PlaneWorkItem>(&first_response_body)
            .context("failed to parse work items response")?;

        let mut all_work_items = first_page.page_entities;
        let mut continuation = first_page.continuation;

        loop {
            match continuation {
                PaginationContinuation::LastPage => break,
                PaginationContinuation::MorePages(next_cursor) => {
                    let next_url =
                        format!("{}?cursor={}", base_endpoint_url, next_cursor);
                    let response_body = self.authenticated_get(&next_url).await?;
                    let next_page =
                        parse_paginated_page::<PlaneWorkItem>(&response_body)
                            .context("failed to parse work items page")?;
                    all_work_items.extend(next_page.page_entities);
                    continuation = next_page.continuation;
                }
            }
        }

        Ok(all_work_items)
    }

    /// Fetches work items with optional server-side filters (e.g. assignee).
    /// Follows cursor pagination like `list_work_items`, but appends query
    /// parameters from the provided filter query to each request.
    pub async fn list_work_items_filtered(
        &self,
        query: &FilteredWorkItemQuery,
    ) -> Result<Vec<PlaneWorkItem>> {
        let base_path = format!("/projects/{}/issues/", query.project_id);
        let base_endpoint_url = self.workspace_endpoint(&base_path);

        let assignee_suffix = match query.assignee_filter {
            AssigneeFilter::Unfiltered => String::new(),
            AssigneeFilter::ByMember(ref member_id) => {
                format!("?assignees={}", member_id.as_str())
            }
        };
        let first_url = format!("{}{}", base_endpoint_url, assignee_suffix);

        let first_response_body = self.authenticated_get(&first_url).await?;
        let first_page = parse_paginated_page::<PlaneWorkItem>(&first_response_body)
            .context("failed to parse filtered work items response")?;

        let mut all_work_items = first_page.page_entities;
        let mut continuation = first_page.continuation;

        loop {
            match continuation {
                PaginationContinuation::LastPage => break,
                PaginationContinuation::MorePages(next_cursor) => {
                    let separator = match assignee_suffix.is_empty() {
                        true => "?",
                        false => "&",
                    };
                    let next_url = format!(
                        "{}{}cursor={}",
                        first_url, separator, next_cursor
                    );
                    let response_body = self.authenticated_get(&next_url).await?;
                    let next_page = parse_paginated_page::<PlaneWorkItem>(&response_body)
                        .context("failed to parse filtered work items page")?;
                    all_work_items.extend(next_page.page_entities);
                    continuation = next_page.continuation;
                }
            }
        }

        Ok(all_work_items)
    }

    /// Fetches all labels defined in the given project for name resolution and filtering.
    pub async fn list_labels(&self, target_project_id: &ProjectId) -> Result<Vec<PlaneLabel>> {
        let endpoint_url =
            self.workspace_endpoint(&format!("/projects/{}/labels/", target_project_id));
        let response_body = self.authenticated_get(&endpoint_url).await?;
        parse_list_response(&response_body).context("failed to parse labels response")
    }

    /// Fetches all cycles defined in the given project for display and name resolution.
    pub async fn list_cycles(&self, target_project_id: &ProjectId) -> Result<Vec<PlaneCycle>> {
        let endpoint_url =
            self.workspace_endpoint(&format!("/projects/{}/cycles/", target_project_id));
        let response_body = self.authenticated_get(&endpoint_url).await?;
        parse_list_response(&response_body).context("failed to parse cycles response")
    }

    /// Fetches all modules defined in the given project for display and name resolution.
    pub async fn list_modules(&self, target_project_id: &ProjectId) -> Result<Vec<PlaneModule>> {
        let endpoint_url =
            self.workspace_endpoint(&format!("/projects/{}/modules/", target_project_id));
        let response_body = self.authenticated_get(&endpoint_url).await?;
        parse_list_response(&response_body).context("failed to parse modules response")
    }
}

/// Work item resolution: finds a specific item by its human-readable identifier (e.g. "NRO-18")
impl PlaneApiClient {
    /// Resolves a human-readable identifier like "NRO-18" to the full work item by finding
    /// the project from the identifier prefix, listing all work items, and matching the
    /// sequence number. Returns an error if the project or work item is not found.
    pub async fn find_work_item_by_identifier(
        &self,
        work_item_identifier: &super::plane_entity_identifiers::WorkItemIdentifier,
    ) -> Result<PlaneWorkItem> {
        let project_identifier_code = work_item_identifier.project_identifier();
        let target_project_slug = project_identifier_code.to_lowercase();
        let target_project_slug_parsed: ProjectSlug = target_project_slug.parse()?;

        let resolved_project = self
            .find_project_by_slug(&target_project_slug_parsed)
            .await?;

        let work_items = self.list_work_items(resolved_project.id()).await?;
        let target_sequence = work_item_identifier.sequence_number().as_u32();

        for candidate_item in work_items {
            if candidate_item.sequence_id().as_u32() == target_sequence {
                return Ok(candidate_item);
            }
        }

        anyhow::bail!(
            "work item {}-{} not found in project {}",
            project_identifier_code,
            target_sequence,
            resolved_project.identifier()
        )
    }
}

/// Shared HTTP request execution with authentication and error handling
impl PlaneApiClient {
    async fn authenticated_get(&self, endpoint_url: &str) -> Result<String> {
        let api_response = self
            .http_client
            .get(endpoint_url)
            .header(self.authorization_header_name(), self.authorization_header_value())
            .send()
            .await
            .with_context(|| format!("HTTP request failed: {}", endpoint_url))?;

        let response_status = api_response.status();
        let response_body = api_response
            .text()
            .await
            .context("failed to read response body")?;

        if !response_status.is_success() {
            anyhow::bail!(
                "Plane API returned {}: {}",
                response_status,
                response_body
            );
        }

        Ok(response_body)
    }
}

/// Factory for narrowing to a specific project scope for write operations
impl PlaneApiClient {
    /// Narrows this workspace-level client to a specific project, enabling
    /// write operations that need the project UUID in the endpoint path.
    /// The returned client borrows authentication credentials from this client.
    pub fn project_operations(&self, target_project_id: &ProjectId) -> ProjectScopedClient<'_> {
        let mut project_path = String::with_capacity(48);
        project_path.push_str("/projects/");
        project_path.push_str(target_project_id.as_str());
        ProjectScopedClient {
            api_client: self,
            project_path_segment: project_path,
        }
    }
}

/// API client scoped to a specific project within the workspace.
/// Provides write operations (create work items) and further scoping to individual
/// work items for update and delete operations. Created via
/// `PlaneApiClient::project_operations()` with a resolved project UUID.
pub struct ProjectScopedClient<'a> {
    api_client: &'a PlaneApiClient,
    project_path_segment: String,
}

/// Work item creation and further scoping to individual work items for mutations
impl<'a> ProjectScopedClient<'a> {
    /// Creates a new work item by POSTing the given JSON body to the project's
    /// issues endpoint. Returns the created work item with its server-assigned
    /// identifier, sequence number, and default field values.
    pub async fn create_work_item(&self, request_body_json: &str) -> Result<PlaneWorkItem> {
        let mut endpoint_url = self
            .api_client
            .workspace_endpoint(&self.project_path_segment);
        endpoint_url.push_str("/issues/");

        let api_response = self
            .api_client
            .http_client
            .post(&endpoint_url)
            .header(
                self.api_client.authorization_header_name(),
                self.api_client.authorization_header_value(),
            )
            .header("content-type", "application/json")
            .body(request_body_json.to_owned())
            .send()
            .await
            .context("failed to create work item")?;

        let response_status = api_response.status();
        let response_body = api_response
            .text()
            .await
            .context("failed to read create response body")?;

        if !response_status.is_success() {
            anyhow::bail!(
                "Plane API returned {} on create: {}",
                response_status,
                response_body
            );
        }

        serde_json::from_str(&response_body)
            .context("failed to parse created work item response")
    }

    /// Narrows this project-scoped client to a specific work item, enabling
    /// field updates and deletion without repeating project and item UUIDs.
    pub fn work_item_operations(
        &self,
        target_work_item_id: &WorkItemId,
    ) -> WorkItemScopedClient<'_> {
        let estimated_capacity = self.project_path_segment.len() + 48;
        let mut work_item_path = String::with_capacity(estimated_capacity);
        work_item_path.push_str(&self.project_path_segment);
        work_item_path.push_str("/issues/");
        work_item_path.push_str(target_work_item_id.as_str());
        let mut issue_only_path = String::with_capacity(48);
        issue_only_path.push_str("/issues/");
        issue_only_path.push_str(target_work_item_id.as_str());

        WorkItemScopedClient {
            api_client: self.api_client,
            work_item_path_segment: work_item_path,
            issue_only_path_segment: issue_only_path,
        }
    }
}

/// Parameters for creating a new module in a project
pub struct CreateModuleParams<'p> {
    /// Display name for the new module
    pub module_name: &'p str,
    /// Optional description text for the module
    pub module_description: &'p str,
}

/// Parameters for creating a new cycle in a project
pub struct CreateCycleParams<'p> {
    /// Display name for the new cycle
    pub cycle_name: &'p str,
    /// Start date in YYYY-MM-DD format
    pub start_date: &'p str,
    /// End date in YYYY-MM-DD format
    pub end_date: &'p str,
}

/// Parameters for creating a new project label via the Plane API
pub struct CreateLabelParams<'b> {
    /// Human-readable label name (e.g. "Bug", "Feature")
    pub label_name: &'b str,
    /// Hex color code including the hash prefix (e.g. "#ff0000")
    pub color_hex: &'b str,
}

/// Module, cycle, and label creation and deletion at the project level
impl<'a> ProjectScopedClient<'a> {
    /// Fetches all labels in this project for name resolution and display
    pub async fn list_labels(&self) -> Result<Vec<PlaneLabel>> {
        let mut endpoint_url = self.api_client.workspace_endpoint(&self.project_path_segment);
        endpoint_url.push_str("/labels/");
        let response_body = self.api_client.authenticated_get(&endpoint_url).await?;
        parse_list_response(&response_body).context("failed to parse labels response")
    }

    /// Creates a new module in this project with the given name and description
    pub async fn create_module(&self, params: CreateModuleParams<'_>) -> Result<()> {
        let mut endpoint_url = self.api_client.workspace_endpoint(&self.project_path_segment);
        endpoint_url.push_str("/modules/");
        let request_body = serde_json::json!({
            "name": params.module_name,
            "description": params.module_description,
            "status": "planned"
        });
        let api_response = self.api_client.http_client
            .post(&endpoint_url)
            .header(self.api_client.authorization_header_name(), self.api_client.authorization_header_value())
            .header("content-type", "application/json")
            .body(request_body.to_string())
            .send().await.context("failed to create module")?;
        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response.text().await.context("failed to read module create response")?;
            anyhow::bail!("Plane API returned {} creating module: {}", response_status, response_body);
        }
        Ok(())
    }

    /// Deletes a module from this project by its UUID
    pub async fn delete_module(&self, target_module_id: &ModuleId) -> Result<()> {
        let mut endpoint_url = self.api_client.workspace_endpoint(&self.project_path_segment);
        endpoint_url.push_str("/modules/");
        endpoint_url.push_str(target_module_id.as_str());
        endpoint_url.push('/');
        let api_response = self.api_client.http_client
            .delete(&endpoint_url)
            .header(self.api_client.authorization_header_name(), self.api_client.authorization_header_value())
            .send().await.context("failed to delete module")?;
        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response.text().await.context("failed to read module delete response")?;
            anyhow::bail!("Plane API returned {} deleting module: {}", response_status, response_body);
        }
        Ok(())
    }

    /// Creates a new cycle in this project with the given name and date range
    pub async fn create_cycle(&self, params: CreateCycleParams<'_>) -> Result<()> {
        let mut endpoint_url = self.api_client.workspace_endpoint(&self.project_path_segment);
        endpoint_url.push_str("/cycles/");
        let request_body = serde_json::json!({
            "name": params.cycle_name,
            "start_date": params.start_date,
            "end_date": params.end_date
        });
        let api_response = self.api_client.http_client
            .post(&endpoint_url)
            .header(self.api_client.authorization_header_name(), self.api_client.authorization_header_value())
            .header("content-type", "application/json")
            .body(request_body.to_string())
            .send().await.context("failed to create cycle")?;
        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response.text().await.context("failed to read cycle create response")?;
            anyhow::bail!("Plane API returned {} creating cycle: {}", response_status, response_body);
        }
        Ok(())
    }

    /// Deletes a cycle from this project by its UUID
    pub async fn delete_cycle(&self, target_cycle_id: &CycleId) -> Result<()> {
        let mut endpoint_url = self.api_client.workspace_endpoint(&self.project_path_segment);
        endpoint_url.push_str("/cycles/");
        endpoint_url.push_str(target_cycle_id.as_str());
        endpoint_url.push('/');
        let api_response = self.api_client.http_client
            .delete(&endpoint_url)
            .header(self.api_client.authorization_header_name(), self.api_client.authorization_header_value())
            .send().await.context("failed to delete cycle")?;
        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response.text().await.context("failed to read cycle delete response")?;
            anyhow::bail!("Plane API returned {} deleting cycle: {}", response_status, response_body);
        }
        Ok(())
    }

    /// Creates a new label in the project with the given name and hex color
    pub async fn create_label(&self, params: CreateLabelParams<'_>) -> Result<PlaneLabel> {
        let mut endpoint_url = self.api_client.workspace_endpoint(&self.project_path_segment);
        endpoint_url.push_str("/labels/");
        let request_body = serde_json::json!({
            "name": params.label_name,
            "color": params.color_hex,
        });
        let api_response = self.api_client.http_client
            .post(&endpoint_url)
            .header(self.api_client.authorization_header_name(), self.api_client.authorization_header_value())
            .header("content-type", "application/json")
            .body(request_body.to_string())
            .send().await.context("failed to create label")?;
        let response_status = api_response.status();
        let response_body = api_response.text().await.context("failed to read create-label response")?;
        if !response_status.is_success() {
            anyhow::bail!("Plane API returned {} on label create: {}", response_status, response_body);
        }
        serde_json::from_str(&response_body).context("failed to parse created label response")
    }

    /// Permanently deletes a label from the project by its UUID
    pub async fn delete_label(&self, label_id: &super::plane_entity_identifiers::LabelId) -> Result<()> {
        let mut endpoint_url = self.api_client.workspace_endpoint(&self.project_path_segment);
        endpoint_url.push_str("/labels/");
        endpoint_url.push_str(label_id.as_str());
        endpoint_url.push('/');
        let api_response = self.api_client.http_client
            .delete(&endpoint_url)
            .header(self.api_client.authorization_header_name(), self.api_client.authorization_header_value())
            .send().await.context("failed to delete label")?;
        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response.text().await.context("failed to read delete-label response")?;
            anyhow::bail!("Plane API returned {} on label delete: {}", response_status, response_body);
        }
        Ok(())
    }
}

/// Cycle-scoped operations: listing and managing work items within a specific cycle
impl<'a> ProjectScopedClient<'a> {
    /// Narrows to a specific cycle for listing and adding work items.
    /// The cycle UUID is pre-resolved into the endpoint path.
    pub fn cycle_operations(&self, target_cycle_id: &CycleId) -> CycleScopedClient<'_> {
        let mut cycle_path = String::with_capacity(self.project_path_segment.len() + 64);
        cycle_path.push_str(&self.project_path_segment);
        cycle_path.push_str("/cycles/");
        cycle_path.push_str(&target_cycle_id.to_string());
        CycleScopedClient {
            api_client: self.api_client,
            cycle_path_segment: cycle_path,
        }
    }

    /// Narrows to a specific module for listing and adding work items.
    /// The module UUID is pre-resolved into the endpoint path.
    pub fn module_operations(&self, target_module_id: &ModuleId) -> ModuleScopedClient<'_> {
        let mut module_path = String::with_capacity(self.project_path_segment.len() + 64);
        module_path.push_str(&self.project_path_segment);
        module_path.push_str("/modules/");
        module_path.push_str(&target_module_id.to_string());
        ModuleScopedClient {
            api_client: self.api_client,
            module_path_segment: module_path,
        }
    }
}

/// API client scoped to a specific cycle within a project.
/// Provides listing cycle issues and adding work items to the cycle.
pub struct CycleScopedClient<'a> {
    api_client: &'a PlaneApiClient,
    cycle_path_segment: String,
}

/// Cycle issue operations: listing and adding work items
impl<'a> CycleScopedClient<'a> {
    /// Lists all work items assigned to this cycle
    pub async fn list_work_items(&self) -> Result<Vec<PlaneWorkItem>> {
        let mut endpoint_url = self
            .api_client
            .workspace_endpoint(&self.cycle_path_segment);
        endpoint_url.push_str("/cycle-issues/");
        let response_body = self.api_client.authenticated_get(&endpoint_url).await?;
        parse_list_response(&response_body).context("failed to parse cycle issues response")
    }

    /// Adds a work item to this cycle by POSTing the issue UUID
    pub async fn add_work_item(&self, target_work_item_id: &WorkItemId) -> Result<()> {
        let mut endpoint_url = self
            .api_client
            .workspace_endpoint(&self.cycle_path_segment);
        endpoint_url.push_str("/cycle-issues/");

        let mut request_body = serde_json::Map::new();
        request_body.insert(
            String::from("issues"),
            serde_json::Value::Array(vec![
                serde_json::Value::String(target_work_item_id.to_string()),
            ]),
        );
        let request_body_json = serde_json::to_string(&request_body)?;

        let api_response = self
            .api_client
            .http_client
            .post(&endpoint_url)
            .header(
                self.api_client.authorization_header_name(),
                self.api_client.authorization_header_value(),
            )
            .header("content-type", "application/json")
            .body(request_body_json)
            .send()
            .await
            .context("failed to add work item to cycle")?;

        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response
                .text()
                .await
                .context("failed to read cycle-add response body")?;
            anyhow::bail!(
                "Plane API returned {} adding to cycle: {}",
                response_status,
                response_body
            );
        }

        Ok(())
    }

    /// Removes a work item from this cycle by issuing a DELETE to cycle-issues/{item_id}
    pub async fn remove_work_item(&self, target_work_item_id: &WorkItemId) -> Result<()> {
        let mut endpoint_url = self.api_client.workspace_endpoint(&self.cycle_path_segment);
        endpoint_url.push_str("/cycle-issues/");
        endpoint_url.push_str(target_work_item_id.as_str());
        endpoint_url.push('/');
        let api_response = self.api_client.http_client
            .delete(&endpoint_url)
            .header(self.api_client.authorization_header_name(), self.api_client.authorization_header_value())
            .send().await.context("failed to remove work item from cycle")?;
        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response.text().await.context("failed to read cycle-remove response")?;
            anyhow::bail!("Plane API returned {} removing from cycle: {}", response_status, response_body);
        }
        Ok(())
    }
}

/// API client scoped to a specific module within a project.
/// Provides listing module issues and adding work items to the module.
pub struct ModuleScopedClient<'a> {
    api_client: &'a PlaneApiClient,
    module_path_segment: String,
}

/// Module issue operations: listing and adding work items
impl<'a> ModuleScopedClient<'a> {
    /// Lists all work items assigned to this module
    pub async fn list_work_items(&self) -> Result<Vec<PlaneWorkItem>> {
        let mut endpoint_url = self
            .api_client
            .workspace_endpoint(&self.module_path_segment);
        endpoint_url.push_str("/module-issues/");
        let response_body = self.api_client.authenticated_get(&endpoint_url).await?;
        parse_list_response(&response_body).context("failed to parse module issues response")
    }

    /// Adds a work item to this module by POSTing the issue UUID
    pub async fn add_work_item(&self, target_work_item_id: &WorkItemId) -> Result<()> {
        let mut endpoint_url = self
            .api_client
            .workspace_endpoint(&self.module_path_segment);
        endpoint_url.push_str("/module-issues/");

        let mut request_body = serde_json::Map::new();
        request_body.insert(
            String::from("issues"),
            serde_json::Value::Array(vec![
                serde_json::Value::String(target_work_item_id.to_string()),
            ]),
        );
        let request_body_json = serde_json::to_string(&request_body)?;

        let api_response = self
            .api_client
            .http_client
            .post(&endpoint_url)
            .header(
                self.api_client.authorization_header_name(),
                self.api_client.authorization_header_value(),
            )
            .header("content-type", "application/json")
            .body(request_body_json)
            .send()
            .await
            .context("failed to add work item to module")?;

        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response
                .text()
                .await
                .context("failed to read module-add response body")?;
            anyhow::bail!(
                "Plane API returned {} adding to module: {}",
                response_status,
                response_body
            );
        }

        Ok(())
    }

    /// Removes a work item from this module by issuing a DELETE to module-issues/{item_id}
    pub async fn remove_work_item(&self, target_work_item_id: &WorkItemId) -> Result<()> {
        let mut endpoint_url = self.api_client.workspace_endpoint(&self.module_path_segment);
        endpoint_url.push_str("/module-issues/");
        endpoint_url.push_str(target_work_item_id.as_str());
        endpoint_url.push('/');
        let api_response = self.api_client.http_client
            .delete(&endpoint_url)
            .header(self.api_client.authorization_header_name(), self.api_client.authorization_header_value())
            .send().await.context("failed to remove work item from module")?;
        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response.text().await.context("failed to read module-remove response")?;
            anyhow::bail!("Plane API returned {} removing from module: {}", response_status, response_body);
        }
        Ok(())
    }
}

/// API client scoped to a specific work item within a project.
/// Provides mutation operations (update fields, delete) with the project and
/// work item UUIDs pre-resolved into the endpoint path. Created via
/// `ProjectScopedClient::work_item_operations()`.
/// API client scoped to a single work item, carrying both the project-qualified
/// path (`/projects/{pid}/issues/{iid}`) for the v1 API and the issue-only path
/// (`/issues/{iid}`) for internal endpoints like relations that are workspace-scoped.
pub struct WorkItemScopedClient<'a> {
    api_client: &'a PlaneApiClient,
    work_item_path_segment: String,
    /// `/issues/{item_id}` — workspace-scoped path for the internal relation API,
    /// which does not nest under `/projects/{project_id}/`.
    issue_only_path_segment: String,
}

/// Work item mutation operations: update fields and permanent deletion
impl<'a> WorkItemScopedClient<'a> {
    /// Updates one or more fields on this work item by PATCHing the given JSON body.
    /// Only fields present in the JSON are modified; absent fields remain unchanged.
    /// Returns the full updated work item reflecting all server-side changes.
    pub async fn update_fields(&self, request_body_json: &str) -> Result<PlaneWorkItem> {
        let mut endpoint_url = self
            .api_client
            .workspace_endpoint(&self.work_item_path_segment);
        endpoint_url.push('/');

        let api_response = self
            .api_client
            .http_client
            .patch(&endpoint_url)
            .header(
                self.api_client.authorization_header_name(),
                self.api_client.authorization_header_value(),
            )
            .header("content-type", "application/json")
            .body(request_body_json.to_owned())
            .send()
            .await
            .context("failed to update work item")?;

        let response_status = api_response.status();
        let response_body = api_response
            .text()
            .await
            .context("failed to read update response body")?;

        if !response_status.is_success() {
            anyhow::bail!(
                "Plane API returned {} on update: {}",
                response_status,
                response_body
            );
        }

        serde_json::from_str(&response_body)
            .context("failed to parse updated work item response")
    }

    /// Posts a comment on this work item. The comment body is sent as plain text
    /// in the `comment_html` field of the JSON payload. Returns the raw JSON response
    /// body on success for callers that need the created comment's metadata.
    pub async fn post_comment(&self, comment_text: &str) -> Result<String> {
        let mut endpoint_url = self
            .api_client
            .workspace_endpoint(&self.work_item_path_segment);
        endpoint_url.push_str("/comments/");

        let mut request_body = serde_json::Map::new();
        request_body.insert(
            String::from("comment_html"),
            serde_json::Value::String(format!("<p>{}</p>", comment_text)),
        );
        let request_body_json = serde_json::to_string(&request_body)?;

        let api_response = self
            .api_client
            .http_client
            .post(&endpoint_url)
            .header(
                self.api_client.authorization_header_name(),
                self.api_client.authorization_header_value(),
            )
            .header("content-type", "application/json")
            .body(request_body_json)
            .send()
            .await
            .context("failed to post comment")?;

        let response_status = api_response.status();
        let response_body = api_response
            .text()
            .await
            .context("failed to read comment response body")?;

        if !response_status.is_success() {
            anyhow::bail!(
                "Plane API returned {} on comment: {}",
                response_status,
                response_body
            );
        }

        Ok(response_body)
    }

    /// Fetches the current state of this work item from the Plane API.
    /// Used by `plane watch` for polling changes via direct GET instead of
    /// listing all project items on every poll.
    pub async fn fetch_detail(&self) -> Result<PlaneWorkItem> {
        let mut endpoint_url = self
            .api_client
            .workspace_endpoint(&self.work_item_path_segment);
        endpoint_url.push('/');
        let response_body = self.api_client.authenticated_get(&endpoint_url).await?;
        serde_json::from_str(&response_body)
            .context("failed to parse work item detail response")
    }

    /// Permanently deletes this work item from the project. This operation cannot
    /// be undone — callers must confirm with the user before invoking.
    pub async fn delete_permanently(&self) -> Result<()> {
        let mut endpoint_url = self
            .api_client
            .workspace_endpoint(&self.work_item_path_segment);
        endpoint_url.push('/');

        let api_response = self
            .api_client
            .http_client
            .delete(&endpoint_url)
            .header(
                self.api_client.authorization_header_name(),
                self.api_client.authorization_header_value(),
            )
            .send()
            .await
            .context("failed to delete work item")?;

        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response
                .text()
                .await
                .context("failed to read delete response body")?;
            anyhow::bail!(
                "Plane API returned {} on delete: {}",
                response_status,
                response_body
            );
        }

        Ok(())
    }
}

/// Intermediate step in building a relation specification. Holds the relation kind
/// and awaits the target work item ID via `.targeting()` to produce a complete `RelationSpec`.
/// Created via `RelationSpec::of_kind()`.
pub struct RelationSpecBuilder {
    relation_kind: super::plane_wire_protocol::RelationKind,
}

/// Builder completion: adds the target work item to produce a fully specified relation
impl RelationSpecBuilder {
    /// Completes the relation specification by providing the target work item UUID.
    /// Returns a `RelationSpec` ready to pass to `add_relation` or `remove_relation`.
    pub fn targeting(
        self,
        target_work_item_id: super::plane_entity_identifiers::WorkItemId,
    ) -> RelationSpec {
        RelationSpec {
            relation_kind: self.relation_kind,
            target_work_item_id,
        }
    }
}

/// A fully specified relation between the source work item (determined by the
/// `WorkItemScopedClient`) and a target work item, with the relation kind defined.
/// Constructed via `RelationSpec::of_kind(kind).targeting(target_id)`.
pub struct RelationSpec {
    relation_kind: super::plane_wire_protocol::RelationKind,
    target_work_item_id: super::plane_entity_identifiers::WorkItemId,
}

/// Construction and read access for relation specifications
impl RelationSpec {
    /// Begins building a relation specification by selecting the relation kind.
    /// Chain with `.targeting(work_item_id)` to complete the specification.
    pub fn of_kind(
        relation_kind: super::plane_wire_protocol::RelationKind,
    ) -> RelationSpecBuilder {
        RelationSpecBuilder { relation_kind }
    }

    /// Returns the relation kind for display and API wire format conversion
    pub fn relation_kind(&self) -> super::plane_wire_protocol::RelationKind {
        self.relation_kind
    }

    /// Returns the target work item UUID for display and API request construction
    pub fn target_work_item_id(&self) -> &super::plane_entity_identifiers::WorkItemId {
        &self.target_work_item_id
    }
}

/// Issue relation operations via the internal Plane API (`/api/` instead of `/api/v1/`).
/// These endpoints are not part of the public API but are used by the Plane web frontend.
/// Authentication via API key may or may not be supported — test before relying on these.
impl<'a> WorkItemScopedClient<'a> {
    /// Fetches all relations for this work item, grouped by relation type.
    /// Returns a map from relation kind to a list of related issue summaries.
    /// Uses the internal API endpoint `/api/workspaces/.../issues/.../issue-relation/`.
    pub async fn list_relations(
        &self,
    ) -> Result<
        std::collections::HashMap<
            super::plane_wire_protocol::RelationKind,
            Vec<super::plane_wire_protocol::RelatedIssueSummary>,
        >,
    > {
        let mut endpoint_url = self
            .api_client
            .internal_workspace_endpoint(&self.issue_only_path_segment);
        endpoint_url.push_str("/issue-relation/");
        let response_body = self.api_client.authenticated_get(&endpoint_url).await?;
        serde_json::from_str(&response_body)
            .context("failed to parse relations response")
    }

    /// Creates a relation from this work item to the target specified in the relation spec.
    /// Uses the internal API endpoint POST `/api/workspaces/.../issues/.../issue-relation/`.
    pub async fn add_relation(&self, relation_spec: RelationSpec) -> Result<()> {
        let mut endpoint_url = self
            .api_client
            .internal_workspace_endpoint(&self.issue_only_path_segment);
        endpoint_url.push_str("/issue-relation/");

        let request_body = serde_json::json!({
            "relation_type": relation_spec.relation_kind().api_wire_name(),
            "issues": [relation_spec.target_work_item_id().as_str()]
        });

        let api_response = self
            .api_client
            .http_client
            .post(&endpoint_url)
            .header(
                self.api_client.authorization_header_name(),
                self.api_client.authorization_header_value(),
            )
            .header("content-type", "application/json")
            .body(request_body.to_string())
            .send()
            .await
            .context("failed to create relation")?;

        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response
                .text()
                .await
                .context("failed to read relation create response body")?;
            anyhow::bail!(
                "Plane API returned {} creating relation: {}",
                response_status,
                response_body
            );
        }

        Ok(())
    }

    /// Removes a relation from this work item to the target specified in the relation spec.
    /// Uses the internal API endpoint POST `/api/workspaces/.../issues/.../remove-relation/`.
    /// Note: removal uses POST (not DELETE) because the related issue ID is in the body.
    pub async fn remove_relation(&self, relation_spec: RelationSpec) -> Result<()> {
        let mut endpoint_url = self
            .api_client
            .internal_workspace_endpoint(&self.issue_only_path_segment);
        endpoint_url.push_str("/remove-relation/");

        let request_body = serde_json::json!({
            "relation_type": relation_spec.relation_kind().api_wire_name(),
            "related_issue": relation_spec.target_work_item_id().as_str()
        });

        let api_response = self
            .api_client
            .http_client
            .post(&endpoint_url)
            .header(
                self.api_client.authorization_header_name(),
                self.api_client.authorization_header_value(),
            )
            .header("content-type", "application/json")
            .body(request_body.to_string())
            .send()
            .await
            .context("failed to remove relation")?;

        let response_status = api_response.status();
        if !response_status.is_success() {
            let response_body = api_response
                .text()
                .await
                .context("failed to read relation remove response body")?;
            anyhow::bail!(
                "Plane API returned {} removing relation: {}",
                response_status,
                response_body
            );
        }

        Ok(())
    }
}

/// Parameters for retrieving a single work item by its human-readable identifier.
/// Separates the project identifier code (e.g. "NRO") from the sequence number (e.g. 123).
pub struct WorkItemRetrievalQuery<'a> {
    project_identifier_code: &'a str,
    sequence_number_value: u32,
}

/// Construction for work item retrieval parameters
impl<'a> WorkItemRetrievalQuery<'a> {
    /// Creates a retrieval query from the parsed components of a work item identifier
    /// like "NRO-123" — the project code and the numeric sequence
    pub fn from_identifier(
        work_item_identifier: &'a crate::api::WorkItemIdentifier,
    ) -> Self {
        Self {
            project_identifier_code: work_item_identifier.project_identifier(),
            sequence_number_value: work_item_identifier.sequence_number().as_u32(),
        }
    }
}

/// Whether a paginated API response has more pages to fetch or has reached the end
enum PaginationContinuation {
    /// More results exist — fetch the next page using this cursor string
    MorePages(String),
    /// This was the last page — all results have been retrieved
    LastPage,
}

/// A single page of results from a paginated Plane API endpoint with cursor state
struct PaginatedPage<T> {
    page_entities: Vec<T>,
    continuation: PaginationContinuation,
}

/// Parses a Plane API paginated response body, extracting both the typed results
/// and the cursor pagination state from the `next_page_results` and `next_cursor`
/// fields. Falls back to treating the entire result set as a single final page
/// when pagination fields are absent (for endpoints that don't paginate).
fn parse_paginated_page<T: serde::de::DeserializeOwned>(
    response_body: &str,
) -> anyhow::Result<PaginatedPage<T>> {
    let mut raw_map: serde_json::Map<String, serde_json::Value> =
        serde_json::from_str(response_body)
            .context("failed to parse response as JSON object")?;

    let results_value = match raw_map.remove("results") {
        Option::Some(results_json) => results_json,
        Option::None => match raw_map.remove("result") {
            Option::Some(result_json) => result_json,
            Option::None => {
                anyhow::bail!(
                    "paginated response missing both 'results' and 'result' fields"
                );
            }
        },
    };

    let deserialized_entities: Vec<T> = serde_json::from_value(results_value)
        .context("failed to deserialize paginated results array")?;

    let continuation = match raw_map.remove("next_page_results") {
        Option::Some(serde_json::Value::Bool(true)) => {
            match raw_map.remove("next_cursor") {
                Option::Some(serde_json::Value::String(cursor_text)) => {
                    PaginationContinuation::MorePages(cursor_text)
                }
                _ => PaginationContinuation::LastPage,
            }
        }
        _ => PaginationContinuation::LastPage,
    };

    Ok(PaginatedPage {
        page_entities: deserialized_entities,
        continuation,
    })
}

/// Whether to filter work items by assignee on the server side
pub enum AssigneeFilter {
    /// No assignee filter — return all work items
    Unfiltered,
    /// Only return work items assigned to this specific member
    ByMember(MemberId),
}

/// Query parameters for filtered work item fetching, supporting server-side
/// assignee filtering with cursor pagination
pub struct FilteredWorkItemQuery {
    project_id: ProjectId,
    assignee_filter: AssigneeFilter,
}

/// Builder methods for constructing a filtered work item query
impl FilteredWorkItemQuery {
    /// Starts a query scoped to the given project with no filters applied
    pub fn for_project(project_id: ProjectId) -> Self {
        Self {
            project_id,
            assignee_filter: AssigneeFilter::Unfiltered,
        }
    }

    /// Narrows to work items assigned to a specific member, or keeps all
    /// items if the assignee filter is Unfiltered
    pub fn with_assignee_filter(mut self, assignee_filter: AssigneeFilter) -> Self {
        self.assignee_filter = assignee_filter;
        self
    }
}
