use async_trait::async_trait;
use reqwest::StatusCode;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::time::sleep;
use tracing::warn;

use crate::lfd::pm::{
    PmError, PmItem, PmItemCreate, PmItemUpdate, PmProject, PmProvider, PmResult,
    RATE_LIMIT_RETRIES,
};

const LINEAR_BASE_URL: &str = "https://api.linear.app/graphql";
const LIST_ITEMS_PAGE_SIZE: u32 = 50;
const COMPLETED_STATE_TYPE: &str = "completed";
const DEFAULT_LOOPFLOW_TEAM_NAME: &str = "Loopflow";

const LIST_TEAMS_QUERY: &str = r#"query ListTeams {
  teams {
    nodes {
      id
      name
    }
  }
}"#;

const CREATE_TEAM_MUTATION: &str = r#"mutation CreateTeam($name: String!, $key: String!) {
  teamCreate(input: { name: $name, key: $key }) {
    team {
      id
    }
  }
}"#;

const CREATE_PROJECT_MUTATION: &str = r#"mutation CreateProject($name: String!, $description: String!, $teamId: String!) {
  projectCreate(input: { name: $name, description: $description, teamIds: [$teamId] }) {
    project {
      id
    }
  }
}"#;

const LIST_ITEMS_QUERY: &str = r#"query ListProjectIssues($projectId: String!, $after: String, $first: Int!) {
  project(id: $projectId) {
    issues(first: $first, after: $after) {
      nodes {
        id
        title
        description
        prioritySortOrder
        sortOrder
        assignee {
          id
        }
        state {
          type
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
}"#;

const CREATE_ITEM_MUTATION: &str = r#"mutation CreateIssue($teamId: String!, $projectId: String!, $title: String!, $description: String!) {
  issueCreate(input: { teamId: $teamId, projectId: $projectId, title: $title, description: $description }) {
    issue {
      id
    }
  }
}"#;

const UPDATE_ITEM_MUTATION: &str = r#"mutation UpdateIssue($id: String!, $title: String, $description: String) {
  issueUpdate(id: $id, input: { title: $title, description: $description }) {
    issue {
      id
    }
  }
}"#;

const CLAIM_ITEM_MUTATION: &str = r#"mutation ClaimIssue($id: String!, $assigneeId: String!, $branchName: String!) {
  issueUpdate(id: $id, input: { assigneeId: $assigneeId, branchName: $branchName }) {
    issue {
      id
      assignee {
        id
      }
      branchName
    }
  }
}"#;

const CLAIM_ITEM_STATE_QUERY: &str = r#"query ClaimIssueState($id: String!) {
  issue(id: $id) {
    assignee {
      id
    }
    branchName
  }
}"#;

const VIEWER_QUERY: &str = r#"query Viewer {
  viewer {
    id
  }
}"#;

const COMPLETE_ITEM_MUTATION: &str = r#"mutation CompleteIssue($id: String!, $stateId: String!) {
  issueUpdate(id: $id, input: { stateId: $stateId }) {
    issue {
      id
    }
  }
}"#;

const LIST_COMPLETED_WORKFLOW_STATES_QUERY: &str = r#"query CompletedWorkflowStates($teamId: String!) {
  workflowStates(filter: { team: { id: { eq: $teamId } }, type: { eq: "completed" } }) {
    nodes {
      id
      name
      type
    }
  }
}"#;

const LIST_TEAM_PROJECTS_QUERY: &str = r#"query ListTeamProjects($teamId: String!) {
  team(id: $teamId) {
    projects {
      nodes {
        id
        name
      }
    }
  }
}"#;

const CREATE_COMMENT_MUTATION: &str = r#"mutation CreateComment($issueId: String!, $body: String!) {
  commentCreate(input: { issueId: $issueId, body: $body }) {
    comment {
      id
    }
  }
}"#;

#[derive(Debug, Clone)]
pub struct LinearClient {
    client: reqwest::Client,
    token: String,
    team_id: Option<String>,
    base_url: String,
}

impl LinearClient {
    pub fn new(token: String, team_id: Option<String>) -> Self {
        Self {
            client: reqwest::Client::new(),
            token,
            team_id,
            base_url: LINEAR_BASE_URL.to_string(),
        }
    }

    #[cfg(test)]
    fn with_base_url(token: String, team_id: Option<String>, base_url: String) -> Self {
        Self {
            client: reqwest::Client::new(),
            token,
            team_id,
            base_url,
        }
    }

    async fn resolve_team_id(&self) -> PmResult<String> {
        if let Some(team_id) = &self.team_id {
            return Ok(team_id.clone());
        }

        // Look for a team named "Loopflow", create if not found
        let response: TeamsData = self.graphql(LIST_TEAMS_QUERY, json!({})).await?;
        if let Some(team) = response
            .teams
            .nodes
            .iter()
            .find(|t| t.name.eq_ignore_ascii_case(DEFAULT_LOOPFLOW_TEAM_NAME))
        {
            return Ok(team.id.clone());
        }

        // Create the team
        let key = team_key_from_name(DEFAULT_LOOPFLOW_TEAM_NAME);
        let response: TeamCreateData = self
            .graphql(
                CREATE_TEAM_MUTATION,
                json!({ "name": DEFAULT_LOOPFLOW_TEAM_NAME, "key": key }),
            )
            .await?;
        Ok(response.team_create.team.id)
    }

    async fn graphql<T>(&self, query: &str, variables: Value) -> PmResult<T>
    where
        T: DeserializeOwned,
    {
        let request = GraphqlRequest { query, variables };

        for attempt in 0..=RATE_LIMIT_RETRIES {
            let response = self
                .client
                .post(&self.base_url)
                .header(
                    reqwest::header::AUTHORIZATION,
                    format!("Bearer {}", self.token),
                )
                .json(&request)
                .send()
                .await
                .map_err(|err| PmError::Message(format!("linear request failed: {err}")))?;

            if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < RATE_LIMIT_RETRIES {
                let delay = super::retry_after_delay(response.headers());
                warn!(
                    attempt = attempt + 1,
                    delay_seconds = delay.as_secs(),
                    "linear rate limited; retrying"
                );
                sleep(delay).await;
                continue;
            }

            return parse_graphql_response(response).await;
        }

        Err(PmError::Message(
            "linear request failed after retries".to_string(),
        ))
    }

    async fn completed_state_id(&self) -> PmResult<String> {
        let team_id = self.resolve_team_id().await?;
        let response: WorkflowStatesData = self
            .graphql(
                LIST_COMPLETED_WORKFLOW_STATES_QUERY,
                json!({ "teamId": team_id }),
            )
            .await?;

        response
            .workflow_states
            .nodes
            .into_iter()
            .next()
            .map(|state| state.id)
            .ok_or_else(|| {
                PmError::Message(format!(
                    "no completed Linear workflow state found for team {team_id}"
                ))
            })
    }
}

#[async_trait]
impl PmProvider for LinearClient {
    async fn create_project(&self, name: &str, description: &str) -> PmResult<String> {
        let team_id = self.resolve_team_id().await?;
        let description = linear_project_description(description);
        let response: ProjectCreateData = self
            .graphql(
                CREATE_PROJECT_MUTATION,
                json!({
                    "name": name,
                    "description": description,
                    "teamId": team_id,
                }),
            )
            .await?;

        Ok(response.project_create.project.id)
    }

    async fn list_projects(&self, team_id: &str) -> PmResult<Vec<PmProject>> {
        let response: TeamProjectsData = self
            .graphql(LIST_TEAM_PROJECTS_QUERY, json!({ "teamId": team_id }))
            .await?;
        Ok(response
            .team
            .projects
            .nodes
            .into_iter()
            .map(|p| PmProject {
                id: p.id,
                name: p.name,
            })
            .collect())
    }

    async fn list_items(&self, project_id: &str) -> PmResult<Vec<PmItem>> {
        let mut after = None;
        let mut issues = Vec::new();

        loop {
            let response: ProjectIssuesData = self
                .graphql(
                    LIST_ITEMS_QUERY,
                    json!({
                        "projectId": project_id,
                        "after": after,
                        "first": LIST_ITEMS_PAGE_SIZE,
                    }),
                )
                .await?;

            let page = response.project.issues;
            issues.extend(page.nodes);

            if !page.page_info.has_next_page {
                issues.sort_by(|left, right| {
                    left.priority_sort_order
                        .total_cmp(&right.priority_sort_order)
                        .then_with(|| left.sort_order.total_cmp(&right.sort_order))
                });
                return Ok(issues
                    .into_iter()
                    .enumerate()
                    .map(|(rank, issue)| issue.into_pm_item(rank as u32))
                    .collect());
            }

            after = page.page_info.end_cursor;
        }
    }

    async fn create_item(&self, project_id: &str, item: &PmItemCreate) -> PmResult<String> {
        let team_id = self.resolve_team_id().await?;
        let response: IssueCreateData = self
            .graphql(
                CREATE_ITEM_MUTATION,
                json!({
                    "teamId": team_id,
                    "projectId": project_id,
                    "title": item.name,
                    "description": item.description,
                }),
            )
            .await?;

        Ok(response.issue_create.issue.id)
    }

    async fn update_item(&self, item_id: &str, update: &PmItemUpdate) -> PmResult<()> {
        let Some(update) = update.text_update() else {
            return Ok(());
        };

        let _: Value = self
            .graphql(
                UPDATE_ITEM_MUTATION,
                json!({
                    "id": item_id,
                    "title": update.name,
                    "description": update.description,
                }),
            )
            .await?;
        Ok(())
    }

    async fn complete_item(&self, item_id: &str) -> PmResult<()> {
        let state_id = self.completed_state_id().await?;
        let _: Value = self
            .graphql(
                COMPLETE_ITEM_MUTATION,
                json!({
                    "id": item_id,
                    "stateId": state_id,
                }),
            )
            .await?;
        Ok(())
    }

    async fn comment(&self, item_id: &str, body: &str) -> PmResult<()> {
        let _: Value = self
            .graphql(
                CREATE_COMMENT_MUTATION,
                json!({
                    "issueId": item_id,
                    "body": body,
                }),
            )
            .await?;
        Ok(())
    }

    async fn claim_item(&self, item_id: &str, branch: &str) -> PmResult<()> {
        // Look up the authenticated user's ID
        let viewer: Value = self.graphql(VIEWER_QUERY, json!({})).await?;
        let viewer_id = viewer
            .pointer("/viewer/id")
            .and_then(|v| v.as_str())
            .ok_or_else(|| PmError::Message("failed to resolve viewer ID".to_string()))?
            .to_string();

        let current: Value = self
            .graphql(CLAIM_ITEM_STATE_QUERY, json!({ "id": item_id }))
            .await?;
        let current_assignee = current
            .pointer("/issue/assignee/id")
            .and_then(|v| v.as_str());
        if current_assignee.is_some_and(|assignee| assignee != viewer_id) {
            return Err(PmError::Message(format!(
                "item claimed by another assignee (expected {viewer_id}, got {current_assignee:?})"
            )));
        }
        let current_branch = current
            .pointer("/issue/branchName")
            .and_then(|v| v.as_str());
        if current_branch.is_some_and(|actual| !actual.is_empty() && actual != branch) {
            return Err(PmError::Message(format!(
                "item claimed by another worker (expected branch {branch}, got {current_branch:?})"
            )));
        }

        let response: Value = self
            .graphql(
                CLAIM_ITEM_MUTATION,
                json!({
                    "id": item_id,
                    "assigneeId": viewer_id,
                    "branchName": branch,
                }),
            )
            .await?;

        // Optimistic verify: check the response confirms we're the assignee.
        // Another worker may have assigned themselves between our read and write.
        let actual_assignee = response
            .pointer("/issueUpdate/issue/assignee/id")
            .and_then(|v| v.as_str());
        if actual_assignee != Some(&viewer_id) {
            return Err(PmError::Message(format!(
                "item claimed by another assignee (expected {viewer_id}, got {actual_assignee:?})"
            )));
        }

        let actual_branch = response
            .pointer("/issueUpdate/issue/branchName")
            .and_then(|v| v.as_str());
        if actual_branch != Some(branch) {
            return Err(PmError::Message(format!(
                "item claimed by another worker (expected branch {branch}, got {actual_branch:?})"
            )));
        }

        Ok(())
    }
}

#[derive(Serialize)]
struct GraphqlRequest<'a> {
    query: &'a str,
    variables: Value,
}

#[derive(Deserialize)]
struct GraphqlResponse {
    #[serde(default)]
    data: Option<Value>,
    #[serde(default)]
    errors: Vec<GraphqlError>,
}

#[derive(Debug, Deserialize)]
struct GraphqlError {
    message: String,
    #[serde(default)]
    extensions: Option<GraphqlErrorExtensions>,
}

impl GraphqlError {
    fn display_message(&self) -> &str {
        self.extensions
            .as_ref()
            .and_then(|extensions| extensions.user_presentable_message.as_deref())
            .filter(|message| !message.trim().is_empty())
            .unwrap_or(&self.message)
    }
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GraphqlErrorExtensions {
    #[serde(default)]
    user_presentable_message: Option<String>,
}

#[derive(Deserialize)]
struct ProjectCreateData {
    #[serde(rename = "projectCreate")]
    project_create: ProjectPayload,
}

#[derive(Deserialize)]
struct IssueCreateData {
    #[serde(rename = "issueCreate")]
    issue_create: IssuePayload,
}

#[derive(Deserialize)]
struct ProjectPayload {
    project: IdNode,
}

#[derive(Deserialize)]
struct IssuePayload {
    issue: IdNode,
}

#[derive(Deserialize)]
struct IdNode {
    id: String,
}

#[derive(Deserialize)]
struct IssueNode {
    id: String,
    #[serde(default)]
    title: String,
    #[serde(default)]
    description: Option<String>,
    #[serde(rename = "prioritySortOrder", default)]
    priority_sort_order: f64,
    #[serde(rename = "sortOrder", default)]
    sort_order: f64,
    #[serde(default)]
    assignee: Option<IdNode>,
    #[serde(default)]
    state: Option<WorkflowStateRef>,
}

impl IssueNode {
    fn into_pm_item(self, rank: u32) -> PmItem {
        let completed = self
            .state
            .as_ref()
            .is_some_and(|state| state.r#type.eq_ignore_ascii_case(COMPLETED_STATE_TYPE));

        PmItem {
            id: self.id,
            name: self.title,
            description: self.description.unwrap_or_default(),
            rank,
            completed,
            assignee: self.assignee.map(|a| a.id),
        }
    }
}

#[derive(Deserialize)]
struct WorkflowStateRef {
    #[serde(rename = "type")]
    r#type: String,
}

#[derive(Deserialize)]
struct ProjectIssuesData {
    project: ProjectWithIssues,
}

#[derive(Deserialize)]
struct ProjectWithIssues {
    issues: IssuesConnection,
}

#[derive(Deserialize)]
struct IssuesConnection {
    nodes: Vec<IssueNode>,
    #[serde(rename = "pageInfo")]
    page_info: PageInfo,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PageInfo {
    has_next_page: bool,
    end_cursor: Option<String>,
}

#[derive(Deserialize)]
struct WorkflowStatesData {
    #[serde(rename = "workflowStates")]
    workflow_states: WorkflowStatesConnection,
}

#[derive(Deserialize)]
struct WorkflowStatesConnection {
    nodes: Vec<WorkflowStateNode>,
}

#[derive(Deserialize)]
struct WorkflowStateNode {
    id: String,
}

#[derive(Deserialize)]
struct TeamsData {
    teams: TeamsConnection,
}

#[derive(Deserialize)]
struct TeamsConnection {
    nodes: Vec<TeamNode>,
}

#[derive(Deserialize)]
struct TeamNode {
    id: String,
    name: String,
}

#[derive(Deserialize)]
struct TeamCreateData {
    #[serde(rename = "teamCreate")]
    team_create: TeamCreatePayload,
}

#[derive(Deserialize)]
struct TeamCreatePayload {
    team: IdNode,
}

#[derive(Deserialize)]
struct TeamProjectsData {
    team: TeamWithProjects,
}

#[derive(Deserialize)]
struct TeamWithProjects {
    projects: ProjectsConnection,
}

#[derive(Deserialize)]
struct ProjectsConnection {
    nodes: Vec<ProjectNode>,
}

#[derive(Deserialize)]
struct ProjectNode {
    id: String,
    name: String,
}

async fn parse_graphql_response<T: DeserializeOwned>(response: reqwest::Response) -> PmResult<T> {
    let status = response.status();
    let body = response
        .bytes()
        .await
        .map_err(|err| PmError::Message(format!("failed to read Linear response: {err}")))?;

    let parsed = serde_json::from_slice::<GraphqlResponse>(&body)
        .map_err(|err| PmError::Message(format!("failed to decode Linear response: {err}")))?;

    if let Some(error) = parsed.errors.first() {
        if status.is_success() {
            return Err(PmError::Message(error.display_message().to_string()));
        }
        return Err(PmError::Message(format!(
            "linear request failed with status {status}: {}",
            error.display_message()
        )));
    }

    if !status.is_success() {
        let body_text = String::from_utf8_lossy(&body).trim().to_string();
        if body_text.is_empty() {
            return Err(PmError::Message(format!(
                "linear request failed with status {status}"
            )));
        }
        return Err(PmError::Message(format!(
            "linear request failed with status {status}: {body_text}"
        )));
    }

    let data = parsed
        .data
        .ok_or_else(|| PmError::Message("linear response missing data".to_string()))?;

    serde_json::from_value(data)
        .map_err(|err| PmError::Message(format!("failed to decode Linear response: {err}")))
}

/// Derive a Linear team key from a name: uppercase letters, max 5 chars.
/// "Loopflow" → "LF", "My Team" → "MT".
fn team_key_from_name(name: &str) -> String {
    let key: String = name
        .split_whitespace()
        .filter_map(|word| word.chars().next())
        .map(|ch| ch.to_ascii_uppercase())
        .collect();
    if key.is_empty() {
        "LF".to_string()
    } else {
        key[..key.len().min(5)].to_string()
    }
}

fn linear_project_description(description: &str) -> String {
    let summary = first_meaningful_paragraph(description);
    if summary.is_empty() {
        return String::new();
    }

    const MAX_DESCRIPTION_LEN: usize = 255;
    let mut truncated = String::new();
    for ch in summary.chars().take(MAX_DESCRIPTION_LEN) {
        truncated.push(ch);
    }
    truncated
}

fn first_meaningful_paragraph(description: &str) -> String {
    let mut lines = description.lines().peekable();
    while let Some(line) = lines.next() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }

        let mut paragraph = vec![trimmed];
        while let Some(next_line) = lines.peek() {
            let trimmed = next_line.trim();
            if trimmed.is_empty() {
                break;
            }
            if trimmed.starts_with('#') {
                lines.next();
                break;
            }
            paragraph.push(trimmed);
            lines.next();
        }

        return paragraph.join(" ");
    }

    String::new()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lfd::pm::test_server::{self, json_response, response};
    use axum::http::StatusCode;
    use serde_json::json;

    #[tokio::test]
    async fn create_project_sends_team_id_and_bare_api_key_header() {
        let (base_url, requests) = test_server::spawn(vec![json_response(
            StatusCode::OK,
            json!({
                "data": {
                    "projectCreate": {
                        "project": { "id": "project-123" }
                    }
                }
            }),
        )])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        let project_id = client
            .create_project("Wave PM", "Ship the Linear client")
            .await
            .expect("create project should succeed");

        assert_eq!(project_id, "project-123");
        let requests = requests.lock().await;
        assert_eq!(requests.len(), 1);
        assert_eq!(requests[0].method, "POST");
        assert_eq!(requests[0].path, "/");
        assert_eq!(
            requests[0].authorization.as_deref(),
            Some("Bearer linear-secret")
        );
        assert_eq!(
            serde_json::from_str::<Value>(&requests[0].body).expect("json body"),
            json!({
                "query": CREATE_PROJECT_MUTATION,
                "variables": {
                    "name": "Wave PM",
                    "description": "Ship the Linear client",
                    "teamId": "team-9"
                }
            })
        );
    }

    #[tokio::test]
    async fn create_project_uses_short_summary_for_long_readme_descriptions() {
        let (base_url, requests) = test_server::spawn(vec![json_response(
            StatusCode::OK,
            json!({
                "data": {
                    "projectCreate": {
                        "project": { "id": "project-123" }
                    }
                }
            }),
        )])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        client
            .create_project(
                "Wave PM",
                "## Vision\n\nLoopflow syncs with the PM tools teams already use.\n\n## Strategy\n\nThis paragraph should not become the summary.",
            )
            .await
            .expect("create project should succeed");

        let requests = requests.lock().await;
        assert_eq!(
            serde_json::from_str::<Value>(&requests[0].body).expect("json body"),
            json!({
                "query": CREATE_PROJECT_MUTATION,
                "variables": {
                    "name": "Wave PM",
                    "description": "Loopflow syncs with the PM tools teams already use.",
                    "teamId": "team-9"
                }
            })
        );
    }

    #[tokio::test]
    async fn list_items_paginates_and_assigns_rank_by_priority_sort_order() {
        let (base_url, requests) = test_server::spawn(vec![
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "project": {
                            "issues": {
                                "nodes": [
                                    {
                                        "id": "issue-1",
                                        "title": "First",
                                        "description": "one",
                                        "prioritySortOrder": 30.0,
                                        "sortOrder": 30.0,
                                        "state": { "type": "backlog" }
                                    },
                                    {
                                        "id": "issue-2",
                                        "title": "Second",
                                        "description": "two",
                                        "prioritySortOrder": 10.0,
                                        "sortOrder": 10.0,
                                        "state": { "type": "completed" }
                                    }
                                ],
                                "pageInfo": {
                                    "hasNextPage": true,
                                    "endCursor": "cursor-2"
                                }
                            }
                        }
                    }
                }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "project": {
                            "issues": {
                                "nodes": [
                                    {
                                        "id": "issue-3",
                                        "title": "Third",
                                        "description": null,
                                        "prioritySortOrder": 20.0,
                                        "sortOrder": 20.0,
                                        "state": { "type": "unstarted" }
                                    }
                                ],
                                "pageInfo": {
                                    "hasNextPage": false,
                                    "endCursor": null
                                }
                            }
                        }
                    }
                }),
            ),
        ])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        let items = client
            .list_items("project-123")
            .await
            .expect("list items should succeed");

        assert_eq!(
            items,
            vec![
                PmItem {
                    id: "issue-2".to_string(),
                    name: "Second".to_string(),
                    description: "two".to_string(),
                    rank: 0,
                    completed: true,
                    assignee: None,
                },
                PmItem {
                    id: "issue-3".to_string(),
                    name: "Third".to_string(),
                    description: "".to_string(),
                    rank: 1,
                    completed: false,
                    assignee: None,
                },
                PmItem {
                    id: "issue-1".to_string(),
                    name: "First".to_string(),
                    description: "one".to_string(),
                    rank: 2,
                    completed: false,
                    assignee: None,
                },
            ]
        );

        let requests = requests.lock().await;
        assert_eq!(requests.len(), 2);
        assert_eq!(
            serde_json::from_str::<Value>(&requests[0].body).expect("json body"),
            json!({
                "query": LIST_ITEMS_QUERY,
                "variables": {
                    "projectId": "project-123",
                    "after": null,
                    "first": LIST_ITEMS_PAGE_SIZE,
                }
            })
        );
        assert_eq!(
            serde_json::from_str::<Value>(&requests[1].body).expect("json body"),
            json!({
                "query": LIST_ITEMS_QUERY,
                "variables": {
                    "projectId": "project-123",
                    "after": "cursor-2",
                    "first": LIST_ITEMS_PAGE_SIZE,
                }
            })
        );
    }

    #[tokio::test]
    async fn create_update_and_comment_map_to_linear_mutations() {
        let (base_url, requests) = test_server::spawn(vec![
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issueCreate": {
                            "issue": { "id": "issue-123" }
                        }
                    }
                }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issueUpdate": {
                            "issue": { "id": "issue-123" }
                        }
                    }
                }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "commentCreate": {
                            "comment": { "id": "comment-1" }
                        }
                    }
                }),
            ),
        ])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        let item_id = client
            .create_item(
                "project-123",
                &PmItemCreate {
                    name: "Implement client".to_string(),
                    description: "Build the GraphQL adapter".to_string(),
                    rank: 7,
                },
            )
            .await
            .expect("create item should succeed");
        client
            .update_item(
                &item_id,
                &PmItemUpdate {
                    name: Some("Implement Linear client".to_string()),
                    description: Some("Build the GraphQL adapter and tests".to_string()),
                    rank: Some(0),
                },
            )
            .await
            .expect("update item should succeed");
        client
            .comment(&item_id, "Shipped in v0.9.9")
            .await
            .expect("comment should succeed");

        assert_eq!(item_id, "issue-123");
        let requests = requests.lock().await;
        assert_eq!(requests.len(), 3);
        assert_eq!(
            serde_json::from_str::<Value>(&requests[0].body).expect("json body"),
            json!({
                "query": CREATE_ITEM_MUTATION,
                "variables": {
                    "teamId": "team-9",
                    "projectId": "project-123",
                    "title": "Implement client",
                    "description": "Build the GraphQL adapter"
                }
            })
        );
        assert_eq!(
            serde_json::from_str::<Value>(&requests[1].body).expect("json body"),
            json!({
                "query": UPDATE_ITEM_MUTATION,
                "variables": {
                    "id": "issue-123",
                    "title": "Implement Linear client",
                    "description": "Build the GraphQL adapter and tests"
                }
            })
        );
        assert_eq!(
            serde_json::from_str::<Value>(&requests[2].body).expect("json body"),
            json!({
                "query": CREATE_COMMENT_MUTATION,
                "variables": {
                    "issueId": "issue-123",
                    "body": "Shipped in v0.9.9"
                }
            })
        );
    }

    #[tokio::test]
    async fn complete_item_queries_completed_workflow_state_before_update() {
        let (base_url, requests) = test_server::spawn(vec![
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "workflowStates": {
                            "nodes": [
                                {
                                    "id": "state-complete",
                                    "name": "Done",
                                    "type": "completed"
                                }
                            ]
                        }
                    }
                }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issueUpdate": {
                            "issue": { "id": "issue-123" }
                        }
                    }
                }),
            ),
        ])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        client
            .complete_item("issue-123")
            .await
            .expect("complete item should succeed");

        let requests = requests.lock().await;
        assert_eq!(requests.len(), 2);
        assert_eq!(
            serde_json::from_str::<Value>(&requests[0].body).expect("json body"),
            json!({
                "query": LIST_COMPLETED_WORKFLOW_STATES_QUERY,
                "variables": {
                    "teamId": "team-9"
                }
            })
        );
        assert_eq!(
            serde_json::from_str::<Value>(&requests[1].body).expect("json body"),
            json!({
                "query": COMPLETE_ITEM_MUTATION,
                "variables": {
                    "id": "issue-123",
                    "stateId": "state-complete"
                }
            })
        );
    }

    #[tokio::test]
    async fn complete_item_fails_when_team_has_no_completed_state() {
        let (base_url, requests) = test_server::spawn(vec![json_response(
            StatusCode::OK,
            json!({
                "data": {
                    "workflowStates": {
                        "nodes": []
                    }
                }
            }),
        )])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        let error = client
            .complete_item("issue-123")
            .await
            .expect_err("completion should fail without a completed state");

        assert_eq!(
            error,
            PmError::Message(
                "no completed Linear workflow state found for team team-9".to_string()
            )
        );
        assert_eq!(requests.lock().await.len(), 1);
    }

    #[tokio::test]
    async fn update_item_skips_rank_only_updates() {
        let (base_url, requests) = test_server::spawn(Vec::new()).await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        client
            .update_item(
                "issue-123",
                &PmItemUpdate {
                    name: None,
                    description: None,
                    rank: Some(1),
                },
            )
            .await
            .expect("rank-only update should no-op");

        assert!(requests.lock().await.is_empty());
    }

    #[tokio::test]
    async fn retries_after_rate_limit_response() {
        let (base_url, requests) = test_server::spawn(vec![
            response(
                StatusCode::TOO_MANY_REQUESTS,
                vec![("retry-after", "0")],
                json!({ "errors": [{ "message": "slow down" }] }).to_string(),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issueCreate": {
                            "issue": { "id": "issue-123" }
                        }
                    }
                }),
            ),
        ])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        let item_id = client
            .create_item(
                "project-123",
                &PmItemCreate {
                    name: "Implement client".to_string(),
                    description: "Build the GraphQL adapter".to_string(),
                    rank: 0,
                },
            )
            .await
            .expect("request should succeed after retry");

        assert_eq!(item_id, "issue-123");
        assert_eq!(requests.lock().await.len(), 2);
    }

    #[tokio::test]
    async fn graphql_errors_override_data() {
        let (base_url, _requests) = test_server::spawn(vec![json_response(
            StatusCode::OK,
            json!({
                "data": {
                    "issueCreate": {
                        "issue": { "id": "issue-123" }
                    }
                },
                "errors": [{ "message": "project is required" }]
            }),
        )])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        let error = client
            .create_item(
                "project-123",
                &PmItemCreate {
                    name: "Implement client".to_string(),
                    description: "Build the GraphQL adapter".to_string(),
                    rank: 0,
                },
            )
            .await
            .expect_err("graphql errors should win over data");

        assert_eq!(error, PmError::Message("project is required".to_string()));
    }

    #[test]
    fn parse_graphql_response_surfaces_http_error_messages() {
        let message = parse_linear_error_message(
            StatusCode::BAD_REQUEST,
            &json!({ "errors": [{ "message": "team is required" }] }).to_string(),
        );

        assert_eq!(
            message,
            "linear request failed with status 400 Bad Request: team is required"
        );
    }

    #[test]
    fn parse_graphql_response_prefers_user_presentable_messages() {
        let message = parse_linear_error_message(
            StatusCode::BAD_REQUEST,
            &json!({
                "errors": [{
                    "message": "Argument Validation Error",
                    "extensions": {
                        "userPresentableMessage": "description must be shorter than or equal to 255 characters."
                    }
                }]
            })
            .to_string(),
        );

        assert_eq!(
            message,
            "linear request failed with status 400 Bad Request: description must be shorter than or equal to 255 characters."
        );
    }

    fn parse_linear_error_message(status: StatusCode, body: &str) -> String {
        let response: GraphqlResponse =
            serde_json::from_str(body).expect("graphql response should parse");
        if let Some(error) = response.errors.first() {
            return format!(
                "linear request failed with status {status}: {}",
                error.display_message()
            );
        }
        panic!("expected graphql error message")
    }

    #[tokio::test]
    async fn claim_item_verifies_branch_matches() {
        let (base_url, requests) = test_server::spawn(vec![
            // viewer query
            json_response(
                StatusCode::OK,
                json!({ "data": { "viewer": { "id": "user-42" } } }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issue": {
                            "id": "issue-1",
                            "assignee": null,
                            "branchName": null
                        }
                    }
                }),
            ),
            // claim mutation — response confirms our assignee
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issueUpdate": {
                            "issue": {
                                "id": "issue-1",
                                "assignee": { "id": "user-42" },
                                "branchName": "feature/test"
                            }
                        }
                    }
                }),
            ),
        ])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        client
            .claim_item("issue-1", "feature/test")
            .await
            .expect("claim should succeed when branch matches");

        let requests = requests.lock().await;
        assert_eq!(requests.len(), 3);
        assert!(requests[1].body.contains("ClaimIssueState"));
        assert!(requests[2].body.contains("ClaimIssue"));
    }

    #[tokio::test]
    async fn claim_item_fails_when_another_assignee_wins() {
        let (base_url, _requests) = test_server::spawn(vec![
            // viewer query
            json_response(
                StatusCode::OK,
                json!({ "data": { "viewer": { "id": "user-42" } } }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issue": {
                            "id": "issue-1",
                            "assignee": null,
                            "branchName": null
                        }
                    }
                }),
            ),
            // claim mutation — response shows a different assignee won
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issueUpdate": {
                            "issue": {
                                "id": "issue-1",
                                "assignee": { "id": "user-99" },
                                "branchName": "feature/test"
                            }
                        }
                    }
                }),
            ),
        ])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        let error = client
            .claim_item("issue-1", "feature/test")
            .await
            .expect_err("claim should fail when another assignee wins");
        assert!(error.to_string().contains("another assignee"));
    }

    #[tokio::test]
    async fn claim_item_fails_same_account_concurrent() {
        let (base_url, _requests) = test_server::spawn(vec![
            json_response(
                StatusCode::OK,
                json!({ "data": { "viewer": { "id": "user-42" } } }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issue": {
                            "id": "issue-1",
                            "assignee": null,
                            "branchName": null
                        }
                    }
                }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issueUpdate": {
                            "issue": {
                                "id": "issue-1",
                                "assignee": { "id": "user-42" },
                                "branchName": "worker-a"
                            }
                        }
                    }
                }),
            ),
            json_response(
                StatusCode::OK,
                json!({ "data": { "viewer": { "id": "user-42" } } }),
            ),
            json_response(
                StatusCode::OK,
                json!({
                    "data": {
                        "issue": {
                            "id": "issue-1",
                            "assignee": { "id": "user-42" },
                            "branchName": "worker-a"
                        }
                    }
                }),
            ),
        ])
        .await;
        let client = LinearClient::with_base_url(
            "linear-secret".to_string(),
            Some("team-9".to_string()),
            base_url,
        );

        client
            .claim_item("issue-1", "worker-a")
            .await
            .expect("first branch should win");
        let error = client
            .claim_item("issue-1", "worker-b")
            .await
            .expect_err("second branch should lose even with the same account");
        assert!(error.to_string().contains("another worker"));
    }

    #[test]
    fn linear_project_description_skips_headings_and_truncates() {
        let summary = linear_project_description(
            "## Vision\n\nThis is the first paragraph.\n\n## Strategy\n\nSecond paragraph.",
        );
        assert_eq!(summary, "This is the first paragraph.".to_string());

        let long = "a".repeat(300);
        let summary = linear_project_description(&long);
        assert_eq!(summary.len(), 255);

        assert_eq!(linear_project_description(""), "");
    }
}
