//! MCP server implementation
//!
//! This module provides the MCP server that exposes `NuAnalytics` tools.

use std::sync::Arc;

use crate::core::config::DatabaseConfig;
use crate::mcp::tools::{
    analyze, audit, cache, convert, course_detail, json_schema, match_courses, pipeline,
    plan_graph, report, samples, schema, shared, trim, validate, visualize, AnalyzeDegreeRequest,
    AuditDegreeRequest, CacheYamlRequest, ConvertDegreeRequest, DegreePipelineRequest,
    FindCoursesMatchingRequest, GenerateDegreeReportRequest, GetCourseDetailRequest,
    GetCurriculumVisualizationRequest, GetDegreeJsonSchemaRequest, GetSchemaRequest,
    ListSampleDegreesRequest, RenderPlanGraphRequest, TrimDegreeRequest, ValidateDegreeRequest,
};
use rmcp::{
    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
    model::{ServerCapabilities, ServerInfo},
    tool, tool_handler, tool_router,
    transport::stdio,
    ServerHandler, ServiceExt,
};

#[cfg(feature = "database")]
use crate::core::database::DbClient;
#[cfg(feature = "database")]
use crate::mcp::tools::{
    cip_codes, completions, degrees, import, institutions, lookup, scaffold, CompareDegreesRequest,
    CompletionDemographicsRequest, GetDegreeRequest, GetInstitutionCompletionsRequest,
    GetInstitutionRequest, GetLookupCodesRequest, GetSchoolsCompletionDemographicsRequest,
    ImportDegreeRequest, ScaffoldDegreeYamlRequest, SearchCipCodesRequest, SearchDegreesRequest,
    SearchInstitutionsRequest,
};
// `StoreDegreeRequest` import kept out of the active list while the
// store_degree MCP tool is shelved (see TODO in the impl block); the type
// itself still lives in `degrees::StoreDegreeRequest` for when the DB
// write path is provisioned.

// ============================================================================
// MCP Server Implementation
// ============================================================================

/// MCP server that exposes `NuAnalytics` tools via stdio transport.
///
/// Non-database tools (degree YAML validation and analysis) are always available.
/// Database tools (IPEDS queries) are enabled when `db` is `Some`.
#[derive(Debug, Clone)]
pub struct NuAnalyticsMcpServer {
    // Read indirectly by the rmcp #[tool_handler] macro's generated trait impl
    // for dispatch; rustc's dead_code analysis under release/LTO can't trace
    // that read through the macro expansion, so silence the false positive.
    #[allow(dead_code)]
    tool_router: ToolRouter<Self>,
    /// Database client — `None` if database is not configured or disabled
    #[cfg(feature = "database")]
    db: Option<Arc<DbClient>>,
}

#[tool_router]
impl NuAnalyticsMcpServer {
    /// Create a new MCP server instance without database access
    #[must_use]
    pub fn new() -> Self {
        Self {
            tool_router: Self::tool_router(),
            #[cfg(feature = "database")]
            db: None,
        }
    }

    /// Create a new MCP server instance with database access
    #[cfg(feature = "database")]
    #[must_use]
    pub fn with_db(db: Option<Arc<DbClient>>) -> Self {
        Self {
            tool_router: Self::tool_router(),
            db,
        }
    }

    // ── Degree tools (no DB required) ──────────────────────────────────────

    /// Get the degree YAML schema documentation
    #[tool(
        description = "Get the degree YAML schema documentation. Returns structured information about how to create valid degree program YAML files, including field descriptions, requirement types, and examples."
    )]
    #[allow(clippy::unused_self)]
    fn get_degree_schema(&self, Parameters(req): Parameters<GetSchemaRequest>) -> String {
        schema::execute(req.section.as_deref())
    }

    /// Return the unified-degree JSON Schema (machine-validatable)
    #[tool(
        description = "Return the machine-validatable JSON Schema (2020-12) for the unified degree format — the structure convert_degree produces and that analyze_degree / validate_degree / trim_degree accept. Use it to validate a unified degree document or to understand the format, including wildcard `from` pools (e.g. pattern \"CS:2500+\" or \"*:*\"). Distinct from get_degree_schema, which returns the human-readable YAML reference."
    )]
    #[allow(clippy::unused_self)]
    fn get_degree_json_schema(
        &self,
        Parameters(_req): Parameters<GetDegreeJsonSchemaRequest>,
    ) -> String {
        json_schema::execute()
    }

    /// Convert an ai-landscape program JSON to the unified degree JSON
    #[tool(
        description = "Convert an ai-landscape program JSON into the unified NuAnalytics degree JSON (the same format analyze_degree / validate_degree accept). Provide exactly ONE source: json_content (inline) or json_path (file on the server). For a multi-program cluster pipeline file, omit `program` to get the program inventory, or set `program` to convert one of them. Returns the unified JSON plus any conversion_warnings and a cache:<hash> handle you can pass as `degree_id` to validate_degree / analyze_degree without re-pasting."
    )]
    #[allow(clippy::unused_self)]
    fn convert_degree(&self, Parameters(req): Parameters<ConvertDegreeRequest>) -> String {
        convert::execute_json(
            req.json_content,
            req.json_path,
            req.program.as_deref(),
            req.pretty.unwrap_or(true),
        )
    }

    /// Validate a degree program YAML
    #[tool(
        description = "Validate a degree program YAML. Returns detailed validation results including errors, warnings, and suggestions. Provide exactly ONE YAML source: yaml_content (inline string), yaml_path (file on the server's filesystem), or degree_id (DB lookup). Pass allow_unmatched_patterns=true to surface external gen-ed pool patterns (e.g. \"*:100+\") as warnings instead of errors when courses aren't enumerated locally."
    )]
    fn validate_degree(&self, Parameters(req): Parameters<ValidateDegreeRequest>) -> String {
        let allow_unmatched_patterns = req.allow_unmatched_patterns.unwrap_or(false);
        let include_hidden_prereq_warnings = req.include_hidden_prereq_warnings.unwrap_or(true);
        let source = match shared::parse_yaml_source(req.yaml_content, req.yaml_path, req.degree_id)
        {
            Ok(s) => s,
            Err(e) => return e,
        };
        self.run_yaml_tool("validate_degree", source, move |yaml| {
            validate::execute_json(
                yaml,
                allow_unmatched_patterns,
                include_hidden_prereq_warnings,
            )
        })
    }

    /// Audit a degree program YAML
    #[tool(
        description = "Run a comprehensive audit on a degree program YAML: validation, missing prerequisites on upper-level courses, and deep prerequisite chains. Provide exactly ONE YAML source: yaml_content (inline), yaml_path (file path), or degree_id (DB lookup). Returns structured findings. For each deep-chain entry, prefer the structured `branches: [{length, path: [course_id]}]` field over the legacy human-readable `chain` string when consuming results programmatically."
    )]
    fn audit_degree(&self, Parameters(req): Parameters<AuditDegreeRequest>) -> String {
        let chain_threshold = req.chain_threshold;
        let include_missing_intermediate = req.include_missing_intermediate_prereqs.unwrap_or(true);
        let source = match shared::parse_yaml_source(req.yaml_content, req.yaml_path, req.degree_id)
        {
            Ok(s) => s,
            Err(e) => return e,
        };
        self.run_yaml_tool("audit_degree", source, move |yaml| {
            audit::execute_json(yaml, chain_threshold, include_missing_intermediate)
        })
    }

    /// Analyze a degree program YAML
    #[tool(
        description = "Run full degree analysis: generate all possible course plans, compute aggregate metrics (complexity, delay, credits), and identify shortest/longest paths. Provide exactly ONE YAML source: yaml_content (inline), yaml_path (file path), or degree_id (DB lookup). Returns aggregate stats across the analyzed plans plus a curated selected_plans list (always shortest + longest + optional calc-ready-shortest + 3 random samples — 5-6 plans typical, independent of max_plans). The complexity/longest_delay/total_credits objects use the standard 5-number summary (min/q1/median/q3/max + mean/std_dev) — to plot as a boxplot in Chart.js, add the chartjs-chart-boxplot plugin. The response also includes is_full_population: when true, plans_analyzed is the entire population for this YAML; when false, it's a sample capped at max_plans. Set include_graph_spec=true (default false) when you need to render visualizations — each spec is ~30 KB so omitting them keeps the response compact. Set include_per_course_metrics=true to also receive a sorted per_course_metrics array (one entry per tracked course with complexity/centrality/delay/blocking medians) — use this when you want per-course numbers without rendering the graph HTML. The plan-generation loop is wall-clock-bounded: when analysis_timeout_seconds (default 180) trips, the response sets time_limit_reached=true alongside was_truncated=true; reservoir sampling is still uniform across plans actually seen. For large degrees (140+ courses, 50K+ plan populations), prefer the CV-stable cutoff in tool_followups over bumping max_plans blindly — that's the same advice the followup will echo. Use after validate_degree confirms the YAML is valid. Optionally specify include_courses to constrain all plans to include specific courses."
    )]
    fn analyze_degree(&self, Parameters(req): Parameters<AnalyzeDegreeRequest>) -> String {
        let include_courses = req.include_courses.map(|s| shared::parse_comma_list(&s));
        let include_graph_spec = req.include_graph_spec.unwrap_or(false);
        let include_per_course_metrics = req.include_per_course_metrics.unwrap_or(false);
        let include_placeholder_metrics = req.include_placeholder_metrics.unwrap_or(false);
        let random_seed = req.random_seed;
        let analysis_timeout_seconds = req.analysis_timeout_seconds;
        let max_plans = req.max_plans;
        let plan_indices: Option<Vec<usize>> = req
            .plan_indices
            .as_deref()
            .map(shared::parse_comma_list_usize);
        let source = match shared::parse_yaml_source(req.yaml_content, req.yaml_path, req.degree_id)
        {
            Ok(s) => s,
            Err(e) => return e,
        };
        self.run_yaml_tool("analyze_degree", source, move |yaml| {
            analyze::execute_json(
                yaml,
                max_plans,
                include_courses.as_deref(),
                include_graph_spec,
                plan_indices.as_deref(),
                include_per_course_metrics,
                include_placeholder_metrics,
                random_seed,
                analysis_timeout_seconds,
            )
        })
    }

    /// Look up a single course's prerequisites, dependents, and stats
    #[tool(
        description = "Return everything an LLM typically wants to know about one course in a degree YAML: title/credits/level, raw + direct + transitive prerequisites, dependents, requirements that reference it, cross-listed equivalents, and (with include_analysis=true, default) per-course metric medians + term placement in every selected plan. Set include_analysis=false to skip the analysis pipeline for static-only data (~10x faster). Same yaml source modes as analyze_degree (yaml_content / yaml_path / degree_id). Use this instead of analyze_degree when the question is 'tell me about CS370 in this degree'."
    )]
    fn get_course_detail(&self, Parameters(req): Parameters<GetCourseDetailRequest>) -> String {
        let course_id = req.course_id.clone();
        let include_analysis = req.include_analysis.unwrap_or(true);
        let max_plans = req.max_plans;
        let source = match shared::parse_yaml_source(req.yaml_content, req.yaml_path, req.degree_id)
        {
            Ok(s) => s,
            Err(e) => return e,
        };
        self.run_yaml_tool("get_course_detail", source, move |yaml| {
            course_detail::execute_json(yaml, &course_id, include_analysis, max_plans)
        })
    }

    /// Run validate + audit + analyze in a single MCP call
    #[tool(
        description = "Combined pipeline: runs validate_degree, audit_degree, and analyze_degree on the same YAML in one call. Short-circuits when validate hits a YAML parse error (audit/analyze are then null). Use skip_audit / skip_analyze to stop earlier when you only need validate (or validate + audit). Same yaml source modes as the individual tools (yaml_content / yaml_path / degree_id). Forwards allow_unmatched_patterns to validate, chain_threshold to audit, max_plans + include_courses to analyze. Returns {validate, audit?, analyze?} — saves three round-trips for the common 'look at this degree' prompt."
    )]
    fn degree_pipeline(&self, Parameters(req): Parameters<DegreePipelineRequest>) -> String {
        let allow_unmatched_patterns = req.allow_unmatched_patterns.unwrap_or(false);
        let chain_threshold = req.chain_threshold;
        let max_plans = req.max_plans;
        let include_courses = req.include_courses.map(|s| shared::parse_comma_list(&s));
        let skip_audit = req.skip_audit.unwrap_or(false);
        let skip_analyze = req.skip_analyze.unwrap_or(false);
        let source = match shared::parse_yaml_source(req.yaml_content, req.yaml_path, req.degree_id)
        {
            Ok(s) => s,
            Err(e) => return e,
        };
        self.run_yaml_tool("degree_pipeline", source, move |yaml| {
            pipeline::execute_json(
                yaml,
                allow_unmatched_patterns,
                chain_threshold,
                max_plans,
                include_courses.as_deref(),
                skip_audit,
                skip_analyze,
            )
        })
    }

    /// Trim a degree program YAML to a single shortest-entry-path variant
    #[tool(
        description = "Collapse a degree YAML's prerequisite alternatives and `Select` option lists down to a single shared shortest entry path per course, except inside protected subjects (the degree's declared `major_subjects` plus anything you pass via `keep_all`). Equivalents groups (`{A, B, C}`) record substitutions so downstream prerequisite references to dropped courses get rewritten and the dropped courses are then orphan-pruned. Pattern-pool members (e.g. `ICS:400+` electives) survive the prune even when no requirement names them. Provide exactly ONE YAML source: yaml_content (inline), yaml_path (file path), or degree_id (cache:<hash> handle or DB row). Returns the trimmed YAML inline plus a fresh `cache:<hash>` handle (`trimmed_cache_id`) so you can chain validate_degree / audit_degree against the result without re-pasting. Optionally set `output_path` to also write the file to disk; the call refuses to overwrite a `yaml_path` input. Comments in the source YAML are not preserved on serialisation."
    )]
    fn trim_degree(&self, Parameters(req): Parameters<TrimDegreeRequest>) -> String {
        let source = match shared::parse_yaml_source(req.yaml_content, req.yaml_path, req.degree_id)
        {
            Ok(s) => s,
            Err(e) => return e,
        };
        // We need the original on-disk path (when supplied) so we can refuse
        // overwriting the input — `run_yaml_tool` discards that information,
        // so do the resolve inline here.
        let (yaml, source_path, cache_meta) = match source {
            shared::YamlSource::Content(y) => (y, None, None),
            shared::YamlSource::Path(p) => match shared::read_yaml_file(&p) {
                Ok(y) => (y, Some(p), None),
                Err(e) => return e,
            },
            shared::YamlSource::DegreeId(id) => match self.resolve_degree_id("trim_degree", &id) {
                Ok((y, meta)) => (y, None, meta),
                Err(e) => return e,
            },
        };
        let keep_all = req.keep_all.unwrap_or_default();
        let include = req.include.unwrap_or_default();
        let output_path = req.output_path;
        let raw = guard_panics("trim_degree", || {
            trim::execute_json(
                &yaml,
                &keep_all,
                &include,
                output_path.as_deref(),
                source_path.as_deref(),
            )
        });
        inject_cache_meta(&raw, cache_meta)
    }

    /// Generate the full HTML degree report (plus optional CSV / JSONL / index artifacts)
    #[tool(
        description = "Build the full HTML degree analysis report — the same artifact the CLI `degree --analyze` command produces. Provide exactly ONE YAML source: yaml_content (inline), yaml_path (file path), or degree_id (DB lookup). Same analysis knobs as analyze_degree (max_plans, include_courses). Set output_dir to write the HTML report + optional per-plan CSVs + JSONL summary + index.csv into a directory; in that mode html_content is omitted from the response (override with return_html_inline=true). Without output_dir, the rendered HTML is returned inline (~200-300 KB for a typical degree). Companion outputs (write_plan_csvs / write_jsonl_summary / write_index_csv) default to true in disk mode and are ignored in inline mode."
    )]
    fn generate_degree_report(
        &self,
        Parameters(req): Parameters<GenerateDegreeReportRequest>,
    ) -> String {
        let max_plans = req.max_plans;
        let include_courses = req.include_courses.map(|s| shared::parse_comma_list(&s));
        let output_dir = req.output_dir;
        let write_plan_csvs = req.write_plan_csvs;
        let write_jsonl_summary = req.write_jsonl_summary;
        let write_index_csv = req.write_index_csv;
        let return_html_inline = req.return_html_inline;
        let source = match shared::parse_yaml_source(req.yaml_content, req.yaml_path, req.degree_id)
        {
            Ok(s) => s,
            Err(e) => return e,
        };
        self.run_yaml_tool("generate_degree_report", source, move |yaml| {
            report::execute_json(
                yaml,
                max_plans,
                include_courses.as_deref(),
                output_dir.as_deref(),
                write_plan_csvs,
                write_jsonl_summary,
                write_index_csv,
                return_html_inline,
            )
        })
    }

    /// Cache an inline YAML body and return a degree_id-style handle
    #[tool(
        description = "Cache an inline degree YAML body in the server and return a handle (\"cache:{hex}\") that any other tool accepts as a `degree_id`. Removes the per-call repaste tax for hosted MCP clients whose filesystem the server can't see (yaml_path returns ENOENT in that setup). Handle is content-hashed and idempotent — caching the same body twice returns the same handle. TTL is 24 hours (handles live only in this server process — a restart invalidates them). After caching: pass the handle as `degree_id` on validate_degree / audit_degree / analyze_degree / generate_degree_report / get_course_detail / render_plan_graph / find_courses_matching / degree_pipeline / compare_degrees."
    )]
    #[allow(clippy::unused_self)]
    fn cache_yaml(&self, Parameters(req): Parameters<CacheYamlRequest>) -> String {
        cache::execute_json(req.yaml_content)
    }

    /// List the bundled sample degree YAMLs
    #[tool(
        description = "List the sample degree YAMLs bundled with this MCP server (three real curricula: CSU Fort Collins, Northeastern Khoury, UH Manoa). Default response is metadata-only (institution, program, total_credits, summary). Pass include_yaml=true to also receive the full embedded YAML body for each sample — pipe that body into validate_degree / audit_degree / analyze_degree / generate_degree_report. Use this to bootstrap exploration when the caller doesn't have a YAML in hand."
    )]
    #[allow(clippy::unused_self)]
    fn list_sample_degrees(&self, Parameters(req): Parameters<ListSampleDegreesRequest>) -> String {
        samples::execute_json(req.include_yaml.unwrap_or(false))
    }

    /// Preview which courses match a set of patterns in a degree YAML
    #[tool(
        description = "Resolve a set of include/exclude patterns against the courses defined in a YAML and return the matched course list with titles + levels. Same pattern grammar as `select` requirements (e.g. \"CS:300+\", \"MATH:300-499\"). Useful when sketching a new requirement and you want to preview the resulting pool before committing it to the YAML. Same yaml source modes as analyze_degree."
    )]
    fn find_courses_matching(
        &self,
        Parameters(req): Parameters<FindCoursesMatchingRequest>,
    ) -> String {
        let patterns = shared::parse_comma_list(&req.patterns);
        let exclude = req
            .exclude
            .as_deref()
            .map(shared::parse_comma_list)
            .unwrap_or_default();
        let source = match shared::parse_yaml_source(req.yaml_content, req.yaml_path, req.degree_id)
        {
            Ok(s) => s,
            Err(e) => return e,
        };
        self.run_yaml_tool("find_courses_matching", source, move |yaml| {
            match_courses::execute_json(yaml, patterns, exclude)
        })
    }

    /// Render the curriculum graph for one selected plan in a single call
    #[tool(
        description = "Render the curriculum graph HTML for one selected plan in a single call (analyze + extract graph_spec + visualize in one tool). Pick a plan via plan_category=\"shortest\" | \"longest\" | \"calc-ready-shortest\" | \"sample\" (with optional sample_index, 1-indexed) OR via plan_index (0-indexed offset into the analyze response's selected_plans). format defaults to \"standalone\" — pass \"fragment\" or \"fragment-no-library\" to embed in another HTML document. Set dry_run=true to skip the HTML payload and return only the picker metadata + node_count + projected html_bytes (cheap probe before committing to a 100-300 KB render). Same yaml source modes + analyze knobs (max_plans, include_courses) as analyze_degree."
    )]
    fn render_plan_graph(&self, Parameters(req): Parameters<RenderPlanGraphRequest>) -> String {
        let plan_category = req.plan_category;
        let sample_index = req.sample_index;
        let plan_index = req.plan_index;
        let format = req.format;
        let max_plans = req.max_plans;
        let include_courses = req.include_courses.map(|s| shared::parse_comma_list(&s));
        let dry_run = req.dry_run.unwrap_or(false);
        let source = match shared::parse_yaml_source(req.yaml_content, req.yaml_path, req.degree_id)
        {
            Ok(s) => s,
            Err(e) => return e,
        };
        self.run_yaml_tool("render_plan_graph", source, move |yaml| {
            plan_graph::execute_json(
                yaml,
                plan_category.as_deref(),
                sample_index,
                plan_index,
                format,
                max_plans,
                include_courses.as_deref(),
                dry_run,
            )
        })
    }

    /// Render a curriculum graph visualization from an `analyze_degree` result
    #[tool(
        description = "Low-level: most callers should use `render_plan_graph` instead — that tool runs analyze + extracts the graph_spec + renders in one call. Use this tool only when you already have a graph_spec in hand and want full control over rendering. Renders an interactive HTML curriculum graph from a graph_spec produced by analyze_degree. The output shows course nodes arranged by term, prerequisite/corequisite edges drawn as Bezier curves, complexity badges, hover highlighting of prerequisite chains, and a click-to-open course detail modal. Set format=\"standalone\" (default) for a full HTML page openable in a browser, format=\"fragment\" for an embeddable snippet, or format=\"fragment-no-library\" when the page already loaded the shared library."
    )]
    #[allow(clippy::unused_self)]
    fn get_curriculum_visualization(
        &self,
        Parameters(req): Parameters<GetCurriculumVisualizationRequest>,
    ) -> String {
        visualize::execute_html(&req.graph_spec_json, req.format)
    }

    // ── Institution tools ───────────────────────────────────────────────────

    /// Search institutions from the IPEDS database
    #[cfg(feature = "database")]
    #[tool(
        description = "Search institutions from the IPEDS database. Filter by name, state, Carnegie classification (15=R1, 16=R2, 21=R1-2021), control (1=public, 2=private nonprofit), HBCU/tribal status, or minimum size (inst_size_min: 2=1000+ students). Returns UNITID and metadata. Use get_lookup_codes(\"carnegie_class\") for the full classification list."
    )]
    fn search_institutions(
        &self,
        Parameters(req): Parameters<SearchInstitutionsRequest>,
    ) -> String {
        self.call_db("search_institutions", |db| async move {
            institutions::execute_search_json(&db, req).await
        })
    }

    /// Get full details for a specific institution by UNITID
    #[cfg(feature = "database")]
    #[tool(
        description = "Get full institution details by IPEDS Unit ID (UNITID). Returns all fields including sector, locale, Carnegie class, HBCU/tribal status, and institution size. Use search_institutions to find the UNITID first."
    )]
    fn get_institution(&self, Parameters(req): Parameters<GetInstitutionRequest>) -> String {
        self.call_db("get_institution", |db| async move {
            institutions::execute_get_json(&db, req).await
        })
    }

    // ── CIP code tools ──────────────────────────────────────────────────────

    /// Search CIP program codes by title keyword or code prefix
    #[cfg(feature = "database")]
    #[tool(
        description = "Search CIP (Classification of Instructional Programs) codes. Use query for title keyword search (e.g. \"cybersecurity\"), prefix for code prefix (e.g. \"11.\" for all CS, \"30.70\" for Data Science). Use trailing dot for families: \"11.\" not \"11\". Results include cip_code (dot notation) and title."
    )]
    fn search_cip_codes(&self, Parameters(req): Parameters<SearchCipCodesRequest>) -> String {
        self.call_db("search_cip_codes", |db| async move {
            cip_codes::execute_json(&db, req).await
        })
    }

    /// Get the full contents of an IPEDS lookup table
    #[cfg(feature = "database")]
    #[tool(
        description = "Get all rows from an IPEDS lookup table to discover numeric code meanings. Tables: \"carnegie_class\" (R1/R2 codes), \"award_levels\" (bachelor's/master's codes), \"institution_control\", \"institution_level\", \"institution_sector\", \"institution_locale\", \"institution_size\". Call this before filtering to confirm the right code."
    )]
    fn get_lookup_codes(&self, Parameters(req): Parameters<GetLookupCodesRequest>) -> String {
        self.call_db("get_lookup_codes", |db| async move {
            lookup::execute_json(&db, req).await
        })
    }

    // ── Completion demographic tools ────────────────────────────────────────

    /// Get CS completion demographics for a single institution
    #[cfg(feature = "database")]
    #[tool(
        description = "Get completion demographics per CIP program for a single institution. Returns per-CIP rows with demographic counts + representation ratios, plus a cross_tab section showing the race×gender breakdown aggregated across all selected programs. cross_tab fields: women_pct_within_group (gender parity: % of race group that are women), women/men_pct_of_total (share of all CS completions), women/men_representation_ratio (vs institution baseline). Use cip_prefix=\"11.\" for CS. Provide year for accurate ratios. When the CIP filter matches no rows, the response includes nearby_cips_with_data — top CIPs at this institution that DO have completions — so you can see whether the school files the program under a different CIP code (the most common IPEDS user error)."
    )]
    fn get_institution_completions(
        &self,
        Parameters(req): Parameters<GetInstitutionCompletionsRequest>,
    ) -> String {
        self.call_db("get_institution_completions", |db| async move {
            completions::execute_institution_json(&db, req).await
        })
    }

    /// Get per-school CS completion demographics across many institutions in one call
    #[cfg(feature = "database")]
    #[tool(
        description = "Per-school CS completion demographics. Use unitid for a single school, or combine carnegie_class/control/state/hbcu/tribal/inst_size_min for a group. Returns demographics (Women%, race%, representation ratios) AND cross_tab (race×gender: women_pct_within_group, representation_ratio per cell). Omitting cip_prefix/cip_codes returns all CIPs; set cip_prefix=\"11.\" for CS. Provide year for accurate ratios."
    )]
    fn get_schools_completion_demographics(
        &self,
        Parameters(req): Parameters<GetSchoolsCompletionDemographicsRequest>,
    ) -> String {
        self.call_db("get_schools_completion_demographics", |db| async move {
            completions::execute_schools_json(&db, req).await
        })
    }

    /// Query completion demographics aggregated across institutions
    #[cfg(feature = "database")]
    #[tool(
        description = "Aggregate completion demographics across matching institutions. Returns demographics AND cross_tab (race×gender). Filter by unitid (single school), carnegie_class, control, state, cip_prefix (\"11.\" for CS — no default, omit for all CIPs), cip_codes (comma-separated exact codes), award level, year. For per-school breakdown use get_schools_completion_demographics."
    )]
    fn get_completion_demographics(
        &self,
        Parameters(req): Parameters<CompletionDemographicsRequest>,
    ) -> String {
        self.call_db("get_completion_demographics", |db| async move {
            completions::execute_json(&db, req).await
        })
    }

    /// Generate a starter degree YAML for a UNITID + CIP code
    #[cfg(feature = "database")]
    #[tool(
        description = "Generate a minimal degree YAML scaffold for a UNITID + CIP code: pulls institution name from IPEDS, CIP title from the cip_codes lookup, derives a slug like \"{inst-slug}-{cip-slug}-bscs-{year}\", and emits a `degree:` header with empty `requirements:` + `courses:` ready for the caller to fill in. Defaults system_type to \"semester\" (override with the parameter). Optional catalog_year populates both the slug suffix and the YAML field. Removes the cold-start barrier for building a new degree definition."
    )]
    fn scaffold_degree_yaml(
        &self,
        Parameters(req): Parameters<ScaffoldDegreeYamlRequest>,
    ) -> String {
        self.call_db("scaffold_degree_yaml", |db| async move {
            scaffold::execute_json(&db, req).await
        })
    }

    // ── Degree storage tools ────────────────────────────────────────────────

    /// Search stored degree programs in the database
    #[cfg(feature = "database")]
    #[tool(
        description = "Search stored programs in the database (the normalized `programs` table written by import_degree). Filter by institution UNITID, CIP code prefix (\"11.\" for CS), catalog year, and the queryable dimensions degree_type (\"BS\"/\"BA\"/\"MS\"/\"MINOR\"), program_kind (\"major\"/\"minor\"/\"concentration\"/…), and discipline (\"cs\"/\"ai\"/\"ds\"/…). Returns matching programs with metadata (program_key, name, verified, has_impossible_requirements, …). Use get_degree to retrieve the full unified-JSON document."
    )]
    fn search_degrees(&self, Parameters(req): Parameters<SearchDegreesRequest>) -> String {
        self.call_db("search_degrees", |db| async move {
            degrees::execute_search_json(&db, req).await
        })
    }

    /// Retrieve a stored degree program by ID or natural key
    #[cfg(feature = "database")]
    #[tool(
        description = "Retrieve a full stored program from the `programs` table. Lookup precedence: program_key (unique, strongest) → degree_id → natural key (unitid + cip_code + catalog_year). If multiple programs match, returns a list of summaries — narrow with program_key or more filters. On a single match, returns the program metadata plus its `document` (the lossless unified-JSON degree) directly usable with validate_degree / analyze_degree / cache_yaml."
    )]
    fn get_degree(&self, Parameters(req): Parameters<GetDegreeRequest>) -> String {
        self.call_db("get_degree", |db| async move {
            degrees::execute_get_json(&db, req).await
        })
    }

    /// Compare multiple stored degree programs (and/or inline YAMLs)
    #[cfg(feature = "database")]
    #[tool(
        description = "Compare multiple degrees side-by-side. Provide `sources` (structured list of {label?, degree_id?|yaml_content?|yaml_path?}, exactly one source per entry) and/or `degree_ids` (legacy comma-separated stored-IDs form; each id is matched against program_key then degree_id in the `programs` table). `sources` lets you benchmark an in-progress inline YAML against a stored peer without storing it first. Returns per entry: program_key, name, the degree `source` text (unified JSON for stored programs, raw YAML for inline), plus (default) analyze-style metrics (complexity, longest_delay, total_credits, plans_analyzed). Set include_metrics=false to skip the analysis pass."
    )]
    fn compare_degrees(&self, Parameters(req): Parameters<CompareDegreesRequest>) -> String {
        self.call_db("compare_degrees", |db| async move {
            degrees::execute_compare_json(&db, req).await
        })
    }

    /// Import a degree report into the normalized program tables
    #[cfg(feature = "database")]
    #[tool(
        description = "Import a degree-first analysis report (or a plain unified degree) JSON into the normalized program tables (programs, courses, program_courses, program_requirements); when the input carries an `analysis` block, one analysis run is also attached. Provide exactly ONE source: json_content (inline) or json_path (file on the server's filesystem). Set dry_run=true to preview the write counts without touching the database. The institution is resolved read-only; when the name matches multiple institutions the result is \"institution_ambiguous\" with institution_candidates [{unitid, name}] — re-call with an explicit unitid to pick one. variant defaults to \"full\" (writes the program projection); a non-full variant only attaches an analysis run. Use force/replace to overwrite an existing program and skip_existing to leave it untouched."
    )]
    fn import_degree(&self, Parameters(req): Parameters<ImportDegreeRequest>) -> String {
        self.call_db("import_degree", |db| async move {
            import::execute_json(&db, req).await
        })
    }

    /// Run a degree-pipeline tool against a [`YamlSource`].
    ///
    /// Resolves the YAML from inline content, a filesystem path, or a stored
    /// degree id, then invokes `run` with the resolved string. When the source
    /// was a `cache:` handle, the returned JSON is augmented with
    /// `cache_handle` + `cache_ttl_remaining_seconds` so callers can plan
    /// re-caching. Errors at any resolution step return a JSON error string.
    fn run_yaml_tool<F>(&self, tool: &'static str, source: shared::YamlSource, run: F) -> String
    where
        F: FnOnce(&str) -> String,
    {
        let (yaml, cache_meta) = match source {
            shared::YamlSource::Content(y) => (y, None),
            shared::YamlSource::Path(p) => match shared::read_yaml_file(&p) {
                Ok(y) => (y, None),
                Err(e) => return e,
            },
            shared::YamlSource::DegreeId(id) => match self.resolve_degree_id(tool, &id) {
                Ok((y, meta)) => (y, meta),
                Err(e) => return e,
            },
        };
        let raw = guard_panics(tool, || run(&yaml));
        inject_cache_meta(&raw, cache_meta)
    }

    /// Resolve a `degree_id` to a YAML body using the layered lookup:
    ///
    /// 1. **YAML cache** — handles minted by `cache_yaml` (prefix
    ///    `cache:`). In-memory; works regardless of DB availability.
    /// 2. **Bundled samples** — the three sample keys (`csu`, `neu-khoury`,
    ///    `uhm`) returned by `list_sample_degrees`. Embedded at compile time.
    /// 3. **Database** — stored degrees by id (requires the `database`
    ///    feature + a configured client).
    ///
    /// Returns a JSON error string on any failure path so the calling
    /// handler can return it verbatim.
    fn resolve_degree_id(
        &self,
        #[cfg_attr(not(feature = "database"), allow(unused_variables))] tool: &'static str,
        id: &str,
    ) -> Result<(String, Option<CacheMeta>), String> {
        if id.starts_with(crate::mcp::cache::YAML_CACHE_PREFIX) {
            use crate::mcp::cache::CacheLookup;
            // Resolve under the lock, then drop the guard before returning so we
            // don't hold the cache mutex across the (owned) result construction.
            let lookup = crate::mcp::cache::YAML_CACHE
                .lock()
                .expect("yaml cache mutex poisoned")
                .lookup(id);
            return match lookup {
                CacheLookup::Expired => Err(serde_json::json!({
                    "error": "YAML cache handle expired",
                    "degree_id": id,
                    "hint": "This handle was valid but aged out (TTL is 24 hours). Call cache_yaml(yaml_content=...) to mint a fresh one.",
                })
                .to_string()),
                CacheLookup::Unknown => Err(serde_json::json!({
                    "error": "Unknown YAML cache handle",
                    "degree_id": id,
                    "hint": "No such handle was minted in this server process (a typo, or the server was restarted). Call cache_yaml(yaml_content=...) to mint one.",
                })
                .to_string()),
                CacheLookup::Hit(arc, remaining) => {
                    Ok((
                        (*arc).to_string(),
                        Some(CacheMeta {
                            handle: id.to_string(),
                            ttl_remaining_seconds: remaining.as_secs(),
                        }),
                    ))
                }
            };
        }

        if let Some(yaml) = crate::mcp::tools::samples::yaml_for_key(id) {
            return Ok((yaml.to_string(), None));
        }

        #[cfg(feature = "database")]
        {
            let db = self.get_db(tool)?;
            let id_owned = id.to_string();
            let yaml = run_db_async_result(move || async move {
                shared::fetch_yaml_by_degree_id(&db, &id_owned).await
            })?;
            Ok((yaml, None))
        }
        #[cfg(not(feature = "database"))]
        {
            Err(shared::error_json(&format!(
                "degree_id '{id}' did not match any cache handle or bundled sample key; database lookups require the nu-analytics 'database' feature"
            )))
        }
    }

    // TODO(database): re-enable `store_degree` once the deployed Supabase
    // schema provisions the `degrees` write path and the auth flow is set up
    // (`nuanalytics db login`). The implementation in
    // `degrees::execute_store_json` + `StoreDegreeRequest` is in place and
    // works against a locally-configured DB; only the MCP exposure is
    // shelved so callers don't see a tool that returns auth errors.
    //
    // #[cfg(feature = "database")]
    // #[tool(
    //     description = "Save a validated degree program YAML to the database. Requires authentication (run `nuanalytics db login` first). Uses upsert on degree_id — safe to re-run after updates. Provide unitid (from search_institutions) and cip_code for the program. The degree YAML should be validated with validate_degree before storing."
    // )]
    // fn store_degree(&self, Parameters(req): Parameters<StoreDegreeRequest>) -> String {
    //     self.call_db("store_degree", |db| async move {
    //         degrees::execute_store_json(&db, req).await
    //     })
    // }
}

/// Run a tool body inside `catch_unwind` so an unexpected panic in the
/// analysis pipeline surfaces as a structured JSON error instead of taking
/// down the MCP server. The tool body is treated as unwind-safe — every
/// MCP tool body is pure aside from process-wide `Arc`-shared caches.
fn guard_panics<F>(tool: &'static str, f: F) -> String
where
    F: FnOnce() -> String,
{
    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
        Ok(s) => s,
        Err(payload) => {
            let message = panic_payload_to_string(&*payload);
            serde_json::json!({
                "success": false,
                "error": format!("{tool} panicked: {message}"),
                "hint": "This is a bug in the MCP server — please report the YAML that triggered it. The server is still running.",
            })
            .to_string()
        }
    }
}

/// Pull a human-readable message out of a panic payload. Panics started by
/// `panic!("literal")` carry a `&'static str`; panics with formatting carry
/// a `String`; anything else falls back to a generic placeholder.
fn panic_payload_to_string(payload: &(dyn std::any::Any + Send)) -> String {
    payload
        .downcast_ref::<&'static str>()
        .map(|s| (*s).to_string())
        .or_else(|| payload.downcast_ref::<String>().cloned())
        .unwrap_or_else(|| "non-string panic payload".to_string())
}

/// Cache-resolution metadata, carried from `resolve_degree_id` back through
/// `run_yaml_tool` so the caller can plan re-caching before expiry.
#[derive(Debug, Clone)]
struct CacheMeta {
    handle: String,
    ttl_remaining_seconds: u64,
}

/// Augment a tool's JSON response with `cache_handle` +
/// `cache_ttl_remaining_seconds` when the YAML source was a `cache:` handle.
///
/// Single-point injection avoids threading the metadata through every tool's
/// `execute()` signature. The function preserves the original string on any
/// failure (non-object root, parse error) so a malformed response — which is
/// a separate bug — surfaces unchanged for debugging.
fn inject_cache_meta(raw: &str, meta: Option<CacheMeta>) -> String {
    let Some(meta) = meta else {
        return raw.to_string();
    };
    let Ok(mut value) = serde_json::from_str::<serde_json::Value>(raw) else {
        return raw.to_string();
    };
    let serde_json::Value::Object(map) = &mut value else {
        return raw.to_string();
    };
    map.insert(
        "cache_handle".to_string(),
        serde_json::Value::String(meta.handle),
    );
    map.insert(
        "cache_ttl_remaining_seconds".to_string(),
        serde_json::Value::Number(meta.ttl_remaining_seconds.into()),
    );
    serde_json::to_string_pretty(&value).unwrap_or_else(|_| raw.to_string())
}

/// Database access helpers.
#[cfg(feature = "database")]
impl NuAnalyticsMcpServer {
    /// Return the configured DB client, or a JSON error response naming `tool_name`
    /// when the database is not configured.
    fn get_db(&self, tool_name: &'static str) -> Result<Arc<DbClient>, String> {
        self.db
            .as_ref()
            .map(Arc::clone)
            .ok_or_else(|| db_not_configured_response(tool_name))
    }

    /// Fetch the DB client, run an async tool function, and return JSON.
    ///
    /// Returns an error JSON string if the database is not configured.
    fn call_db<F, Fut>(&self, tool: &'static str, f: F) -> String
    where
        F: FnOnce(Arc<DbClient>) -> Fut,
        Fut: std::future::Future<Output = String>,
    {
        match self.get_db(tool) {
            Ok(db) => run_db_async(move || f(db)),
            Err(e) => e,
        }
    }
}

/// Run an async database operation from a synchronous MCP tool handler.
#[cfg(feature = "database")]
fn run_db_async<F, Fut>(f: F) -> String
where
    F: FnOnce() -> Fut,
    Fut: std::future::Future<Output = String>,
{
    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f()))
}

/// Run an async database operation that yields a `Result<T, String>`.
///
/// Used by tools that need to thread a fetched value back into a sync
/// pipeline (e.g. resolving `degree_id` to YAML before calling validate).
#[cfg(feature = "database")]
fn run_db_async_result<T, F, Fut>(f: F) -> Result<T, String>
where
    F: FnOnce() -> Fut,
    Fut: std::future::Future<Output = Result<T, String>>,
{
    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(f()))
}

/// Return a standardized error JSON when the database is not available.
///
/// The MCP server skips registering a DB client whenever
/// `DbClient::from_config` fails at startup — that covers both missing
/// configuration (no endpoint / anon key / `enabled = false`) and missing
/// authentication (no `auth.json`, expired/revoked refresh token). The
/// error covers both reasons so the model knows what to suggest.
#[cfg(feature = "database")]
fn db_not_configured_response(tool: &str) -> String {
    serde_json::json!({
        "error": "Database not available",
        "tool": tool,
        "suggestion": "Two possible causes: (a) database config is incomplete \
            (set `endpoint`, `anon_key`, and `enabled = true` in the [database] \
            section of your nuanalytics config), or (b) you're not signed in \
            (run `nuanalytics db login` and restart the MCP server). Both reads \
            and writes now require an authenticated session."
    })
    .to_string()
}

impl Default for NuAnalyticsMcpServer {
    fn default() -> Self {
        Self::new()
    }
}

#[tool_handler]
impl ServerHandler for NuAnalyticsMcpServer {
    fn get_info(&self) -> ServerInfo {
        #[cfg(feature = "database")]
        let db_section = if self.db.is_some() {
            "\n\n## Database Query Workflow (IPEDS data available)\n\
            \n\
            Discover codes first (optional):\n\
            - get_lookup_codes(\"carnegie_class\") → R1=15, R2=16, R1-2021=21\n\
            - get_lookup_codes(\"award_levels\")   → associate=3, bachelors=5, masters=7, doctoral=9\n\
            - search_cip_codes(\"computer science\") or prefix=\"11.\" → find CIP codes\n\
            \n\
            Find institutions:\n\
            - search_institutions(carnegie_class=15, state=\"MA\") → list with UNITIDs\n\
            - get_institution(unitid=167358) → full institution details\n\
            \n\
            Query completion demographics:\n\
            - get_institution_completions(unitid=167358, year=2024, cip_prefix=\"11.\") → per-CIP rows + representation ratios\n\
            - get_schools_completion_demographics(carnegie_class=15, inst_size_min=2, cip_prefix=\"11.\", year=2024)\n\
            \t→ per-school CS demographics for all R1 schools >1000 students (3 DB calls, not 130)\n\
            - get_completion_demographics(carnegie_class=15, award_level=5, year=2024) → aggregate across all matched schools\n\
            \n\
            Degree programs (normalized `programs` table):\n\
            - search_degrees(unitid=167358) / search_degrees(degree_type=\"BS\", discipline=\"cs\") → browse stored programs\n\
            - get_degree(unitid=167358, cip_code=\"11.0101\", catalog_year=\"2025-2026\") or get_degree(program_key=…) → full program + its unified-JSON document\n\
            - import_degree(json_path=…) → write a program (and optional analysis run) from a report/degree JSON"
        } else {
            "\n\nDatabase tools: Not available (database not configured or disabled)"
        };

        #[cfg(not(feature = "database"))]
        let db_section = "";

        ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
            format!(
                "NuAnalytics MCP Server\n\
                \n\
                ## Degree YAML Workflow\n\
                1. get_degree_schema — understand the YAML format\n\
                2. Build a degree YAML\n\
                3. validate_degree — check for structural errors\n\
                4. Fix issues; repeat until valid\n\
                5. audit_degree — comprehensive quality check (missing prereqs, deep chains)\n\
                6. analyze_degree — plan generation and metrics (JSON)\n\
                7. generate_degree_report — full HTML report (same artifact as `degree --analyze`); optional CSV / JSONL outputs via output_dir\
                {db_section}"
            ),
        )
    }
}

// ============================================================================
// Server Entry Points
// ============================================================================

/// Run the MCP server (async)
///
/// # Errors
///
/// Returns an error if the server fails to start or encounters a fatal transport error.
pub async fn run_server(db_config: &DatabaseConfig) -> Result<(), Box<dyn std::error::Error>> {
    eprintln!("Starting NuAnalytics MCP server...");

    #[cfg(feature = "database")]
    let server = {
        let db = match DbClient::from_config(db_config).await {
            Ok(client) => {
                eprintln!("Database client initialized.");
                Some(Arc::new(client))
            }
            Err(e) => {
                eprintln!("Database unavailable: {e}");
                eprintln!(
                    "DB-backed MCP tools will not be registered. \
                    Run `nuanalytics db login` and restart this server to enable them."
                );
                None
            }
        };
        NuAnalyticsMcpServer::with_db(db)
    };

    #[cfg(not(feature = "database"))]
    let server = {
        let _ = db_config;
        NuAnalyticsMcpServer::new()
    };

    let service = server
        .serve(stdio())
        .await
        .map_err(|e| format!("Failed to start MCP server: {e}"))?;

    eprintln!("NuAnalytics MCP server running. Waiting for requests...");

    service.waiting().await?;

    eprintln!("NuAnalytics MCP server shut down.");
    Ok(())
}

/// Synchronous wrapper to run the MCP server
///
/// # Errors
///
/// Returns an error string if the tokio runtime cannot be created or the server fails.
pub fn run(db_config: &DatabaseConfig) -> Result<(), String> {
    let rt = tokio::runtime::Runtime::new()
        .map_err(|e| format!("Failed to create tokio runtime: {e}"))?;

    rt.block_on(run_server(db_config))
        .map_err(|e| format!("MCP server error: {e}"))
}

// ============================================================================
// Tests
// ============================================================================

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

    #[test]
    fn test_guard_panics_returns_value_on_normal_completion() {
        let result = guard_panics("noop", || "{\"success\":true}".to_string());
        assert_eq!(result, "{\"success\":true}");
    }

    #[test]
    fn test_guard_panics_translates_string_panic_into_json_error() {
        let result = guard_panics("explode", || panic!("intentional test panic"));
        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["success"].as_bool(), Some(false));
        let error = parsed["error"].as_str().expect("error string");
        assert!(
            error.contains("explode panicked"),
            "error must name the tool: got {error:?}"
        );
        assert!(
            error.contains("intentional test panic"),
            "error must include the panic message: got {error:?}"
        );
        assert!(parsed["hint"].is_string(), "hint must be populated");
    }

    #[test]
    fn test_guard_panics_extracts_formatted_string_panic_message() {
        // panic! with formatting allocates a String payload — exercises the
        // downcast_ref::<String>() branch in panic_payload_to_string.
        let result = guard_panics("fmt", || panic!("dynamic {} value", 42));
        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
        let error = parsed["error"].as_str().unwrap();
        assert!(
            error.contains("dynamic 42 value"),
            "formatted panic message must reach the response: got {error:?}"
        );
    }

    #[test]
    fn test_guard_panics_handles_non_string_payload() {
        let result = guard_panics("weird", || {
            std::panic::panic_any(42_i32);
        });
        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["success"].as_bool(), Some(false));
        assert!(parsed["error"]
            .as_str()
            .unwrap()
            .contains("non-string panic payload"));
    }

    #[test]
    fn test_inject_cache_meta_adds_fields_when_meta_present() {
        let raw = r#"{"success": true, "is_valid": true}"#;
        let injected = inject_cache_meta(
            raw,
            Some(CacheMeta {
                handle: "cache:abc123".to_string(),
                ttl_remaining_seconds: 12_345,
            }),
        );
        let parsed: serde_json::Value =
            serde_json::from_str(&injected).expect("injected output must be valid JSON");
        assert_eq!(parsed["cache_handle"], "cache:abc123");
        assert_eq!(parsed["cache_ttl_remaining_seconds"], 12_345);
        // Existing fields preserved.
        assert_eq!(parsed["success"], true);
        assert_eq!(parsed["is_valid"], true);
    }

    #[test]
    fn test_inject_cache_meta_preserves_raw_when_meta_none() {
        let raw = r#"{"success": true}"#;
        assert_eq!(inject_cache_meta(raw, None), raw);
    }

    #[test]
    fn test_inject_cache_meta_preserves_non_object_root() {
        // Tool bodies always emit objects; defend against accidental
        // array/string roots so a parser bug doesn't get swallowed by
        // the injection helper.
        let raw = r#"["just", "an", "array"]"#;
        let injected = inject_cache_meta(
            raw,
            Some(CacheMeta {
                handle: "cache:xyz".to_string(),
                ttl_remaining_seconds: 1,
            }),
        );
        assert_eq!(injected, raw);
    }
}
