use anyhow::Result;
use rmcp::model::*;
use rmcp::schemars::JsonSchema;
use rmcp::{tool, ServerHandler};
use serde::{Deserialize, Serialize};

use crate::config::Config;
use crate::db::Database;
use crate::embedding::{self, Embedder};
use crate::linear::LinearClient;
use crate::search::{self, SearchMode};

/// Scan a JSON value for issue identifiers (e.g. "CUT-42") and add a `referenced_issues`
/// field with their URLs so agents can render them as clickable links.
fn enrich_with_issue_links(value: &mut serde_json::Value, db: &Database) {
    let text = value.to_string();
    let identifiers = extract_issue_identifiers(&text);
    if identifiers.is_empty() {
        return;
    }

    let mut refs = serde_json::Map::new();
    for ident in &identifiers {
        if let Ok(Some(issue)) = db.get_issue(ident) {
            if !issue.url.is_empty() {
                refs.insert(
                    ident.clone(),
                    serde_json::json!({
                        "url": issue.url,
                        "title": issue.title,
                        "state": issue.state_name,
                    }),
                );
            }
        }
    }

    if !refs.is_empty() {
        if let serde_json::Value::Object(map) = value {
            map.insert(
                "referenced_issues".to_string(),
                serde_json::Value::Object(refs),
            );
        }
    }
}

/// Extract issue identifiers (e.g. "CUT-42", "ENG-123") from text.
/// Matches patterns like 1-4 uppercase letters followed by a dash and digits.
fn extract_issue_identifiers(text: &str) -> Vec<String> {
    let mut result = Vec::new();
    let mut seen = std::collections::HashSet::new();

    let bytes = text.as_bytes();
    let len = bytes.len();
    let mut i = 0;

    while i < len {
        // Look for uppercase letter start
        if bytes[i].is_ascii_uppercase() {
            let start = i;
            // Consume 1-6 uppercase letters
            while i < len && bytes[i].is_ascii_uppercase() {
                i += 1;
            }
            let key_len = i - start;
            if (1..=6).contains(&key_len) && i < len && bytes[i] == b'-' {
                i += 1; // skip dash
                let digit_start = i;
                while i < len && bytes[i].is_ascii_digit() {
                    i += 1;
                }
                if i > digit_start {
                    // Make sure it's not part of a larger word
                    let before_ok = start == 0 || !bytes[start - 1].is_ascii_alphanumeric();
                    let after_ok = i >= len || !bytes[i].is_ascii_alphanumeric();
                    if before_ok && after_ok {
                        let ident = text[start..i].to_string();
                        if seen.insert(ident.clone()) {
                            result.push(ident);
                        }
                    }
                }
            }
        } else {
            i += 1;
        }
    }

    result
}

/// Extract markdown image references from text (e.g., `![alt](url)` or `![](url)`).
fn extract_image_references(text: &str) -> Vec<&str> {
    let mut images = Vec::new();
    let mut remaining = text;
    while let Some(start) = remaining.find("![") {
        if let Some(alt_end) = remaining[start + 2..].find("](") {
            let paren_start = start + 2 + alt_end + 2;
            if let Some(paren_end) = remaining[paren_start..].find(')') {
                let full_end = paren_start + paren_end + 1;
                images.push(&remaining[start..full_end]);
                remaining = &remaining[full_end..];
                continue;
            }
        }
        remaining = &remaining[start + 2..];
    }
    images
}

/// If `new_description` would drop image references present in `original`, append them.
pub(crate) fn preserve_images(original: &str, new_description: &str) -> String {
    let original_images = extract_image_references(original);
    if original_images.is_empty() {
        return new_description.to_string();
    }

    let mut missing: Vec<&str> = Vec::new();
    for img in &original_images {
        if !new_description.contains(img) {
            missing.push(img);
        }
    }

    if missing.is_empty() {
        return new_description.to_string();
    }

    let mut result = new_description.to_string();
    result.push_str("\n\n");
    result.push_str(&missing.join("\n"));
    result
}

/// Extract code-relevant search hints from issue title, description, and labels.
/// Returns terms that Claude should search for in the codebase.
fn extract_code_hints(title: &str, description: &str, labels: &[String]) -> Vec<String> {
    let mut hints = Vec::new();
    let combined = format!("{} {}", title, description);

    // Extract file paths (e.g. src/foo.rs, Components/Bar.swift)
    for word in combined.split_whitespace() {
        let word = word.trim_matches(|c: char| {
            !c.is_alphanumeric() && c != '/' && c != '.' && c != '_' && c != '-'
        });
        if (word.contains('/') && word.contains('.'))
            || word.ends_with(".rs")
            || word.ends_with(".ts")
            || word.ends_with(".swift")
        {
            hints.push(word.to_string());
        }
    }

    // Extract backtick-quoted identifiers (e.g. `WorktreeManager`, `cleanup()`)
    for cap in combined.split('`').collect::<Vec<_>>().chunks(2) {
        if cap.len() == 2 && !cap[1].is_empty() && cap[1].len() < 80 {
            hints.push(cap[1].trim().to_string());
        }
    }

    // Extract PascalCase and snake_case identifiers from title
    for word in title.split_whitespace() {
        let word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
        // PascalCase: at least 2 uppercase letters with lowercase between
        let upper_count = word.chars().filter(|c| c.is_uppercase()).count();
        if upper_count >= 2
            && word.len() >= 4
            && word.chars().next().is_some_and(|c| c.is_uppercase())
        {
            hints.push(word.to_string());
        }
        // snake_case
        if word.contains('_')
            && word.chars().all(|c| c.is_alphanumeric() || c == '_')
            && word.len() >= 4
        {
            hints.push(word.to_string());
        }
    }

    // Add labels as search terms
    for label in labels {
        if !label.is_empty() {
            hints.push(label.clone());
        }
    }

    // Deduplicate
    hints.sort();
    hints.dedup();
    hints
}

/// Suggest up to 3 label names from the local catalog matching any unknown name as a substring.
/// Returns an empty vec if catalog can't be read or no candidates match.
fn suggest_label_names(db: &Database, workspace: &str, unknown: &[String]) -> Vec<String> {
    let Ok(catalog) = db.list_labels(workspace) else { return Vec::new() };
    let mut hits: Vec<String> = Vec::new();
    for u in unknown {
        let needle = u.to_lowercase();
        for label in &catalog {
            let hay = label.name.to_lowercase();
            if hay.contains(&needle) || needle.contains(&hay) {
                if !hits.contains(&label.name) {
                    hits.push(label.name.clone());
                    if hits.len() >= 3 { return hits; }
                }
            }
        }
    }
    hits
}

#[derive(Clone)]
pub struct RectilinearMcp {
    db: Database,
    config: Config,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ListWorkspacesArgs {}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ListLabelsArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct SearchArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Search query text
    query: String,
    /// Filter by team key (e.g., "ENG")
    team: Option<String>,
    /// Filter by state name
    state: Option<String>,
    /// Search mode: "fts", "vector", or "hybrid"
    mode: Option<String>,
    /// Maximum number of results
    limit: Option<usize>,
    /// Filter to issues that have ALL of these labels (case-insensitive).
    labels: Option<Vec<String>>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct FindDuplicatesArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Title of the potential new issue
    title: String,
    /// Description of the potential new issue
    description: Option<String>,
    /// Filter by team key
    team: Option<String>,
    /// Minimum similarity threshold (0.0-1.0)
    threshold: Option<f32>,
    /// Maximum number of results
    limit: Option<usize>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct GetIssueArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Issue ID or identifier (e.g., "ENG-123")
    id: String,
    /// Whether to include comments
    include_comments: Option<bool>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct CreateIssueArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Team key (e.g., "ENG")
    team: String,
    /// Issue title
    title: String,
    /// Issue description
    description: Option<String>,
    /// Priority: 1=Urgent, 2=High, 3=Medium, 4=Low
    priority: Option<i32>,
    /// Parent issue identifier to create as sub-issue (e.g., "CUT-42")
    parent: Option<String>,
    /// Set labels by name (case-insensitive). Use list_labels to discover.
    labels: Option<Vec<String>>,
    /// Assignee. Pass "me" to assign to the authenticated user, or a name (case-insensitive).
    assignee: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct UpdateIssueArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Issue ID or identifier
    id: String,
    /// New title
    title: Option<String>,
    /// New description
    description: Option<String>,
    /// New priority
    priority: Option<i32>,
    /// Set issue state by name (e.g., "Done", "Cancelled", "In Progress")
    state: Option<String>,
    /// Set labels by name (replaces all existing labels)
    labels: Option<Vec<String>>,
    /// Set project by name (or "none" to remove from project)
    project: Option<String>,
    /// Assignee. Pass "me" for self-assign, "none" to clear, or a name (case-insensitive).
    assignee: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct AppendArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Issue ID or identifier
    id: String,
    /// Comment text to add
    comment: Option<String>,
    /// Text to append to description
    description: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct SyncTeamArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Team key to sync
    team: String,
    /// Whether to do a full re-sync
    full: Option<bool>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct IssueContextArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Issue ID or identifier
    id: String,
    /// Number of similar issues to return
    similar_count: Option<usize>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct GetTriageQueueArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Team key (e.g., "CUT")
    team: String,
    /// Max issues to return (default 10)
    limit: Option<usize>,
    /// Issue identifiers to skip (already triaged this session)
    exclude: Option<Vec<String>>,
    /// Randomize issue order instead of chronological (default false)
    shuffle: Option<bool>,
    /// Include completed/canceled issues (default false). Useful for archival prioritization.
    include_completed: Option<bool>,
    /// Filter to issues that have ALL of these labels (case-insensitive).
    labels: Option<Vec<String>>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct MarkTriagedArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Issue identifier (e.g., "CUT-42")
    id: String,
    /// New priority (1=Urgent, 2=High, 3=Medium, 4=Low)
    priority: i32,
    /// Improved title (optional)
    title: Option<String>,
    /// Improved description (optional)
    description: Option<String>,
    /// Triage comment explaining the decision (optional)
    comment: Option<String>,
    /// Set issue state (e.g., "Done", "Cancelled", "Duplicate", "Backlog"). Looked up by name for the issue's team.
    state: Option<String>,
    /// Set labels by name (replaces all existing labels)
    labels: Option<Vec<String>>,
    /// Set project by name (or "none" to remove from project)
    project: Option<String>,
    /// Assignee. Pass "me" for self-assign, "none" to clear, or a name (case-insensitive).
    assignee: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ManageRelationArgs {
    /// Workspace name (required). Use list_workspaces to see available workspaces.
    workspace: Option<String>,
    /// Action: "add" or "remove"
    action: String,
    /// Source issue identifier (e.g., "CUT-42")
    issue: String,
    /// Related issue identifier (e.g., "CUT-99")
    related_issue: String,
    /// Relation type: "blocks", "blocked_by", "related", "duplicate"
    relation_type: String,
}

#[tool(tool_box)]
impl RectilinearMcp {
    pub fn new(db: Database, config: Config) -> Self {
        Self { db, config }
    }

    #[tool(
        name = "list_workspaces",
        description = "List all configured workspaces. Use this to discover available workspace names before calling other tools."
    )]
    async fn list_workspaces(
        &self,
        #[tool(aggr)] _args: ListWorkspacesArgs,
    ) -> Result<String, String> {
        let names = self.config.workspace_names();
        let active = self.config.resolve_active_workspace().ok();

        let mut workspaces = Vec::new();
        for name in &names {
            let ws = self
                .config
                .workspace_config(name)
                .map_err(|e| e.to_string())?;
            let db_info = self.db.get_workspace(name).map_err(|e| e.to_string())?;
            workspaces.push(serde_json::json!({
                "name": name,
                "active": active.as_deref() == Some(name.as_str()),
                "default_team": ws.default_team,
                "org_name": db_info.as_ref().and_then(|w| w.display_name.clone()),
            }));
        }

        serde_json::to_string_pretty(&serde_json::json!({
            "workspaces": workspaces,
            "instruction": "Pass the workspace name to all other tools."
        }))
        .map_err(|e| e.to_string())
    }

    #[tool(
        name = "list_labels",
        description = "List all labels in the workspace, grouped by parent. Pure local read — no Linear API call. If empty, run sync_team to refresh the catalog."
    )]
    async fn list_labels(
        &self,
        #[tool(aggr)] args: ListLabelsArgs,
    ) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let labels = self.db.list_labels(&workspace).map_err(|e| e.to_string())?;

        // Index by id for parent name lookup.
        let by_id: std::collections::HashMap<&str, &str> =
            labels.iter().map(|l| (l.id.as_str(), l.name.as_str())).collect();

        // Group: top-level (no parent) and grouped-by-parent.
        let mut top_level: Vec<&_> = labels.iter().filter(|l| l.parent_id.is_none()).collect();
        top_level.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));

        let mut groups: std::collections::BTreeMap<String, Vec<&_>> = std::collections::BTreeMap::new();
        for l in labels.iter().filter(|l| l.parent_id.is_some()) {
            let parent_name = l.parent_id.as_deref()
                .and_then(|pid| by_id.get(pid).copied())
                .unwrap_or("(unknown group)")
                .to_string();
            groups.entry(parent_name).or_default().push(l);
        }
        for v in groups.values_mut() {
            v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
        }

        let payload = serde_json::json!({
            "workspace": workspace,
            "count": labels.len(),
            "top_level": top_level.iter().map(|l| serde_json::json!({
                "name": l.name,
                "color": l.color,
            })).collect::<Vec<_>>(),
            "groups": groups.iter().map(|(parent, members)| serde_json::json!({
                "parent": parent,
                "labels": members.iter().map(|l| serde_json::json!({
                    "name": l.name,
                    "color": l.color,
                })).collect::<Vec<_>>(),
            })).collect::<Vec<_>>(),
            "note": if labels.is_empty() {
                "Catalog is empty. Run sync_team to populate labels."
            } else { "" },
        });

        serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())
    }

    #[tool(
        name = "search_issues",
        description = "Search Linear issues using hybrid FTS + vector search. Supports filtering by team and state."
    )]
    async fn search_issues(&self, #[tool(aggr)] args: SearchArgs) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let mode: SearchMode = args
            .mode
            .as_deref()
            .unwrap_or("hybrid")
            .parse()
            .map_err(|e: anyhow::Error| e.to_string())?;

        let limit = args.limit.unwrap_or(self.config.search.default_limit);

        let embedder = if mode != SearchMode::Fts {
            Embedder::new(&self.config).ok()
        } else {
            None
        };

        let label_ids = if let Some(ref names) = args.labels {
            let (resolved, unknown) = self.db
                .resolve_label_ids_local(&workspace, names)
                .map_err(|e| e.to_string())?;
            if !unknown.is_empty() {
                if resolved.is_empty()
                    && self.db.list_labels(&workspace).map_err(|e| e.to_string())?.is_empty()
                {
                    return Err(format!(
                        "No labels synced yet for workspace '{}'. Run sync_team first.",
                        workspace
                    ));
                }
                let suggestions = suggest_label_names(&self.db, &workspace, &unknown);
                return Err(format!(
                    "Label{} {} not found. {}Run list_labels for the full set.",
                    if unknown.len() == 1 { "" } else { "s" },
                    unknown.iter().map(|s| format!("'{}'", s)).collect::<Vec<_>>().join(", "),
                    if suggestions.is_empty() { String::new() }
                    else { format!("Did you mean: {}? ", suggestions.join(", ")) }
                ));
            }
            Some(resolved)
        } else {
            None
        };

        let results = search::search(
            &self.db,
            search::SearchParams {
                query: &args.query,
                mode,
                team_key: args.team.as_deref(),
                state_filter: args.state.as_deref(),
                label_ids: label_ids.as_deref(),
                limit,
                embedder: embedder.as_ref(),
                rrf_k: self.config.search.rrf_k,
                workspace_id: &workspace,
            },
        )
        .await
        .map_err(|e| e.to_string())?;

        serde_json::to_string_pretty(&results).map_err(|e| e.to_string())
    }

    #[tool(
        name = "find_duplicates",
        description = "Find potential duplicate issues. Provide a title and optional description to find similar existing issues with similarity scores."
    )]
    async fn find_duplicates(
        &self,
        #[tool(aggr)] args: FindDuplicatesArgs,
    ) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let embedder = Embedder::new(&self.config).map_err(|e| e.to_string())?;

        let search_text = if let Some(ref desc) = args.description {
            format!("{}\n\n{}", args.title, desc)
        } else {
            args.title.clone()
        };

        let threshold = args
            .threshold
            .unwrap_or(self.config.search.duplicate_threshold);
        let limit = args.limit.unwrap_or(10);

        let results = search::find_duplicates(
            &self.db,
            &search_text,
            args.team.as_deref(),
            threshold,
            limit,
            &embedder,
            self.config.search.rrf_k,
            &workspace,
        )
        .await
        .map_err(|e| e.to_string())?;

        serde_json::to_string_pretty(&results).map_err(|e| e.to_string())
    }

    #[tool(
        name = "get_issue",
        description = "Get full details of an issue by ID or identifier (e.g., 'ENG-123'). Includes description, state, priority, labels, and optionally comments. Falls back to fetching from Linear API if not found locally."
    )]
    async fn get_issue(&self, #[tool(aggr)] args: GetIssueArgs) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let issue = match self.db.get_issue(&args.id).map_err(|e| e.to_string())? {
            Some(issue) => issue,
            None => {
                // Not found locally — try fetching from Linear API by identifier
                let client = self.client_for_workspace(&workspace)?;
                let result = client
                    .fetch_issue_by_identifier(&args.id)
                    .await
                    .map_err(|e| e.to_string())?;
                match result {
                    Some((issue, relations, label_ids)) => {
                        self.db.upsert_issue(&issue).map_err(|e| e.to_string())?;
                        self.db
                            .upsert_relations(&issue.id, &relations)
                            .map_err(|e| e.to_string())?;
                        self.db
                            .replace_issue_labels(&issue.id, &label_ids)
                            .map_err(|e| e.to_string())?;
                        issue
                    }
                    None => return Err(format!("Issue '{}' not found", args.id)),
                }
            }
        };

        let mut value = serde_json::to_value(&issue).map_err(|e| e.to_string())?;

        let relations = self
            .db
            .get_relations_enriched(&issue.id)
            .map_err(|e| e.to_string())?;
        if !relations.is_empty() {
            value["relations"] = serde_json::to_value(&relations).map_err(|e| e.to_string())?;
        }

        if args.include_comments.unwrap_or(false) {
            let comments = self.db.get_comments(&issue.id).map_err(|e| e.to_string())?;
            value["comments"] = serde_json::to_value(&comments).map_err(|e| e.to_string())?;
        }

        enrich_with_issue_links(&mut value, &self.db);
        serde_json::to_string_pretty(&value).map_err(|e| e.to_string())
    }

    #[tool(
        name = "create_issue",
        description = "Create a new issue in Linear. Specify team (key like 'ENG'), title, and optionally description, priority (1=Urgent, 2=High, 3=Medium, 4=Low), labels (list of names), and assignee ('me' for self-assign, or a user's display name).

IMPORTANT — Before calling this tool, you MUST:

1. **Disambiguate the request.** Ask the user 2-4 clarifying questions to sharpen scope, acceptance criteria, and edge cases. Think like a principal engineer: what assumptions are you making? What could go wrong? What's in vs. out of scope? Do not create the issue until the user has answered.

2. **Check for duplicates.** Call find_duplicates with the intended title/description to verify this issue doesn't already exist. If a match is found (>0.8 similarity), show it to the user and ask whether to proceed, update the existing issue, or cancel.

3. **Write a clear title and description.** The title should be imperative and specific (e.g. 'Add rate limiting to /api/upload endpoint' not 'rate limiting'). The description should include: what the desired behavior is, why it matters, and any constraints or acceptance criteria surfaced during disambiguation."
    )]
    async fn create_issue(&self, #[tool(aggr)] args: CreateIssueArgs) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let client = self.client_for_workspace(&workspace)?;

        let team_id = client
            .get_team_id(&args.team)
            .await
            .map_err(|e| e.to_string())?;

        let parent_id = if let Some(ref parent_ident) = args.parent {
            let parent = self
                .db
                .get_issue(parent_ident)
                .map_err(|e| e.to_string())?
                .ok_or_else(|| format!("Parent issue '{}' not found", parent_ident))?;
            Some(parent.id)
        } else {
            None
        };

        let label_ids: Vec<String> = if let Some(ref names) = args.labels {
            self.resolve_labels_for_mutation(&workspace, &client, names).await?
        } else {
            Vec::new()
        };

        // Resolve assignee.
        let assignee_id: Option<String> = if let Some(ref a) = args.assignee {
            if a.eq_ignore_ascii_case("none") {
                return Err("Cannot use 'none' on create_issue; omit the parameter to leave unassigned.".to_string());
            }
            Some(client.resolve_assignee_id(a).await.map_err(|e| e.to_string())?)
        } else {
            None
        };

        let (issue_id, identifier) = client
            .create_issue(
                &team_id,
                &args.title,
                args.description.as_deref(),
                args.priority,
                &label_ids,
                assignee_id.as_deref(),
                parent_id.as_deref(),
            )
            .await
            .map_err(|e| e.to_string())?;

        let (issue, relations, label_ids) = client
            .fetch_single_issue(&issue_id)
            .await
            .map_err(|e| e.to_string())?;
        self.db.upsert_issue(&issue).map_err(|e| e.to_string())?;
        self.db
            .upsert_relations(&issue.id, &relations)
            .map_err(|e| e.to_string())?;
        self.db
            .replace_issue_labels(&issue.id, &label_ids)
            .map_err(|e| e.to_string())?;

        Ok(serde_json::json!({
            "id": issue_id,
            "identifier": identifier,
            "url": issue.url,
            "status": "created"
        })
        .to_string())
    }

    #[tool(
        name = "update_issue",
        description = "Update an existing Linear issue. Provide the issue ID/identifier and fields to update. Prefer append_to_issue for adding context. Image references in the original description are automatically preserved when updating."
    )]
    async fn update_issue(&self, #[tool(aggr)] args: UpdateIssueArgs) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let issue = self
            .db
            .get_issue(&args.id)
            .map_err(|e| e.to_string())?
            .ok_or_else(|| format!("Issue '{}' not found", args.id))?;

        let client = self.client_for_workspace(&workspace)?;

        let state_id = if let Some(ref state_name) = args.state {
            Some(
                client
                    .get_state_id(&issue.team_key, state_name)
                    .await
                    .map_err(|e| e.to_string())?,
            )
        } else {
            None
        };

        let label_ids = if let Some(ref label_names) = args.labels {
            Some(self.resolve_labels_for_mutation(&workspace, &client, label_names).await?)
        } else {
            None
        };

        let project_id = if let Some(ref project_name) = args.project {
            if project_name.eq_ignore_ascii_case("none") {
                Some(String::new()) // Empty string removes project in Linear
            } else {
                Some(
                    client
                        .get_project_id(project_name)
                        .await
                        .map_err(|e| e.to_string())?,
                )
            }
        } else {
            None
        };

        let assignee_id: Option<String> = if let Some(ref a) = args.assignee {
            Some(client.resolve_assignee_id(a).await.map_err(|e| e.to_string())?)
        } else {
            None
        };

        // If updating description, re-fetch from Linear to preserve any image references
        let safe_description = if args.description.is_some() {
            let (latest, _, _) = client
                .fetch_single_issue(&issue.id)
                .await
                .map_err(|e| e.to_string())?;
            args.description
                .as_ref()
                .map(|new_desc| match &latest.description {
                    Some(original) => preserve_images(original, new_desc),
                    None => new_desc.clone(),
                })
        } else {
            None
        };

        client
            .update_issue(
                &issue.id,
                args.title.as_deref(),
                safe_description.as_deref(),
                args.priority,
                state_id.as_deref(),
                label_ids.as_deref(),
                project_id.as_deref(),
                assignee_id.as_deref(),
            )
            .await
            .map_err(|e| e.to_string())?;

        let (updated, relations, label_ids) = client
            .fetch_single_issue(&issue.id)
            .await
            .map_err(|e| e.to_string())?;
        self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
        self.db
            .upsert_relations(&updated.id, &relations)
            .map_err(|e| e.to_string())?;
        self.db
            .replace_issue_labels(&updated.id, &label_ids)
            .map_err(|e| e.to_string())?;

        Ok(serde_json::json!({
            "identifier": issue.identifier,
            "url": issue.url,
            "status": "updated"
        })
        .to_string())
    }

    #[tool(
        name = "append_to_issue",
        description = "Add a comment to an issue or append text to its description."
    )]
    async fn append_to_issue(&self, #[tool(aggr)] args: AppendArgs) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let issue = self
            .db
            .get_issue(&args.id)
            .map_err(|e| e.to_string())?
            .ok_or_else(|| format!("Issue '{}' not found", args.id))?;

        let client = self.client_for_workspace(&workspace)?;
        let mut actions: Vec<&str> = Vec::new();

        if let Some(ref comment_text) = args.comment {
            client
                .add_comment(&issue.id, comment_text)
                .await
                .map_err(|e| e.to_string())?;
            actions.push("comment_added");
        }

        if let Some(ref desc_text) = args.description {
            let new_desc = match &issue.description {
                Some(existing) => format!("{}\n\n{}", existing, desc_text),
                None => desc_text.clone(),
            };
            client
                .update_issue(&issue.id, None, Some(&new_desc), None, None, None, None, None)
                .await
                .map_err(|e| e.to_string())?;
            actions.push("description_updated");
        }

        let (updated, relations, label_ids) = client
            .fetch_single_issue(&issue.id)
            .await
            .map_err(|e| e.to_string())?;
        self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
        self.db
            .upsert_relations(&updated.id, &relations)
            .map_err(|e| e.to_string())?;
        self.db
            .replace_issue_labels(&updated.id, &label_ids)
            .map_err(|e| e.to_string())?;

        Ok(serde_json::json!({
            "identifier": issue.identifier,
            "actions": actions
        })
        .to_string())
    }

    #[tool(
        name = "sync_team",
        description = "Sync issues from Linear for a specific team. Use full=true for a complete re-sync."
    )]
    async fn sync_team(&self, #[tool(aggr)] args: SyncTeamArgs) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let client = self.client_for_workspace(&workspace)?;
        let full = args.full.unwrap_or(false);

        let count = client
            .sync_team(&self.db, &args.team, &workspace, full, false, None)
            .await
            .map_err(|e| e.to_string())?;

        let total = self
            .db
            .count_issues(Some(&args.team), &workspace)
            .map_err(|e| e.to_string())?;

        Ok(serde_json::json!({
            "synced": count,
            "total": total,
            "team": args.team
        })
        .to_string())
    }

    #[tool(
        name = "issue_context",
        description = "Get an issue along with its N most similar issues, useful for understanding context and related work."
    )]
    async fn issue_context(&self, #[tool(aggr)] args: IssueContextArgs) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let issue = self
            .db
            .get_issue(&args.id)
            .map_err(|e| e.to_string())?
            .ok_or_else(|| format!("Issue '{}' not found", args.id))?;

        let similar_count = args.similar_count.unwrap_or(5);

        let search_text = format!(
            "{}\n\n{}",
            issue.title,
            issue.description.as_deref().unwrap_or("")
        );

        let similar = if let Ok(embedder) = Embedder::new(&self.config) {
            search::find_duplicates(
                &self.db,
                &search_text,
                Some(&issue.team_key),
                0.3,
                similar_count + 1,
                &embedder,
                self.config.search.rrf_k,
                &workspace,
            )
            .await
            .unwrap_or_default()
            .into_iter()
            .filter(|r| r.issue_id != issue.id)
            .take(similar_count)
            .collect::<Vec<_>>()
        } else {
            Vec::new()
        };

        let comments = self.db.get_comments(&issue.id).map_err(|e| e.to_string())?;
        let relations = self
            .db
            .get_relations_enriched(&issue.id)
            .map_err(|e| e.to_string())?;

        let mut result = serde_json::json!({
            "issue": issue,
            "comments": comments,
            "similar_issues": similar,
            "relations": relations,
        });

        enrich_with_issue_links(&mut result, &self.db);
        serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
    }

    #[tool(
        name = "get_triage_queue",
        description = "Get a batch of unprioritized issues for triage. Returns enriched issues with similar issues and code_search_hints. IMPORTANT: For each issue, BEFORE presenting it to the user, you MUST search the codebase using the code_search_hints (via Grep, Glob, Read, or Cuttlefish MCP tools like get_symbols/find_references). Spend 2-4 tool calls per issue exploring relevant code, then include your findings when asking the user questions."
    )]
    async fn get_triage_queue(
        &self,
        #[tool(aggr)] args: GetTriageQueueArgs,
    ) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        // Incremental sync to pick up changes made by other users
        if let Ok(client) = self.client_for_workspace(&workspace) {
            let _ = client
                .sync_team(&self.db, &args.team, &workspace, false, false, None)
                .await;
        }

        let label_ids = if let Some(ref names) = args.labels {
            let (resolved, unknown) = self.db
                .resolve_label_ids_local(&workspace, names)
                .map_err(|e| e.to_string())?;
            if !unknown.is_empty() {
                if resolved.is_empty()
                    && self.db.list_labels(&workspace).map_err(|e| e.to_string())?.is_empty()
                {
                    return Err(format!(
                        "No labels synced yet for workspace '{}'. Run sync_team first.",
                        workspace
                    ));
                }
                let suggestions = suggest_label_names(&self.db, &workspace, &unknown);
                return Err(format!(
                    "Label{} {} not found. {}Run list_labels for the full set.",
                    if unknown.len() == 1 { "" } else { "s" },
                    unknown.iter().map(|s| format!("'{}'", s)).collect::<Vec<_>>().join(", "),
                    if suggestions.is_empty() { String::new() }
                    else { format!("Did you mean: {}? ", suggestions.join(", ")) }
                ));
            }
            Some(resolved)
        } else {
            None
        };

        let all_issues = self
            .db
            .get_unprioritized_issues_filtered(
                Some(&args.team),
                args.include_completed.unwrap_or(false),
                &workspace,
                label_ids.as_deref(),
            )
            .map_err(|e| e.to_string())?;

        let exclude_set: std::collections::HashSet<&str> = args
            .exclude
            .as_ref()
            .map(|v| v.iter().map(|s| s.as_str()).collect())
            .unwrap_or_default();

        let filtered: Vec<_> = all_issues
            .into_iter()
            .filter(|i| !exclude_set.contains(i.identifier.as_str()))
            .collect();

        let limit = args.limit.unwrap_or(10);
        let batch: Vec<_> = if args.shuffle.unwrap_or(false) {
            use rand::seq::SliceRandom;
            let mut indices: Vec<usize> = (0..filtered.len()).collect();
            indices.shuffle(&mut rand::rng());
            indices
                .into_iter()
                .take(limit)
                .map(|i| &filtered[i])
                .collect()
        } else {
            filtered.iter().take(limit).collect()
        };

        let total_remaining = filtered.len();

        let embedder = Embedder::new(&self.config).ok();

        let mut enriched = Vec::new();
        for issue in &batch {
            let description = issue.description.as_deref().map(|d| {
                if d.len() > 2000 {
                    let mut end = 2000;
                    while end > 0 && !d.is_char_boundary(end) {
                        end -= 1;
                    }
                    &d[..end]
                } else {
                    d
                }
            });

            let similar = if let Some(ref embedder) = embedder {
                let search_text = format!("{}\n\n{}", issue.title, description.unwrap_or(""));
                search::find_duplicates(
                    &self.db,
                    &search_text,
                    Some(&args.team),
                    0.3,
                    4,
                    embedder,
                    self.config.search.rrf_k,
                    &workspace,
                )
                .await
                .unwrap_or_default()
                .into_iter()
                .filter(|r| r.issue_id != issue.id)
                .take(3)
                .collect::<Vec<_>>()
            } else {
                Vec::new()
            };

            let relations = self
                .db
                .get_relations_enriched(&issue.id)
                .unwrap_or_default();

            // Extract search hints from title and description for code exploration
            let code_search_hints =
                extract_code_hints(&issue.title, description.unwrap_or(""), &issue.labels());

            enriched.push(serde_json::json!({
                "identifier": issue.identifier,
                "url": issue.url,
                "title": issue.title,
                "description": description,
                "state_name": issue.state_name,
                "assignee_name": issue.assignee_name,
                "project_name": issue.project_name,
                "labels": issue.labels(),
                "created_at": issue.created_at,
                "similar_issues": similar,
                "relations": relations,
                "code_search_hints": code_search_hints,
            }));
        }

        let mut result = serde_json::json!({
            "instruction": "IMPORTANT: For each issue below, BEFORE asking the user any questions, search the codebase using the code_search_hints. Use Grep, Glob, Read, or Cuttlefish MCP tools (get_symbols, find_references) to understand the current code state. Then present your code findings alongside the issue summary. Always include the issue's Linear URL as a clickable markdown link [IDENTIFIER](url). Assume the perspective of a principal staff software engineer who has been tasked to implement this issue. Ask 2-4 thoughtful clarifying questions that would help elucidate any ambiguity or uncertainty in the issue description — the kind of questions an experienced engineer asks before writing code.",
            "queue": enriched,
            "total_remaining": total_remaining,
            "team": args.team,
        });

        enrich_with_issue_links(&mut result, &self.db);
        serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
    }

    #[tool(
        name = "mark_triaged",
        description = "Mark an issue as triaged by setting priority and optionally updating title, description, and adding a triage comment. Combines update + comment into one call. Prefer using the comment field over description for adding context — description updates risk losing formatting. Image references in the original description are automatically preserved."
    )]
    async fn mark_triaged(&self, #[tool(aggr)] args: MarkTriagedArgs) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        if args.priority < 1 || args.priority > 4 {
            return Err("Priority must be 1 (Urgent), 2 (High), 3 (Medium), or 4 (Low)".into());
        }

        // Resolve from local DB to get the Linear UUID
        let local_issue = self
            .db
            .get_issue(&args.id)
            .map_err(|e| e.to_string())?
            .ok_or_else(|| format!("Issue '{}' not found", args.id))?;

        let client = self.client_for_workspace(&workspace)?;

        // Re-fetch from Linear to get the latest version
        let (issue, issue_relations, issue_label_ids) = client
            .fetch_single_issue(&local_issue.id)
            .await
            .map_err(|e| e.to_string())?;
        self.db.upsert_issue(&issue).map_err(|e| e.to_string())?;
        self.db
            .upsert_relations(&issue.id, &issue_relations)
            .map_err(|e| e.to_string())?;
        self.db
            .replace_issue_labels(&issue.id, &issue_label_ids)
            .map_err(|e| e.to_string())?;

        // If someone else already prioritized it, let the caller know
        if issue.priority != 0 {
            return Ok(serde_json::json!({
                "identifier": issue.identifier,
                "url": issue.url,
                "status": "already_triaged",
                "current_priority": issue.priority,
                "current_priority_label": issue.priority_label(),
                "message": format!(
                    "{} was already prioritized as {} — skipping",
                    issue.identifier, issue.priority_label()
                ),
            })
            .to_string());
        }

        // Flag if the issue was modified since we last saw it
        let was_modified = issue.content_hash != local_issue.content_hash;
        if was_modified {
            let mut changes = Vec::new();
            if issue.title != local_issue.title {
                changes.push(format!(
                    "title changed: \"{}\" → \"{}\"",
                    local_issue.title, issue.title
                ));
            }
            if issue.description != local_issue.description {
                changes.push("description was updated".to_string());
            }
            if issue.state_name != local_issue.state_name {
                changes.push(format!(
                    "state changed: {} → {}",
                    local_issue.state_name, issue.state_name
                ));
            }
            if issue.assignee_name != local_issue.assignee_name {
                changes.push(format!(
                    "assignee changed: {} → {}",
                    local_issue.assignee_name.as_deref().unwrap_or("unassigned"),
                    issue.assignee_name.as_deref().unwrap_or("unassigned")
                ));
            }
            // Re-embed with the updated content
            self.reembed_issue(&issue).await;

            return Ok(serde_json::json!({
                "identifier": issue.identifier,
                "url": issue.url,
                "status": "modified_since_queued",
                "changes": changes,
                "current_title": issue.title,
                "current_description": issue.description,
                "current_state": issue.state_name,
                "message": format!(
                    "{} was modified since the queue was fetched — review the latest version before triaging",
                    issue.identifier
                ),
            })
            .to_string());
        }

        // Resolve state name to ID if provided
        let state_id = if let Some(ref state_name) = args.state {
            Some(
                client
                    .get_state_id(&issue.team_key, state_name)
                    .await
                    .map_err(|e| e.to_string())?,
            )
        } else {
            None
        };

        let label_ids = if let Some(ref label_names) = args.labels {
            Some(self.resolve_labels_for_mutation(&workspace, &client, label_names).await?)
        } else {
            None
        };

        let project_id = if let Some(ref project_name) = args.project {
            if project_name.eq_ignore_ascii_case("none") {
                Some(String::new())
            } else {
                Some(
                    client
                        .get_project_id(project_name)
                        .await
                        .map_err(|e| e.to_string())?,
                )
            }
        } else {
            None
        };

        let assignee_id: Option<String> = if let Some(ref a) = args.assignee {
            Some(client.resolve_assignee_id(a).await.map_err(|e| e.to_string())?)
        } else {
            None
        };

        // Preserve any image references from the original description
        let safe_description = args
            .description
            .as_ref()
            .map(|new_desc| match &issue.description {
                Some(original) => preserve_images(original, new_desc),
                None => new_desc.clone(),
            });

        client
            .update_issue(
                &issue.id,
                args.title.as_deref(),
                safe_description.as_deref(),
                Some(args.priority),
                state_id.as_deref(),
                label_ids.as_deref(),
                project_id.as_deref(),
                assignee_id.as_deref(),
            )
            .await
            .map_err(|e| e.to_string())?;

        if let Some(ref comment_text) = args.comment {
            client
                .add_comment(&issue.id, comment_text)
                .await
                .map_err(|e| e.to_string())?;
        }

        let (updated, updated_relations, updated_label_ids) = client
            .fetch_single_issue(&issue.id)
            .await
            .map_err(|e| e.to_string())?;
        self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
        self.db
            .upsert_relations(&updated.id, &updated_relations)
            .map_err(|e| e.to_string())?;
        self.db
            .replace_issue_labels(&updated.id, &updated_label_ids)
            .map_err(|e| e.to_string())?;

        // Re-embed if title or description changed
        if args.title.is_some() || args.description.is_some() {
            self.reembed_issue(&updated).await;
        }

        let priority_label = match args.priority {
            1 => "Urgent",
            2 => "High",
            3 => "Medium",
            4 => "Low",
            _ => "Unknown",
        };

        Ok(serde_json::json!({
            "identifier": issue.identifier,
            "url": issue.url,
            "priority": args.priority,
            "priority_label": priority_label,
            "title": args.title.as_deref().unwrap_or(&issue.title),
            "status": "triaged",
        })
        .to_string())
    }

    #[tool(
        name = "manage_relation",
        description = "Add or remove a relation between two issues. Relation types: 'blocks', 'blocked_by', 'related', 'duplicate'. Use action 'add' to create or 'remove' to delete a relation."
    )]
    async fn manage_relation(
        &self,
        #[tool(aggr)] args: ManageRelationArgs,
    ) -> Result<String, String> {
        let workspace = self.require_workspace(&args.workspace)?;
        let valid_types = ["blocks", "blocked_by", "related", "duplicate"];
        if !valid_types.contains(&args.relation_type.as_str()) {
            return Err(format!(
                "Invalid relation_type '{}'. Must be one of: {}",
                args.relation_type,
                valid_types.join(", ")
            ));
        }

        let source = self
            .db
            .get_issue(&args.issue)
            .map_err(|e| e.to_string())?
            .ok_or_else(|| format!("Issue '{}' not found", args.issue))?;
        let target = self
            .db
            .get_issue(&args.related_issue)
            .map_err(|e| e.to_string())?
            .ok_or_else(|| format!("Issue '{}' not found", args.related_issue))?;

        let client = self.client_for_workspace(&workspace)?;

        match args.action.as_str() {
            "add" => {
                let relation_id = client
                    .create_relation(&source.id, &target.id, &args.relation_type)
                    .await
                    .map_err(|e| e.to_string())?;

                // Re-fetch to update local relations
                let (updated, relations, label_ids) = client
                    .fetch_single_issue(&source.id)
                    .await
                    .map_err(|e| e.to_string())?;
                self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
                self.db
                    .upsert_relations(&updated.id, &relations)
                    .map_err(|e| e.to_string())?;
                self.db
                    .replace_issue_labels(&updated.id, &label_ids)
                    .map_err(|e| e.to_string())?;

                Ok(serde_json::json!({
                    "status": "added",
                    "relation_id": relation_id,
                    "issue": source.identifier,
                    "related_issue": target.identifier,
                    "relation_type": args.relation_type,
                })
                .to_string())
            }
            "remove" => {
                // For blocked_by, the stored relation is reversed
                let (db_source, db_target, db_type) = if args.relation_type == "blocked_by" {
                    (&target.id, &source.id, "blocks")
                } else {
                    (&source.id, &target.id, args.relation_type.as_str())
                };

                let relation_id = self
                    .db
                    .find_relation_id(db_source, db_target, db_type)
                    .map_err(|e| e.to_string())?
                    .ok_or_else(|| {
                        format!(
                            "No '{}' relation found between {} and {}",
                            args.relation_type, args.issue, args.related_issue
                        )
                    })?;

                client
                    .delete_relation(&relation_id)
                    .await
                    .map_err(|e| e.to_string())?;

                // Re-fetch to update local relations
                let (updated, relations, label_ids) = client
                    .fetch_single_issue(&source.id)
                    .await
                    .map_err(|e| e.to_string())?;
                self.db.upsert_issue(&updated).map_err(|e| e.to_string())?;
                self.db
                    .upsert_relations(&updated.id, &relations)
                    .map_err(|e| e.to_string())?;
                self.db
                    .replace_issue_labels(&updated.id, &label_ids)
                    .map_err(|e| e.to_string())?;

                Ok(serde_json::json!({
                    "status": "removed",
                    "issue": source.identifier,
                    "related_issue": target.identifier,
                    "relation_type": args.relation_type,
                })
                .to_string())
            }
            _ => Err("action must be 'add' or 'remove'".into()),
        }
    }
}

impl RectilinearMcp {
    fn require_workspace(&self, workspace: &Option<String>) -> Result<String, String> {
        match workspace {
            Some(ws) if !ws.is_empty() => {
                let names = self.config.workspace_names();
                if !names.contains(ws) {
                    return Err(format!(
                        "Workspace '{}' not found. Use list_workspaces to see available workspaces. Available: {}",
                        ws, names.join(", ")
                    ));
                }
                Ok(ws.clone())
            }
            _ => Err(
                "workspace is required. Use list_workspaces to see available workspaces."
                    .to_string(),
            ),
        }
    }

    fn client_for_workspace(&self, workspace: &str) -> Result<LinearClient, String> {
        let api_key = self
            .config
            .workspace_api_key(workspace)
            .map_err(|e| e.to_string())?;
        Ok(LinearClient::with_api_key(&api_key))
    }

    /// Resolve label names to ids using the same logic as create_issue:
    /// - empty catalog → defer to remote query (fresh install).
    /// - non-empty catalog with unknowns → return user-facing error with did-you-mean.
    /// - non-empty catalog, all resolved → return resolved ids.
    async fn resolve_labels_for_mutation(
        &self,
        workspace: &str,
        client: &LinearClient,
        names: &[String],
    ) -> Result<Vec<String>, String> {
        let catalog_size = self.db
            .list_labels(workspace)
            .map_err(|e| e.to_string())?
            .len();
        if catalog_size == 0 {
            eprintln!(
                "info: labels catalog empty for workspace '{}', resolving via remote query",
                workspace
            );
            return client.get_label_ids(names).await.map_err(|e| e.to_string());
        }
        let (resolved, unknown) = self.db
            .resolve_label_ids_local(workspace, names)
            .map_err(|e| e.to_string())?;
        if !unknown.is_empty() {
            let suggestions = suggest_label_names(&self.db, workspace, &unknown);
            return Err(format!(
                "Label{} {} not found. {}Run list_labels for the full set.",
                if unknown.len() == 1 { "" } else { "s" },
                unknown.iter().map(|s| format!("'{}'", s)).collect::<Vec<_>>().join(", "),
                if suggestions.is_empty() { String::new() }
                else { format!("Did you mean: {}? ", suggestions.join(", ")) }
            ));
        }
        Ok(resolved)
    }

    /// Re-chunk and re-embed a single issue. Best-effort — failures are silently ignored.
    async fn reembed_issue(&self, issue: &crate::db::Issue) {
        let Ok(embedder) = Embedder::new(&self.config) else {
            return;
        };
        let chunks = embedding::chunk_text(
            &issue.title,
            issue.description.as_deref().unwrap_or(""),
            512,
            64,
        );
        if let Ok(embeddings) = embedder.embed_batch(&chunks).await {
            let chunk_data: Vec<(usize, String, Vec<u8>)> = chunks
                .into_iter()
                .zip(embeddings)
                .enumerate()
                .map(|(i, (text, emb))| (i, text, embedding::embedding_to_bytes(&emb)))
                .collect();
            let _ = self.db.upsert_chunks(&issue.id, &chunk_data);
        }
    }
}

#[tool(tool_box)]
impl ServerHandler for RectilinearMcp {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            protocol_version: ProtocolVersion::V_2024_11_05,
            capabilities: ServerCapabilities {
                tools: Some(ToolsCapability {
                    list_changed: None,
                }),
                ..Default::default()
            },
            server_info: Implementation {
                name: "rectilinear".into(),
                version: env!("CARGO_PKG_VERSION").into(),
            },
            instructions: Some(
                "## Workspace Selection\n\
                 All tools (except list_workspaces) require a `workspace` parameter. Call list_workspaces first to discover available workspaces.\n\n\
                 Rectilinear provides Linear issue intelligence with search, duplicate detection, and triage.\n\n\
                 ## Triage Workflow\n\
                 IMPORTANT: Present exactly ONE issue at a time. Wait for the user's response and call mark_triaged before presenting the next issue. \
                 Never batch multiple issues into a single message.\n\n\
                 When the user asks to triage issues:\n\
                 1. Call get_triage_queue with the team key. IMPORTANT: Always use get_triage_queue from rectilinear — \
                 never use Linear's list_issues as a substitute. If the user asks to include completed issues, \
                 pass include_completed: true.\n\
                 2. Take the FIRST issue from the queue. BEFORE presenting it to the user, use the code_search_hints field to explore the codebase. \
                 Search for the mentioned files, symbols, and keywords using Grep, Glob, Read, or Cuttlefish MCP tools (get_symbols, find_references, get_hover_info). \
                 Spend 2-4 tool calls understanding the current code state for THIS issue.\n\
                 3. Present a brief summary of the issue AND what you found in the code. \
                 Assume the perspective of a principal staff software engineer who has been tasked to implement this issue. \
                 Ask 2-4 thoughtful clarifying questions that would help elucidate any ambiguity or uncertainty in the issue description — \
                 the kind of questions an experienced engineer asks before writing code \
                 (e.g. \"I found WorktreeManager.cleanup() at src/worktree.rs:142 — it already handles orphaned worktrees. Is this issue about a gap in that logic, or something else entirely?\"). \
                 Suggest best-guess answers.\n\
                 4. WAIT for the user to respond. Based on their answers, propose: priority (1-4), improved title, \
                 triage comment (use the comment field for adding context, code references, and file paths — prefer comments over description changes to avoid losing images or formatting), \
                 state change if appropriate (e.g. Done, Cancelled, Duplicate), and any label or project changes. \
                 Only update the description if the original is genuinely wrong or missing key information.\n\
                 5. WAIT for user confirmation, then call mark_triaged with all agreed changes.\n\
                 6. Only after mark_triaged succeeds, move to the NEXT issue. Repeat from step 2. \
                 When the batch is exhausted, call get_triage_queue again with processed identifiers in exclude.\n\n\
                 ## Archival Mode\n\
                 To triage completed/canceled issues (for archival prioritization), pass include_completed: true to get_triage_queue. \
                 This surfaces Done/Canceled/Duplicate issues that have no priority set. The workflow is the same — set a priority for historical record.\n\n\
                 ## Priority Framework\n\
                 1=Urgent (production down, data loss, security)\n\
                 2=High (major feature broken, significant user impact, no workaround)\n\
                 3=Medium (degraded experience, workarounds exist)\n\
                 4=Low (minor polish, nice-to-have)\n\n\
                 ## Duplicate Handling\n\
                 If similar_issues show >0.8 similarity, flag as potential duplicate. Ask user whether to merge, close as dup, or keep separate. \
                 Use the state field in mark_triaged to set the status (e.g. state: \"Duplicate\", state: \"Done\", state: \"Cancelled\").\n\n\
                 ## Labels and Projects\n\
                 When triaging, consider whether the issue should be labeled or assigned to a project. \
                 Use the labels and project fields in mark_triaged to set these. Pass project: \"none\" to remove an issue from its current project.\n\n\
                 ## Issue Relations\n\
                 Issues may have relations (blocks, blocked_by, related, duplicate) visible in the `relations` field. \
                 When triaging, surface blocking relationships — they affect priority. Use manage_relation to add/remove relations. \
                 If an issue blocks or is blocked by another, always mention this prominently.\n\n\
                 ## Linear Links\n\
                 ALWAYS include the Linear issue URL (from the `url` field) as a clickable markdown link when presenting issues to the user. \
                 Format as [IDENTIFIER](url) so the user can click through to Linear directly. \
                 Do this for the main issue being discussed AND for any related, blocking, or similar issues referenced. \
                 Tool responses include a `referenced_issues` field that maps any issue identifiers found in descriptions/comments \
                 to their URLs and titles — use these to render all mentioned issues as clickable links.\n\n\
                 ## Progress Tracking\n\
                 Use TodoWrite to make progress visible to the user:\n\
                 - When triaging multiple issues from a get_triage_queue batch, create a TodoWrite list with one item per issue \
                 (e.g. \"Triage ENG-123: <title>\"). Mark each in_progress when you present it, completed after mark_triaged succeeds. \
                 This gives the user a visible remaining-work indicator across a long batch.\n\
                 - When a single issue needs multiple clarifying questions or decisions (refinement, priority, tradeoffs, \
                 related issues, label/project assignment), enumerate them as TodoWrite items before asking. Update the list as you go.\n\
                 - Skip TodoWrite for trivial one-shot interactions (a single search, a single create_issue with no ambiguity)."
                    .into(),
            ),
        }
    }
}

pub async fn serve(db: Database, config: Config) -> Result<()> {
    eprintln!("MCP server ready (stdio transport)");
    let handler = RectilinearMcp::new(db, config);
    let transport = rmcp::transport::io::stdio();
    let server = rmcp::serve_server(handler, transport).await?;
    server.waiting().await?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_extract_image_references() {
        let text = "Some text ![screenshot](https://uploads.linear.app/abc.png) more text";
        let images = extract_image_references(text);
        assert_eq!(
            images,
            vec!["![screenshot](https://uploads.linear.app/abc.png)"]
        );
    }

    #[test]
    fn test_extract_multiple_images() {
        let text = "![a](url1) text ![b](url2)";
        let images = extract_image_references(text);
        assert_eq!(images, vec!["![a](url1)", "![b](url2)"]);
    }

    #[test]
    fn test_extract_empty_alt() {
        let text = "![](https://example.com/img.png)";
        let images = extract_image_references(text);
        assert_eq!(images, vec!["![](https://example.com/img.png)"]);
    }

    #[test]
    fn test_no_images() {
        let text = "Just some regular text with [a link](url)";
        let images = extract_image_references(text);
        assert!(images.is_empty());
    }

    #[test]
    fn test_preserve_images_no_originals() {
        let result = preserve_images("no images here", "new description");
        assert_eq!(result, "new description");
    }

    #[test]
    fn test_preserve_images_keeps_missing() {
        let original = "Text ![img](https://uploads.linear.app/abc.png) more";
        let new_desc = "Rewritten description";
        let result = preserve_images(original, new_desc);
        assert!(result.starts_with("Rewritten description"));
        assert!(result.contains("![img](https://uploads.linear.app/abc.png)"));
    }

    #[test]
    fn test_preserve_images_already_present() {
        let original = "Text ![img](url)";
        let new_desc = "New text ![img](url)";
        let result = preserve_images(original, new_desc);
        assert_eq!(result, "New text ![img](url)");
    }
}
