Skip to main content

spreadsheet_kit/tools/
mod.rs

1pub mod filters;
2#[cfg(feature = "recalc")]
3pub mod fork;
4pub mod param_enums;
5#[cfg(feature = "recalc")]
6pub mod rules_batch;
7#[cfg(feature = "recalc")]
8pub mod sheet_layout;
9#[cfg(feature = "recalc")]
10pub mod structure_impact;
11pub mod vba;
12#[cfg(feature = "recalc")]
13pub mod write_normalize;
14
15use crate::analysis::{formula::FormulaGraph, stats};
16use crate::config::OutputProfile;
17use crate::model::*;
18use crate::state::AppState;
19use crate::utils::column_number_to_name;
20use crate::verification::{VerifyOptions, VerifyResponse, compare_workbooks};
21use crate::workbook::{WorkbookContext, cell_to_value};
22use anyhow::{Context, Result, anyhow};
23use regex::Regex;
24use schemars::JsonSchema;
25use serde::Deserialize;
26use std::cmp::Ordering;
27use std::collections::{BTreeMap, HashMap, HashSet};
28use std::path::PathBuf;
29use std::sync::Arc;
30
31#[cfg(feature = "recalc")]
32fn fork_recalc_needed(state: &AppState, workbook_or_fork_id: &WorkbookId) -> bool {
33    state
34        .fork_registry()
35        .and_then(|registry| registry.get_fork(workbook_or_fork_id.as_str()).ok())
36        .is_some_and(|ctx| ctx.recalc_needed)
37}
38
39#[cfg(feature = "recalc")]
40fn sheet_has_formula_in_bounds(sheet: &umya_spreadsheet::Worksheet, bounds: &[CellBounds]) -> bool {
41    if bounds.is_empty() {
42        return false;
43    }
44    for cell in sheet.get_cell_collection() {
45        if !cell.is_formula() {
46            continue;
47        }
48        let address = cell.get_coordinate().get_coordinate().to_string();
49        let Some((col, row)) = parse_address(&address) else {
50            continue;
51        };
52        if bounds
53            .iter()
54            .any(|b| col >= b.0.0 && col <= b.1.0 && row >= b.0.1 && row <= b.1.1)
55        {
56            return true;
57        }
58    }
59    false
60}
61
62const DEFAULT_TRACE_PAGE_SIZE: usize = 20;
63const TRACE_PAGE_MIN: usize = 5;
64const TRACE_PAGE_MAX: usize = 200;
65const TRACE_RANGE_THRESHOLD: usize = 4;
66const TRACE_RANGE_HIGHLIGHT_LIMIT: usize = 3;
67const TRACE_GROUP_HIGHLIGHT_LIMIT: usize = 3;
68const TRACE_CELL_HIGHLIGHT_LIMIT: usize = 5;
69const TRACE_RANGE_VALUE_SAMPLES: usize = 3;
70const TRACE_RANGE_FORMULA_SAMPLES: usize = 2;
71const TRACE_GROUP_SAMPLE_LIMIT: usize = 5;
72const TRACE_DEPENDENTS_PER_CELL_LIMIT: usize = 500;
73
74const DEFAULT_OVERVIEW_MAX_REGIONS: u32 = 25;
75const DEFAULT_OVERVIEW_MAX_HEADERS: u32 = 50;
76const DEFAULT_OVERVIEW_INCLUDE_HEADERS: bool = true;
77
78const ENTRY_POINT_MAX_ROWS: u32 = 10_000;
79const ENTRY_POINT_MAX_COLS: u32 = 200;
80
81#[cfg(feature = "recalc")]
82type CellBounds = ((u32, u32), (u32, u32));
83
84pub async fn list_workbooks(
85    state: Arc<AppState>,
86    params: ListWorkbooksParams,
87) -> Result<WorkbookListResponse> {
88    let config = state.config();
89    let output_profile = config.output_profile();
90    let include_paths = params
91        .include_paths
92        .unwrap_or(!matches!(output_profile, OutputProfile::TokenDense));
93
94    let offset = params.offset.unwrap_or(0) as usize;
95    let limit = params.limit.unwrap_or(100) as usize;
96    let filter = params.into_filter()?;
97    let mut response = state.list_workbooks(filter)?;
98    let total_count = response.workbooks.len();
99
100    if offset < total_count {
101        let end = (offset + limit).min(total_count);
102        response.workbooks = response
103            .workbooks
104            .into_iter()
105            .skip(offset)
106            .take(end - offset)
107            .collect();
108    } else {
109        response.workbooks.clear();
110    }
111
112    // Apply include_paths toggle
113    if !include_paths {
114        for wb in &mut response.workbooks {
115            wb.path = None;
116            wb.caps = None;
117            wb.client_path = None;
118        }
119    } else if !config.path_mappings.is_empty() {
120        for wb in &mut response.workbooks {
121            if let Some(p) = wb.path.as_ref() {
122                let abs = config.resolve_path(PathBuf::from(p));
123                wb.client_path = config
124                    .map_path_for_client(&abs)
125                    .map(|mapped| mapped.display().to_string());
126            }
127        }
128    }
129
130    // Set next_offset if more data exists
131    response.next_offset = if offset + response.workbooks.len() < total_count {
132        Some((offset + response.workbooks.len()) as u32)
133    } else {
134        None
135    };
136
137    Ok(response)
138}
139
140pub async fn describe_workbook(
141    state: Arc<AppState>,
142    params: DescribeWorkbookParams,
143) -> Result<WorkbookDescription> {
144    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
145    let mut desc = workbook.describe();
146    let config = state.config();
147    if !config.path_mappings.is_empty() {
148        let internal = PathBuf::from(&desc.path);
149        desc.client_path = config
150            .map_path_for_client(&internal)
151            .map(|mapped| mapped.display().to_string());
152    }
153    Ok(desc)
154}
155
156#[derive(Debug, Deserialize, JsonSchema)]
157pub struct ListWorkbooksParams {
158    /// Filter by workbook slug prefix
159    pub slug_prefix: Option<String>,
160    /// Filter by folder path
161    pub folder: Option<String>,
162    /// Filter by glob pattern (e.g., "**/*.xlsx")
163    pub path_glob: Option<String>,
164    /// Maximum number of workbooks to return (default: 100)
165    #[serde(default)]
166    pub limit: Option<u32>,
167    /// Offset for pagination; use next_offset from previous response
168    #[serde(default)]
169    pub offset: Option<u32>,
170    /// Include file paths and capabilities (default: false in token_dense profile)
171    #[serde(default)]
172    pub include_paths: Option<bool>,
173}
174
175impl ListWorkbooksParams {
176    fn into_filter(self) -> Result<filters::WorkbookFilter> {
177        filters::WorkbookFilter::new(self.slug_prefix, self.folder, self.path_glob)
178    }
179}
180
181#[derive(Debug, Deserialize, JsonSchema)]
182pub struct DescribeWorkbookParams {
183    #[serde(alias = "workbook_id")]
184    pub workbook_or_fork_id: WorkbookId,
185}
186
187#[derive(Debug, Deserialize, JsonSchema)]
188pub struct ListSheetsParams {
189    /// Workbook ID or fork ID
190    #[serde(alias = "workbook_id")]
191    pub workbook_or_fork_id: WorkbookId,
192    /// Maximum number of sheets to return (default: 100)
193    #[serde(default)]
194    pub limit: Option<u32>,
195    /// Offset for pagination; use next_offset from previous response
196    #[serde(default)]
197    pub offset: Option<u32>,
198    /// Include row/column counts and metrics (default: false in token_dense profile)
199    #[serde(default)]
200    pub include_bounds: Option<bool>,
201}
202
203pub async fn list_sheets(
204    state: Arc<AppState>,
205    params: ListSheetsParams,
206) -> Result<SheetListResponse> {
207    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
208    let config = state.config();
209    let output_profile = config.output_profile();
210    let include_bounds = params
211        .include_bounds
212        .unwrap_or(!matches!(output_profile, OutputProfile::TokenDense));
213    let mut summaries = workbook.list_summaries(include_bounds)?;
214
215    let total_count = summaries.len();
216    let offset = params.offset.unwrap_or(0) as usize;
217    let limit = params.limit.unwrap_or(100) as usize;
218
219    if offset < total_count {
220        summaries = summaries.into_iter().skip(offset).take(limit).collect();
221    } else {
222        summaries.clear();
223    }
224
225    let next_offset = if offset + summaries.len() < total_count {
226        Some((offset + summaries.len()) as u32)
227    } else {
228        None
229    };
230
231    let response = SheetListResponse {
232        workbook_id: workbook.id.clone(),
233        sheets: summaries,
234        next_offset,
235    };
236    Ok(response)
237}
238
239#[derive(Debug, Deserialize, JsonSchema)]
240pub struct SheetOverviewParams {
241    /// Workbook ID or fork ID
242    #[serde(alias = "workbook_id")]
243    pub workbook_or_fork_id: WorkbookId,
244    /// Sheet name
245    pub sheet_name: String,
246    /// Maximum detected regions to return (default: 25)
247    #[serde(default)]
248    pub max_regions: Option<u32>,
249    /// Maximum headers per region (default: 50)
250    #[serde(default)]
251    pub max_headers: Option<u32>,
252    /// Include headers in region info (default: true)
253    #[serde(default)]
254    pub include_headers: Option<bool>,
255}
256
257#[derive(Debug, Deserialize, JsonSchema)]
258pub struct WorkbookSummaryParams {
259    /// Workbook ID or fork ID
260    #[serde(alias = "workbook_id")]
261    pub workbook_or_fork_id: WorkbookId,
262    /// Return minimal summary without entry points or named ranges (default: true in token_dense profile)
263    #[serde(default)]
264    pub summary_only: Option<bool>,
265    /// Include suggested entry points for exploration (default: !summary_only)
266    #[serde(default)]
267    pub include_entry_points: Option<bool>,
268    /// Include key named ranges and tables (default: !summary_only)
269    #[serde(default)]
270    pub include_named_ranges: Option<bool>,
271}
272
273pub async fn workbook_summary(
274    state: Arc<AppState>,
275    params: WorkbookSummaryParams,
276) -> Result<WorkbookSummaryResponse> {
277    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
278    let config = state.config();
279    let output_profile = config.output_profile();
280    let summary_only = params
281        .summary_only
282        .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
283    let include_entry_points = params.include_entry_points.unwrap_or(!summary_only);
284    let include_named_ranges = params.include_named_ranges.unwrap_or(!summary_only);
285
286    tokio::task::spawn_blocking(move || {
287        build_workbook_summary(workbook, include_entry_points, include_named_ranges)
288    })
289    .await?
290}
291
292fn build_workbook_summary(
293    workbook: Arc<WorkbookContext>,
294    include_entry_points: bool,
295    include_named_ranges: bool,
296) -> Result<WorkbookSummaryResponse> {
297    let sheet_names = workbook.sheet_names();
298
299    let mut total_cells: u64 = 0;
300    let mut total_formulas: u64 = 0;
301    let mut breakdown = WorkbookBreakdown::default();
302    let mut region_counts = RegionCountSummary::default();
303    let mut entry_points: Vec<EntryPoint> = Vec::new();
304    let mut key_named_ranges: Vec<NamedRangeDescriptor> = Vec::new();
305    let mut notes: Vec<String> = Vec::new();
306
307    for sheet_name in &sheet_names {
308        let entry = workbook.get_sheet_metrics_fast(sheet_name)?;
309        total_cells += (entry.metrics.row_count as u64) * (entry.metrics.column_count as u64);
310        total_formulas += entry.metrics.formula_cells as u64;
311
312        match entry.metrics.classification {
313            SheetClassification::Calculator => breakdown.calculator_sheets += 1,
314            SheetClassification::Metadata => breakdown.metadata_sheets += 1,
315            SheetClassification::Empty => {}
316            _ => breakdown.data_sheets += 1,
317        }
318
319        if entry.metrics.non_empty_cells == 0 {
320            continue;
321        }
322
323        match entry.metrics.classification {
324            SheetClassification::Calculator => region_counts.calculator += 1,
325            SheetClassification::Metadata => region_counts.metadata += 1,
326            SheetClassification::Empty => {}
327            _ => region_counts.data += 1,
328        }
329
330        if include_entry_points {
331            let priority = entry_point_priority(&entry.metrics.classification);
332            entry_points.push(EntryPoint {
333                sheet_name: sheet_name.clone(),
334                region_id: None,
335                bounds: entry_point_bounds(&entry.metrics),
336                rationale: format!(
337                    "Fast summary p{}: {:?} sheet",
338                    priority, entry.metrics.classification
339                ),
340            });
341        }
342    }
343
344    if include_entry_points {
345        entry_points.sort_by(|a, b| {
346            let pa = priority_from_rationale(&a.rationale);
347            let pb = priority_from_rationale(&b.rationale);
348            pa.cmp(&pb)
349                .then_with(|| {
350                    a.bounds
351                        .as_ref()
352                        .map(|_| 1)
353                        .cmp(&b.bounds.as_ref().map(|_| 1))
354                })
355                .then_with(|| a.sheet_name.cmp(&b.sheet_name))
356        });
357        entry_points.truncate(5);
358    }
359
360    if include_named_ranges {
361        let mut seen_ranges = std::collections::HashSet::new();
362        for item in workbook.named_items()? {
363            if item.kind != NamedItemKind::NamedRange && item.kind != NamedItemKind::Table {
364                continue;
365            }
366            if !seen_ranges.insert(item.refers_to.clone()) {
367                continue;
368            }
369            key_named_ranges.push(item);
370            if key_named_ranges.len() >= 10 {
371                break;
372            }
373        }
374    }
375
376    notes.push("Region counts and entry points are inferred from sheet metrics; use sheet_overview for full region detection.".to_string());
377
378    Ok(WorkbookSummaryResponse {
379        workbook_id: workbook.id.clone(),
380        slug: workbook.slug.clone(),
381        sheet_count: sheet_names.len(),
382        total_cells,
383        total_formulas,
384        breakdown,
385        region_counts,
386        key_named_ranges,
387        suggested_entry_points: entry_points,
388        notes,
389    })
390}
391
392fn entry_point_priority(classification: &SheetClassification) -> u32 {
393    match classification {
394        SheetClassification::Data => 1,
395        SheetClassification::Mixed => 2,
396        SheetClassification::Calculator => 3,
397        SheetClassification::Metadata => 4,
398        SheetClassification::Empty => 5,
399    }
400}
401
402fn entry_point_bounds(metrics: &crate::workbook::SheetMetrics) -> Option<String> {
403    if metrics.row_count == 0 || metrics.column_count == 0 {
404        return None;
405    }
406    if metrics.row_count > ENTRY_POINT_MAX_ROWS || metrics.column_count > ENTRY_POINT_MAX_COLS {
407        return None;
408    }
409    let end_col = column_number_to_name(metrics.column_count.max(1));
410    let end_cell = format!("{}{}", end_col, metrics.row_count.max(1));
411    Some(format!("A1:{}", end_cell))
412}
413
414fn priority_from_rationale(rationale: &str) -> u32 {
415    if rationale.contains("p0") {
416        0
417    } else if rationale.contains("p1") {
418        1
419    } else if rationale.contains("p2") {
420        2
421    } else if rationale.contains("p3") {
422        3
423    } else if rationale.contains("p4") {
424        4
425    } else {
426        5
427    }
428}
429
430pub async fn sheet_overview(
431    state: Arc<AppState>,
432    params: SheetOverviewParams,
433) -> Result<SheetOverviewResponse> {
434    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
435    let sheet_name = params.sheet_name.clone();
436    let mut overview =
437        tokio::task::spawn_blocking(move || workbook.sheet_overview(&sheet_name)).await??;
438
439    let max_regions = params
440        .max_regions
441        .unwrap_or(DEFAULT_OVERVIEW_MAX_REGIONS)
442        .max(1);
443    let max_headers = params
444        .max_headers
445        .unwrap_or(DEFAULT_OVERVIEW_MAX_HEADERS)
446        .max(1);
447    let include_headers = params
448        .include_headers
449        .unwrap_or(DEFAULT_OVERVIEW_INCLUDE_HEADERS);
450
451    let region_limit = if params.max_regions == Some(0) {
452        usize::MAX
453    } else {
454        max_regions as usize
455    };
456    let header_limit = if params.max_headers == Some(0) {
457        usize::MAX
458    } else {
459        max_headers as usize
460    };
461
462    let total_regions = overview.detected_regions.len() as u32;
463    let mut headers_truncated = false;
464
465    for region in &mut overview.detected_regions {
466        let header_count = region.header_count.max(region.headers.len() as u32);
467        region.header_count = header_count;
468        if !include_headers {
469            region.headers.clear();
470        } else if region.headers.len() > header_limit {
471            region.headers.truncate(header_limit);
472        }
473        region.headers_truncated = region.headers.len() as u32 != header_count;
474        headers_truncated |= region.headers_truncated;
475    }
476
477    let regions_truncated = if overview.detected_regions.len() > region_limit {
478        overview.detected_regions.truncate(region_limit);
479        true
480    } else {
481        false
482    };
483
484    overview.detected_region_count = total_regions;
485    overview.detected_regions_truncated = regions_truncated;
486
487    if regions_truncated {
488        overview.notes.push(format!(
489            "Detected regions truncated to {} ({} total).",
490            region_limit, total_regions
491        ));
492    }
493    if headers_truncated {
494        overview.notes.push(format!(
495            "Region headers truncated to {} columns.",
496            header_limit
497        ));
498    }
499
500    Ok(overview)
501}
502
503fn default_start_row() -> u32 {
504    1
505}
506
507fn default_page_size() -> u32 {
508    50
509}
510
511fn default_include_formulas() -> bool {
512    true
513}
514
515fn default_include_header() -> bool {
516    true
517}
518
519#[derive(Debug, Deserialize, JsonSchema)]
520pub struct SheetPageParams {
521    /// Workbook ID or fork ID
522    #[serde(alias = "workbook_id")]
523    pub workbook_or_fork_id: WorkbookId,
524    /// Sheet name
525    pub sheet_name: String,
526    /// 1-based starting row (default: 1)
527    #[serde(default = "default_start_row")]
528    pub start_row: u32,
529    /// Number of rows per page (default: 50, max: 500)
530    #[serde(default = "default_page_size")]
531    pub page_size: u32,
532    /// Limit to specific columns by letter (e.g., ["A", "C", "D"])
533    #[serde(default)]
534    pub columns: Option<Vec<String>>,
535    /// Limit to columns by header text (matched case-insensitively)
536    #[serde(default)]
537    pub columns_by_header: Option<Vec<String>>,
538    /// Include formulas (default: false in token_dense profile)
539    #[serde(default = "default_include_formulas")]
540    pub include_formulas: bool,
541    /// Include style information (default: false)
542    #[serde(default)]
543    pub include_styles: bool,
544    /// Include header row in response (default: true)
545    #[serde(default = "default_include_header")]
546    pub include_header: bool,
547    /// Output format: "compact" (default in token_dense) or "full" (per-cell objects)
548    #[serde(default)]
549    pub format: Option<SheetPageFormat>,
550}
551
552impl Default for SheetPageParams {
553    fn default() -> Self {
554        SheetPageParams {
555            workbook_or_fork_id: WorkbookId(String::new()),
556            sheet_name: String::new(),
557            start_row: default_start_row(),
558            page_size: default_page_size(),
559            columns: None,
560            columns_by_header: None,
561            include_formulas: default_include_formulas(),
562            include_styles: false,
563            include_header: default_include_header(),
564            format: None,
565        }
566    }
567}
568
569fn default_find_limit() -> u32 {
570    50
571}
572
573#[derive(Debug, Deserialize, JsonSchema)]
574pub struct FindValueParams {
575    /// Workbook ID or fork ID
576    #[serde(alias = "workbook_id")]
577    pub workbook_or_fork_id: WorkbookId,
578    /// Value or pattern to search for
579    pub query: String,
580    /// For label mode: find cells near this label text
581    #[serde(default)]
582    pub label: Option<String>,
583    /// Search mode: "value" (default) or "label" for key-value lookups
584    #[serde(default)]
585    pub mode: Option<FindMode>,
586    /// Match mode for text comparison
587    #[serde(default)]
588    pub match_mode: Option<MatchMode>,
589    /// Case-sensitive matching (default: false)
590    #[serde(default)]
591    pub case_sensitive: bool,
592    /// Limit search to specific sheet
593    #[serde(default)]
594    pub sheet_name: Option<String>,
595    /// Limit search to specific detected region
596    #[serde(default)]
597    pub region_id: Option<u32>,
598    /// Limit search to specific named table
599    #[serde(default)]
600    pub table_name: Option<String>,
601    /// Filter by value types
602    #[serde(default)]
603    pub value_types: Option<Vec<ValueTypeFilter>>,
604    /// Only search in header rows (default: false)
605    #[serde(default)]
606    pub search_headers_only: bool,
607    /// For label mode: direction to look for value
608    #[serde(default)]
609    pub direction: Option<LabelDirection>,
610    /// Maximum matches to return (default: 50)
611    #[serde(default = "default_find_limit")]
612    pub limit: u32,
613    /// Offset for pagination
614    #[serde(default)]
615    pub offset: Option<u32>,
616    /// Context to include with matches
617    #[serde(default)]
618    pub context: Option<FindContext>,
619    /// Number of cells in each direction for context (default: 3)
620    #[serde(default)]
621    pub context_width: Option<u32>,
622}
623
624impl Default for FindValueParams {
625    fn default() -> Self {
626        Self {
627            workbook_or_fork_id: WorkbookId(String::new()),
628            query: String::new(),
629            label: None,
630            mode: None,
631            match_mode: None,
632            case_sensitive: false,
633            sheet_name: None,
634            region_id: None,
635            table_name: None,
636            value_types: None,
637            search_headers_only: false,
638            direction: None,
639            limit: default_find_limit(),
640            offset: None,
641            context: None,
642            context_width: None,
643        }
644    }
645}
646
647#[derive(Debug, Deserialize, JsonSchema, Default)]
648pub struct ReadTableParams {
649    /// Workbook ID or fork ID
650    #[serde(alias = "workbook_id")]
651    pub workbook_or_fork_id: WorkbookId,
652    /// Sheet name (uses first sheet if omitted)
653    #[serde(default)]
654    pub sheet_name: Option<String>,
655    /// Read from a named Excel table
656    #[serde(default)]
657    pub table_name: Option<String>,
658    /// Read from a detected region by ID (from sheet_overview)
659    #[serde(default)]
660    pub region_id: Option<u32>,
661    /// A1-style range (e.g., "A1:D100")
662    #[serde(default)]
663    pub range: Option<String>,
664    /// 1-based row number for headers (auto-detected if omitted)
665    #[serde(default)]
666    pub header_row: Option<u32>,
667    /// Number of header rows for multi-row headers (default: 1)
668    #[serde(default)]
669    pub header_rows: Option<u32>,
670    /// Limit to specific columns by letter (e.g., ["A", "C", "D"])
671    #[serde(default)]
672    pub columns: Option<Vec<String>>,
673    /// Row filters to apply
674    #[serde(default)]
675    pub filters: Option<Vec<TableFilter>>,
676    /// Sampling mode for selecting rows
677    #[serde(default)]
678    pub sample_mode: Option<SampleMode>,
679    /// Maximum rows to return
680    #[serde(default)]
681    pub limit: Option<u32>,
682    /// Offset for pagination; use next_offset from previous response
683    #[serde(default)]
684    pub offset: Option<u32>,
685    /// Output format: "csv" (default), "values" (arrays), or "json" (typed CellValue)
686    #[serde(default)]
687    pub format: Option<TableOutputFormat>,
688    /// Include header row in output (default: true for csv)
689    #[serde(default)]
690    pub include_headers: Option<bool>,
691    /// Include column type information (default: false)
692    #[serde(default)]
693    pub include_types: Option<bool>,
694}
695
696#[derive(Debug, Deserialize, JsonSchema, Clone)]
697pub struct TableFilter {
698    /// Column letter or header name
699    pub column: String,
700    /// Comparison operator
701    pub op: FilterOp,
702    /// Value to compare against
703    pub value: serde_json::Value,
704}
705
706#[derive(Debug, Deserialize, JsonSchema, Default)]
707pub struct TableProfileParams {
708    /// Workbook ID or fork ID
709    #[serde(alias = "workbook_id")]
710    pub workbook_or_fork_id: WorkbookId,
711    /// Sheet name (uses first sheet if omitted)
712    #[serde(default)]
713    pub sheet_name: Option<String>,
714    /// Profile a detected region by ID
715    #[serde(default)]
716    pub region_id: Option<u32>,
717    /// Profile a named Excel table
718    #[serde(default)]
719    pub table_name: Option<String>,
720    /// Sampling mode for selecting sample rows
721    #[serde(default)]
722    pub sample_mode: Option<SampleMode>,
723    /// Number of sample rows to include (default: 5)
724    #[serde(default)]
725    pub sample_size: Option<u32>,
726    /// Return only column types without samples (default: true in token_dense profile)
727    #[serde(default)]
728    pub summary_only: Option<bool>,
729}
730
731#[derive(Debug, Deserialize, JsonSchema)]
732pub struct RangeValuesParams {
733    /// Workbook ID or fork ID
734    #[serde(alias = "workbook_id")]
735    pub workbook_or_fork_id: WorkbookId,
736    /// Sheet name
737    pub sheet_name: String,
738    /// A1-style ranges to read (e.g., ["A1:C10", "E1:E10"])
739    pub ranges: Vec<String>,
740    /// Include detected header row (default: true)
741    #[serde(default)]
742    pub include_headers: Option<bool>,
743    /// Include formula text payload (matrix for json, sparse list for dense) (default: false)
744    #[serde(default)]
745    pub include_formulas: Option<bool>,
746    /// Output format: "dense" (default), "values", "csv", or "json"
747    #[serde(default)]
748    pub format: Option<TableOutputFormat>,
749    /// Maximum rows per range before pagination
750    #[serde(default)]
751    pub page_size: Option<u32>,
752}
753
754#[derive(Debug, Deserialize, JsonSchema)]
755pub struct InspectCellsParams {
756    /// Workbook ID or fork ID
757    #[serde(alias = "workbook_id")]
758    pub workbook_or_fork_id: WorkbookId,
759    /// Sheet name
760    pub sheet_name: String,
761    /// One or more A1 targets (cells or ranges) to inspect
762    pub targets: Vec<String>,
763    /// Include empty cells in the response (default: false)
764    #[serde(default)]
765    pub include_empty: Option<bool>,
766    /// Override the per-request cell budget (default 25, max 200).
767    /// Values outside 1..=200 are rejected.
768    #[serde(default)]
769    pub budget: Option<u32>,
770}
771
772pub async fn sheet_page(
773    state: Arc<AppState>,
774    params: SheetPageParams,
775) -> Result<SheetPageResponse> {
776    if params.page_size == 0 {
777        return Err(anyhow!("page_size must be greater than zero"));
778    }
779
780    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
781    let metrics = workbook.get_sheet_metrics_fast(&params.sheet_name)?;
782    let config = state.config();
783    let output_profile = config.output_profile();
784    let format = params.format.unwrap_or(match output_profile {
785        OutputProfile::TokenDense => SheetPageFormat::Compact,
786        OutputProfile::Verbose => SheetPageFormat::Full,
787    });
788
789    let start_row = params.start_row.max(1);
790    let page_size = params.page_size.min(500);
791    let include_formulas =
792        if params.format.is_none() && matches!(output_profile, OutputProfile::TokenDense) {
793            false
794        } else {
795            params.include_formulas
796        };
797    let include_styles =
798        if params.format.is_none() && matches!(output_profile, OutputProfile::TokenDense) {
799            false
800        } else {
801            params.include_styles
802        };
803    let columns = params.columns.clone();
804    let columns_by_header = params.columns_by_header.clone();
805    let include_header = params.include_header;
806
807    let mut page = workbook.with_sheet(&params.sheet_name, |sheet| {
808        build_page(
809            sheet,
810            start_row,
811            page_size,
812            columns.clone(),
813            columns_by_header.clone(),
814            include_formulas,
815            include_styles,
816            include_header,
817        )
818    })?;
819
820    let max_cells = config.max_cells();
821    let max_payload_bytes = config.max_payload_bytes();
822    let cells_per_row = page.rows.first().map(|row| row.cells.len()).unwrap_or(0);
823    let original_row_count = page.rows.len();
824    let mut row_limit = cap_rows_by_cells(page.rows.len(), cells_per_row, max_cells);
825
826    if row_limit > 0 {
827        row_limit = cap_rows_by_payload_bytes(row_limit, max_payload_bytes, |count| {
828            let response = build_sheet_page_response(
829                &workbook,
830                &params.sheet_name,
831                format,
832                include_header,
833                &page.header,
834                &page.rows[..count],
835                None,
836            );
837            serde_json::to_vec(&response)
838                .map(|payload| payload.len())
839                .unwrap_or(usize::MAX)
840        });
841    }
842
843    let truncated = row_limit < original_row_count;
844    if truncated {
845        page.rows.truncate(row_limit);
846    }
847
848    let last_row_index = page
849        .rows
850        .last()
851        .map(|row| row.row_index)
852        .unwrap_or(start_row.saturating_sub(1));
853    let next_start_row = if last_row_index < metrics.metrics.row_count {
854        Some(last_row_index + 1)
855    } else {
856        None
857    };
858
859    let rows_returned = page.rows.len();
860    let cells_returned = rows_returned * cells_per_row;
861    let total_rows_available = metrics.metrics.row_count;
862
863    // Build budget metadata when truncation occurred or limits are configured.
864    let budget = if truncated || max_cells.is_some() || max_payload_bytes.is_some() {
865        let continuation = next_start_row.map(|nsr| {
866            format!(
867                "use start_row={} to fetch the next page ({} rows remaining)",
868                nsr,
869                total_rows_available.saturating_sub(last_row_index)
870            )
871        });
872        Some(ReadBudget {
873            max_cells,
874            max_payload_bytes,
875            rows_returned,
876            cells_returned,
877            total_rows_available: Some(total_rows_available),
878            continuation,
879        })
880    } else {
881        None
882    };
883
884    let mut response = build_sheet_page_response(
885        &workbook,
886        &params.sheet_name,
887        format,
888        include_header,
889        &page.header,
890        &page.rows,
891        next_start_row,
892    );
893    response.truncated = truncated;
894    response.budget = budget;
895    Ok(response)
896}
897
898#[derive(Debug, Deserialize, JsonSchema)]
899pub struct SheetFormulaMapParams {
900    /// Workbook ID or fork ID
901    #[serde(alias = "workbook_id")]
902    pub workbook_or_fork_id: WorkbookId,
903    /// Sheet name
904    pub sheet_name: String,
905    /// Limit to A1-style range (e.g., "D2:D100")
906    pub range: Option<String>,
907    /// Expand range references in formulas (default: false)
908    #[serde(default)]
909    pub expand: bool,
910    /// Maximum formula groups to return
911    #[serde(default)]
912    pub limit: Option<u32>,
913    /// Sort by: "address" (default), "complexity" (longest formulas first), "count" (most repeated first)
914    #[serde(default)]
915    pub sort_by: Option<FormulaSortBy>,
916    /// Return only formula text and count without addresses (default: true in token_dense profile)
917    #[serde(default)]
918    pub summary_only: Option<bool>,
919    /// Include cell addresses for each formula group (default: !summary_only)
920    #[serde(default)]
921    pub include_addresses: Option<bool>,
922    /// Maximum addresses to include per formula group (default: 15)
923    #[serde(default)]
924    pub addresses_limit: Option<u32>,
925    /// Formula parse policy: fail, warn (default), or off
926    #[serde(default)]
927    pub formula_parse_policy: Option<FormulaParsePolicy>,
928}
929
930#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
931#[serde(rename_all = "snake_case")]
932pub enum FormulaSortBy {
933    #[default]
934    Address,
935    Complexity,
936    Count,
937}
938
939/// Match mode for text searches
940#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)]
941#[serde(rename_all = "snake_case")]
942pub enum MatchMode {
943    /// Substring match (default)
944    #[default]
945    Contains,
946    /// Exact match
947    Exact,
948    /// Prefix match
949    Prefix,
950    /// Regular expression match
951    Regex,
952}
953
954/// Context to include with find_value matches
955#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)]
956#[serde(rename_all = "snake_case")]
957pub enum FindContext {
958    /// No context (default)
959    #[default]
960    None,
961    /// Include neighboring cells
962    Neighbors,
963    /// Include full row context
964    Row,
965    /// Include both neighbors and row context
966    Both,
967}
968
969/// Sampling mode for table reads
970#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)]
971#[serde(rename_all = "snake_case")]
972pub enum SampleMode {
973    /// First N rows (default)
974    #[default]
975    First,
976    /// Last N rows
977    Last,
978    /// Evenly distributed sample
979    Distributed,
980}
981
982/// Granularity for style analysis
983#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema, PartialEq, Eq)]
984#[serde(rename_all = "snake_case")]
985pub enum StyleGranularity {
986    /// Group contiguous cells with same style (default)
987    #[default]
988    Runs,
989    /// Report each cell individually
990    Cells,
991}
992
993/// Filter operators for table queries
994#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)]
995#[serde(rename_all = "snake_case")]
996pub enum FilterOp {
997    /// Equal
998    Eq,
999    /// Not equal
1000    #[serde(alias = "ne")]
1001    Neq,
1002    /// Greater than
1003    Gt,
1004    /// Less than
1005    Lt,
1006    /// Greater than or equal
1007    Gte,
1008    /// Less than or equal
1009    Lte,
1010    /// Contains substring (text only)
1011    Contains,
1012    /// Starts with prefix (text only)
1013    StartsWith,
1014    /// Ends with suffix (text only)
1015    EndsWith,
1016    /// Value is in list
1017    In,
1018}
1019
1020/// Cell value types for filtering
1021#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)]
1022#[serde(rename_all = "snake_case")]
1023pub enum ValueTypeFilter {
1024    Text,
1025    Number,
1026    Bool,
1027    Date,
1028    Null,
1029}
1030
1031pub async fn sheet_formula_map(
1032    state: Arc<AppState>,
1033    params: SheetFormulaMapParams,
1034) -> Result<SheetFormulaMapResponse> {
1035    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
1036    let config = state.config();
1037    let output_profile = config.output_profile();
1038    let summary_only = params
1039        .summary_only
1040        .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
1041    let include_addresses = params.include_addresses.unwrap_or(!summary_only);
1042    let addresses_limit = params.addresses_limit.unwrap_or(15);
1043    let max_items = config.max_items();
1044    let max_payload_bytes = config.max_payload_bytes();
1045
1046    let policy = params
1047        .formula_parse_policy
1048        .unwrap_or(FormulaParsePolicy::Warn);
1049    let (graph, diagnostics) =
1050        workbook.formula_graph_with_diagnostics(&params.sheet_name, policy)?;
1051    let formula_parse_diagnostics = if diagnostics.total_errors > 0 {
1052        Some(diagnostics)
1053    } else {
1054        None
1055    };
1056    let all_groups = graph.groups();
1057    let mut groups = Vec::new();
1058
1059    for mut group in all_groups {
1060        if let Some(range) = &params.range {
1061            group.addresses.retain(|addr| address_in_range(addr, range));
1062            if group.addresses.is_empty() {
1063                continue;
1064            }
1065        }
1066
1067        let address_count = group.addresses.len();
1068
1069        if summary_only || !include_addresses {
1070            group.addresses.clear();
1071        } else if !params.expand && address_count > addresses_limit as usize {
1072            group.addresses.truncate(addresses_limit as usize);
1073        }
1074
1075        groups.push(group);
1076    }
1077
1078    let total_groups = groups.len();
1079
1080    let sort_by = params.sort_by.unwrap_or_default();
1081    match sort_by {
1082        FormulaSortBy::Address => {
1083            groups.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
1084        }
1085        FormulaSortBy::Complexity => {
1086            groups.sort_by(|a, b| b.formula.len().cmp(&a.formula.len()));
1087        }
1088        FormulaSortBy::Count => {
1089            groups.sort_by(|a, b| {
1090                let count_a = a.count.unwrap_or(a.addresses.len() as u32);
1091                let count_b = b.count.unwrap_or(b.addresses.len() as u32);
1092                count_b.cmp(&count_a)
1093            });
1094        }
1095    }
1096
1097    if let Some(limit) = params.limit
1098        && groups.len() > limit as usize
1099    {
1100        groups.truncate(limit as usize);
1101    }
1102
1103    if let Some(max_items) = max_items
1104        && groups.len() > max_items
1105    {
1106        groups.truncate(max_items);
1107    }
1108
1109    if let Some(max_bytes) = max_payload_bytes {
1110        let group_limit = cap_rows_by_payload_bytes(groups.len(), Some(max_bytes), |count| {
1111            let response = SheetFormulaMapResponse {
1112                workbook_id: workbook.id.clone(),
1113                sheet_name: params.sheet_name.clone(),
1114                groups: groups[..count].to_vec(),
1115                formula_parse_diagnostics: formula_parse_diagnostics.clone(),
1116                next_offset: None,
1117            };
1118            serde_json::to_vec(&response)
1119                .map(|payload| payload.len())
1120                .unwrap_or(usize::MAX)
1121        });
1122
1123        if group_limit < groups.len() {
1124            groups.truncate(group_limit);
1125        }
1126    }
1127
1128    let next_offset = if groups.len() < total_groups {
1129        Some(groups.len() as u32)
1130    } else {
1131        None
1132    };
1133
1134    let response = SheetFormulaMapResponse {
1135        workbook_id: workbook.id.clone(),
1136        sheet_name: params.sheet_name.clone(),
1137        groups,
1138        formula_parse_diagnostics,
1139        next_offset,
1140    };
1141    Ok(response)
1142}
1143
1144#[derive(Debug, Deserialize, JsonSchema)]
1145pub struct FormulaTraceParams {
1146    #[serde(alias = "workbook_id")]
1147    pub workbook_or_fork_id: WorkbookId,
1148    pub sheet_name: String,
1149    pub cell_address: String,
1150    pub direction: TraceDirection,
1151    pub depth: Option<u32>,
1152    pub limit: Option<u32>,
1153    #[serde(default)]
1154    pub page_size: Option<usize>,
1155    #[serde(default)]
1156    pub cursor: Option<TraceCursor>,
1157    /// Formula parse policy: fail, warn (default), or off
1158    #[serde(default)]
1159    pub formula_parse_policy: Option<FormulaParsePolicy>,
1160}
1161
1162pub async fn formula_trace(
1163    state: Arc<AppState>,
1164    params: FormulaTraceParams,
1165) -> Result<FormulaTraceResponse> {
1166    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
1167    let policy = params
1168        .formula_parse_policy
1169        .unwrap_or(FormulaParsePolicy::Warn);
1170    let (graph, diagnostics) =
1171        workbook.formula_graph_with_diagnostics(&params.sheet_name, policy)?;
1172    let formula_parse_diagnostics = if diagnostics.total_errors > 0 {
1173        Some(diagnostics)
1174    } else {
1175        None
1176    };
1177    let formula_lookup = build_formula_lookup(&graph);
1178    let depth = params.depth.unwrap_or(3).clamp(1, 5);
1179    let page_size = params
1180        .page_size
1181        .or_else(|| params.limit.map(|v| v as usize))
1182        .unwrap_or(DEFAULT_TRACE_PAGE_SIZE)
1183        .clamp(TRACE_PAGE_MIN, TRACE_PAGE_MAX);
1184
1185    let origin = params.cell_address.to_uppercase();
1186    let config = TraceConfig {
1187        direction: &params.direction,
1188        origin: &origin,
1189        sheet_name: &params.sheet_name,
1190        depth_limit: depth,
1191        page_size,
1192    };
1193    let (layers, next_cursor, notes) = build_trace_layers(
1194        &workbook,
1195        &graph,
1196        &formula_lookup,
1197        &config,
1198        params.cursor.clone(),
1199    )?;
1200
1201    let response = FormulaTraceResponse {
1202        workbook_id: workbook.id.clone(),
1203        sheet_name: params.sheet_name.clone(),
1204        origin,
1205        direction: params.direction.clone(),
1206        layers,
1207        next_cursor,
1208        formula_parse_diagnostics,
1209        notes,
1210    };
1211    Ok(response)
1212}
1213
1214#[derive(Debug, Deserialize, JsonSchema)]
1215pub struct NamedRangesParams {
1216    #[serde(alias = "workbook_id")]
1217    pub workbook_or_fork_id: WorkbookId,
1218    pub sheet_name: Option<String>,
1219    pub name_prefix: Option<String>,
1220}
1221
1222pub async fn named_ranges(
1223    state: Arc<AppState>,
1224    params: NamedRangesParams,
1225) -> Result<NamedRangesResponse> {
1226    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
1227    let mut items = workbook.named_items()?;
1228
1229    if let Some(sheet_filter) = &params.sheet_name {
1230        items.retain(|item| {
1231            item.sheet_name
1232                .as_ref()
1233                .map(|name| name.eq_ignore_ascii_case(sheet_filter))
1234                .unwrap_or(false)
1235        });
1236    }
1237    if let Some(prefix) = &params.name_prefix {
1238        let prefix_lower = prefix.to_ascii_lowercase();
1239        items.retain(|item| item.name.to_ascii_lowercase().starts_with(&prefix_lower));
1240    }
1241
1242    let response = NamedRangesResponse {
1243        workbook_id: workbook.id.clone(),
1244        items,
1245    };
1246    Ok(response)
1247}
1248
1249#[derive(Debug, Deserialize, JsonSchema)]
1250pub struct VerifyWorkbookParams {
1251    #[serde(alias = "baseline_id")]
1252    pub baseline_workbook_or_fork_id: WorkbookId,
1253    #[serde(alias = "current_id")]
1254    pub current_workbook_or_fork_id: WorkbookId,
1255    #[serde(default)]
1256    pub targets: Vec<String>,
1257    pub sheet_name: Option<String>,
1258    #[serde(default)]
1259    pub include_named_range_deltas: bool,
1260    #[serde(default)]
1261    pub errors_only: bool,
1262    #[serde(default)]
1263    pub targets_only: bool,
1264}
1265
1266pub async fn verify_workbook(
1267    state: Arc<AppState>,
1268    params: VerifyWorkbookParams,
1269) -> Result<VerifyResponse> {
1270    let options = VerifyOptions {
1271        targets: params.targets.clone(),
1272        sheet_filter: params.sheet_name.clone(),
1273        include_named_range_deltas: params.include_named_range_deltas,
1274        errors_only: params.errors_only,
1275        targets_only: params.targets_only,
1276    };
1277    options.validate()?;
1278
1279    let baseline_workbook = state
1280        .open_workbook(&params.baseline_workbook_or_fork_id)
1281        .await?;
1282    let current_workbook = state
1283        .open_workbook(&params.current_workbook_or_fork_id)
1284        .await?;
1285
1286    let baseline_named = if params.include_named_range_deltas {
1287        Some(
1288            named_ranges(
1289                state.clone(),
1290                NamedRangesParams {
1291                    workbook_or_fork_id: params.baseline_workbook_or_fork_id.clone(),
1292                    sheet_name: params.sheet_name.clone(),
1293                    name_prefix: None,
1294                },
1295            )
1296            .await?,
1297        )
1298    } else {
1299        None
1300    };
1301    let current_named = if params.include_named_range_deltas {
1302        Some(
1303            named_ranges(
1304                state.clone(),
1305                NamedRangesParams {
1306                    workbook_or_fork_id: params.current_workbook_or_fork_id.clone(),
1307                    sheet_name: params.sheet_name.clone(),
1308                    name_prefix: None,
1309                },
1310            )
1311            .await?,
1312        )
1313    } else {
1314        None
1315    };
1316
1317    compare_workbooks(
1318        params.baseline_workbook_or_fork_id.as_str().to_string(),
1319        params.current_workbook_or_fork_id.as_str().to_string(),
1320        &baseline_workbook,
1321        &current_workbook,
1322        &options,
1323        baseline_named.as_ref().map(|r| r.items.as_slice()),
1324        current_named.as_ref().map(|r| r.items.as_slice()),
1325    )
1326}
1327
1328// ── Named Range CRUD ─────────────────────────────────────────────────────────
1329
1330#[derive(Debug, Deserialize, JsonSchema)]
1331pub struct DefineNameParams {
1332    #[serde(alias = "workbook_id")]
1333    pub fork_id: WorkbookId,
1334    /// Name to define (e.g. "SalesTotal").
1335    pub name: String,
1336    /// Formula or range the name refers to (e.g. "Sheet1!$A$1:$B$10").
1337    pub refers_to: String,
1338    /// Scope: "workbook" (default) or "sheet".
1339    #[serde(default)]
1340    pub scope: Option<String>,
1341    /// Required when scope is "sheet". The sheet to scope the name to.
1342    pub scope_sheet_name: Option<String>,
1343}
1344
1345#[derive(Debug, Deserialize, JsonSchema)]
1346pub struct UpdateNameParams {
1347    #[serde(alias = "workbook_id")]
1348    pub fork_id: WorkbookId,
1349    /// Existing name to update.
1350    pub name: String,
1351    /// New refers_to value. If omitted, keeps existing.
1352    pub refers_to: Option<String>,
1353    /// Scope filter to disambiguate: "workbook" or "sheet".
1354    pub scope: Option<String>,
1355    /// Sheet name to disambiguate when scope is "sheet".
1356    pub scope_sheet_name: Option<String>,
1357}
1358
1359#[derive(Debug, Deserialize, JsonSchema)]
1360pub struct DeleteNameParams {
1361    #[serde(alias = "workbook_id")]
1362    pub fork_id: WorkbookId,
1363    /// Name to delete.
1364    pub name: String,
1365    /// Scope filter: "workbook" or "sheet".
1366    pub scope: Option<String>,
1367    /// Sheet name to disambiguate when scope is "sheet".
1368    pub scope_sheet_name: Option<String>,
1369}
1370
1371pub fn parse_scope_kind(scope: Option<&str>) -> Result<NamedRangeScope> {
1372    match scope {
1373        Some("sheet") => Ok(NamedRangeScope::Sheet),
1374        Some("workbook") | None => Ok(NamedRangeScope::Workbook),
1375        Some(other) => Err(anyhow!(
1376            "invalid scope '{}': expected 'workbook' or 'sheet'",
1377            other
1378        )),
1379    }
1380}
1381
1382pub fn parse_scope_kind_optional(scope: Option<&str>) -> Result<Option<NamedRangeScope>> {
1383    match scope {
1384        Some("sheet") => Ok(Some(NamedRangeScope::Sheet)),
1385        Some("workbook") => Ok(Some(NamedRangeScope::Workbook)),
1386        None => Ok(None),
1387        Some(other) => Err(anyhow!(
1388            "invalid scope '{}': expected 'workbook' or 'sheet'",
1389            other
1390        )),
1391    }
1392}
1393
1394fn resolve_sheet_index_on_book(
1395    book: &umya_spreadsheet::Spreadsheet,
1396    sheet_name: &str,
1397) -> Result<u32> {
1398    for (idx, sheet) in book.get_sheet_collection().iter().enumerate() {
1399        if sheet.get_name() == sheet_name {
1400            return Ok(idx as u32);
1401        }
1402    }
1403    Err(anyhow!("sheet '{}' not found", sheet_name))
1404}
1405
1406/// Apply define_name to an on-disk workbook file.
1407pub(crate) fn define_name_in_file(
1408    path: &std::path::Path,
1409    name: &str,
1410    refers_to: &str,
1411    scope_kind: NamedRangeScope,
1412    scope_sheet_name: Option<&str>,
1413) -> Result<()> {
1414    let mut book = umya_spreadsheet::reader::xlsx::read(path)
1415        .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
1416
1417    match scope_kind {
1418        NamedRangeScope::Sheet => {
1419            let sn = scope_sheet_name
1420                .ok_or_else(|| anyhow!("scope_sheet_name required for sheet scope"))?;
1421            let sheet_index = resolve_sheet_index_on_book(&book, sn)?;
1422            let sheet = book
1423                .get_sheet_by_name_mut(sn)
1424                .ok_or_else(|| anyhow!("sheet '{}' not found", sn))?;
1425            sheet
1426                .add_defined_name(name.to_string(), refers_to.to_string())
1427                .map_err(|e| anyhow!("failed to add defined name: {e}"))?;
1428            // Set local_sheet_id on the just-added entry.
1429            let sheet = book
1430                .get_sheet_by_name_mut(sn)
1431                .ok_or_else(|| anyhow!("sheet '{}' disappeared", sn))?;
1432            if let Some(last) = sheet.get_defined_names_mut().last_mut()
1433                && last.get_name() == name
1434            {
1435                last.set_local_sheet_id(sheet_index);
1436            }
1437        }
1438        NamedRangeScope::Workbook => {
1439            // set_name is pub(crate) in umya, so we create through a sheet then move
1440            // to workbook level.
1441            let first_sheet: String = book
1442                .get_sheet_collection()
1443                .first()
1444                .map(|s| s.get_name().to_string())
1445                .ok_or_else(|| anyhow!("workbook has no sheets"))?;
1446            let sheet = book
1447                .get_sheet_by_name_mut(&first_sheet)
1448                .ok_or_else(|| anyhow!("sheet '{}' not found", first_sheet))?;
1449            sheet
1450                .add_defined_name(name.to_string(), refers_to.to_string())
1451                .map_err(|e| anyhow!("failed to add defined name: {e}"))?;
1452            // Move the just-added entry from sheet-level to workbook-level.
1453            let sheet = book
1454                .get_sheet_by_name_mut(&first_sheet)
1455                .ok_or_else(|| anyhow!("sheet disappeared"))?;
1456            let entry = sheet.get_defined_names_mut().pop();
1457            if let Some(entry) = entry {
1458                book.add_defined_names(entry);
1459            }
1460        }
1461    }
1462
1463    umya_spreadsheet::writer::xlsx::write(&book, path)?;
1464    Ok(())
1465}
1466
1467/// Apply update_name to an on-disk workbook file.
1468pub(crate) fn update_name_in_file(
1469    path: &std::path::Path,
1470    name: &str,
1471    new_refers_to: Option<&str>,
1472    scope_kind: Option<NamedRangeScope>,
1473    scope_sheet_name: Option<&str>,
1474) -> Result<(String, NamedRangeScope, Option<String>)> {
1475    let mut book = umya_spreadsheet::reader::xlsx::read(path)
1476        .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
1477
1478    let mut found = false;
1479    let mut previous_refers_to = String::new();
1480    let mut effective_scope = NamedRangeScope::Workbook;
1481    let mut effective_sheet: Option<String> = None;
1482
1483    // Try workbook-level defined names.
1484    if scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Workbook) {
1485        for defined in book.get_defined_names_mut().iter_mut() {
1486            if defined.get_name() == name
1487                && (scope_kind == Some(NamedRangeScope::Workbook) || !defined.has_local_sheet_id())
1488            {
1489                previous_refers_to = defined.get_address();
1490                if let Some(new_addr) = new_refers_to {
1491                    defined.set_address(new_addr.to_string());
1492                }
1493                effective_scope = NamedRangeScope::Workbook;
1494                found = true;
1495                break;
1496            }
1497        }
1498    }
1499
1500    // Try sheet-level.
1501    if !found && (scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Sheet)) {
1502        let sheet_names: Vec<String> = book
1503            .get_sheet_collection()
1504            .iter()
1505            .map(|s: &umya_spreadsheet::Worksheet| s.get_name().to_string())
1506            .collect();
1507        for sn in &sheet_names {
1508            if let Some(filter_sheet) = scope_sheet_name
1509                && !sn.eq_ignore_ascii_case(filter_sheet)
1510            {
1511                continue;
1512            }
1513            if let Some(sheet) = book.get_sheet_by_name_mut(sn) {
1514                for defined in sheet.get_defined_names_mut().iter_mut() {
1515                    if defined.get_name() == name {
1516                        previous_refers_to = defined.get_address();
1517                        if let Some(new_addr) = new_refers_to {
1518                            defined.set_address(new_addr.to_string());
1519                        }
1520                        effective_scope = NamedRangeScope::Sheet;
1521                        effective_sheet = Some(sn.clone());
1522                        found = true;
1523                        break;
1524                    }
1525                }
1526            }
1527            if found {
1528                break;
1529            }
1530        }
1531    }
1532
1533    if !found {
1534        return Err(anyhow!("named range '{}' not found", name));
1535    }
1536
1537    umya_spreadsheet::writer::xlsx::write(&book, path)?;
1538    Ok((previous_refers_to, effective_scope, effective_sheet))
1539}
1540
1541/// Apply delete_name to an on-disk workbook file.
1542pub(crate) fn delete_name_in_file(
1543    path: &std::path::Path,
1544    name: &str,
1545    scope_kind: Option<NamedRangeScope>,
1546    scope_sheet_name: Option<&str>,
1547) -> Result<bool> {
1548    let mut book = umya_spreadsheet::reader::xlsx::read(path)
1549        .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
1550
1551    let mut deleted = false;
1552
1553    // Try workbook-level.
1554    if scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Workbook) {
1555        let names = book.get_defined_names_mut();
1556        let before_len = names.len();
1557        names.retain(|d: &umya_spreadsheet::DefinedName| d.get_name() != name);
1558        if names.len() < before_len {
1559            deleted = true;
1560        }
1561    }
1562
1563    // Try sheet-level.
1564    if !deleted && (scope_kind.is_none() || scope_kind == Some(NamedRangeScope::Sheet)) {
1565        let sheet_names: Vec<String> = book
1566            .get_sheet_collection()
1567            .iter()
1568            .map(|s: &umya_spreadsheet::Worksheet| s.get_name().to_string())
1569            .collect();
1570        for sn in &sheet_names {
1571            if let Some(filter_sheet) = scope_sheet_name
1572                && !sn.eq_ignore_ascii_case(filter_sheet)
1573            {
1574                continue;
1575            }
1576            if let Some(sheet) = book.get_sheet_by_name_mut(sn) {
1577                let names = sheet.get_defined_names_mut();
1578                let before_len = names.len();
1579                names.retain(|d: &umya_spreadsheet::DefinedName| d.get_name() != name);
1580                if names.len() < before_len {
1581                    deleted = true;
1582                    break;
1583                }
1584            }
1585        }
1586    }
1587
1588    if !deleted {
1589        return Err(anyhow!("named range '{}' not found", name));
1590    }
1591
1592    umya_spreadsheet::writer::xlsx::write(&book, path)?;
1593    Ok(true)
1594}
1595
1596#[cfg(feature = "recalc")]
1597pub async fn define_name(
1598    state: Arc<AppState>,
1599    params: DefineNameParams,
1600) -> Result<DefineNameResponse> {
1601    let scope_kind = parse_scope_kind(params.scope.as_deref())?;
1602    if scope_kind == NamedRangeScope::Sheet && params.scope_sheet_name.is_none() {
1603        return Err(anyhow!(
1604            "scope_sheet_name is required when scope is 'sheet'"
1605        ));
1606    }
1607    if params.name.trim().is_empty() {
1608        return Err(anyhow!("name must not be empty"));
1609    }
1610    if params.refers_to.trim().is_empty() {
1611        return Err(anyhow!("refers_to must not be empty"));
1612    }
1613
1614    let registry = state
1615        .fork_registry()
1616        .ok_or_else(|| anyhow!("fork registry not available (recalc feature required)"))?;
1617    let fork_ctx = registry.get_fork(params.fork_id.as_str())?;
1618    let work_path = fork_ctx.work_path.clone();
1619
1620    let name = params.name.clone();
1621    let refers_to = params.refers_to.clone();
1622    let scope_sheet = params.scope_sheet_name.clone();
1623
1624    tokio::task::spawn_blocking(move || {
1625        define_name_in_file(
1626            &work_path,
1627            &name,
1628            &refers_to,
1629            scope_kind,
1630            scope_sheet.as_deref(),
1631        )
1632    })
1633    .await??;
1634
1635    // Mark fork as needing recalc and invalidate cache.
1636    registry.with_fork_mut(params.fork_id.as_str(), |ctx| {
1637        ctx.recalc_needed = true;
1638        Ok(())
1639    })?;
1640    let fork_workbook_id = WorkbookId(params.fork_id.as_str().to_string());
1641    let _ = state.close_workbook(&fork_workbook_id);
1642
1643    Ok(DefineNameResponse {
1644        workbook_id: params.fork_id,
1645        name: params.name,
1646        refers_to: params.refers_to,
1647        scope_kind,
1648        scope_sheet_name: params.scope_sheet_name,
1649    })
1650}
1651
1652#[cfg(feature = "recalc")]
1653pub async fn update_name(
1654    state: Arc<AppState>,
1655    params: UpdateNameParams,
1656) -> Result<UpdateNameResponse> {
1657    let scope_kind = parse_scope_kind_optional(params.scope.as_deref())?;
1658    if params.name.trim().is_empty() {
1659        return Err(anyhow!("name must not be empty"));
1660    }
1661
1662    let registry = state
1663        .fork_registry()
1664        .ok_or_else(|| anyhow!("fork registry not available (recalc feature required)"))?;
1665    let fork_ctx = registry.get_fork(params.fork_id.as_str())?;
1666    let work_path = fork_ctx.work_path.clone();
1667
1668    let name = params.name.clone();
1669    let new_refers_to = params.refers_to.clone();
1670    let scope_sheet = params.scope_sheet_name.clone();
1671
1672    let (previous_refers_to, effective_scope, effective_sheet) =
1673        tokio::task::spawn_blocking(move || {
1674            update_name_in_file(
1675                &work_path,
1676                &name,
1677                new_refers_to.as_deref(),
1678                scope_kind,
1679                scope_sheet.as_deref(),
1680            )
1681        })
1682        .await??;
1683
1684    registry.with_fork_mut(params.fork_id.as_str(), |ctx| {
1685        ctx.recalc_needed = true;
1686        Ok(())
1687    })?;
1688    let fork_workbook_id = WorkbookId(params.fork_id.as_str().to_string());
1689    let _ = state.close_workbook(&fork_workbook_id);
1690
1691    let final_refers_to = params
1692        .refers_to
1693        .unwrap_or_else(|| previous_refers_to.clone());
1694
1695    Ok(UpdateNameResponse {
1696        workbook_id: params.fork_id,
1697        name: params.name,
1698        refers_to: final_refers_to,
1699        scope_kind: effective_scope,
1700        scope_sheet_name: effective_sheet.or(params.scope_sheet_name),
1701        previous_refers_to: Some(previous_refers_to),
1702    })
1703}
1704
1705#[cfg(feature = "recalc")]
1706pub async fn delete_name(
1707    state: Arc<AppState>,
1708    params: DeleteNameParams,
1709) -> Result<DeleteNameResponse> {
1710    let scope_kind = parse_scope_kind_optional(params.scope.as_deref())?;
1711    if params.name.trim().is_empty() {
1712        return Err(anyhow!("name must not be empty"));
1713    }
1714
1715    let registry = state
1716        .fork_registry()
1717        .ok_or_else(|| anyhow!("fork registry not available (recalc feature required)"))?;
1718    let fork_ctx = registry.get_fork(params.fork_id.as_str())?;
1719    let work_path = fork_ctx.work_path.clone();
1720
1721    let name = params.name.clone();
1722    let scope_sheet = params.scope_sheet_name.clone();
1723
1724    tokio::task::spawn_blocking(move || {
1725        delete_name_in_file(&work_path, &name, scope_kind, scope_sheet.as_deref())
1726    })
1727    .await??;
1728
1729    registry.with_fork_mut(params.fork_id.as_str(), |ctx| {
1730        ctx.recalc_needed = true;
1731        Ok(())
1732    })?;
1733    let fork_workbook_id = WorkbookId(params.fork_id.as_str().to_string());
1734    let _ = state.close_workbook(&fork_workbook_id);
1735
1736    Ok(DeleteNameResponse {
1737        workbook_id: params.fork_id,
1738        name: params.name,
1739        deleted: true,
1740    })
1741}
1742
1743struct PageBuildResult {
1744    rows: Vec<RowSnapshot>,
1745    header: Option<RowSnapshot>,
1746}
1747
1748#[allow(clippy::too_many_arguments)]
1749fn build_page(
1750    sheet: &umya_spreadsheet::Worksheet,
1751    start_row: u32,
1752    page_size: u32,
1753    columns: Option<Vec<String>>,
1754    columns_by_header: Option<Vec<String>>,
1755    include_formulas: bool,
1756    include_styles: bool,
1757    include_header: bool,
1758) -> PageBuildResult {
1759    let max_col = sheet.get_highest_column();
1760    let end_row = (start_row + page_size - 1).min(sheet.get_highest_row().max(start_row));
1761    let column_indices =
1762        resolve_columns_with_headers(sheet, columns.as_ref(), columns_by_header.as_ref(), max_col);
1763
1764    let header = if include_header {
1765        Some(build_row_snapshot(
1766            sheet,
1767            1,
1768            &column_indices,
1769            include_formulas,
1770            include_styles,
1771        ))
1772    } else {
1773        None
1774    };
1775
1776    let mut rows = Vec::new();
1777    for row_idx in start_row..=end_row {
1778        rows.push(build_row_snapshot(
1779            sheet,
1780            row_idx,
1781            &column_indices,
1782            include_formulas,
1783            include_styles,
1784        ));
1785    }
1786
1787    PageBuildResult { rows, header }
1788}
1789
1790fn build_row_snapshot(
1791    sheet: &umya_spreadsheet::Worksheet,
1792    row_index: u32,
1793    columns: &[u32],
1794    include_formulas: bool,
1795    include_styles: bool,
1796) -> RowSnapshot {
1797    let mut cells = Vec::new();
1798    for &col in columns {
1799        if let Some(cell) = sheet.get_cell((col, row_index)) {
1800            cells.push(build_cell_snapshot(cell, include_formulas, include_styles));
1801        } else {
1802            let address = crate::utils::cell_address(col, row_index);
1803            cells.push(CellSnapshot {
1804                address,
1805                value: None,
1806                formula: None,
1807                cached_value: None,
1808                number_format: None,
1809                style_tags: Vec::new(),
1810                notes: Vec::new(),
1811            });
1812        }
1813    }
1814
1815    RowSnapshot { row_index, cells }
1816}
1817
1818fn build_cell_snapshot(
1819    cell: &umya_spreadsheet::Cell,
1820    include_formulas: bool,
1821    include_styles: bool,
1822) -> CellSnapshot {
1823    let address = cell.get_coordinate().get_coordinate();
1824    let value = crate::workbook::cell_to_value(cell);
1825    let formula = if include_formulas && cell.is_formula() {
1826        Some(cell.get_formula().to_string())
1827    } else {
1828        None
1829    };
1830    let cached_value = if cell.is_formula() {
1831        value.clone()
1832    } else {
1833        None
1834    };
1835    let number_format = if include_styles {
1836        cell.get_style()
1837            .get_number_format()
1838            .map(|fmt| fmt.get_format_code().to_string())
1839    } else {
1840        None
1841    };
1842    let style_tags = if include_styles {
1843        crate::analysis::style::tag_cell(cell)
1844            .map(|(_, tagging)| tagging.tags)
1845            .unwrap_or_default()
1846    } else {
1847        Vec::new()
1848    };
1849
1850    CellSnapshot {
1851        address,
1852        value,
1853        formula,
1854        cached_value,
1855        number_format,
1856        style_tags,
1857        notes: Vec::new(),
1858    }
1859}
1860
1861fn resolve_columns(columns: Option<&Vec<String>>, max_column: u32) -> Vec<u32> {
1862    use std::collections::BTreeSet;
1863    use umya_spreadsheet::helper::coordinate::column_index_from_string;
1864
1865    let mut indices = BTreeSet::new();
1866    if let Some(specs) = columns {
1867        for spec in specs {
1868            if let Some((start, end)) = spec.split_once(':') {
1869                let start_idx = column_index_from_string(start);
1870                let end_idx = column_index_from_string(end);
1871                let (min_idx, max_idx) = if start_idx <= end_idx {
1872                    (start_idx, end_idx)
1873                } else {
1874                    (end_idx, start_idx)
1875                };
1876                for idx in min_idx..=max_idx {
1877                    indices.insert(idx);
1878                }
1879            } else {
1880                indices.insert(column_index_from_string(spec));
1881            }
1882        }
1883    } else {
1884        for idx in 1..=max_column.max(1) {
1885            indices.insert(idx);
1886        }
1887    }
1888
1889    indices.into_iter().collect()
1890}
1891
1892fn resolve_columns_with_headers(
1893    sheet: &umya_spreadsheet::Worksheet,
1894    columns: Option<&Vec<String>>,
1895    columns_by_header: Option<&Vec<String>>,
1896    max_column: u32,
1897) -> Vec<u32> {
1898    use std::collections::BTreeSet;
1899
1900    if columns_by_header.is_none() {
1901        return resolve_columns(columns, max_column);
1902    }
1903
1904    let mut selected: BTreeSet<u32> = if columns.is_some() {
1905        resolve_columns(columns, max_column).into_iter().collect()
1906    } else {
1907        BTreeSet::new()
1908    };
1909    let mut matched_header = false;
1910    let header_targets: Vec<String> = columns_by_header
1911        .unwrap()
1912        .iter()
1913        .map(|h| h.trim().to_ascii_lowercase())
1914        .collect();
1915
1916    for col_idx in 1..=max_column.max(1) {
1917        let header_cell = sheet.get_cell((col_idx, 1u32));
1918        let header_value = header_cell
1919            .and_then(cell_to_value)
1920            .map(cell_value_to_string_lower);
1921        if let Some(hval) = header_value
1922            && header_targets.iter().any(|target| target == &hval)
1923        {
1924            selected.insert(col_idx);
1925            matched_header = true;
1926        }
1927    }
1928
1929    if !matched_header && columns.is_none() {
1930        resolve_columns(None, max_column)
1931    } else {
1932        selected.into_iter().collect()
1933    }
1934}
1935
1936fn cell_value_to_string_lower(value: CellValue) -> String {
1937    match value {
1938        CellValue::Text(s) => s.to_ascii_lowercase(),
1939        CellValue::Number(n) => n.to_string().to_ascii_lowercase(),
1940        CellValue::Bool(b) => b.to_string(),
1941        CellValue::Error(e) => e.to_ascii_lowercase(),
1942        CellValue::Date(d) => d.to_ascii_lowercase(),
1943    }
1944}
1945
1946fn cell_value_to_plain_string(value: &CellValue) -> String {
1947    match value {
1948        CellValue::Text(s) => s.clone(),
1949        CellValue::Number(n) => n.to_string(),
1950        CellValue::Bool(b) => b.to_string(),
1951        CellValue::Error(e) => e.clone(),
1952        CellValue::Date(d) => d.clone(),
1953    }
1954}
1955
1956fn cell_value_to_kind(value: &CellValue) -> CellValueKind {
1957    match value {
1958        CellValue::Text(_) => CellValueKind::Text,
1959        CellValue::Number(_) => CellValueKind::Number,
1960        CellValue::Bool(_) => CellValueKind::Bool,
1961        CellValue::Error(_) => CellValueKind::Error,
1962        CellValue::Date(_) => CellValueKind::Date,
1963    }
1964}
1965
1966fn cell_value_to_primitive(value: &CellValue) -> CellValuePrimitive {
1967    match value {
1968        CellValue::Text(s) => CellValuePrimitive::Text(s.clone()),
1969        CellValue::Number(n) => CellValuePrimitive::Number(*n),
1970        CellValue::Bool(b) => CellValuePrimitive::Bool(*b),
1971        CellValue::Error(e) => CellValuePrimitive::Text(e.clone()),
1972        CellValue::Date(d) => CellValuePrimitive::Text(d.clone()),
1973    }
1974}
1975
1976fn csv_escape_field(field: &str) -> String {
1977    if field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r') {
1978        let mut escaped = String::with_capacity(field.len() + 2);
1979        escaped.push('"');
1980        for ch in field.chars() {
1981            if ch == '"' {
1982                escaped.push('"');
1983            }
1984            escaped.push(ch);
1985        }
1986        escaped.push('"');
1987        escaped
1988    } else {
1989        field.to_string()
1990    }
1991}
1992
1993fn push_csv_row<I>(buffer: &mut String, fields: I)
1994where
1995    I: IntoIterator<Item = String>,
1996{
1997    let mut first = true;
1998    for field in fields {
1999        if !first {
2000            buffer.push(',');
2001        }
2002        first = false;
2003        let escaped = csv_escape_field(&field);
2004        buffer.push_str(&escaped);
2005    }
2006    buffer.push('\n');
2007}
2008
2009fn table_rows_to_values(
2010    headers: &[String],
2011    rows: &[TableRow],
2012) -> Vec<Vec<Option<CellValuePrimitive>>> {
2013    let mut out = Vec::with_capacity(rows.len());
2014    for row in rows {
2015        let mut vals = Vec::with_capacity(headers.len());
2016        for header in headers {
2017            let value = row
2018                .get(header)
2019                .and_then(|cell| cell.as_ref())
2020                .map(cell_value_to_primitive);
2021            vals.push(value);
2022        }
2023        out.push(vals);
2024    }
2025    out
2026}
2027
2028fn table_rows_to_types(headers: &[String], rows: &[TableRow]) -> Vec<Vec<Option<CellValueKind>>> {
2029    let mut out = Vec::with_capacity(rows.len());
2030    for row in rows {
2031        let mut kinds = Vec::with_capacity(headers.len());
2032        for header in headers {
2033            let kind = row
2034                .get(header)
2035                .and_then(|cell| cell.as_ref())
2036                .map(cell_value_to_kind);
2037            kinds.push(kind);
2038        }
2039        out.push(kinds);
2040    }
2041    out
2042}
2043
2044fn table_rows_to_csv(headers: &[String], rows: &[TableRow], include_headers: bool) -> String {
2045    let mut csv = String::new();
2046    if include_headers {
2047        push_csv_row(&mut csv, headers.iter().cloned());
2048    }
2049    for row in rows {
2050        let values = headers.iter().map(|header| {
2051            row.get(header)
2052                .and_then(|cell| cell.as_ref())
2053                .map(cell_value_to_plain_string)
2054                .unwrap_or_default()
2055        });
2056        push_csv_row(&mut csv, values);
2057    }
2058    csv
2059}
2060
2061fn filter_table_row(row: &TableRow, headers: &[String]) -> TableRow {
2062    let mut filtered = TableRow::new();
2063    for header in headers {
2064        if let Some(value) = row.get(header) {
2065            filtered.insert(header.clone(), value.clone());
2066        }
2067    }
2068    filtered
2069}
2070
2071type ReadTablePayload = (
2072    Vec<String>,
2073    Vec<TableRow>,
2074    Option<Vec<Vec<Option<CellValuePrimitive>>>>,
2075    Option<Vec<Vec<Option<CellValueKind>>>>,
2076    Option<String>,
2077);
2078
2079fn build_read_table_payload(
2080    format: TableOutputFormat,
2081    headers: &[String],
2082    rows: &[TableRow],
2083    include_headers: bool,
2084    include_types: bool,
2085) -> ReadTablePayload {
2086    let headers_out = if include_headers {
2087        headers.to_vec()
2088    } else {
2089        Vec::new()
2090    };
2091
2092    let types_out = if include_types {
2093        Some(table_rows_to_types(headers, rows))
2094    } else {
2095        None
2096    };
2097
2098    match format {
2099        TableOutputFormat::Json | TableOutputFormat::Rows => {
2100            (headers_out, rows.to_vec(), None, types_out, None)
2101        }
2102        TableOutputFormat::Values | TableOutputFormat::Dense => (
2103            headers_out,
2104            Vec::new(),
2105            Some(table_rows_to_values(headers, rows)),
2106            types_out,
2107            None,
2108        ),
2109        TableOutputFormat::Csv => (
2110            Vec::new(),
2111            Vec::new(),
2112            None,
2113            types_out,
2114            Some(table_rows_to_csv(headers, rows, include_headers)),
2115        ),
2116    }
2117}
2118
2119fn cell_matrix_to_values(rows: &[Vec<Option<CellValue>>]) -> Vec<Vec<Option<CellValuePrimitive>>> {
2120    let mut out = Vec::with_capacity(rows.len());
2121    for row in rows {
2122        let mut vals = Vec::with_capacity(row.len());
2123        for cell in row {
2124            vals.push(cell.as_ref().map(cell_value_to_primitive));
2125        }
2126        out.push(vals);
2127    }
2128    out
2129}
2130
2131fn cell_matrix_to_csv(rows: &[Vec<Option<CellValue>>]) -> String {
2132    let mut csv = String::new();
2133    for row in rows {
2134        let values = row.iter().map(|cell| {
2135            cell.as_ref()
2136                .map(cell_value_to_plain_string)
2137                .unwrap_or_default()
2138        });
2139        push_csv_row(&mut csv, values);
2140    }
2141    csv
2142}
2143
2144#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2145enum DensePrimitiveKey {
2146    Text(String),
2147    NumberBits(u64),
2148    Bool(bool),
2149}
2150
2151fn dense_key(value: &CellValuePrimitive) -> DensePrimitiveKey {
2152    match value {
2153        CellValuePrimitive::Text(text) => DensePrimitiveKey::Text(text.clone()),
2154        CellValuePrimitive::Number(number) => DensePrimitiveKey::NumberBits(number.to_bits()),
2155        CellValuePrimitive::Bool(flag) => DensePrimitiveKey::Bool(*flag),
2156    }
2157}
2158
2159fn row_to_dense_runs(indexes: &[u32]) -> Vec<RangeValuesDenseRun> {
2160    if indexes.is_empty() {
2161        return Vec::new();
2162    }
2163    let mut runs = Vec::new();
2164    let mut current = indexes[0];
2165    let mut len: u32 = 1;
2166
2167    for idx in indexes.iter().copied().skip(1) {
2168        if idx == current {
2169            len += 1;
2170        } else {
2171            runs.push(RangeValuesDenseRun {
2172                value_idx: current,
2173                len,
2174            });
2175            current = idx;
2176            len = 1;
2177        }
2178    }
2179
2180    runs.push(RangeValuesDenseRun {
2181        value_idx: current,
2182        len,
2183    });
2184    runs
2185}
2186
2187fn cell_matrix_to_dense(
2188    rows: &[Vec<Option<CellValue>>],
2189    formulas: Option<&[Vec<Option<String>>]>,
2190) -> RangeValuesDensePayload {
2191    let primitive_rows = cell_matrix_to_values(rows);
2192    let mut dictionary: Vec<Option<CellValuePrimitive>> = vec![None];
2193    let mut dict_index: HashMap<DensePrimitiveKey, u32> = HashMap::new();
2194    let mut row_runs: Vec<Vec<RangeValuesDenseRun>> = Vec::with_capacity(primitive_rows.len());
2195
2196    for row in &primitive_rows {
2197        let mut indexes = Vec::with_capacity(row.len());
2198        for cell in row {
2199            let idx = match cell {
2200                None => 0,
2201                Some(value) => {
2202                    let key = dense_key(value);
2203                    if let Some(existing) = dict_index.get(&key).copied() {
2204                        existing
2205                    } else {
2206                        let next = dictionary.len() as u32;
2207                        dictionary.push(Some(value.clone()));
2208                        dict_index.insert(key, next);
2209                        next
2210                    }
2211                }
2212            };
2213            indexes.push(idx);
2214        }
2215        row_runs.push(row_to_dense_runs(&indexes));
2216    }
2217
2218    let dense_formulas = formulas
2219        .map(|matrix| {
2220            let mut out = Vec::new();
2221            for (row_idx, row) in matrix.iter().enumerate() {
2222                for (col_idx, formula) in row.iter().enumerate() {
2223                    if let Some(formula) = formula {
2224                        out.push(RangeValuesDenseFormula {
2225                            row: row_idx as u32,
2226                            col: col_idx as u32,
2227                            formula: formula.clone(),
2228                        });
2229                    }
2230                }
2231            }
2232            out
2233        })
2234        .unwrap_or_default();
2235
2236    let col_count = primitive_rows.first().map_or(0, |row| row.len() as u32);
2237
2238    RangeValuesDensePayload {
2239        encoding: "dense_v1".to_string(),
2240        col_count,
2241        dictionary,
2242        row_runs,
2243        formulas: dense_formulas,
2244    }
2245}
2246
2247fn cell_matrix_to_rows_keyed(
2248    range: &str,
2249    rows: &[Vec<Option<CellValue>>],
2250) -> Vec<RangeValuesRowEntry> {
2251    let ((start_col, start_row), _) = parse_range(range).unwrap_or(((1, 1), (1, 1)));
2252    let mut out = Vec::with_capacity(rows.len());
2253    for (row_offset, row) in rows.iter().enumerate() {
2254        let row_number = start_row + row_offset as u32;
2255        let mut cells = std::collections::BTreeMap::new();
2256        for (col_offset, cell) in row.iter().enumerate() {
2257            if let Some(value) = cell {
2258                let col_letter = column_number_to_name(start_col + col_offset as u32);
2259                cells.insert(col_letter, cell_value_to_primitive(value));
2260            }
2261        }
2262        out.push(RangeValuesRowEntry {
2263            row: row_number,
2264            cells,
2265        });
2266    }
2267    out
2268}
2269
2270fn build_range_values_entry(
2271    format: TableOutputFormat,
2272    range: &str,
2273    rows: &[Vec<Option<CellValue>>],
2274    formulas: Option<&[Vec<Option<String>>]>,
2275    next_start_row: Option<u32>,
2276) -> RangeValuesEntry {
2277    match format {
2278        TableOutputFormat::Json => RangeValuesEntry {
2279            range: range.to_string(),
2280            rows: Some(rows.to_vec()),
2281            formulas: formulas.map(|matrix| matrix.to_vec()),
2282            values: None,
2283            dense: None,
2284            csv: None,
2285            rows_keyed: None,
2286            next_start_row,
2287        },
2288        TableOutputFormat::Values => RangeValuesEntry {
2289            range: range.to_string(),
2290            rows: None,
2291            formulas: None,
2292            values: Some(cell_matrix_to_values(rows)),
2293            dense: None,
2294            csv: None,
2295            rows_keyed: None,
2296            next_start_row,
2297        },
2298        TableOutputFormat::Csv => RangeValuesEntry {
2299            range: range.to_string(),
2300            rows: None,
2301            formulas: None,
2302            values: None,
2303            dense: None,
2304            csv: Some(cell_matrix_to_csv(rows)),
2305            rows_keyed: None,
2306            next_start_row,
2307        },
2308        TableOutputFormat::Dense => RangeValuesEntry {
2309            range: range.to_string(),
2310            rows: None,
2311            formulas: None,
2312            values: None,
2313            dense: Some(cell_matrix_to_dense(rows, formulas)),
2314            csv: None,
2315            rows_keyed: None,
2316            next_start_row,
2317        },
2318        TableOutputFormat::Rows => RangeValuesEntry {
2319            range: range.to_string(),
2320            rows: None,
2321            formulas: None,
2322            values: None,
2323            dense: None,
2324            csv: None,
2325            rows_keyed: Some(cell_matrix_to_rows_keyed(range, rows)),
2326            next_start_row,
2327        },
2328    }
2329}
2330
2331fn cap_rows_by_cells(row_count: usize, cells_per_row: usize, max_cells: Option<usize>) -> usize {
2332    let Some(max_cells) = max_cells else {
2333        return row_count;
2334    };
2335    if cells_per_row == 0 {
2336        return row_count;
2337    }
2338    let allowed = max_cells / cells_per_row;
2339    row_count.min(allowed)
2340}
2341
2342fn cap_rows_by_payload_bytes<F>(
2343    row_count: usize,
2344    max_bytes: Option<usize>,
2345    mut size_for_rows: F,
2346) -> usize
2347where
2348    F: FnMut(usize) -> usize,
2349{
2350    let Some(max_bytes) = max_bytes else {
2351        return row_count;
2352    };
2353    if row_count == 0 {
2354        return 0;
2355    }
2356    let mut low = 0usize;
2357    let mut high = row_count;
2358    while low < high {
2359        let mid = (low + high).div_ceil(2);
2360        if size_for_rows(mid) <= max_bytes {
2361            low = mid;
2362        } else {
2363            high = mid - 1;
2364        }
2365    }
2366    low
2367}
2368
2369fn build_compact_payload(
2370    header: &Option<RowSnapshot>,
2371    rows: &[RowSnapshot],
2372    include_header: bool,
2373) -> SheetPageCompact {
2374    let headers = derive_headers(header, rows);
2375    let header_row = if include_header {
2376        header
2377            .as_ref()
2378            .map(|h| h.cells.iter().map(|c| c.value.clone()).collect())
2379            .unwrap_or_default()
2380    } else {
2381        Vec::new()
2382    };
2383    let data_rows = rows
2384        .iter()
2385        .map(|row| {
2386            let mut vals: Vec<Option<CellValue>> = Vec::new();
2387            vals.push(Some(CellValue::Number(row.row_index as f64)));
2388            vals.extend(row.cells.iter().map(|c| c.value.clone()));
2389            vals
2390        })
2391        .collect();
2392
2393    SheetPageCompact {
2394        headers,
2395        header_row,
2396        rows: data_rows,
2397    }
2398}
2399
2400fn build_values_only_payload(
2401    header: &Option<RowSnapshot>,
2402    rows: &[RowSnapshot],
2403    include_header: bool,
2404) -> SheetPageValues {
2405    let mut data = Vec::new();
2406    if include_header && let Some(h) = header {
2407        data.push(h.cells.iter().map(|c| c.value.clone()).collect());
2408    }
2409    for row in rows {
2410        data.push(row.cells.iter().map(|c| c.value.clone()).collect());
2411    }
2412
2413    SheetPageValues { rows: data }
2414}
2415
2416fn build_sheet_page_response(
2417    workbook: &WorkbookContext,
2418    sheet_name: &str,
2419    format: SheetPageFormat,
2420    include_header: bool,
2421    header: &Option<RowSnapshot>,
2422    rows: &[RowSnapshot],
2423    next_start_row: Option<u32>,
2424) -> SheetPageResponse {
2425    let compact_payload = if matches!(format, SheetPageFormat::Compact) {
2426        Some(build_compact_payload(header, rows, include_header))
2427    } else {
2428        None
2429    };
2430
2431    let values_only_payload = if matches!(format, SheetPageFormat::ValuesOnly) {
2432        Some(build_values_only_payload(header, rows, include_header))
2433    } else {
2434        None
2435    };
2436
2437    let rows_payload = if matches!(format, SheetPageFormat::Full) {
2438        rows.to_vec()
2439    } else {
2440        Vec::new()
2441    };
2442
2443    let header_row = if include_header && matches!(format, SheetPageFormat::Full) {
2444        header.clone()
2445    } else {
2446        None
2447    };
2448
2449    SheetPageResponse {
2450        workbook_id: workbook.id.clone(),
2451        sheet_name: sheet_name.to_string(),
2452        rows: rows_payload,
2453        next_start_row,
2454        header_row,
2455        compact: compact_payload,
2456        values_only: values_only_payload,
2457        format,
2458        truncated: false,
2459        budget: None,
2460    }
2461}
2462
2463fn derive_headers(header: &Option<RowSnapshot>, rows: &[RowSnapshot]) -> Vec<String> {
2464    if let Some(h) = header {
2465        let mut headers: Vec<String> = h
2466            .cells
2467            .iter()
2468            .map(|c| match &c.value {
2469                Some(CellValue::Text(t)) => t.clone(),
2470                Some(CellValue::Number(n)) => n.to_string(),
2471                Some(CellValue::Bool(b)) => b.to_string(),
2472                Some(CellValue::Date(d)) => d.clone(),
2473                Some(CellValue::Error(e)) => e.clone(),
2474                None => c.address.clone(),
2475            })
2476            .collect();
2477        headers.insert(0, "Row".to_string());
2478        headers
2479    } else if let Some(first) = rows.first() {
2480        let mut headers = Vec::new();
2481        headers.push("Row".to_string());
2482        for cell in &first.cells {
2483            headers.push(cell.address.clone());
2484        }
2485        headers
2486    } else {
2487        vec![]
2488    }
2489}
2490fn default_stats_sample() -> usize {
2491    500
2492}
2493
2494#[derive(Debug, Deserialize, JsonSchema)]
2495pub struct SheetStatisticsParams {
2496    /// Workbook ID or fork ID
2497    #[serde(alias = "workbook_id")]
2498    pub workbook_or_fork_id: WorkbookId,
2499    /// Sheet name
2500    pub sheet_name: String,
2501    /// Number of rows to sample for statistics (default: 500)
2502    #[serde(default)]
2503    pub sample_rows: Option<usize>,
2504    /// Return stats without sample values (default: true in token_dense profile)
2505    #[serde(default)]
2506    pub summary_only: Option<bool>,
2507}
2508
2509pub async fn sheet_statistics(
2510    state: Arc<AppState>,
2511    params: SheetStatisticsParams,
2512) -> Result<SheetStatisticsResponse> {
2513    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
2514    let config = state.config();
2515    let output_profile = config.output_profile();
2516    let summary_only = params
2517        .summary_only
2518        .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
2519    let sheet_metrics = workbook.get_sheet_metrics_fast(&params.sheet_name)?;
2520    let sample_rows = params.sample_rows.unwrap_or_else(default_stats_sample);
2521    let stats = workbook.with_sheet(&params.sheet_name, |sheet| {
2522        stats::compute_sheet_statistics(sheet, sample_rows)
2523    })?;
2524    let mut numeric_columns = stats.numeric_columns;
2525    let mut text_columns = stats.text_columns;
2526
2527    if summary_only {
2528        for column in &mut numeric_columns {
2529            column.samples.clear();
2530        }
2531        for column in &mut text_columns {
2532            column.samples.clear();
2533        }
2534    }
2535
2536    let max_items = config.max_items();
2537    let max_payload_bytes = config.max_payload_bytes();
2538
2539    if let Some(max_items) = max_items {
2540        if numeric_columns.len() > max_items {
2541            numeric_columns.truncate(max_items);
2542        }
2543        if text_columns.len() > max_items {
2544            text_columns.truncate(max_items);
2545        }
2546    }
2547
2548    if let Some(max_bytes) = max_payload_bytes {
2549        let response_size = |numeric: &Vec<ColumnSummary>, text: &Vec<ColumnSummary>| {
2550            let response = SheetStatisticsResponse {
2551                workbook_id: workbook.id.clone(),
2552                sheet_name: params.sheet_name.clone(),
2553                row_count: sheet_metrics.metrics.row_count,
2554                column_count: sheet_metrics.metrics.column_count,
2555                density: stats.density,
2556                numeric_columns: numeric.clone(),
2557                text_columns: text.clone(),
2558                null_counts: stats.null_counts.clone(),
2559                duplicate_warnings: stats.duplicate_warnings.clone(),
2560            };
2561            serde_json::to_vec(&response)
2562                .map(|payload| payload.len())
2563                .unwrap_or(usize::MAX)
2564        };
2565
2566        let mut current_size = response_size(&numeric_columns, &text_columns);
2567        if current_size > max_bytes && !summary_only {
2568            for column in &mut numeric_columns {
2569                column.samples.clear();
2570            }
2571            for column in &mut text_columns {
2572                column.samples.clear();
2573            }
2574            current_size = response_size(&numeric_columns, &text_columns);
2575        }
2576
2577        while current_size > max_bytes && (!text_columns.is_empty() || !numeric_columns.is_empty())
2578        {
2579            if !text_columns.is_empty() {
2580                text_columns.pop();
2581            } else if !numeric_columns.is_empty() {
2582                numeric_columns.pop();
2583            }
2584            current_size = response_size(&numeric_columns, &text_columns);
2585        }
2586    }
2587
2588    Ok(SheetStatisticsResponse {
2589        workbook_id: workbook.id.clone(),
2590        sheet_name: params.sheet_name,
2591        row_count: sheet_metrics.metrics.row_count,
2592        column_count: sheet_metrics.metrics.column_count,
2593        density: stats.density,
2594        numeric_columns,
2595        text_columns,
2596        null_counts: stats.null_counts,
2597        duplicate_warnings: stats.duplicate_warnings,
2598    })
2599}
2600
2601fn address_in_range(address: &str, range: &str) -> bool {
2602    parse_range(range).is_none_or(|((start_col, start_row), (end_col, end_row))| {
2603        if let Some((col, row)) = parse_address(address) {
2604            col >= start_col && col <= end_col && row >= start_row && row <= end_row
2605        } else {
2606            false
2607        }
2608    })
2609}
2610
2611fn parse_range(range: &str) -> Option<((u32, u32), (u32, u32))> {
2612    let mut parts = range.split(':');
2613    let start = parts.next()?;
2614    let end = parts.next().unwrap_or(start);
2615    let start_idx = parse_address(start)?;
2616    let end_idx = parse_address(end)?;
2617    Some((
2618        (start_idx.0.min(end_idx.0), start_idx.1.min(end_idx.1)),
2619        (start_idx.0.max(end_idx.0), start_idx.1.max(end_idx.1)),
2620    ))
2621}
2622
2623fn parse_address(address: &str) -> Option<(u32, u32)> {
2624    use umya_spreadsheet::helper::coordinate::index_from_coordinate;
2625    let (col, row, _, _) = index_from_coordinate(address);
2626    match (col, row) {
2627        (Some(c), Some(r)) => Some((c, r)),
2628        _ => None,
2629    }
2630}
2631
2632#[derive(Clone)]
2633struct TableTarget {
2634    sheet_name: String,
2635    table_name: Option<String>,
2636    range: ((u32, u32), (u32, u32)),
2637    header_hint: Option<u32>,
2638}
2639
2640fn resolve_table_target(
2641    workbook: &WorkbookContext,
2642    params: &ReadTableParams,
2643) -> Result<TableTarget> {
2644    if let Some(region_id) = params.region_id
2645        && let Some(sheet) = &params.sheet_name
2646        && let Ok(region) = workbook.detected_region(sheet, region_id)
2647    {
2648        return Ok(TableTarget {
2649            sheet_name: sheet.clone(),
2650            table_name: None,
2651            range: parse_range(&region.bounds).unwrap_or(((1, 1), (1, 1))),
2652            header_hint: region.header_row,
2653        });
2654    }
2655
2656    if let Some(table_name) = &params.table_name {
2657        let items = workbook.named_items()?;
2658        for item in items {
2659            if item.name.eq_ignore_ascii_case(table_name)
2660                || item
2661                    .name
2662                    .to_ascii_lowercase()
2663                    .contains(&table_name.to_ascii_lowercase())
2664            {
2665                let mut sheet_name = item
2666                    .sheet_name
2667                    .clone()
2668                    .or_else(|| params.sheet_name.clone())
2669                    .unwrap_or_else(|| workbook.sheet_names().first().cloned().unwrap_or_default());
2670                let refers_to = item.refers_to.trim_start_matches('=');
2671                let mut range_part = refers_to;
2672                if let Some((sheet_part, rest)) = refers_to.split_once('!') {
2673                    sheet_name = sheet_part.trim_matches('\'').to_string();
2674                    range_part = rest;
2675                }
2676                if let Some(range) = parse_range(range_part) {
2677                    return Ok(TableTarget {
2678                        sheet_name,
2679                        table_name: Some(item.name.clone()),
2680                        range,
2681                        header_hint: if item.kind == NamedItemKind::Table {
2682                            Some(range.0.1)
2683                        } else {
2684                            None
2685                        },
2686                    });
2687                }
2688            }
2689        }
2690    }
2691
2692    let sheet_name = params
2693        .sheet_name
2694        .clone()
2695        .unwrap_or_else(|| workbook.sheet_names().first().cloned().unwrap_or_default());
2696
2697    if let Some(rng) = &params.range
2698        && let Some(range) = parse_range(rng)
2699    {
2700        return Ok(TableTarget {
2701            sheet_name,
2702            table_name: None,
2703            range,
2704            header_hint: None,
2705        });
2706    }
2707
2708    let metrics = workbook.get_sheet_metrics_fast(&sheet_name)?;
2709    let end_col = metrics.metrics.column_count.max(1);
2710    let end_row = metrics.metrics.row_count.max(1);
2711    Ok(TableTarget {
2712        sheet_name,
2713        table_name: None,
2714        range: ((1, 1), (end_col, end_row)),
2715        header_hint: None,
2716    })
2717}
2718
2719#[allow(clippy::too_many_arguments)]
2720fn extract_table_rows(
2721    sheet: &umya_spreadsheet::Worksheet,
2722    target: &TableTarget,
2723    header_row: Option<u32>,
2724    header_rows: Option<u32>,
2725    columns: Option<Vec<String>>,
2726    filters: Option<Vec<TableFilter>>,
2727    limit: usize,
2728    offset: usize,
2729    sample_mode: SampleMode,
2730) -> Result<(Vec<String>, Vec<TableRow>, u32)> {
2731    let ((start_col, start_row), (end_col, end_row)) = target.range;
2732    let mut header_start = header_row.or(target.header_hint).unwrap_or(start_row);
2733    if header_start < start_row {
2734        header_start = start_row;
2735    }
2736    if header_start > end_row {
2737        header_start = start_row;
2738    }
2739    let header_rows_count = header_rows.unwrap_or(1).max(1);
2740    let data_start_row = (header_start + header_rows_count).max(start_row + header_rows_count);
2741    let column_indices: Vec<u32> = if let Some(cols) = columns.as_ref() {
2742        resolve_columns(Some(cols), end_col).into_iter().collect()
2743    } else {
2744        (start_col..=end_col).collect()
2745    };
2746
2747    let headers = build_headers(sheet, &column_indices, header_start, header_rows_count);
2748    let mut all_rows: Vec<TableRow> = Vec::new();
2749    let mut total_rows: u32 = 0;
2750
2751    for row_idx in data_start_row..=end_row {
2752        let mut row = BTreeMap::new();
2753        for (i, col_idx) in column_indices.iter().enumerate() {
2754            let header = headers
2755                .get(i)
2756                .cloned()
2757                .unwrap_or_else(|| format!("Col{col_idx}"));
2758            let value = sheet.get_cell((*col_idx, row_idx)).and_then(cell_to_value);
2759            row.insert(header, value);
2760        }
2761        if !row_passes_filters(&row, filters.as_ref()) {
2762            continue;
2763        }
2764        total_rows += 1;
2765        if matches!(sample_mode, SampleMode::First) && total_rows as usize > offset + limit {
2766            continue;
2767        }
2768        all_rows.push(row);
2769    }
2770
2771    let rows = sample_rows(all_rows, limit, offset, sample_mode);
2772
2773    Ok((headers, rows, total_rows))
2774}
2775
2776fn build_headers(
2777    sheet: &umya_spreadsheet::Worksheet,
2778    columns: &[u32],
2779    header_start: u32,
2780    header_rows: u32,
2781) -> Vec<String> {
2782    let mut headers = Vec::new();
2783    for col_idx in columns {
2784        let mut parts = Vec::new();
2785        for h in header_start..(header_start + header_rows) {
2786            let (origin_col, origin_row) = sheet.map_merged_cell((*col_idx, h));
2787            if let Some(value) = sheet
2788                .get_cell((origin_col, origin_row))
2789                .and_then(cell_to_value)
2790            {
2791                match value {
2792                    CellValue::Text(ref s) if s.trim().is_empty() => {}
2793                    CellValue::Text(s) => parts.push(s),
2794                    CellValue::Number(n) => parts.push(n.to_string()),
2795                    CellValue::Bool(b) => parts.push(b.to_string()),
2796                    CellValue::Error(e) => parts.push(e),
2797                    CellValue::Date(d) => parts.push(d),
2798                }
2799            }
2800        }
2801        if parts.is_empty() {
2802            headers.push(crate::utils::column_number_to_name(*col_idx));
2803        } else {
2804            headers.push(parts.join(" / "));
2805        }
2806    }
2807
2808    if headers.iter().all(|h| h.trim().is_empty()) {
2809        return columns
2810            .iter()
2811            .map(|c| crate::utils::column_number_to_name(*c))
2812            .collect();
2813    }
2814
2815    dedupe_headers(headers)
2816}
2817
2818fn dedupe_headers(mut headers: Vec<String>) -> Vec<String> {
2819    let mut seen: HashMap<String, u32> = HashMap::new();
2820    for h in headers.iter_mut() {
2821        let key = h.clone();
2822        if key.trim().is_empty() {
2823            continue;
2824        }
2825        let count = seen.entry(key.clone()).or_insert(0);
2826        if *count > 0 {
2827            h.push_str(&format!("_{}", *count + 1));
2828        }
2829        *count += 1;
2830    }
2831    headers
2832}
2833
2834fn row_passes_filters(row: &TableRow, filters: Option<&Vec<TableFilter>>) -> bool {
2835    if let Some(filters) = filters {
2836        for filter in filters {
2837            if let Some(value) = row.get(&filter.column) {
2838                match filter.op {
2839                    FilterOp::Eq => {
2840                        if !value_eq(value, &filter.value) {
2841                            return false;
2842                        }
2843                    }
2844                    FilterOp::Neq => {
2845                        if value_eq(value, &filter.value) {
2846                            return false;
2847                        }
2848                    }
2849                    FilterOp::Contains => {
2850                        if !value_contains(value, &filter.value) {
2851                            return false;
2852                        }
2853                    }
2854                    FilterOp::Gt => {
2855                        if !value_gt(value, &filter.value) {
2856                            return false;
2857                        }
2858                    }
2859                    FilterOp::Lt => {
2860                        if !value_lt(value, &filter.value) {
2861                            return false;
2862                        }
2863                    }
2864                    FilterOp::Gte => {
2865                        if !value_gte(value, &filter.value) {
2866                            return false;
2867                        }
2868                    }
2869                    FilterOp::Lte => {
2870                        if !value_lte(value, &filter.value) {
2871                            return false;
2872                        }
2873                    }
2874                    FilterOp::StartsWith => {
2875                        if !value_starts_with(value, &filter.value) {
2876                            return false;
2877                        }
2878                    }
2879                    FilterOp::EndsWith => {
2880                        if !value_ends_with(value, &filter.value) {
2881                            return false;
2882                        }
2883                    }
2884                    FilterOp::In => {
2885                        let list = filter
2886                            .value
2887                            .as_array()
2888                            .cloned()
2889                            .unwrap_or_else(|| vec![filter.value.clone()]);
2890                        if !list.iter().any(|cmp| value_eq(value, cmp)) {
2891                            return false;
2892                        }
2893                    }
2894                }
2895            }
2896        }
2897    }
2898    true
2899}
2900
2901fn value_eq(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2902    match (cell, cmp) {
2903        (Some(CellValue::Text(s)), serde_json::Value::String(t)) => s == t,
2904        (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2905            v.as_f64().is_some_and(|f| (*n - f).abs() < f64::EPSILON)
2906        }
2907        (Some(CellValue::Number(n)), serde_json::Value::String(t)) => t
2908            .parse::<f64>()
2909            .map(|f| (*n - f).abs() < f64::EPSILON)
2910            .unwrap_or(false),
2911        (Some(CellValue::Bool(b)), serde_json::Value::Bool(v)) => b == v,
2912        (Some(CellValue::Bool(b)), serde_json::Value::String(t)) => {
2913            t.eq_ignore_ascii_case("true") == *b
2914        }
2915        (Some(CellValue::Date(d)), serde_json::Value::String(t)) => d == t,
2916        (None, serde_json::Value::Null) => true,
2917        _ => false,
2918    }
2919}
2920
2921fn value_contains(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2922    if let (Some(CellValue::Text(s)), serde_json::Value::String(t)) = (cell, cmp) {
2923        return s.to_ascii_lowercase().contains(&t.to_ascii_lowercase());
2924    }
2925    false
2926}
2927
2928fn value_gt(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2929    match (cell, cmp) {
2930        (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2931            v.as_f64().is_some_and(|f| *n > f)
2932        }
2933        _ => false,
2934    }
2935}
2936
2937fn value_lt(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2938    match (cell, cmp) {
2939        (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2940            v.as_f64().is_some_and(|f| *n < f)
2941        }
2942        _ => false,
2943    }
2944}
2945
2946fn value_gte(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2947    match (cell, cmp) {
2948        (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2949            v.as_f64().is_some_and(|f| *n >= f)
2950        }
2951        _ => false,
2952    }
2953}
2954
2955fn value_lte(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2956    match (cell, cmp) {
2957        (Some(CellValue::Number(n)), serde_json::Value::Number(v)) => {
2958            v.as_f64().is_some_and(|f| *n <= f)
2959        }
2960        _ => false,
2961    }
2962}
2963
2964fn value_starts_with(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2965    if let (Some(CellValue::Text(s)), serde_json::Value::String(t)) = (cell, cmp) {
2966        return s.to_ascii_lowercase().starts_with(&t.to_ascii_lowercase());
2967    }
2968    false
2969}
2970
2971fn value_ends_with(cell: &Option<CellValue>, cmp: &serde_json::Value) -> bool {
2972    if let (Some(CellValue::Text(s)), serde_json::Value::String(t)) = (cell, cmp) {
2973        return s.to_ascii_lowercase().ends_with(&t.to_ascii_lowercase());
2974    }
2975    false
2976}
2977
2978fn sample_rows(
2979    rows: Vec<TableRow>,
2980    limit: usize,
2981    offset: usize,
2982    mode: SampleMode,
2983) -> Vec<TableRow> {
2984    if rows.is_empty() {
2985        return rows;
2986    }
2987
2988    match mode {
2989        SampleMode::Distributed => {
2990            if limit == 0 {
2991                return Vec::new();
2992            }
2993            let mut indices = Vec::new();
2994            let span = rows.len().saturating_sub(1);
2995            let step = std::cmp::max(1, span / std::cmp::max(1, limit.saturating_sub(1)));
2996            let mut idx = offset;
2997            while idx < rows.len() && indices.len() < limit {
2998                indices.push(idx);
2999                idx = idx.saturating_add(step);
3000                if idx == indices.last().copied().unwrap_or(0) {
3001                    idx += 1;
3002                }
3003            }
3004            if indices.len() < limit {
3005                let last_idx = rows.len().saturating_sub(1);
3006                if !indices.contains(&last_idx) {
3007                    indices.push(last_idx);
3008                }
3009            }
3010            indices
3011                .into_iter()
3012                .filter_map(|i| rows.get(i).cloned())
3013                .collect()
3014        }
3015        SampleMode::Last => {
3016            let start = rows.len().saturating_sub(limit + offset);
3017            rows.into_iter().skip(start + offset).take(limit).collect()
3018        }
3019        SampleMode::First => rows.into_iter().skip(offset).take(limit).collect(),
3020    }
3021}
3022
3023fn summarize_columns(headers: &[String], rows: &[TableRow]) -> Vec<ColumnTypeSummary> {
3024    let mut summaries = Vec::new();
3025    for header in headers {
3026        let mut nulls = 0u32;
3027        let mut distinct_set: HashSet<String> = HashSet::new();
3028        let mut values: Vec<f64> = Vec::new();
3029        let mut top_counts: HashMap<String, u32> = HashMap::new();
3030
3031        for row in rows {
3032            match row.get(header) {
3033                Some(Some(CellValue::Number(n))) => {
3034                    values.push(*n);
3035                    let key = n.to_string();
3036                    *top_counts.entry(key).or_default() += 1;
3037                }
3038                Some(Some(CellValue::Text(s))) => {
3039                    distinct_set.insert(s.clone());
3040                    *top_counts.entry(s.clone()).or_default() += 1;
3041                }
3042                Some(Some(CellValue::Bool(b))) => {
3043                    let key = b.to_string();
3044                    distinct_set.insert(key.clone());
3045                    *top_counts.entry(key).or_default() += 1;
3046                }
3047                Some(Some(CellValue::Date(d))) => {
3048                    distinct_set.insert(d.clone());
3049                    *top_counts.entry(d.clone()).or_default() += 1;
3050                }
3051                Some(Some(CellValue::Error(e))) => {
3052                    distinct_set.insert(e.clone());
3053                    *top_counts.entry(e.clone()).or_default() += 1;
3054                }
3055                _ => {
3056                    nulls += 1;
3057                }
3058            }
3059        }
3060
3061        let inferred_type = if !values.is_empty() {
3062            "number"
3063        } else if !distinct_set.is_empty() {
3064            "text"
3065        } else {
3066            "unknown"
3067        }
3068        .to_string();
3069
3070        let min = values.iter().cloned().reduce(f64::min);
3071        let max = values.iter().cloned().reduce(f64::max);
3072        let mean = if values.is_empty() {
3073            None
3074        } else {
3075            Some(values.iter().sum::<f64>() / values.len() as f64)
3076        };
3077
3078        let mut top_values: Vec<(String, u32)> = top_counts.into_iter().collect();
3079        top_values.sort_by(|a, b| b.1.cmp(&a.1));
3080        let top_values = top_values.into_iter().take(3).map(|(v, _)| v).collect();
3081
3082        summaries.push(ColumnTypeSummary {
3083            name: header.clone(),
3084            inferred_type,
3085            nulls,
3086            distinct: distinct_set.len() as u32,
3087            top_values,
3088            min,
3089            max,
3090            mean,
3091        });
3092    }
3093    summaries
3094}
3095
3096#[allow(clippy::too_many_arguments)]
3097fn collect_value_matches(
3098    sheet: &umya_spreadsheet::Worksheet,
3099    sheet_name: &str,
3100    mode: &FindMode,
3101    match_mode: MatchMode,
3102    direction: &LabelDirection,
3103    params: &FindValueParams,
3104    region: Option<&DetectedRegion>,
3105    default_bounds: ((u32, u32), (u32, u32)),
3106    offset: u32,
3107    limit: u32,
3108    seen_so_far: u32,
3109) -> Result<(Vec<FindValueMatch>, u32, bool)> {
3110    let mut results = Vec::new();
3111    let mut seen = seen_so_far;
3112    let regex = if match_mode == MatchMode::Regex {
3113        Regex::new(&params.query).ok()
3114    } else {
3115        None
3116    };
3117    let bounds = region
3118        .as_ref()
3119        .and_then(|r| parse_range(&r.bounds))
3120        .unwrap_or(default_bounds);
3121
3122    let header_row = region.and_then(|r| r.header_row).unwrap_or(1);
3123
3124    let context_mode = params.context.unwrap_or_default();
3125    let include_neighbors = matches!(context_mode, FindContext::Neighbors | FindContext::Both);
3126    let include_row_context = matches!(context_mode, FindContext::Row | FindContext::Both);
3127    let context_width = params.context_width.unwrap_or(3).max(1);
3128
3129    for cell in sheet.get_cell_collection() {
3130        let coord = cell.get_coordinate();
3131        let col = *coord.get_col_num();
3132        let row = *coord.get_row_num();
3133        if col < bounds.0.0 || col > bounds.1.0 || row < bounds.0.1 || row > bounds.1.1 {
3134            continue;
3135        }
3136        if params.search_headers_only && row != header_row {
3137            continue;
3138        }
3139
3140        let value = cell_to_value(cell);
3141        if let Some(ref allowed) = params.value_types
3142            && !value_type_matches(&value, allowed)
3143        {
3144            continue;
3145        }
3146        if matches!(mode, FindMode::Value) {
3147            if !value_matches(
3148                &value,
3149                &params.query,
3150                match_mode,
3151                params.case_sensitive,
3152                &regex,
3153            ) {
3154                continue;
3155            }
3156        } else if let Some(label) = &params.label {
3157            if !label_matches(cell, label, match_mode, params.case_sensitive, &regex) {
3158                continue;
3159            }
3160        } else {
3161            continue;
3162        }
3163
3164        if seen < offset {
3165            seen += 1;
3166            continue;
3167        }
3168
3169        if results.len() as u32 >= limit {
3170            return Ok((results, seen, true));
3171        }
3172
3173        let neighbors = if include_neighbors {
3174            collect_neighbors(sheet, row, col)
3175        } else {
3176            None
3177        };
3178        let (label_hit, match_value) = if matches!(mode, FindMode::Label) {
3179            let target_value = match direction {
3180                LabelDirection::Right => sheet.get_cell((col + 1, row)),
3181                LabelDirection::Below => sheet.get_cell((col, row + 1)),
3182                LabelDirection::Any => sheet
3183                    .get_cell((col + 1, row))
3184                    .or_else(|| sheet.get_cell((col, row + 1))),
3185            }
3186            .and_then(cell_to_value);
3187            if target_value.is_none() {
3188                continue;
3189            }
3190            (
3191                Some(LabelHit {
3192                    label_address: coord.get_coordinate(),
3193                    label: label_from_cell(cell),
3194                }),
3195                target_value,
3196            )
3197        } else {
3198            (None, value.clone())
3199        };
3200
3201        let row_context = if include_row_context {
3202            build_row_context(sheet, row, col, context_width)
3203        } else {
3204            None
3205        };
3206
3207        results.push(FindValueMatch {
3208            address: coord.get_coordinate(),
3209            sheet_name: sheet_name.to_string(),
3210            value: match_value,
3211            row_context,
3212            neighbors,
3213            label_hit,
3214        });
3215        seen += 1;
3216    }
3217
3218    Ok((results, seen, false))
3219}
3220
3221fn label_from_cell(cell: &umya_spreadsheet::Cell) -> String {
3222    cell_to_value(cell)
3223        .map(|v| match v {
3224            CellValue::Text(s) => s,
3225            CellValue::Number(n) => n.to_string(),
3226            CellValue::Bool(b) => b.to_string(),
3227            CellValue::Date(d) => d,
3228            CellValue::Error(e) => e,
3229        })
3230        .unwrap_or_else(|| cell.get_value().to_string())
3231}
3232
3233fn value_matches(
3234    value: &Option<CellValue>,
3235    query: &str,
3236    mode: MatchMode,
3237    case_sensitive: bool,
3238    regex: &Option<Regex>,
3239) -> bool {
3240    if value.is_none() {
3241        return false;
3242    }
3243    let haystack = cell_value_to_string_lower(value.clone().unwrap());
3244    let needle = if case_sensitive {
3245        query.to_string()
3246    } else {
3247        query.to_ascii_lowercase()
3248    };
3249
3250    match mode {
3251        MatchMode::Exact => haystack == needle,
3252        MatchMode::Prefix => haystack.starts_with(&needle),
3253        MatchMode::Regex => regex
3254            .as_ref()
3255            .map(|re| re.is_match(&haystack))
3256            .unwrap_or(false),
3257        MatchMode::Contains => haystack.contains(&needle),
3258    }
3259}
3260
3261fn label_matches(
3262    cell: &umya_spreadsheet::Cell,
3263    label: &str,
3264    mode: MatchMode,
3265    case_sensitive: bool,
3266    regex: &Option<Regex>,
3267) -> bool {
3268    let value = cell_to_value(cell);
3269    if value.is_none() {
3270        return false;
3271    }
3272    let haystack = cell_value_to_string_lower(value.unwrap());
3273    let needle = if case_sensitive {
3274        label.to_string()
3275    } else {
3276        label.to_ascii_lowercase()
3277    };
3278    match mode {
3279        MatchMode::Exact => haystack == needle,
3280        MatchMode::Prefix => haystack.starts_with(&needle),
3281        MatchMode::Regex => regex
3282            .as_ref()
3283            .map(|re| re.is_match(&haystack))
3284            .unwrap_or(false),
3285        MatchMode::Contains => haystack.contains(&needle),
3286    }
3287}
3288
3289fn value_type_matches(value: &Option<CellValue>, allowed: &[ValueTypeFilter]) -> bool {
3290    if value.is_none() {
3291        return allowed.contains(&ValueTypeFilter::Null);
3292    }
3293    match value.as_ref().unwrap() {
3294        CellValue::Text(_) => allowed.contains(&ValueTypeFilter::Text),
3295        CellValue::Number(_) => allowed.contains(&ValueTypeFilter::Number),
3296        CellValue::Bool(_) => allowed.contains(&ValueTypeFilter::Bool),
3297        CellValue::Date(_) => allowed.contains(&ValueTypeFilter::Date),
3298        CellValue::Error(_) => true,
3299    }
3300}
3301
3302fn collect_neighbors(
3303    sheet: &umya_spreadsheet::Worksheet,
3304    row: u32,
3305    col: u32,
3306) -> Option<NeighborValues> {
3307    Some(NeighborValues {
3308        left: if col > 1 {
3309            sheet.get_cell((col - 1, row)).and_then(cell_to_value)
3310        } else {
3311            None
3312        },
3313        right: sheet.get_cell((col + 1, row)).and_then(cell_to_value),
3314        up: if row > 1 {
3315            sheet.get_cell((col, row - 1)).and_then(cell_to_value)
3316        } else {
3317            None
3318        },
3319        down: sheet.get_cell((col, row + 1)).and_then(cell_to_value),
3320    })
3321}
3322
3323fn build_row_context(
3324    sheet: &umya_spreadsheet::Worksheet,
3325    row: u32,
3326    col: u32,
3327    width: u32,
3328) -> Option<RowContext> {
3329    let width = width.max(1);
3330    let half = width / 2;
3331    let max_col = sheet.get_highest_column().max(1);
3332    let start_col = col.saturating_sub(half).max(1);
3333    let end_col = (col + half).min(max_col);
3334
3335    let mut headers = Vec::new();
3336    let mut values = Vec::new();
3337
3338    for current_col in start_col..=end_col {
3339        let header_value = sheet
3340            .get_cell((current_col, 1u32))
3341            .and_then(cell_to_value)
3342            .map(|v| match v {
3343                CellValue::Text(s) => s,
3344                CellValue::Number(n) => n.to_string(),
3345                CellValue::Bool(b) => b.to_string(),
3346                CellValue::Date(d) => d,
3347                CellValue::Error(e) => e,
3348            })
3349            .unwrap_or_else(|| format!("Col{}", current_col));
3350        let value = sheet.get_cell((current_col, row)).and_then(cell_to_value);
3351        headers.push(header_value);
3352        values.push(value);
3353    }
3354
3355    Some(RowContext { headers, values })
3356}
3357
3358fn default_find_formula_limit() -> u32 {
3359    50
3360}
3361
3362#[derive(Debug, Deserialize, JsonSchema, Default)]
3363pub struct FindFormulaParams {
3364    /// Workbook ID or fork ID
3365    #[serde(alias = "workbook_id")]
3366    pub workbook_or_fork_id: WorkbookId,
3367    /// Text to search for in formulas (e.g., "SUM(", "VLOOKUP")
3368    pub query: String,
3369    /// Limit to specific sheet (searches all if omitted)
3370    pub sheet_name: Option<String>,
3371    /// Case-sensitive matching (default: false)
3372    #[serde(default)]
3373    pub case_sensitive: bool,
3374    /// Include header row and cell context (default: false)
3375    #[serde(default)]
3376    pub include_context: bool,
3377    /// Maximum matches to return (default: 50)
3378    #[serde(default = "default_find_formula_limit")]
3379    pub limit: u32,
3380    /// Offset for pagination; use next_offset from previous response
3381    #[serde(default)]
3382    pub offset: u32,
3383    /// Rows of context to include above/below (requires include_context=true)
3384    #[serde(default)]
3385    pub context_rows: Option<u32>,
3386    /// Columns of context to include left/right (requires include_context=true)
3387    #[serde(default)]
3388    pub context_cols: Option<u32>,
3389}
3390
3391pub async fn find_formula(
3392    state: Arc<AppState>,
3393    params: FindFormulaParams,
3394) -> Result<FindFormulaResponse> {
3395    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
3396    let query = if params.case_sensitive {
3397        params.query.clone()
3398    } else {
3399        params.query.to_ascii_lowercase()
3400    };
3401
3402    let sheet_names: Vec<String> = if let Some(sheet) = &params.sheet_name {
3403        vec![sheet.clone()]
3404    } else {
3405        workbook.sheet_names()
3406    };
3407
3408    let limit = params.limit.clamp(1, 500);
3409    let offset = params.offset;
3410    let context_rows = params.context_rows.unwrap_or(1);
3411    let context_cols = params.context_cols.unwrap_or(1);
3412
3413    let mut matches = Vec::new();
3414    let mut seen: u32 = 0;
3415    let mut truncated = false;
3416
3417    for sheet_name in sheet_names {
3418        let (sheet_matches, sheet_seen, sheet_truncated) =
3419            workbook.with_sheet(&sheet_name, |sheet| {
3420                collect_formula_matches(
3421                    sheet,
3422                    &sheet_name,
3423                    &query,
3424                    params.case_sensitive,
3425                    params.include_context,
3426                    context_rows,
3427                    context_cols,
3428                    offset,
3429                    limit,
3430                    seen,
3431                )
3432            })?;
3433
3434        seen = sheet_seen;
3435        truncated |= sheet_truncated;
3436        matches.extend(sheet_matches);
3437
3438        if truncated {
3439            break;
3440        }
3441    }
3442
3443    let next_offset = if truncated {
3444        Some(offset.saturating_add(matches.len() as u32))
3445    } else {
3446        None
3447    };
3448
3449    let response = FindFormulaResponse {
3450        workbook_id: workbook.id.clone(),
3451        matches,
3452        next_offset,
3453    };
3454    Ok(response)
3455}
3456
3457#[derive(Debug, Deserialize, JsonSchema)]
3458pub struct ScanVolatilesParams {
3459    /// Workbook ID or fork ID
3460    #[serde(alias = "workbook_id")]
3461    pub workbook_or_fork_id: WorkbookId,
3462    /// Limit to specific sheet (scans all if omitted)
3463    pub sheet_name: Option<String>,
3464    /// Return counts only without addresses (default: true in token_dense profile)
3465    #[serde(default)]
3466    pub summary_only: Option<bool>,
3467    /// Include cell addresses for each volatile (default: !summary_only)
3468    #[serde(default)]
3469    pub include_addresses: Option<bool>,
3470    /// Maximum addresses to include per volatile function (default: 15)
3471    #[serde(default)]
3472    pub addresses_limit: Option<u32>,
3473    /// Maximum entries to return for this page
3474    #[serde(default)]
3475    pub limit: Option<u32>,
3476    /// Entry offset for pagination; use next_offset from previous response
3477    #[serde(default)]
3478    pub offset: Option<u32>,
3479    /// Formula parse policy: fail, warn (default), or off
3480    #[serde(default)]
3481    pub formula_parse_policy: Option<FormulaParsePolicy>,
3482}
3483
3484pub async fn scan_volatiles(
3485    state: Arc<AppState>,
3486    params: ScanVolatilesParams,
3487) -> Result<VolatileScanResponse> {
3488    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
3489    let config = state.config();
3490    let output_profile = config.output_profile();
3491    let summary_only = params
3492        .summary_only
3493        .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
3494    let include_addresses = params.include_addresses.unwrap_or(!summary_only);
3495    let addresses_limit = params.addresses_limit.unwrap_or(15);
3496    let policy = params
3497        .formula_parse_policy
3498        .unwrap_or(FormulaParsePolicy::Warn);
3499    let mut combined_builder = FormulaParseDiagnosticsBuilder::new(policy);
3500    let max_items = config.max_items();
3501    let max_payload_bytes = config.max_payload_bytes();
3502
3503    let mut target_sheets: Vec<String> = if let Some(sheet) = &params.sheet_name {
3504        vec![sheet.clone()]
3505    } else {
3506        workbook.sheet_names()
3507    };
3508    target_sheets.sort_by(|left, right| {
3509        left.to_ascii_lowercase()
3510            .cmp(&right.to_ascii_lowercase())
3511            .then_with(|| left.cmp(right))
3512    });
3513
3514    let mut items = Vec::new();
3515    for sheet_name in target_sheets {
3516        let graph = workbook.formula_graph_with_diagnostics_builder(
3517            &sheet_name,
3518            policy,
3519            &mut combined_builder,
3520        )?;
3521        let mut groups = graph
3522            .groups()
3523            .into_iter()
3524            .filter(|group| group.is_volatile)
3525            .collect::<Vec<_>>();
3526        groups.sort_by(|left, right| {
3527            left.formula
3528                .cmp(&right.formula)
3529                .then_with(|| left.fingerprint.cmp(&right.fingerprint))
3530                .then_with(|| left.addresses.len().cmp(&right.addresses.len()))
3531        });
3532
3533        for group in groups {
3534            if summary_only {
3535                items.push(VolatileScanEntry {
3536                    address: String::new(),
3537                    sheet_name: sheet_name.clone(),
3538                    function: "volatile".to_string(),
3539                    note: Some(format!("Count: {}", group.addresses.len())),
3540                });
3541                continue;
3542            }
3543
3544            if include_addresses {
3545                let mut addresses = group.addresses;
3546                addresses.sort();
3547                for address in addresses.into_iter().take(addresses_limit as usize) {
3548                    items.push(VolatileScanEntry {
3549                        address,
3550                        sheet_name: sheet_name.clone(),
3551                        function: "volatile".to_string(),
3552                        note: Some(group.formula.clone()),
3553                    });
3554                }
3555            } else {
3556                items.push(VolatileScanEntry {
3557                    address: String::new(),
3558                    sheet_name: sheet_name.clone(),
3559                    function: "volatile".to_string(),
3560                    note: Some(group.formula.clone()),
3561                });
3562            }
3563        }
3564    }
3565
3566    let diagnostics = combined_builder.build();
3567    let formula_parse_diagnostics = if diagnostics.total_errors > 0 {
3568        Some(diagnostics)
3569    } else {
3570        None
3571    };
3572
3573    items.sort_by(|left, right| {
3574        left.sheet_name
3575            .cmp(&right.sheet_name)
3576            .then_with(|| left.function.cmp(&right.function))
3577            .then_with(|| left.note.cmp(&right.note))
3578            .then_with(|| left.address.cmp(&right.address))
3579    });
3580
3581    let total_items = items.len();
3582    let offset = params.offset.unwrap_or(0) as usize;
3583    let page_limit = params
3584        .limit
3585        .map(|limit| limit.max(1) as usize)
3586        .unwrap_or(usize::MAX);
3587    let start = offset.min(total_items);
3588    let end = start.saturating_add(page_limit).min(total_items);
3589    let mut page_items = items[start..end].to_vec();
3590
3591    if let Some(max_items) = max_items
3592        && page_items.len() > max_items
3593    {
3594        page_items.truncate(max_items);
3595    }
3596
3597    if let Some(max_bytes) = max_payload_bytes {
3598        let item_limit = cap_rows_by_payload_bytes(page_items.len(), Some(max_bytes), |count| {
3599            let response = VolatileScanResponse {
3600                workbook_id: workbook.id.clone(),
3601                items: page_items[..count].to_vec(),
3602                formula_parse_diagnostics: formula_parse_diagnostics.clone(),
3603                next_offset: None,
3604            };
3605            serde_json::to_vec(&response)
3606                .map(|payload| payload.len())
3607                .unwrap_or(usize::MAX)
3608        });
3609
3610        if item_limit < page_items.len() {
3611            page_items.truncate(item_limit);
3612        }
3613    }
3614
3615    let emitted = page_items.len();
3616    let absolute_next = offset.saturating_add(emitted);
3617    let next_offset = if emitted > 0 && absolute_next < total_items {
3618        Some(absolute_next as u32)
3619    } else {
3620        None
3621    };
3622
3623    let response = VolatileScanResponse {
3624        workbook_id: workbook.id.clone(),
3625        items: page_items,
3626        formula_parse_diagnostics,
3627        next_offset,
3628    };
3629    Ok(response)
3630}
3631
3632#[derive(Debug, Deserialize, JsonSchema)]
3633pub struct WorkbookStyleSummaryParams {
3634    /// Workbook ID or fork ID
3635    #[serde(alias = "workbook_id")]
3636    pub workbook_or_fork_id: WorkbookId,
3637    /// Maximum distinct styles to return (default: 50)
3638    pub max_styles: Option<u32>,
3639    /// Maximum conditional format rules to return (default: 20)
3640    pub max_conditional_formats: Option<u32>,
3641    /// Maximum cells to scan per sheet (default: 10000)
3642    pub max_cells_scan: Option<u32>,
3643    /// Return counts and tags only, no descriptors (default: true in token_dense profile)
3644    #[serde(default)]
3645    pub summary_only: Option<bool>,
3646    /// Include full style descriptors (fonts, fills, borders)
3647    #[serde(default)]
3648    pub include_descriptor: Option<bool>,
3649    /// Include example cell addresses for each style
3650    #[serde(default)]
3651    pub include_example_cells: Option<bool>,
3652    /// Include workbook theme colors
3653    #[serde(default)]
3654    pub include_theme: Option<bool>,
3655    /// Include conditional formatting rules
3656    #[serde(default)]
3657    pub include_conditional_formats: Option<bool>,
3658}
3659
3660#[derive(Debug)]
3661struct WorkbookStyleAccum {
3662    descriptor: StyleDescriptor,
3663    occurrences: u32,
3664    tags: HashSet<String>,
3665    example_cells: Vec<String>,
3666}
3667
3668impl WorkbookStyleAccum {
3669    fn new(descriptor: StyleDescriptor) -> Self {
3670        Self {
3671            descriptor,
3672            occurrences: 0,
3673            tags: HashSet::new(),
3674            example_cells: Vec::new(),
3675        }
3676    }
3677}
3678
3679pub async fn workbook_style_summary(
3680    state: Arc<AppState>,
3681    params: WorkbookStyleSummaryParams,
3682) -> Result<WorkbookStyleSummaryResponse> {
3683    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
3684    let config = state.config();
3685    let output_profile = config.output_profile();
3686    let summary_only = params
3687        .summary_only
3688        .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
3689    let include_descriptor = params.include_descriptor.unwrap_or(!summary_only);
3690    let include_example_cells = params.include_example_cells.unwrap_or(!summary_only);
3691    let include_theme = params.include_theme.unwrap_or(!summary_only);
3692    let include_conditional_formats = params.include_conditional_formats.unwrap_or(!summary_only);
3693    let max_payload_bytes = config.max_payload_bytes();
3694    let sheet_names = workbook.sheet_names();
3695
3696    const STYLE_EXAMPLE_LIMIT: usize = 5;
3697    const STYLE_LIMIT_DEFAULT: usize = 200;
3698    const CF_LIMIT_DEFAULT: usize = 200;
3699    const CELL_SCAN_LIMIT_DEFAULT: usize = 500_000;
3700
3701    let style_limit = params
3702        .max_styles
3703        .map(|v| v as usize)
3704        .unwrap_or(STYLE_LIMIT_DEFAULT);
3705    let style_limit = config
3706        .max_items()
3707        .map(|limit| style_limit.min(limit))
3708        .unwrap_or(style_limit);
3709    let cf_limit = params
3710        .max_conditional_formats
3711        .map(|v| v as usize)
3712        .unwrap_or(CF_LIMIT_DEFAULT);
3713    let cf_limit = config
3714        .max_items()
3715        .map(|limit| cf_limit.min(limit))
3716        .unwrap_or(cf_limit);
3717    let cell_scan_limit = params
3718        .max_cells_scan
3719        .map(|v| v as usize)
3720        .unwrap_or(CELL_SCAN_LIMIT_DEFAULT);
3721
3722    let mut acc: HashMap<String, WorkbookStyleAccum> = HashMap::new();
3723    let mut scanned_cells: usize = 0;
3724    let mut scan_truncated = false;
3725
3726    for sheet_name in &sheet_names {
3727        if scan_truncated {
3728            break;
3729        }
3730        workbook.with_sheet(sheet_name, |sheet| {
3731            for cell in sheet.get_cell_collection() {
3732                if scanned_cells >= cell_scan_limit {
3733                    scan_truncated = true;
3734                    break;
3735                }
3736                scanned_cells += 1;
3737
3738                let address = cell.get_coordinate().get_coordinate().to_string();
3739                let descriptor = crate::styles::descriptor_from_style(cell.get_style());
3740                let style_id = crate::styles::stable_style_id(&descriptor);
3741
3742                let entry = acc
3743                    .entry(style_id.clone())
3744                    .or_insert_with(|| WorkbookStyleAccum::new(descriptor.clone()));
3745                entry.occurrences += 1;
3746                if entry.example_cells.len() < STYLE_EXAMPLE_LIMIT {
3747                    entry.example_cells.push(format!("{sheet_name}!{address}"));
3748                }
3749
3750                if let Some((_, tagging)) = crate::analysis::style::tag_cell(cell) {
3751                    for tag in tagging.tags {
3752                        entry.tags.insert(tag);
3753                    }
3754                }
3755            }
3756        })?;
3757    }
3758
3759    let total_styles = acc.len() as u32;
3760    let mut styles: Vec<WorkbookStyleUsage> = acc
3761        .into_iter()
3762        .map(|(style_id, entry)| {
3763            let mut tags: Vec<String> = entry.tags.into_iter().collect();
3764            tags.sort();
3765            WorkbookStyleUsage {
3766                style_id,
3767                occurrences: entry.occurrences,
3768                tags,
3769                example_cells: if include_example_cells {
3770                    entry.example_cells
3771                } else {
3772                    Vec::new()
3773                },
3774                descriptor: if include_descriptor {
3775                    Some(entry.descriptor)
3776                } else {
3777                    None
3778                },
3779            }
3780        })
3781        .collect();
3782
3783    styles.sort_by(|a, b| {
3784        b.occurrences
3785            .cmp(&a.occurrences)
3786            .then_with(|| a.style_id.cmp(&b.style_id))
3787    });
3788
3789    let inferred_default_style_id = styles.first().map(|s| s.style_id.clone());
3790    let mut inferred_default_font = styles
3791        .first()
3792        .and_then(|s| s.descriptor.as_ref().and_then(|d| d.font.clone()));
3793
3794    let mut styles_truncated = if styles.len() > style_limit {
3795        styles.truncate(style_limit);
3796        true
3797    } else {
3798        false
3799    };
3800
3801    let theme = workbook.with_spreadsheet(|book| {
3802        let theme = book.get_theme();
3803        let elements = theme.get_theme_elements();
3804        let scheme = elements.get_color_scheme();
3805        let mut colors = BTreeMap::new();
3806
3807        let mut insert_color = |name: &str, value: String| {
3808            if !value.trim().is_empty() {
3809                colors.insert(name.to_string(), value);
3810            }
3811        };
3812
3813        insert_color("dk1", scheme.get_dk1().get_val());
3814        insert_color("lt1", scheme.get_lt1().get_val());
3815        insert_color("dk2", scheme.get_dk2().get_val());
3816        insert_color("lt2", scheme.get_lt2().get_val());
3817        insert_color("accent1", scheme.get_accent1().get_val());
3818        insert_color("accent2", scheme.get_accent2().get_val());
3819        insert_color("accent3", scheme.get_accent3().get_val());
3820        insert_color("accent4", scheme.get_accent4().get_val());
3821        insert_color("accent5", scheme.get_accent5().get_val());
3822        insert_color("accent6", scheme.get_accent6().get_val());
3823        insert_color("hlink", scheme.get_hlink().get_val());
3824        insert_color("fol_hlink", scheme.get_fol_hlink().get_val());
3825
3826        let font_scheme = elements.get_font_scheme();
3827        let major = font_scheme.get_major_font();
3828        let minor = font_scheme.get_minor_font();
3829        let font_scheme_summary = ThemeFontSchemeSummary {
3830            major_latin: Some(major.get_latin_font().get_typeface().to_string())
3831                .filter(|s| !s.trim().is_empty()),
3832            major_east_asian: Some(major.get_east_asian_font().get_typeface().to_string())
3833                .filter(|s| !s.trim().is_empty()),
3834            major_complex_script: Some(major.get_complex_script_font().get_typeface().to_string())
3835                .filter(|s| !s.trim().is_empty()),
3836            minor_latin: Some(minor.get_latin_font().get_typeface().to_string())
3837                .filter(|s| !s.trim().is_empty()),
3838            minor_east_asian: Some(minor.get_east_asian_font().get_typeface().to_string())
3839                .filter(|s| !s.trim().is_empty()),
3840            minor_complex_script: Some(minor.get_complex_script_font().get_typeface().to_string())
3841                .filter(|s| !s.trim().is_empty()),
3842        };
3843
3844        ThemeSummary {
3845            name: Some(theme.get_name().to_string()).filter(|s| !s.trim().is_empty()),
3846            colors,
3847            font_scheme: font_scheme_summary,
3848        }
3849    })?;
3850
3851    if inferred_default_font.is_none()
3852        && let Some(name) = theme
3853            .font_scheme
3854            .minor_latin
3855            .clone()
3856            .or_else(|| theme.font_scheme.major_latin.clone())
3857    {
3858        inferred_default_font = Some(FontDescriptor {
3859            name: Some(name),
3860            size: None,
3861            bold: None,
3862            italic: None,
3863            underline: None,
3864            strikethrough: None,
3865            color: None,
3866        });
3867    }
3868
3869    let theme = if include_theme { Some(theme) } else { None };
3870
3871    let mut conditional_formats: Vec<ConditionalFormatSummary> = Vec::new();
3872    let mut conditional_formats_truncated = false;
3873    if include_conditional_formats {
3874        use umya_spreadsheet::structs::EnumTrait;
3875        for sheet_name in &sheet_names {
3876            if conditional_formats_truncated {
3877                break;
3878            }
3879            workbook.with_sheet(sheet_name, |sheet| {
3880                for cf in sheet.get_conditional_formatting_collection() {
3881                    if conditional_formats.len() >= cf_limit {
3882                        conditional_formats_truncated = true;
3883                        break;
3884                    }
3885                    let range = cf.get_sequence_of_references().get_sqref().to_string();
3886                    let mut types: HashSet<String> = HashSet::new();
3887                    for rule in cf.get_conditional_collection() {
3888                        types.insert(rule.get_type().get_value_string().to_string());
3889                    }
3890                    let mut rule_types: Vec<String> = types.into_iter().collect();
3891                    rule_types.sort();
3892                    conditional_formats.push(ConditionalFormatSummary {
3893                        sheet_name: sheet_name.clone(),
3894                        range,
3895                        rule_types,
3896                        rule_count: cf.get_conditional_collection().len() as u32,
3897                    });
3898                }
3899            })?;
3900        }
3901    }
3902
3903    let mut notes: Vec<String> = Vec::new();
3904    if scan_truncated {
3905        notes.push(format!(
3906            "Stopped scanning after {cell_scan_limit} cells; style counts may be incomplete."
3907        ));
3908    }
3909    notes.push(
3910        "Named styles are not directly exposed by umya-spreadsheet; styles here are inferred from cell formatting."
3911            .to_string(),
3912    );
3913
3914    if let Some(max_bytes) = max_payload_bytes {
3915        let style_limit = cap_rows_by_payload_bytes(styles.len(), Some(max_bytes), |count| {
3916            let response = WorkbookStyleSummaryResponse {
3917                workbook_id: workbook.id.clone(),
3918                theme: theme.clone(),
3919                inferred_default_style_id: inferred_default_style_id.clone(),
3920                inferred_default_font: inferred_default_font.clone(),
3921                styles: styles[..count].to_vec(),
3922                total_styles,
3923                styles_truncated: false,
3924                conditional_formats: conditional_formats.clone(),
3925                conditional_formats_truncated,
3926                scan_truncated,
3927                notes: notes.clone(),
3928            };
3929            serde_json::to_vec(&response)
3930                .map(|payload| payload.len())
3931                .unwrap_or(usize::MAX)
3932        });
3933
3934        if style_limit < styles.len() {
3935            styles.truncate(style_limit);
3936            styles_truncated = true;
3937        }
3938
3939        if !conditional_formats.is_empty() {
3940            let cf_limit =
3941                cap_rows_by_payload_bytes(conditional_formats.len(), Some(max_bytes), |count| {
3942                    let response = WorkbookStyleSummaryResponse {
3943                        workbook_id: workbook.id.clone(),
3944                        theme: theme.clone(),
3945                        inferred_default_style_id: inferred_default_style_id.clone(),
3946                        inferred_default_font: inferred_default_font.clone(),
3947                        styles: styles.clone(),
3948                        total_styles,
3949                        styles_truncated,
3950                        conditional_formats: conditional_formats[..count].to_vec(),
3951                        conditional_formats_truncated: false,
3952                        scan_truncated,
3953                        notes: notes.clone(),
3954                    };
3955                    serde_json::to_vec(&response)
3956                        .map(|payload| payload.len())
3957                        .unwrap_or(usize::MAX)
3958                });
3959
3960            if cf_limit < conditional_formats.len() {
3961                conditional_formats.truncate(cf_limit);
3962                conditional_formats_truncated = true;
3963            }
3964        }
3965    }
3966
3967    Ok(WorkbookStyleSummaryResponse {
3968        workbook_id: workbook.id.clone(),
3969        theme,
3970        inferred_default_style_id,
3971        inferred_default_font,
3972        styles,
3973        total_styles,
3974        styles_truncated,
3975        conditional_formats,
3976        conditional_formats_truncated,
3977        scan_truncated,
3978        notes,
3979    })
3980}
3981
3982#[derive(Debug, Deserialize, JsonSchema)]
3983pub struct SheetStylesParams {
3984    /// Workbook ID or fork ID
3985    #[serde(alias = "workbook_id")]
3986    pub workbook_or_fork_id: WorkbookId,
3987    /// Sheet name
3988    pub sheet_name: String,
3989    /// Limit scope: use range (e.g., "A1:D100") or region_id
3990    #[serde(default)]
3991    pub scope: Option<SheetStylesScope>,
3992    /// Granularity for style grouping
3993    #[serde(default)]
3994    pub granularity: Option<StyleGranularity>,
3995    /// Maximum style entries to return (default: 100)
3996    #[serde(default)]
3997    pub max_items: Option<usize>,
3998    /// Return counts and tags only (default: true in token_dense profile)
3999    #[serde(default)]
4000    pub summary_only: Option<bool>,
4001    /// Include full style descriptors (fonts, fills, borders)
4002    #[serde(default)]
4003    pub include_descriptor: Option<bool>,
4004    /// Include cell ranges for each style
4005    #[serde(default)]
4006    pub include_ranges: Option<bool>,
4007    /// Include example cell addresses
4008    #[serde(default)]
4009    pub include_example_cells: Option<bool>,
4010}
4011
4012#[derive(Debug, Deserialize, JsonSchema)]
4013#[serde(tag = "kind", rename_all = "snake_case")]
4014pub enum SheetStylesScope {
4015    Range { range: String },
4016    Region { region_id: u32 },
4017}
4018
4019#[derive(Debug)]
4020struct StyleAccum {
4021    descriptor: StyleDescriptor,
4022    occurrences: u32,
4023    tags: HashSet<String>,
4024    example_cells: Vec<String>,
4025    positions: Vec<(u32, u32)>,
4026}
4027
4028impl StyleAccum {
4029    fn new(descriptor: StyleDescriptor) -> Self {
4030        Self {
4031            descriptor,
4032            occurrences: 0,
4033            tags: HashSet::new(),
4034            example_cells: Vec::new(),
4035            positions: Vec::new(),
4036        }
4037    }
4038}
4039
4040pub async fn sheet_styles(
4041    state: Arc<AppState>,
4042    params: SheetStylesParams,
4043) -> Result<SheetStylesResponse> {
4044    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
4045    let config = state.config();
4046    let output_profile = config.output_profile();
4047    let summary_only = params
4048        .summary_only
4049        .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
4050    let include_descriptor = params.include_descriptor.unwrap_or(!summary_only);
4051    let include_ranges = params.include_ranges.unwrap_or(!summary_only);
4052    let include_example_cells = params.include_example_cells.unwrap_or(!summary_only);
4053    const STYLE_EXAMPLE_LIMIT: usize = 5;
4054    const STYLE_RANGE_LIMIT: usize = 50;
4055    const STYLE_LIMIT: usize = 200;
4056    const MAX_MAX_ITEMS: usize = 5000;
4057
4058    let max_payload_bytes = config.max_payload_bytes();
4059    let style_limit = config
4060        .max_items()
4061        .map(|limit| STYLE_LIMIT.min(limit))
4062        .unwrap_or(STYLE_LIMIT);
4063
4064    let metrics = workbook.get_sheet_metrics_fast(&params.sheet_name)?;
4065    let full_bounds = (
4066        (1, 1),
4067        (
4068            metrics.metrics.column_count.max(1),
4069            metrics.metrics.row_count.max(1),
4070        ),
4071    );
4072
4073    let bounds = match &params.scope {
4074        Some(SheetStylesScope::Range { range }) => {
4075            parse_range(range).ok_or_else(|| anyhow!("invalid range: {}", range))?
4076        }
4077        Some(SheetStylesScope::Region { region_id }) => {
4078            let region = workbook.detected_region(&params.sheet_name, *region_id)?;
4079            parse_range(&region.bounds)
4080                .ok_or_else(|| anyhow!("invalid region bounds: {}", region.bounds))?
4081        }
4082        None => full_bounds,
4083    };
4084
4085    let granularity = params.granularity.unwrap_or_default();
4086
4087    let max_items = params
4088        .max_items
4089        .unwrap_or(STYLE_RANGE_LIMIT)
4090        .clamp(1, MAX_MAX_ITEMS);
4091
4092    let (mut styles, total_styles, mut styles_truncated) =
4093        workbook.with_sheet(&params.sheet_name, |sheet| {
4094            let mut acc: HashMap<String, StyleAccum> = HashMap::new();
4095
4096            for cell in sheet.get_cell_collection() {
4097                let address = cell.get_coordinate().get_coordinate().to_string();
4098                let Some((col, row)) = parse_address(&address) else {
4099                    continue;
4100                };
4101                if col < bounds.0.0 || col > bounds.1.0 || row < bounds.0.1 || row > bounds.1.1 {
4102                    continue;
4103                }
4104
4105                let descriptor = crate::styles::descriptor_from_style(cell.get_style());
4106                let style_id = crate::styles::stable_style_id(&descriptor);
4107
4108                let entry = acc
4109                    .entry(style_id.clone())
4110                    .or_insert_with(|| StyleAccum::new(descriptor.clone()));
4111                entry.occurrences += 1;
4112                if entry.example_cells.len() < STYLE_EXAMPLE_LIMIT {
4113                    entry.example_cells.push(address.clone());
4114                }
4115
4116                if let Some((_, tagging)) = crate::analysis::style::tag_cell(cell) {
4117                    for tag in tagging.tags {
4118                        entry.tags.insert(tag);
4119                    }
4120                }
4121
4122                entry.positions.push((row, col));
4123            }
4124
4125            let mut summaries: Vec<StyleSummary> = acc
4126                .into_iter()
4127                .map(|(style_id, mut entry)| {
4128                    entry.positions.sort_unstable();
4129                    entry.positions.dedup();
4130
4131                    let (cell_ranges, ranges_truncated) = if include_ranges {
4132                        if granularity == StyleGranularity::Cells {
4133                            let mut out = Vec::new();
4134                            for (row, col) in entry.positions.iter().take(max_items) {
4135                                out.push(crate::utils::cell_address(*col, *row));
4136                            }
4137                            (out, entry.positions.len() > max_items)
4138                        } else {
4139                            crate::styles::compress_positions_to_ranges(&entry.positions, max_items)
4140                        }
4141                    } else {
4142                        (Vec::new(), false)
4143                    };
4144
4145                    StyleSummary {
4146                        style_id,
4147                        occurrences: entry.occurrences,
4148                        tags: entry.tags.into_iter().collect(),
4149                        example_cells: if include_example_cells {
4150                            entry.example_cells
4151                        } else {
4152                            Vec::new()
4153                        },
4154                        descriptor: if include_descriptor {
4155                            Some(entry.descriptor)
4156                        } else {
4157                            None
4158                        },
4159                        cell_ranges,
4160                        ranges_truncated,
4161                    }
4162                })
4163                .collect();
4164
4165            summaries.sort_by(|a, b| {
4166                b.occurrences
4167                    .cmp(&a.occurrences)
4168                    .then_with(|| a.style_id.cmp(&b.style_id))
4169            });
4170
4171            let total = summaries.len() as u32;
4172            let truncated = if summaries.len() > style_limit {
4173                summaries.truncate(style_limit);
4174                true
4175            } else {
4176                false
4177            };
4178
4179            Ok::<_, anyhow::Error>((summaries, total, truncated))
4180        })??;
4181
4182    if let Some(max_bytes) = max_payload_bytes {
4183        let row_limit = cap_rows_by_payload_bytes(styles.len(), Some(max_bytes), |count| {
4184            let response = SheetStylesResponse {
4185                workbook_id: workbook.id.clone(),
4186                sheet_name: params.sheet_name.clone(),
4187                styles: styles[..count].to_vec(),
4188                conditional_rules: Vec::new(),
4189                total_styles,
4190                styles_truncated: false,
4191            };
4192            serde_json::to_vec(&response)
4193                .map(|payload| payload.len())
4194                .unwrap_or(usize::MAX)
4195        });
4196        if row_limit < styles.len() {
4197            styles.truncate(row_limit);
4198            styles_truncated = true;
4199        }
4200    }
4201
4202    Ok(SheetStylesResponse {
4203        workbook_id: workbook.id.clone(),
4204        sheet_name: params.sheet_name.clone(),
4205        styles,
4206        conditional_rules: Vec::new(),
4207        total_styles,
4208        styles_truncated,
4209    })
4210}
4211
4212pub async fn range_values(
4213    state: Arc<AppState>,
4214    params: RangeValuesParams,
4215) -> Result<RangeValuesResponse> {
4216    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
4217    let config = state.config();
4218    let output_profile = config.output_profile();
4219    let format = params.format.unwrap_or(match output_profile {
4220        OutputProfile::TokenDense => TableOutputFormat::Dense,
4221        OutputProfile::Verbose => TableOutputFormat::Json,
4222    });
4223    let include_headers = params.include_headers.unwrap_or(false);
4224    let include_formulas = params.include_formulas.unwrap_or(false);
4225    if let Some(page_size) = params.page_size
4226        && page_size == 0
4227    {
4228        return Err(anyhow!("page_size must be greater than zero"));
4229    }
4230    let max_cells = config.max_cells();
4231    let max_payload_bytes = config.max_payload_bytes();
4232    #[cfg(feature = "recalc")]
4233    let requested_bounds: Vec<((u32, u32), (u32, u32))> = params
4234        .ranges
4235        .iter()
4236        .filter_map(|r| parse_range(r))
4237        .collect();
4238
4239    #[cfg(feature = "recalc")]
4240    let (values, has_formula_in_target) = workbook.with_sheet(&params.sheet_name, |sheet| {
4241        let has_formula_in_target = sheet_has_formula_in_bounds(sheet, &requested_bounds);
4242        let values = params
4243            .ranges
4244            .iter()
4245            .filter_map(|range| {
4246                parse_range(range).map(|((start_col, start_row), (end_col, end_row))| {
4247                    let total_rows = (end_row - start_row + 1) as usize;
4248                    let total_cols = (end_col - start_col + 1) as usize;
4249                    let mut row_limit = total_rows;
4250                    if let Some(page_size) = params.page_size {
4251                        row_limit = row_limit.min(page_size as usize);
4252                    }
4253
4254                    let include_formula_matrix = include_formulas
4255                        && matches!(format, TableOutputFormat::Json | TableOutputFormat::Dense);
4256                    let mut rows = Vec::new();
4257                    let mut formula_rows = include_formula_matrix.then(Vec::new);
4258                    for r in start_row..=end_row {
4259                        if rows.len() >= row_limit {
4260                            break;
4261                        }
4262                        let mut row_vals = Vec::new();
4263                        let mut row_formulas = include_formula_matrix.then(Vec::new);
4264                        for c in start_col..=end_col {
4265                            let row_index = if include_headers && r == start_row && start_row == 1 {
4266                                1u32
4267                            } else {
4268                                r
4269                            };
4270                            let cell = sheet.get_cell((c, row_index));
4271                            row_vals.push(cell.and_then(cell_to_value));
4272                            if let Some(formulas) = row_formulas.as_mut() {
4273                                formulas.push(cell.and_then(|entry| {
4274                                    entry.is_formula().then(|| entry.get_formula().to_string())
4275                                }));
4276                            }
4277                        }
4278                        rows.push(row_vals);
4279                        if let Some(formulas) = formula_rows.as_mut()
4280                            && let Some(row) = row_formulas
4281                        {
4282                            formulas.push(row);
4283                        }
4284                    }
4285
4286                    let mut row_limit = cap_rows_by_cells(rows.len(), total_cols, max_cells);
4287                    if row_limit > 0 {
4288                        row_limit =
4289                            cap_rows_by_payload_bytes(row_limit, max_payload_bytes, |count| {
4290                                let entry = build_range_values_entry(
4291                                    format,
4292                                    range,
4293                                    &rows[..count],
4294                                    formula_rows.as_ref().map(|matrix| &matrix[..count]),
4295                                    None,
4296                                );
4297                                serde_json::to_vec(&entry)
4298                                    .map(|payload| payload.len())
4299                                    .unwrap_or(usize::MAX)
4300                            });
4301                    }
4302
4303                    if row_limit < rows.len() {
4304                        rows.truncate(row_limit);
4305                        if let Some(formulas) = formula_rows.as_mut() {
4306                            formulas.truncate(row_limit);
4307                        }
4308                    }
4309
4310                    let next_start_row = if rows.len() < total_rows {
4311                        Some(start_row + rows.len() as u32)
4312                    } else {
4313                        None
4314                    };
4315
4316                    build_range_values_entry(
4317                        format,
4318                        range,
4319                        &rows,
4320                        formula_rows.as_deref(),
4321                        next_start_row,
4322                    )
4323                })
4324            })
4325            .collect();
4326
4327        Ok::<_, anyhow::Error>((values, has_formula_in_target))
4328    })??;
4329
4330    #[cfg(not(feature = "recalc"))]
4331    let values = workbook.with_sheet(&params.sheet_name, |sheet| {
4332        let values = params
4333            .ranges
4334            .iter()
4335            .filter_map(|range| {
4336                parse_range(range).map(|((start_col, start_row), (end_col, end_row))| {
4337                    let total_rows = (end_row - start_row + 1) as usize;
4338                    let total_cols = (end_col - start_col + 1) as usize;
4339                    let mut row_limit = total_rows;
4340                    if let Some(page_size) = params.page_size {
4341                        row_limit = row_limit.min(page_size as usize);
4342                    }
4343
4344                    let include_formula_matrix = include_formulas
4345                        && matches!(format, TableOutputFormat::Json | TableOutputFormat::Dense);
4346                    let mut rows = Vec::new();
4347                    let mut formula_rows = include_formula_matrix.then(Vec::new);
4348                    for r in start_row..=end_row {
4349                        if rows.len() >= row_limit {
4350                            break;
4351                        }
4352                        let mut row_vals = Vec::new();
4353                        let mut row_formulas = include_formula_matrix.then(Vec::new);
4354                        for c in start_col..=end_col {
4355                            let row_index = if include_headers && r == start_row && start_row == 1 {
4356                                1u32
4357                            } else {
4358                                r
4359                            };
4360                            let cell = sheet.get_cell((c, row_index));
4361                            row_vals.push(cell.and_then(cell_to_value));
4362                            if let Some(formulas) = row_formulas.as_mut() {
4363                                formulas.push(cell.and_then(|entry| {
4364                                    entry.is_formula().then(|| entry.get_formula().to_string())
4365                                }));
4366                            }
4367                        }
4368                        rows.push(row_vals);
4369                        if let Some(formulas) = formula_rows.as_mut()
4370                            && let Some(row) = row_formulas
4371                        {
4372                            formulas.push(row);
4373                        }
4374                    }
4375
4376                    let mut row_limit = cap_rows_by_cells(rows.len(), total_cols, max_cells);
4377                    if row_limit > 0 {
4378                        row_limit =
4379                            cap_rows_by_payload_bytes(row_limit, max_payload_bytes, |count| {
4380                                let entry = build_range_values_entry(
4381                                    format,
4382                                    range,
4383                                    &rows[..count],
4384                                    formula_rows.as_ref().map(|matrix| &matrix[..count]),
4385                                    None,
4386                                );
4387                                serde_json::to_vec(&entry)
4388                                    .map(|payload| payload.len())
4389                                    .unwrap_or(usize::MAX)
4390                            });
4391                    }
4392
4393                    if row_limit < rows.len() {
4394                        rows.truncate(row_limit);
4395                        if let Some(formulas) = formula_rows.as_mut() {
4396                            formulas.truncate(row_limit);
4397                        }
4398                    }
4399
4400                    let next_start_row = if rows.len() < total_rows {
4401                        Some(start_row + rows.len() as u32)
4402                    } else {
4403                        None
4404                    };
4405
4406                    build_range_values_entry(
4407                        format,
4408                        range,
4409                        &rows,
4410                        formula_rows.as_deref(),
4411                        next_start_row,
4412                    )
4413                })
4414            })
4415            .collect();
4416
4417        Ok::<_, anyhow::Error>(values)
4418    })??;
4419
4420    #[cfg(feature = "recalc")]
4421    let warnings: Vec<Warning> = {
4422        if fork_recalc_needed(&state, &params.workbook_or_fork_id) && has_formula_in_target {
4423            vec![Warning {
4424                code: "WARN_STALE_FORMULAS".to_string(),
4425                message: "Fork has pending edits and may contain stale formula results; call recalculate on the fork for fresh values.".to_string(),
4426            }]
4427        } else {
4428            Vec::new()
4429        }
4430    };
4431
4432    #[cfg(not(feature = "recalc"))]
4433    let warnings: Vec<Warning> = Vec::new();
4434
4435    Ok(RangeValuesResponse {
4436        workbook_id: workbook.id.clone(),
4437        sheet_name: params.sheet_name,
4438        warnings,
4439        values,
4440    })
4441}
4442
4443pub async fn inspect_cells(
4444    state: Arc<AppState>,
4445    params: InspectCellsParams,
4446) -> Result<InspectCellsResponse> {
4447    const DETAIL_LIMIT: usize = 25;
4448    const DETAIL_LIMIT_MAX: usize = 200;
4449
4450    if params.targets.is_empty() {
4451        return Err(anyhow!(
4452            "inspect-cells requires at least one A1 target (cell or range)"
4453        ));
4454    }
4455
4456    if let Some(b) = params.budget
4457        && (b < 1 || b as usize > DETAIL_LIMIT_MAX)
4458    {
4459        return Err(anyhow!(
4460            "budget must be between 1 and {} (got {})",
4461            DETAIL_LIMIT_MAX,
4462            b
4463        ));
4464    }
4465
4466    let effective_cap = params
4467        .budget
4468        .map(|b| (b as usize).min(DETAIL_LIMIT_MAX))
4469        .unwrap_or(DETAIL_LIMIT);
4470
4471    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
4472    let config = state.config();
4473    let detail_limit = config
4474        .max_cells()
4475        .map(|limit| limit.min(effective_cap))
4476        .unwrap_or(effective_cap)
4477        .max(1);
4478    let max_payload_bytes = config.max_payload_bytes();
4479    let include_empty = params.include_empty.unwrap_or(false);
4480
4481    let mut coords = Vec::new();
4482    let mut seen = HashSet::new();
4483    for target in &params.targets {
4484        let ((start_col, start_row), (end_col, end_row)) =
4485            parse_range(target).ok_or_else(|| {
4486                anyhow!(
4487                    "invalid target '{}'; expected A1 cell or range like A1:C10",
4488                    target
4489                )
4490            })?;
4491
4492        for row in start_row..=end_row {
4493            for col in start_col..=end_col {
4494                if seen.insert((col, row)) {
4495                    coords.push((col, row));
4496                }
4497            }
4498        }
4499    }
4500
4501    if coords.len() > detail_limit {
4502        return Err(anyhow!(
4503            "inspect-cells is a detail view and accepts up to {} cells per request (got {}). Narrow your selection or use sheet-page, range-values, or layout-page for broader discovery.",
4504            detail_limit,
4505            coords.len()
4506        ));
4507    }
4508
4509    let mut cells = workbook.with_sheet(&params.sheet_name, |sheet| {
4510        let mut out = Vec::new();
4511        for (col, row) in &coords {
4512            if let Some(cell) = sheet.get_cell((*col, *row)) {
4513                out.push(build_cell_snapshot(cell, true, true));
4514            } else if include_empty {
4515                out.push(CellSnapshot {
4516                    address: format!("{}{}", column_number_to_name(*col), row),
4517                    value: None,
4518                    formula: None,
4519                    cached_value: None,
4520                    number_format: None,
4521                    style_tags: Vec::new(),
4522                    notes: Vec::new(),
4523                });
4524            }
4525        }
4526        Ok::<_, anyhow::Error>(out)
4527    })??;
4528
4529    let total_requested_rows = coords
4530        .iter()
4531        .map(|(_, row)| *row)
4532        .collect::<HashSet<u32>>()
4533        .len();
4534    let mut cell_rows: Vec<u32> = cells
4535        .iter()
4536        .filter_map(|cell| parse_address(&cell.address).map(|(_, row)| row))
4537        .collect();
4538    let mut truncated = false;
4539    let cell_limit = cap_rows_by_payload_bytes(cells.len(), max_payload_bytes, |count| {
4540        let response = InspectCellsResponse {
4541            workbook_id: workbook.id.clone(),
4542            sheet_name: params.sheet_name.clone(),
4543            range: params.targets.join(","),
4544            targets: if params.targets.len() > 1 {
4545                params.targets.clone()
4546            } else {
4547                Vec::new()
4548            },
4549            cells: cells[..count].to_vec(),
4550            truncated: false,
4551            budget: None,
4552        };
4553        serde_json::to_vec(&response)
4554            .map(|payload| payload.len())
4555            .unwrap_or(usize::MAX)
4556    });
4557    if cell_limit < cells.len() {
4558        cells.truncate(cell_limit);
4559        cell_rows.truncate(cell_limit);
4560        truncated = true;
4561    }
4562
4563    let cells_returned = cells.len();
4564    let rows_returned = cell_rows.into_iter().collect::<HashSet<u32>>().len();
4565    let budget = Some(ReadBudget {
4566        max_cells: Some(detail_limit),
4567        max_payload_bytes,
4568        rows_returned,
4569        cells_returned,
4570        total_rows_available: Some(total_requested_rows as u32),
4571        continuation: if truncated {
4572            Some(
4573                "inspect-cells is a strict detail-view tool; narrow your targets or use sheet-page / range-values for bulk reads"
4574                    .to_string(),
4575            )
4576        } else {
4577            None
4578        },
4579    });
4580
4581    Ok(InspectCellsResponse {
4582        workbook_id: workbook.id.clone(),
4583        sheet_name: params.sheet_name,
4584        range: params.targets.join(","),
4585        targets: if params.targets.len() > 1 {
4586            params.targets
4587        } else {
4588            Vec::new()
4589        },
4590        cells,
4591        truncated,
4592        budget,
4593    })
4594}
4595
4596pub async fn find_value(
4597    state: Arc<AppState>,
4598    params: FindValueParams,
4599) -> Result<FindValueResponse> {
4600    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
4601    let mut matches = Vec::new();
4602    let mut truncated = false;
4603    let mut seen: u32 = 0;
4604    let offset = params.offset.unwrap_or(0);
4605    let limit = params.limit;
4606    let mode = params.mode.clone().unwrap_or_else(|| {
4607        if params.label.is_some() {
4608            FindMode::Label
4609        } else {
4610            FindMode::Value
4611        }
4612    });
4613    let match_mode = params.match_mode.unwrap_or_default();
4614    let direction = params.direction.clone().unwrap_or(LabelDirection::Any);
4615
4616    let target_sheets: Vec<String> = if let Some(sheet) = &params.sheet_name {
4617        vec![sheet.clone()]
4618    } else {
4619        workbook.sheet_names()
4620    };
4621
4622    for sheet_name in target_sheets {
4623        let metrics_entry = workbook.get_sheet_metrics_fast(&sheet_name)?;
4624        let default_bounds = (
4625            (1, 1),
4626            (
4627                metrics_entry.metrics.column_count.max(1),
4628                metrics_entry.metrics.row_count.max(1),
4629            ),
4630        );
4631        let region_bounds = params
4632            .region_id
4633            .and_then(|id| workbook.detected_region(&sheet_name, id).ok());
4634        let (sheet_matches, sheet_seen, sheet_truncated) =
4635            workbook.with_sheet(&sheet_name, |sheet| {
4636                collect_value_matches(
4637                    sheet,
4638                    &sheet_name,
4639                    &mode,
4640                    match_mode,
4641                    &direction,
4642                    &params,
4643                    region_bounds.as_ref(),
4644                    default_bounds,
4645                    offset,
4646                    limit,
4647                    seen,
4648                )
4649            })??;
4650        seen = sheet_seen;
4651        matches.extend(sheet_matches);
4652        if sheet_truncated {
4653            truncated = true;
4654            break;
4655        }
4656    }
4657
4658    let next_offset = if truncated {
4659        Some(offset.saturating_add(matches.len() as u32))
4660    } else {
4661        None
4662    };
4663
4664    Ok(FindValueResponse {
4665        workbook_id: workbook.id.clone(),
4666        match_count: matches.len() as u32,
4667        matches,
4668        next_offset,
4669    })
4670}
4671
4672pub async fn read_table(
4673    state: Arc<AppState>,
4674    params: ReadTableParams,
4675) -> Result<ReadTableResponse> {
4676    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
4677    let config = state.config();
4678    let output_profile = config.output_profile();
4679    let format = params.format.unwrap_or(match output_profile {
4680        OutputProfile::TokenDense => TableOutputFormat::Csv,
4681        OutputProfile::Verbose => TableOutputFormat::Json,
4682    });
4683    let include_headers = params.include_headers.unwrap_or(true);
4684    let include_types = params.include_types.unwrap_or(false);
4685    let resolved = resolve_table_target(&workbook, &params)?;
4686    let limit = params.limit.unwrap_or(100) as usize;
4687    let offset = params.offset.unwrap_or(0) as usize;
4688    let sample_mode = params.sample_mode.unwrap_or_default();
4689
4690    #[cfg(feature = "recalc")]
4691    let (headers, rows, total_rows, has_formula_in_target) =
4692        workbook.with_sheet(&resolved.sheet_name, |sheet| {
4693            let has_formula_in_target = sheet_has_formula_in_bounds(sheet, &[resolved.range]);
4694            let (headers, rows, total_rows) = extract_table_rows(
4695                sheet,
4696                &resolved,
4697                params.header_row,
4698                params.header_rows,
4699                params.columns.clone(),
4700                params.filters.clone(),
4701                limit,
4702                offset,
4703                sample_mode,
4704            )?;
4705            Ok::<_, anyhow::Error>((headers, rows, total_rows, has_formula_in_target))
4706        })??;
4707
4708    #[cfg(not(feature = "recalc"))]
4709    let (headers, rows, total_rows) = workbook.with_sheet(&resolved.sheet_name, |sheet| {
4710        let (headers, rows, total_rows) = extract_table_rows(
4711            sheet,
4712            &resolved,
4713            params.header_row,
4714            params.header_rows,
4715            params.columns.clone(),
4716            params.filters.clone(),
4717            limit,
4718            offset,
4719            sample_mode,
4720        )?;
4721        Ok::<_, anyhow::Error>((headers, rows, total_rows))
4722    })??;
4723
4724    #[cfg(feature = "recalc")]
4725    let warnings: Vec<Warning> = {
4726        if fork_recalc_needed(&state, &params.workbook_or_fork_id) && has_formula_in_target {
4727            vec![Warning {
4728                code: "WARN_STALE_FORMULAS".to_string(),
4729                message: "Fork has pending edits and may contain stale formula results; call recalculate on the fork for fresh values.".to_string(),
4730            }]
4731        } else {
4732            Vec::new()
4733        }
4734    };
4735
4736    #[cfg(not(feature = "recalc"))]
4737    let warnings: Vec<Warning> = Vec::new();
4738
4739    let max_cells = config.max_cells();
4740    let max_payload_bytes = config.max_payload_bytes();
4741    let mut row_limit = cap_rows_by_cells(rows.len(), headers.len().max(1), max_cells);
4742    if row_limit > 0 {
4743        row_limit = cap_rows_by_payload_bytes(row_limit, max_payload_bytes, |count| {
4744            let (headers_out, rows_out, values_out, types_out, csv_out) = build_read_table_payload(
4745                format,
4746                &headers,
4747                &rows[..count],
4748                include_headers,
4749                include_types,
4750            );
4751            let response = ReadTableResponse {
4752                workbook_id: workbook.id.clone(),
4753                sheet_name: resolved.sheet_name.clone(),
4754                table_name: resolved.table_name.clone(),
4755                warnings: warnings.clone(),
4756                headers: headers_out,
4757                rows: rows_out,
4758                values: values_out,
4759                types: types_out,
4760                csv: csv_out,
4761                total_rows,
4762                next_offset: None,
4763            };
4764            serde_json::to_vec(&response)
4765                .map(|payload| payload.len())
4766                .unwrap_or(usize::MAX)
4767        });
4768    }
4769
4770    let rows = rows.into_iter().take(row_limit).collect::<Vec<_>>();
4771    let next_offset = if offset + rows.len() < total_rows as usize {
4772        Some((offset + rows.len()) as u32)
4773    } else {
4774        None
4775    };
4776    let (headers_out, rows_out, values_out, types_out, csv_out) =
4777        build_read_table_payload(format, &headers, &rows, include_headers, include_types);
4778
4779    Ok(ReadTableResponse {
4780        workbook_id: workbook.id.clone(),
4781        sheet_name: resolved.sheet_name,
4782        table_name: resolved.table_name,
4783        warnings,
4784        headers: headers_out,
4785        rows: rows_out,
4786        values: values_out,
4787        types: types_out,
4788        csv: csv_out,
4789        total_rows,
4790        next_offset,
4791    })
4792}
4793
4794pub async fn table_profile(
4795    state: Arc<AppState>,
4796    params: TableProfileParams,
4797) -> Result<TableProfileResponse> {
4798    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
4799    let config = state.config();
4800    let output_profile = config.output_profile();
4801    let summary_only = params
4802        .summary_only
4803        .unwrap_or(matches!(output_profile, OutputProfile::TokenDense));
4804    let resolved = resolve_table_target(
4805        &workbook,
4806        &ReadTableParams {
4807            workbook_or_fork_id: params.workbook_or_fork_id.clone(),
4808            sheet_name: params.sheet_name.clone(),
4809            table_name: params.table_name.clone(),
4810            region_id: params.region_id,
4811            range: None,
4812            header_row: None,
4813            header_rows: None,
4814            columns: None,
4815            filters: None,
4816            sample_mode: params.sample_mode,
4817            limit: params.sample_size,
4818            offset: Some(0),
4819            format: Some(TableOutputFormat::Json),
4820            include_headers: None,
4821            include_types: None,
4822        },
4823    )?;
4824
4825    let sample_size = params.sample_size.unwrap_or(10) as usize;
4826    let sample_mode = params.sample_mode.unwrap_or(SampleMode::Distributed);
4827
4828    let (mut headers, rows, total_rows) =
4829        workbook.with_sheet(&resolved.sheet_name, |sheet| {
4830            extract_table_rows(
4831                sheet,
4832                &resolved,
4833                None,
4834                None,
4835                None,
4836                None,
4837                sample_size,
4838                0,
4839                sample_mode,
4840            )
4841        })??;
4842
4843    let max_items = config.max_items();
4844    let max_payload_bytes = config.max_payload_bytes();
4845
4846    if let Some(max_items) = max_items
4847        && headers.len() > max_items
4848    {
4849        headers.truncate(max_items);
4850    }
4851
4852    let mut column_types = summarize_columns(&headers, &rows);
4853
4854    let mut samples: Vec<TableRow> = if summary_only {
4855        Vec::new()
4856    } else {
4857        rows.into_iter()
4858            .map(|row| filter_table_row(&row, &headers))
4859            .collect()
4860    };
4861
4862    if !summary_only {
4863        if let Some(max_items) = max_items
4864            && samples.len() > max_items
4865        {
4866            samples.truncate(max_items);
4867        }
4868
4869        if let Some(max_bytes) = max_payload_bytes {
4870            let sample_limit = cap_rows_by_payload_bytes(samples.len(), Some(max_bytes), |count| {
4871                let response = TableProfileResponse {
4872                    workbook_id: workbook.id.clone(),
4873                    sheet_name: resolved.sheet_name.clone(),
4874                    table_name: resolved.table_name.clone(),
4875                    headers: headers.clone(),
4876                    column_types: column_types.clone(),
4877                    row_count: total_rows,
4878                    samples: samples[..count].to_vec(),
4879                    notes: Vec::new(),
4880                };
4881                serde_json::to_vec(&response)
4882                    .map(|payload| payload.len())
4883                    .unwrap_or(usize::MAX)
4884            });
4885            if sample_limit < samples.len() {
4886                samples.truncate(sample_limit);
4887            }
4888
4889            let response = TableProfileResponse {
4890                workbook_id: workbook.id.clone(),
4891                sheet_name: resolved.sheet_name.clone(),
4892                table_name: resolved.table_name.clone(),
4893                headers: headers.clone(),
4894                column_types: column_types.clone(),
4895                row_count: total_rows,
4896                samples: samples.clone(),
4897                notes: Vec::new(),
4898            };
4899            if serde_json::to_vec(&response)
4900                .map(|payload| payload.len() > max_bytes)
4901                .unwrap_or(false)
4902                && !headers.is_empty()
4903            {
4904                let header_limit =
4905                    cap_rows_by_payload_bytes(headers.len(), Some(max_bytes), |count| {
4906                        let headers_slice = headers[..count].to_vec();
4907                        let column_slice = column_types[..count.min(column_types.len())].to_vec();
4908                        let samples_slice = samples
4909                            .iter()
4910                            .map(|row| filter_table_row(row, &headers_slice))
4911                            .collect::<Vec<_>>();
4912                        let response = TableProfileResponse {
4913                            workbook_id: workbook.id.clone(),
4914                            sheet_name: resolved.sheet_name.clone(),
4915                            table_name: resolved.table_name.clone(),
4916                            headers: headers_slice,
4917                            column_types: column_slice,
4918                            row_count: total_rows,
4919                            samples: samples_slice,
4920                            notes: Vec::new(),
4921                        };
4922                        serde_json::to_vec(&response)
4923                            .map(|payload| payload.len())
4924                            .unwrap_or(usize::MAX)
4925                    });
4926
4927                if header_limit < headers.len() {
4928                    headers.truncate(header_limit);
4929                    column_types.truncate(header_limit.min(column_types.len()));
4930                    samples = samples
4931                        .into_iter()
4932                        .map(|row| filter_table_row(&row, &headers))
4933                        .collect();
4934                }
4935            }
4936        }
4937    }
4938
4939    Ok(TableProfileResponse {
4940        workbook_id: workbook.id.clone(),
4941        sheet_name: resolved.sheet_name,
4942        table_name: resolved.table_name,
4943        headers,
4944        column_types,
4945        row_count: total_rows,
4946        samples,
4947        notes: Vec::new(),
4948    })
4949}
4950
4951#[derive(Debug, Deserialize, JsonSchema)]
4952pub struct ManifestStubParams {
4953    #[serde(alias = "workbook_id")]
4954    pub workbook_or_fork_id: WorkbookId,
4955    pub sheet_filter: Option<String>,
4956}
4957
4958#[cfg(feature = "recalc-formualizer")]
4959pub async fn get_manifest_stub(
4960    state: Arc<AppState>,
4961    params: ManifestStubParams,
4962) -> Result<ManifestStubResponse> {
4963    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
4964    let mut summaries = workbook.list_summaries(true)?;
4965
4966    if let Some(filter) = &params.sheet_filter {
4967        summaries.retain(|summary| summary.name.eq_ignore_ascii_case(filter));
4968    }
4969
4970    let mut ports = Vec::new();
4971    let sanitize_id_re = regex::Regex::new(r"[^a-z0-9_-]").expect("valid id regex");
4972    let sanitize_header_re = regex::Regex::new(r"[^a-zA-Z0-9_]").expect("valid header regex");
4973
4974    for summary in &summaries {
4975        let sheet_name = &summary.name;
4976        if let Ok(overview) = workbook.sheet_overview(sheet_name) {
4977            for (idx, region) in overview.detected_regions.iter().enumerate() {
4978                let classification_str = format!("{:?}", region.classification).to_lowercase();
4979                let id = format!(
4980                    "{}_{}_{}",
4981                    sheet_name.replace(" ", "_"),
4982                    classification_str,
4983                    idx
4984                )
4985                .to_lowercase()
4986                .replace(char::is_whitespace, "_");
4987                let id = sanitize_id_re.replace_all(&id, "").to_string();
4988
4989                let (dir, shape, schema, location) = match region.classification {
4990                    crate::model::RegionKind::Parameters => {
4991                        let shape = formualizer::sheetport_spec::Shape::Range;
4992                        let schema = formualizer::sheetport_spec::Schema::Range(
4993                            formualizer::sheetport_spec::RangeSchema {
4994                                kind: formualizer::sheetport_spec::RangeKind::Range,
4995                                cell_type: formualizer::sheetport_spec::ValueType::Number,
4996                                format: None,
4997                            },
4998                        );
4999                        let a1 = region.bounds.clone();
5000                        let location = formualizer::sheetport_spec::Selector::A1(
5001                            formualizer::sheetport_spec::SelectorA1 {
5002                                a1: format!("'{}'!{}", sheet_name, a1),
5003                            },
5004                        );
5005                        (
5006                            formualizer::sheetport_spec::Direction::In,
5007                            shape,
5008                            schema,
5009                            location,
5010                        )
5011                    }
5012                    crate::model::RegionKind::Data => {
5013                        let shape = formualizer::sheetport_spec::Shape::Table;
5014                        let anchor_col = region
5015                            .bounds
5016                            .chars()
5017                            .take_while(|c| c.is_alphabetic())
5018                            .collect::<String>();
5019                        let anchor_col = if anchor_col.is_empty() {
5020                            "A".to_string()
5021                        } else {
5022                            anchor_col
5023                        };
5024                        let layout = formualizer::sheetport_spec::LayoutDescriptor {
5025                            kind: formualizer::sheetport_spec::LayoutKind::HeaderContiguousV1,
5026                            sheet: sheet_name.clone(),
5027                            header_row: region.header_row.unwrap_or(1),
5028                            anchor_col,
5029                            terminate:
5030                                formualizer::sheetport_spec::LayoutTermination::FirstBlankRow,
5031                            marker_text: None,
5032                        };
5033                        let mut columns = Vec::new();
5034                        for (c_idx, header) in region.headers.iter().enumerate() {
5035                            let clean_name = sanitize_header_re
5036                                .replace_all(header, "_")
5037                                .to_string()
5038                                .to_lowercase();
5039                            let clean_name = if clean_name.is_empty() {
5040                                format!("col_{}", c_idx)
5041                            } else {
5042                                clean_name
5043                            };
5044                            columns.push(formualizer::sheetport_spec::TableColumn {
5045                                name: clean_name,
5046                                value_type: formualizer::sheetport_spec::ValueType::String,
5047                                col: None,
5048                                format: None,
5049                                units: None,
5050                            });
5051                        }
5052                        let schema = formualizer::sheetport_spec::Schema::Table(
5053                            formualizer::sheetport_spec::TableSchema {
5054                                kind: formualizer::sheetport_spec::TableKind::Table,
5055                                columns,
5056                                keys: None,
5057                            },
5058                        );
5059                        (
5060                            formualizer::sheetport_spec::Direction::In,
5061                            shape,
5062                            schema,
5063                            formualizer::sheetport_spec::Selector::Layout(
5064                                formualizer::sheetport_spec::SelectorLayout { layout },
5065                            ),
5066                        )
5067                    }
5068                    crate::model::RegionKind::Calculator | crate::model::RegionKind::Outputs => {
5069                        let shape = formualizer::sheetport_spec::Shape::Range;
5070                        let schema = formualizer::sheetport_spec::Schema::Range(
5071                            formualizer::sheetport_spec::RangeSchema {
5072                                kind: formualizer::sheetport_spec::RangeKind::Range,
5073                                cell_type: formualizer::sheetport_spec::ValueType::Number,
5074                                format: None,
5075                            },
5076                        );
5077                        let a1 = region.bounds.clone();
5078                        let location = formualizer::sheetport_spec::Selector::A1(
5079                            formualizer::sheetport_spec::SelectorA1 {
5080                                a1: format!("'{}'!{}", sheet_name, a1),
5081                            },
5082                        );
5083                        (
5084                            formualizer::sheetport_spec::Direction::Out,
5085                            shape,
5086                            schema,
5087                            location,
5088                        )
5089                    }
5090                    _ => continue,
5091                };
5092
5093                ports.push(formualizer::sheetport_spec::Port {
5094                    id,
5095                    dir,
5096                    shape,
5097                    description: None,
5098                    required: true,
5099                    location,
5100                    schema,
5101                    constraints: None,
5102                    units: None,
5103                    default: None,
5104                    partition_key: None,
5105                });
5106            }
5107        }
5108    }
5109
5110    let manifest_obj = formualizer::sheetport_spec::Manifest {
5111        spec: "fio".to_string(),
5112        spec_version: formualizer::sheetport_spec::SpecVersion("0.3.0".parse().unwrap()),
5113        capabilities: Some(formualizer::sheetport_spec::Capabilities {
5114            profile: formualizer::sheetport_spec::Profile::CoreV0,
5115            features: None,
5116        }),
5117        manifest: formualizer::sheetport_spec::ManifestMeta {
5118            id: workbook
5119                .slug
5120                .clone()
5121                .to_lowercase()
5122                .replace(char::is_whitespace, "-")
5123                .replace(|c: char| !c.is_alphanumeric() && c != '-', ""),
5124            name: workbook.slug.clone(),
5125            description: Some("Auto-generated manifest stub".to_string()),
5126            tags: None,
5127            workbook: Some(formualizer::sheetport_spec::WorkbookMeta {
5128                uri: Some(format!("file://{}", workbook.slug)),
5129                locale: None,
5130                date_system: None,
5131                timezone: None,
5132            }),
5133            metadata: None,
5134        },
5135        ports,
5136    };
5137
5138    let manifest_yaml = manifest_obj.to_yaml().unwrap_or_else(|_| "".to_string());
5139
5140    let sheets = summaries
5141        .into_iter()
5142        .map(|summary| ManifestSheetStub {
5143            sheet_name: summary.name.clone(),
5144            classification: summary.classification.clone(),
5145            candidate_expectations: vec![format!(
5146                "Review {} sheet for expectation candidates",
5147                format!("{:?}", summary.classification).to_ascii_lowercase()
5148            )],
5149            notes: summary.style_tags,
5150        })
5151        .collect();
5152
5153    let response = ManifestStubResponse {
5154        workbook_id: workbook.id.clone(),
5155        slug: workbook.slug.clone(),
5156        manifest_yaml,
5157        sheets,
5158    };
5159    Ok(response)
5160}
5161
5162#[cfg(not(feature = "recalc-formualizer"))]
5163pub async fn get_manifest_stub(
5164    _state: Arc<AppState>,
5165    _params: ManifestStubParams,
5166) -> Result<ManifestStubResponse> {
5167    Err(anyhow!(
5168        "sheetport operations require the 'recalc-formualizer' feature"
5169    ))
5170}
5171
5172#[derive(Debug, Deserialize, JsonSchema)]
5173pub struct CloseWorkbookParams {
5174    #[serde(alias = "workbook_id")]
5175    pub workbook_or_fork_id: WorkbookId,
5176}
5177
5178pub async fn close_workbook(
5179    state: Arc<AppState>,
5180    params: CloseWorkbookParams,
5181) -> Result<CloseWorkbookResponse> {
5182    state.close_workbook(&params.workbook_or_fork_id)?;
5183    Ok(CloseWorkbookResponse {
5184        workbook_id: params.workbook_or_fork_id.clone(),
5185        message: format!("workbook {} evicted", params.workbook_or_fork_id.as_str()),
5186    })
5187}
5188#[allow(clippy::too_many_arguments)]
5189fn collect_formula_matches(
5190    sheet: &umya_spreadsheet::Worksheet,
5191    sheet_name: &str,
5192    query: &str,
5193    case_sensitive: bool,
5194    include_context: bool,
5195    context_rows: u32,
5196    context_cols: u32,
5197    offset: u32,
5198    limit: u32,
5199    seen_so_far: u32,
5200) -> (Vec<FindFormulaMatch>, u32, bool) {
5201    use crate::workbook::cell_to_value;
5202
5203    let mut results = Vec::new();
5204    let mut seen = seen_so_far;
5205
5206    for cell in sheet.get_cell_collection() {
5207        if !cell.is_formula() {
5208            continue;
5209        }
5210        let formula = cell.get_formula();
5211        let haystack = if case_sensitive {
5212            formula.to_string()
5213        } else {
5214            formula.to_ascii_lowercase()
5215        };
5216        if !haystack.contains(query) {
5217            continue;
5218        }
5219
5220        if seen < offset {
5221            seen += 1;
5222            continue;
5223        }
5224
5225        if results.len() as u32 >= limit {
5226            return (results, seen, true);
5227        }
5228
5229        let coord = cell.get_coordinate();
5230        let column = *coord.get_col_num();
5231        let row = *coord.get_row_num();
5232
5233        let context = if include_context {
5234            let col_start = column.saturating_sub(context_cols / 2).max(1);
5235            let col_end = column + context_cols / 2;
5236            let columns: Vec<u32> = (col_start..=col_end).collect();
5237
5238            let mut context_rows_vec = Vec::new();
5239
5240            if context_rows > 0 {
5241                let header_row = build_row_snapshot(sheet, 1, &columns, false, false);
5242                context_rows_vec.push(header_row);
5243            }
5244
5245            let row_start = row.saturating_sub(context_rows / 2).max(1);
5246            let row_end = (row + context_rows / 2).min(sheet.get_highest_row());
5247
5248            for ctx_row in row_start..=row_end {
5249                let ctx_row_snapshot = build_row_snapshot(sheet, ctx_row, &columns, true, false);
5250                context_rows_vec.push(ctx_row_snapshot);
5251            }
5252
5253            context_rows_vec
5254        } else {
5255            Vec::new()
5256        };
5257
5258        results.push(FindFormulaMatch {
5259            address: coord.get_coordinate(),
5260            sheet_name: sheet_name.to_string(),
5261            formula: formula.to_string(),
5262            cached_value: cell_to_value(cell),
5263            context,
5264        });
5265
5266        seen += 1;
5267    }
5268
5269    (results, seen, false)
5270}
5271
5272#[derive(Clone)]
5273struct TraceFormulaInfo {
5274    fingerprint: String,
5275    formula: String,
5276}
5277
5278#[derive(Clone)]
5279struct TraceEdgeRaw {
5280    from: String,
5281    to: String,
5282    neighbor: String,
5283}
5284
5285#[derive(Clone)]
5286struct LayerLinks {
5287    depth: u32,
5288    edges: Vec<TraceEdgeRaw>,
5289    truncated_cells: Vec<String>,
5290}
5291
5292#[derive(Clone)]
5293struct NeighborDetail {
5294    address: String,
5295    column: Option<u32>,
5296    row: Option<u32>,
5297    kind: TraceCellKind,
5298    value: Option<CellValue>,
5299    formula: Option<String>,
5300    fingerprint: Option<String>,
5301    external: bool,
5302}
5303
5304fn build_formula_lookup(graph: &FormulaGraph) -> HashMap<String, TraceFormulaInfo> {
5305    let mut map = HashMap::new();
5306    for group in graph.groups() {
5307        for address in group.addresses.clone() {
5308            map.insert(
5309                address.to_ascii_uppercase(),
5310                TraceFormulaInfo {
5311                    fingerprint: group.fingerprint.clone(),
5312                    formula: group.formula.clone(),
5313                },
5314            );
5315        }
5316    }
5317    map
5318}
5319
5320struct TraceConfig<'a> {
5321    direction: &'a TraceDirection,
5322    origin: &'a str,
5323    sheet_name: &'a str,
5324    depth_limit: u32,
5325    page_size: usize,
5326}
5327
5328fn build_trace_layers(
5329    workbook: &WorkbookContext,
5330    graph: &FormulaGraph,
5331    formula_lookup: &HashMap<String, TraceFormulaInfo>,
5332    config: &TraceConfig<'_>,
5333    cursor: Option<TraceCursor>,
5334) -> Result<(Vec<TraceLayer>, Option<TraceCursor>, Vec<String>)> {
5335    let layer_links =
5336        collect_layer_links(graph, config.direction, config.origin, config.depth_limit);
5337    let mut layers = Vec::new();
5338    let mut next_cursor = None;
5339    let mut notes = Vec::new();
5340    let focus_depth = cursor.as_ref().map(|c| c.depth);
5341
5342    for layer in layer_links {
5343        let produce_edges = focus_depth.is_none_or(|depth| depth == layer.depth);
5344        let offset = cursor
5345            .as_ref()
5346            .filter(|c| c.depth == layer.depth)
5347            .map(|c| c.offset)
5348            .unwrap_or(0);
5349
5350        let mut node_set: HashSet<String> = HashSet::new();
5351        for edge in &layer.edges {
5352            node_set.insert(edge.neighbor.clone());
5353        }
5354        let mut nodes: Vec<String> = node_set.into_iter().collect();
5355        nodes.sort_by(|a, b| compare_addresses(a, b));
5356
5357        let details = workbook.with_sheet(config.sheet_name, |sheet| {
5358            collect_neighbor_details(sheet, config.sheet_name, &nodes, formula_lookup)
5359        })?;
5360        let total_nodes = details.len();
5361        let start = offset.min(total_nodes);
5362        let end = if produce_edges {
5363            (start + config.page_size).min(total_nodes)
5364        } else {
5365            start
5366        };
5367        let selected_slice = if produce_edges {
5368            &details[start..end]
5369        } else {
5370            &details[0..0]
5371        };
5372        let selected_addresses: HashSet<String> = selected_slice
5373            .iter()
5374            .map(|detail| detail.address.clone())
5375            .collect();
5376
5377        let summary = build_layer_summary(&details);
5378        let range_highlights = build_range_highlights(&details);
5379        let group_highlights = build_formula_group_highlights(&details);
5380        let notable_cells = build_notable_cells(&details, &range_highlights, &group_highlights);
5381
5382        let highlights = TraceLayerHighlights {
5383            top_ranges: range_highlights.clone(),
5384            top_formula_groups: group_highlights.clone(),
5385            notable_cells,
5386        };
5387
5388        let edges = if produce_edges {
5389            build_edges_for_layer(&layer.edges, &selected_addresses, formula_lookup)
5390        } else {
5391            Vec::new()
5392        };
5393
5394        let has_more = produce_edges && end < total_nodes;
5395        if has_more && next_cursor.is_none() {
5396            next_cursor = Some(TraceCursor {
5397                depth: layer.depth,
5398                offset: end,
5399            });
5400        }
5401        if has_more {
5402            notes.push(format!(
5403                "Layer {} truncated at {} of {} nodes; supply cursor.depth={} and cursor.offset={} to continue",
5404                layer.depth, end, total_nodes, layer.depth, end
5405            ));
5406        }
5407
5408        if !layer.truncated_cells.is_empty() {
5409            let cell_list = if layer.truncated_cells.len() <= 3 {
5410                layer.truncated_cells.join(", ")
5411            } else {
5412                format!(
5413                    "{}, ... ({} more)",
5414                    layer.truncated_cells[..3].join(", "),
5415                    layer.truncated_cells.len() - 3
5416                )
5417            };
5418            notes.push(format!(
5419                "Layer {}: dependents truncated at {} per cell for: {}",
5420                layer.depth, TRACE_DEPENDENTS_PER_CELL_LIMIT, cell_list
5421            ));
5422        }
5423
5424        layers.push(TraceLayer {
5425            depth: layer.depth,
5426            summary,
5427            highlights,
5428            edges,
5429            has_more,
5430        });
5431    }
5432
5433    Ok((layers, next_cursor, notes))
5434}
5435
5436fn collect_layer_links(
5437    graph: &FormulaGraph,
5438    direction: &TraceDirection,
5439    origin: &str,
5440    depth_limit: u32,
5441) -> Vec<LayerLinks> {
5442    let mut visited: HashSet<String> = HashSet::new();
5443    visited.insert(origin.to_string());
5444    let mut frontier = vec![origin.to_string()];
5445    let mut layers = Vec::new();
5446
5447    for depth in 1..=depth_limit {
5448        let mut next_frontier_set: HashSet<String> = HashSet::new();
5449        let mut edges = Vec::new();
5450        let mut truncated_cells = Vec::new();
5451
5452        for cell in &frontier {
5453            let (neighbors, was_truncated) = match direction {
5454                TraceDirection::Precedents => (graph.precedents(cell), false),
5455                TraceDirection::Dependents => {
5456                    graph.dependents_limited(cell, Some(TRACE_DEPENDENTS_PER_CELL_LIMIT))
5457                }
5458            };
5459
5460            if was_truncated {
5461                truncated_cells.push(cell.clone());
5462            }
5463
5464            for neighbor in neighbors {
5465                let neighbor_upper = neighbor.to_ascii_uppercase();
5466                let edge = match direction {
5467                    TraceDirection::Precedents => TraceEdgeRaw {
5468                        from: cell.clone(),
5469                        to: neighbor_upper.clone(),
5470                        neighbor: neighbor_upper.clone(),
5471                    },
5472                    TraceDirection::Dependents => TraceEdgeRaw {
5473                        from: neighbor_upper.clone(),
5474                        to: cell.clone(),
5475                        neighbor: neighbor_upper.clone(),
5476                    },
5477                };
5478                edges.push(edge);
5479                if visited.insert(neighbor_upper.clone()) {
5480                    next_frontier_set.insert(neighbor_upper);
5481                }
5482            }
5483        }
5484
5485        if edges.is_empty() {
5486            break;
5487        }
5488
5489        layers.push(LayerLinks {
5490            depth,
5491            edges,
5492            truncated_cells,
5493        });
5494        if next_frontier_set.is_empty() {
5495            break;
5496        }
5497        let mut next_frontier: Vec<String> = next_frontier_set.into_iter().collect();
5498        next_frontier.sort();
5499        frontier = next_frontier;
5500    }
5501
5502    layers
5503}
5504
5505fn collect_neighbor_details(
5506    sheet: &umya_spreadsheet::Worksheet,
5507    current_sheet: &str,
5508    addresses: &[String],
5509    formula_lookup: &HashMap<String, TraceFormulaInfo>,
5510) -> Vec<NeighborDetail> {
5511    let mut details = Vec::new();
5512    for address in addresses {
5513        let (sheet_part, cell_part) = split_sheet_and_cell(address);
5514        let normalized_sheet = sheet_part
5515            .as_ref()
5516            .map(|s| clean_sheet_name(s).to_ascii_lowercase());
5517        let is_external = normalized_sheet
5518            .as_ref()
5519            .map(|s| !s.eq_ignore_ascii_case(current_sheet))
5520            .unwrap_or(false);
5521
5522        let Some(cell_ref) = cell_part else {
5523            details.push(NeighborDetail {
5524                address: address.clone(),
5525                column: None,
5526                row: None,
5527                kind: TraceCellKind::External,
5528                value: None,
5529                formula: None,
5530                fingerprint: None,
5531                external: true,
5532            });
5533            continue;
5534        };
5535
5536        let cell_ref_upper = cell_ref.to_ascii_uppercase();
5537
5538        if is_external {
5539            let formula_info = lookup_formula_info(formula_lookup, &cell_ref_upper, address);
5540            details.push(NeighborDetail {
5541                address: address.clone(),
5542                column: None,
5543                row: None,
5544                kind: TraceCellKind::External,
5545                value: None,
5546                formula: formula_info.map(|info| info.formula.clone()),
5547                fingerprint: formula_info.map(|info| info.fingerprint.clone()),
5548                external: true,
5549            });
5550            continue;
5551        }
5552
5553        let Some((col, row)) = parse_address(&cell_ref_upper) else {
5554            details.push(NeighborDetail {
5555                address: address.clone(),
5556                column: None,
5557                row: None,
5558                kind: TraceCellKind::External,
5559                value: None,
5560                formula: None,
5561                fingerprint: None,
5562                external: true,
5563            });
5564            continue;
5565        };
5566
5567        let cell_opt = sheet.get_cell((&col, &row));
5568        let formula_info = lookup_formula_info(formula_lookup, &cell_ref_upper, address);
5569        if let Some(cell) = cell_opt {
5570            let value = cell_to_value(cell);
5571            let kind = if cell.is_formula() {
5572                TraceCellKind::Formula
5573            } else if value.is_some() {
5574                TraceCellKind::Literal
5575            } else {
5576                TraceCellKind::Blank
5577            };
5578            details.push(NeighborDetail {
5579                address: address.clone(),
5580                column: Some(col),
5581                row: Some(row),
5582                kind,
5583                value,
5584                formula: formula_info.map(|info| info.formula.clone()),
5585                fingerprint: formula_info.map(|info| info.fingerprint.clone()),
5586                external: false,
5587            });
5588        } else {
5589            details.push(NeighborDetail {
5590                address: address.clone(),
5591                column: Some(col),
5592                row: Some(row),
5593                kind: TraceCellKind::Blank,
5594                value: None,
5595                formula: formula_info.map(|info| info.formula.clone()),
5596                fingerprint: formula_info.map(|info| info.fingerprint.clone()),
5597                external: false,
5598            });
5599        }
5600    }
5601    details
5602}
5603
5604fn build_layer_summary(details: &[NeighborDetail]) -> TraceLayerSummary {
5605    let mut summary = TraceLayerSummary {
5606        total_nodes: details.len(),
5607        formula_nodes: 0,
5608        value_nodes: 0,
5609        blank_nodes: 0,
5610        external_nodes: 0,
5611        unique_formula_groups: 0,
5612    };
5613
5614    let mut fingerprints: HashSet<String> = HashSet::new();
5615
5616    for detail in details {
5617        match detail.kind {
5618            TraceCellKind::Formula => {
5619                summary.formula_nodes += 1;
5620                if let Some(fp) = &detail.fingerprint {
5621                    fingerprints.insert(fp.clone());
5622                }
5623            }
5624            TraceCellKind::Literal => summary.value_nodes += 1,
5625            TraceCellKind::Blank => summary.blank_nodes += 1,
5626            TraceCellKind::External => summary.external_nodes += 1,
5627        }
5628    }
5629
5630    summary.unique_formula_groups = fingerprints.len();
5631    summary
5632}
5633
5634fn build_formula_group_highlights(details: &[NeighborDetail]) -> Vec<TraceFormulaGroupHighlight> {
5635    let mut aggregates: HashMap<String, (String, usize, Vec<String>)> = HashMap::new();
5636    for detail in details {
5637        if let (Some(fingerprint), Some(formula)) = (&detail.fingerprint, &detail.formula) {
5638            let entry = aggregates
5639                .entry(fingerprint.clone())
5640                .or_insert_with(|| (formula.clone(), 0, Vec::new()));
5641            entry.1 += 1;
5642            if entry.2.len() < TRACE_GROUP_SAMPLE_LIMIT {
5643                entry.2.push(detail.address.clone());
5644            }
5645        }
5646    }
5647
5648    let mut highlights: Vec<TraceFormulaGroupHighlight> = aggregates
5649        .into_iter()
5650        .map(
5651            |(fingerprint, (formula, count, sample_addresses))| TraceFormulaGroupHighlight {
5652                fingerprint,
5653                formula,
5654                count,
5655                sample_addresses,
5656            },
5657        )
5658        .collect();
5659
5660    highlights.sort_by(|a, b| b.count.cmp(&a.count));
5661    highlights.truncate(TRACE_GROUP_HIGHLIGHT_LIMIT);
5662    highlights
5663}
5664
5665fn build_range_highlights(details: &[NeighborDetail]) -> Vec<TraceRangeHighlight> {
5666    let mut by_column: HashMap<u32, Vec<&NeighborDetail>> = HashMap::new();
5667    for detail in details {
5668        if let (Some(col), Some(_row)) = (detail.column, detail.row)
5669            && !detail.external
5670        {
5671            by_column.entry(col).or_default().push(detail);
5672        }
5673    }
5674
5675    for column_entries in by_column.values_mut() {
5676        column_entries.sort_by(|a, b| a.row.cmp(&b.row));
5677    }
5678
5679    let mut ranges = Vec::new();
5680    for entries in by_column.values() {
5681        let mut current: Vec<&NeighborDetail> = Vec::new();
5682        for detail in entries {
5683            if current.is_empty() {
5684                current.push(detail);
5685                continue;
5686            }
5687            let prev_row = current.last().and_then(|d| d.row).unwrap_or(0);
5688            if detail.row.unwrap_or(0) == prev_row + 1 {
5689                current.push(detail);
5690            } else {
5691                if current.len() >= TRACE_RANGE_THRESHOLD {
5692                    ranges.push(make_range_highlight(&current));
5693                }
5694                current.clear();
5695                current.push(detail);
5696            }
5697        }
5698        if current.len() >= TRACE_RANGE_THRESHOLD {
5699            ranges.push(make_range_highlight(&current));
5700        }
5701    }
5702
5703    ranges.sort_by(|a, b| b.count.cmp(&a.count));
5704    ranges.truncate(TRACE_RANGE_HIGHLIGHT_LIMIT);
5705    ranges
5706}
5707
5708fn make_range_highlight(details: &[&NeighborDetail]) -> TraceRangeHighlight {
5709    let mut literals = 0usize;
5710    let mut formulas = 0usize;
5711    let mut blanks = 0usize;
5712    let mut sample_values = Vec::new();
5713    let mut sample_formulas = Vec::new();
5714    let mut sample_addresses = Vec::new();
5715
5716    for detail in details {
5717        match detail.kind {
5718            TraceCellKind::Formula => {
5719                formulas += 1;
5720                if let Some(formula) = &detail.formula
5721                    && sample_formulas.len() < TRACE_RANGE_FORMULA_SAMPLES
5722                    && !sample_formulas.contains(formula)
5723                {
5724                    sample_formulas.push(formula.clone());
5725                }
5726            }
5727            TraceCellKind::Literal => {
5728                literals += 1;
5729                if let Some(value) = &detail.value
5730                    && sample_values.len() < TRACE_RANGE_VALUE_SAMPLES
5731                {
5732                    sample_values.push(value.clone());
5733                }
5734            }
5735            TraceCellKind::Blank => blanks += 1,
5736            TraceCellKind::External => {}
5737        }
5738        if sample_addresses.len() < TRACE_RANGE_VALUE_SAMPLES {
5739            sample_addresses.push(detail.address.clone());
5740        }
5741    }
5742
5743    TraceRangeHighlight {
5744        start: details
5745            .first()
5746            .map(|d| d.address.clone())
5747            .unwrap_or_default(),
5748        end: details
5749            .last()
5750            .map(|d| d.address.clone())
5751            .unwrap_or_default(),
5752        count: details.len(),
5753        literals,
5754        formulas,
5755        blanks,
5756        sample_values,
5757        sample_formulas,
5758        sample_addresses,
5759    }
5760}
5761
5762fn build_notable_cells(
5763    details: &[NeighborDetail],
5764    ranges: &[TraceRangeHighlight],
5765    groups: &[TraceFormulaGroupHighlight],
5766) -> Vec<TraceCellHighlight> {
5767    let mut exclude: HashSet<String> = HashSet::new();
5768    for range in ranges {
5769        exclude.insert(range.start.clone());
5770        exclude.insert(range.end.clone());
5771        for addr in &range.sample_addresses {
5772            exclude.insert(addr.clone());
5773        }
5774    }
5775    for group in groups {
5776        for addr in &group.sample_addresses {
5777            exclude.insert(addr.clone());
5778        }
5779    }
5780
5781    let mut highlights = Vec::new();
5782    let mut kind_counts: HashMap<TraceCellKind, usize> = HashMap::new();
5783
5784    for detail in details {
5785        if highlights.len() >= TRACE_CELL_HIGHLIGHT_LIMIT {
5786            break;
5787        }
5788        if exclude.contains(&detail.address) {
5789            continue;
5790        }
5791        let counter = kind_counts.entry(detail.kind.clone()).or_insert(0);
5792        if *counter >= 2 && detail.kind != TraceCellKind::External {
5793            continue;
5794        }
5795        highlights.push(TraceCellHighlight {
5796            address: detail.address.clone(),
5797            kind: detail.kind.clone(),
5798            value: detail.value.clone(),
5799            formula: detail.formula.clone(),
5800        });
5801        *counter += 1;
5802    }
5803
5804    highlights
5805}
5806
5807fn build_edges_for_layer(
5808    raw_edges: &[TraceEdgeRaw],
5809    selected: &HashSet<String>,
5810    formula_lookup: &HashMap<String, TraceFormulaInfo>,
5811) -> Vec<FormulaTraceEdge> {
5812    let mut edges = Vec::new();
5813    for edge in raw_edges {
5814        if selected.contains(&edge.neighbor) {
5815            let formula = lookup_formula_info(formula_lookup, &edge.neighbor, &edge.neighbor)
5816                .map(|info| info.formula.clone());
5817            edges.push(FormulaTraceEdge {
5818                from: edge.from.clone(),
5819                to: edge.to.clone(),
5820                formula,
5821                note: None,
5822            });
5823        }
5824    }
5825    edges.sort_by(|a, b| compare_addresses(&a.to, &b.to));
5826    edges
5827}
5828
5829fn lookup_formula_info<'a>(
5830    lookup: &'a HashMap<String, TraceFormulaInfo>,
5831    cell_ref: &str,
5832    original: &str,
5833) -> Option<&'a TraceFormulaInfo> {
5834    if let Some(info) = lookup.get(cell_ref) {
5835        return Some(info);
5836    }
5837    if let (Some(_sheet), Some(cell)) = split_sheet_and_cell(original) {
5838        let upper = cell.to_ascii_uppercase();
5839        return lookup.get(&upper);
5840    }
5841    None
5842}
5843
5844fn compare_addresses(left: &str, right: &str) -> Ordering {
5845    let (sheet_left, cell_left) = split_sheet_and_cell(left);
5846    let (sheet_right, cell_right) = split_sheet_and_cell(right);
5847
5848    let sheet_left_key = sheet_left
5849        .as_ref()
5850        .map(|s| clean_sheet_name(s).to_ascii_uppercase())
5851        .unwrap_or_default();
5852    let sheet_right_key = sheet_right
5853        .as_ref()
5854        .map(|s| clean_sheet_name(s).to_ascii_uppercase())
5855        .unwrap_or_default();
5856
5857    match sheet_left_key.cmp(&sheet_right_key) {
5858        Ordering::Equal => {
5859            let left_core = cell_left.unwrap_or_else(|| left.to_string());
5860            let right_core = cell_right.unwrap_or_else(|| right.to_string());
5861            let left_coords = parse_address(&left_core.to_ascii_uppercase());
5862            let right_coords = parse_address(&right_core.to_ascii_uppercase());
5863            match (left_coords, right_coords) {
5864                (Some((lc, lr)), Some((rc, rr))) => lc
5865                    .cmp(&rc)
5866                    .then_with(|| lr.cmp(&rr))
5867                    .then_with(|| left_core.cmp(&right_core)),
5868                _ => left_core.cmp(&right_core),
5869            }
5870        }
5871        other => other,
5872    }
5873}
5874
5875fn split_sheet_and_cell(address: &str) -> (Option<String>, Option<String>) {
5876    if let Some(idx) = address.rfind('!') {
5877        let sheet = address[..idx].to_string();
5878        let cell = address[idx + 1..].to_string();
5879        (Some(sheet), Some(cell))
5880    } else {
5881        (None, Some(address.to_string()))
5882    }
5883}
5884
5885fn clean_sheet_name(sheet: &str) -> String {
5886    let trimmed = sheet.trim_matches(|c| c == '\'' || c == '"');
5887    let after_bracket = trimmed.rsplit(']').next().unwrap_or(trimmed);
5888    after_bracket
5889        .trim_matches(|c| c == '\'' || c == '"')
5890        .to_string()
5891}
5892
5893#[cfg(feature = "recalc-formualizer")]
5894fn json_to_literal(value: &serde_json::Value) -> formualizer::workbook::LiteralValue {
5895    match value {
5896        serde_json::Value::Null => formualizer::workbook::LiteralValue::Empty,
5897        serde_json::Value::Bool(b) => formualizer::workbook::LiteralValue::Boolean(*b),
5898        serde_json::Value::Number(n) => {
5899            if let Some(f) = n.as_f64() {
5900                formualizer::workbook::LiteralValue::Number(f)
5901            } else {
5902                formualizer::workbook::LiteralValue::Empty
5903            }
5904        }
5905        serde_json::Value::String(s) => formualizer::workbook::LiteralValue::Text(s.clone()),
5906        _ => formualizer::workbook::LiteralValue::Empty,
5907    }
5908}
5909
5910#[cfg(feature = "recalc-formualizer")]
5911fn json_to_port_value(value: &serde_json::Value) -> formualizer::sheetport::PortValue {
5912    match value {
5913        serde_json::Value::Object(map) => {
5914            let mut record = std::collections::BTreeMap::new();
5915            for (k, v) in map {
5916                record.insert(k.clone(), json_to_literal(v));
5917            }
5918            formualizer::sheetport::PortValue::Record(record)
5919        }
5920        serde_json::Value::Array(arr) => {
5921            // Check if array of objects (table) or array of arrays (range)
5922            if let Some(serde_json::Value::Object(_)) = arr.first() {
5923                let mut rows = Vec::new();
5924                for row_val in arr {
5925                    if let serde_json::Value::Object(map) = row_val {
5926                        let mut values = std::collections::BTreeMap::new();
5927                        for (k, v) in map {
5928                            values.insert(k.clone(), json_to_literal(v));
5929                        }
5930                        rows.push(formualizer::sheetport::TableRow::new(values));
5931                    }
5932                }
5933                formualizer::sheetport::PortValue::Table(formualizer::sheetport::TableValue::new(
5934                    rows,
5935                ))
5936            } else if let Some(serde_json::Value::Array(_)) = arr.first() {
5937                let mut rows = Vec::new();
5938                for row_val in arr {
5939                    if let serde_json::Value::Array(inner) = row_val {
5940                        rows.push(inner.iter().map(json_to_literal).collect());
5941                    }
5942                }
5943                formualizer::sheetport::PortValue::Range(rows)
5944            } else {
5945                formualizer::sheetport::PortValue::Scalar(
5946                    formualizer::workbook::LiteralValue::Empty,
5947                )
5948            }
5949        }
5950        _ => formualizer::sheetport::PortValue::Scalar(json_to_literal(value)),
5951    }
5952}
5953
5954#[cfg(feature = "recalc-formualizer")]
5955fn port_value_to_json(value: &formualizer::sheetport::PortValue) -> serde_json::Value {
5956    match value {
5957        formualizer::sheetport::PortValue::Scalar(lit) => literal_to_json(lit),
5958        formualizer::sheetport::PortValue::Record(map) => {
5959            let mut obj = serde_json::Map::new();
5960            for (k, v) in map {
5961                obj.insert(k.clone(), literal_to_json(v));
5962            }
5963            serde_json::Value::Object(obj)
5964        }
5965        formualizer::sheetport::PortValue::Range(rows) => {
5966            let arr: Vec<serde_json::Value> = rows
5967                .iter()
5968                .map(|row| serde_json::Value::Array(row.iter().map(literal_to_json).collect()))
5969                .collect();
5970            serde_json::Value::Array(arr)
5971        }
5972        formualizer::sheetport::PortValue::Table(table) => {
5973            let arr: Vec<serde_json::Value> = table
5974                .rows
5975                .iter()
5976                .map(|row| {
5977                    let mut obj = serde_json::Map::new();
5978                    for (k, v) in &row.values {
5979                        obj.insert(k.clone(), literal_to_json(v));
5980                    }
5981                    serde_json::Value::Object(obj)
5982                })
5983                .collect();
5984            serde_json::Value::Array(arr)
5985        }
5986    }
5987}
5988
5989#[cfg(feature = "recalc-formualizer")]
5990fn literal_to_json(lit: &formualizer::workbook::LiteralValue) -> serde_json::Value {
5991    match lit {
5992        formualizer::workbook::LiteralValue::Empty => serde_json::Value::Null,
5993        formualizer::workbook::LiteralValue::Boolean(b) => serde_json::Value::Bool(*b),
5994        formualizer::workbook::LiteralValue::Number(n) => serde_json::json!(n),
5995        formualizer::workbook::LiteralValue::Int(i) => serde_json::json!(i),
5996        formualizer::workbook::LiteralValue::Text(t) => serde_json::Value::String(t.clone()),
5997        formualizer::workbook::LiteralValue::Error(e) => {
5998            serde_json::Value::String(format!("#ERROR: {:?}", e))
5999        }
6000        formualizer::workbook::LiteralValue::Date(d) => serde_json::Value::String(d.to_string()),
6001        formualizer::workbook::LiteralValue::DateTime(dt) => {
6002            serde_json::Value::String(dt.to_string())
6003        }
6004        formualizer::workbook::LiteralValue::Time(t) => serde_json::Value::String(t.to_string()),
6005        formualizer::workbook::LiteralValue::Duration(d) => {
6006            serde_json::Value::String(d.to_string())
6007        }
6008        formualizer::workbook::LiteralValue::Array(arr) => {
6009            let json_rows: Vec<serde_json::Value> = arr
6010                .iter()
6011                .map(|row| serde_json::Value::Array(row.iter().map(literal_to_json).collect()))
6012                .collect();
6013            serde_json::Value::Array(json_rows)
6014        }
6015        formualizer::workbook::LiteralValue::Pending => serde_json::Value::Null,
6016    }
6017}
6018
6019#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
6020pub struct ExecuteManifestParams {
6021    #[serde(alias = "workbook_id")]
6022    pub workbook_or_fork_id: WorkbookId,
6023    pub manifest_yaml: String,
6024    #[serde(default)]
6025    pub inputs: std::collections::BTreeMap<String, serde_json::Value>,
6026    #[serde(default)]
6027    pub rng_seed: Option<u64>,
6028    #[serde(default)]
6029    pub freeze_volatile: bool,
6030}
6031
6032#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
6033pub struct ExecuteManifestResponse {
6034    pub workbook_id: WorkbookId,
6035    pub outputs: serde_json::Value,
6036}
6037
6038#[cfg(feature = "recalc-formualizer")]
6039pub async fn execute_manifest(
6040    state: Arc<AppState>,
6041    params: ExecuteManifestParams,
6042) -> Result<ExecuteManifestResponse> {
6043    use formualizer::workbook::SpreadsheetReader;
6044    let workbook_ctx = state.open_workbook(&params.workbook_or_fork_id).await?;
6045    let path = &workbook_ctx.path;
6046
6047    let workbook_bytes = std::fs::read(path)?;
6048    let adapter = formualizer::workbook::UmyaAdapter::open_bytes(workbook_bytes)
6049        .or_else(|_| formualizer::workbook::UmyaAdapter::open_path(path))
6050        .map_err(|e| anyhow!("Failed to open adapter: {}", e))?;
6051
6052    let workbook = formualizer::workbook::Workbook::from_reader(
6053        adapter,
6054        formualizer::workbook::LoadStrategy::EagerAll,
6055        formualizer::workbook::WorkbookConfig::ephemeral(),
6056    )
6057    .map_err(|e| anyhow!("Failed to load workbook: {}", e))?;
6058
6059    let manifest = formualizer::sheetport_spec::Manifest::from_yaml_str(&params.manifest_yaml)
6060        .map_err(|e| anyhow!("Failed to parse manifest YAML: {}", e))?;
6061
6062    let mut session = formualizer::sheetport::SheetPortSession::new(workbook, manifest)
6063        .map_err(|e| anyhow!("Failed to create SheetPort session: {}", e))?;
6064
6065    let mut input_update = formualizer::sheetport::InputUpdate::new();
6066    for (key, val) in params.inputs {
6067        input_update.insert(key, json_to_port_value(&val));
6068    }
6069
6070    if !input_update.is_empty() {
6071        session
6072            .write_inputs(input_update)
6073            .map_err(|e| anyhow!("Failed to write inputs: {}", e))?;
6074    }
6075
6076    let options = formualizer::sheetport::EvalOptions {
6077        rng_seed: params.rng_seed,
6078        freeze_volatile: params.freeze_volatile,
6079        ..Default::default()
6080    };
6081
6082    let outputs = session
6083        .evaluate_once(options)
6084        .map_err(|e| anyhow!("Failed to evaluate: {}", e))?;
6085
6086    let mut out_map = serde_json::Map::new();
6087    for (k, v) in outputs.into_inner() {
6088        out_map.insert(k, port_value_to_json(&v));
6089    }
6090
6091    Ok(ExecuteManifestResponse {
6092        workbook_id: params.workbook_or_fork_id.clone(),
6093        outputs: serde_json::Value::Object(out_map),
6094    })
6095}
6096
6097#[cfg(not(feature = "recalc-formualizer"))]
6098pub async fn execute_manifest(
6099    _state: Arc<AppState>,
6100    _params: ExecuteManifestParams,
6101) -> Result<ExecuteManifestResponse> {
6102    Err(anyhow!(
6103        "sheetport operations require the 'recalc-formualizer' feature"
6104    ))
6105}
6106
6107// ── layout_page ───────────────────────────────────────────────────────────────
6108
6109// ── grid_export ───────────────────────────────────────────────────────────────
6110
6111#[derive(Debug, Deserialize, JsonSchema)]
6112pub struct GridExportParams {
6113    #[serde(alias = "workbook_id")]
6114    pub workbook_or_fork_id: WorkbookId,
6115    pub sheet_name: String,
6116    pub range: String,
6117}
6118
6119pub async fn grid_export(
6120    state: Arc<AppState>,
6121    params: GridExportParams,
6122) -> Result<crate::model::GridPayload> {
6123    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
6124    let ((min_col, min_row), (max_col, max_row)) =
6125        parse_range(&params.range).ok_or_else(|| anyhow!("invalid range: {}", params.range))?;
6126
6127    let payload = workbook.with_sheet(&params.sheet_name, |sheet| {
6128        let mut columns = Vec::new();
6129        for col_idx in min_col..=max_col {
6130            if let Some(dim) = sheet.get_column_dimension_by_number(&col_idx) {
6131                let w = *dim.get_width();
6132                if w > 0.0 {
6133                    columns.push(crate::model::GridColumnHint {
6134                        offset: col_idx - min_col,
6135                        width_chars: w,
6136                    });
6137                }
6138            }
6139        }
6140
6141        let mut merges = Vec::new();
6142        for mc in sheet.get_merge_cells() {
6143            let m_range = mc.get_range();
6144            if let Some(((c1, r1), (c2, r2))) = parse_range(&m_range)
6145                && c1 <= max_col
6146                && c2 >= min_col
6147                && r1 <= max_row
6148                && r2 >= min_row
6149            {
6150                merges.push(m_range.to_string());
6151            }
6152        }
6153
6154        let mut rows = Vec::new();
6155        for row in min_row..=max_row {
6156            let mut cells = Vec::new();
6157            for col in min_col..=max_col {
6158                if let Some(cell) = sheet.get_cell((&col, &row)) {
6159                    let mut v = None;
6160                    let mut f = None;
6161
6162                    if cell.is_formula() {
6163                        f = Some(format!("={}", cell.get_formula()));
6164                    } else {
6165                        let value = crate::workbook::cell_to_value(cell);
6166                        if let Some(cv) = value {
6167                            match cv {
6168                                crate::model::CellValue::Text(s) => {
6169                                    v = Some(serde_json::Value::String(s))
6170                                }
6171                                crate::model::CellValue::Number(n) => {
6172                                    v = Some(serde_json::json!(n))
6173                                }
6174                                crate::model::CellValue::Bool(b) => {
6175                                    v = Some(serde_json::Value::Bool(b))
6176                                }
6177                                crate::model::CellValue::Error(e) => {
6178                                    v = Some(serde_json::Value::String(e))
6179                                }
6180                                crate::model::CellValue::Date(d) => {
6181                                    v = Some(serde_json::Value::String(d))
6182                                }
6183                            }
6184                        }
6185                    }
6186
6187                    let style = cell.get_style();
6188                    let desc = crate::styles::descriptor_from_style(style);
6189
6190                    let fmt = desc.number_format.clone();
6191
6192                    let mut style_patch = None;
6193                    if desc.font.is_some()
6194                        || desc.fill.is_some()
6195                        || desc.borders.is_some()
6196                        || desc.alignment.is_some()
6197                    {
6198                        style_patch = Some(crate::model::StylePatch {
6199                            font: desc.font.map(|f| {
6200                                Some(crate::model::FontPatch {
6201                                    name: f.name.map(Some),
6202                                    size: f.size.map(Some),
6203                                    bold: f.bold.map(Some),
6204                                    italic: f.italic.map(Some),
6205                                    underline: f.underline.map(Some),
6206                                    strikethrough: f.strikethrough.map(Some),
6207                                    color: f.color.map(Some),
6208                                })
6209                            }),
6210                            fill: desc.fill.map(|f| {
6211                                Some(match f {
6212                                    crate::model::FillDescriptor::Pattern(p) => {
6213                                        crate::model::FillPatch::Pattern(
6214                                            crate::model::PatternFillPatch {
6215                                                pattern_type: p.pattern_type.map(Some),
6216                                                foreground_color: p.foreground_color.map(Some),
6217                                                background_color: p.background_color.map(Some),
6218                                            },
6219                                        )
6220                                    }
6221                                    crate::model::FillDescriptor::Gradient(g) => {
6222                                        crate::model::FillPatch::Gradient(
6223                                            crate::model::GradientFillPatch {
6224                                                degree: g.degree.map(Some),
6225                                                stops: Some(
6226                                                    g.stops
6227                                                        .into_iter()
6228                                                        .map(|s| crate::model::GradientStopPatch {
6229                                                            position: s.position,
6230                                                            color: s.color,
6231                                                        })
6232                                                        .collect(),
6233                                                ),
6234                                            },
6235                                        )
6236                                    }
6237                                })
6238                            }),
6239                            borders: desc.borders.map(|b| {
6240                                Some(crate::model::BordersPatch {
6241                                    left: b.left.map(|s| {
6242                                        Some(crate::model::BorderSidePatch {
6243                                            style: s.style.map(Some),
6244                                            color: s.color.map(Some),
6245                                        })
6246                                    }),
6247                                    right: b.right.map(|s| {
6248                                        Some(crate::model::BorderSidePatch {
6249                                            style: s.style.map(Some),
6250                                            color: s.color.map(Some),
6251                                        })
6252                                    }),
6253                                    top: b.top.map(|s| {
6254                                        Some(crate::model::BorderSidePatch {
6255                                            style: s.style.map(Some),
6256                                            color: s.color.map(Some),
6257                                        })
6258                                    }),
6259                                    bottom: b.bottom.map(|s| {
6260                                        Some(crate::model::BorderSidePatch {
6261                                            style: s.style.map(Some),
6262                                            color: s.color.map(Some),
6263                                        })
6264                                    }),
6265                                    diagonal: b.diagonal.map(|s| {
6266                                        Some(crate::model::BorderSidePatch {
6267                                            style: s.style.map(Some),
6268                                            color: s.color.map(Some),
6269                                        })
6270                                    }),
6271                                    vertical: b.vertical.map(|s| {
6272                                        Some(crate::model::BorderSidePatch {
6273                                            style: s.style.map(Some),
6274                                            color: s.color.map(Some),
6275                                        })
6276                                    }),
6277                                    horizontal: b.horizontal.map(|s| {
6278                                        Some(crate::model::BorderSidePatch {
6279                                            style: s.style.map(Some),
6280                                            color: s.color.map(Some),
6281                                        })
6282                                    }),
6283                                    diagonal_up: b.diagonal_up.map(Some),
6284                                    diagonal_down: b.diagonal_down.map(Some),
6285                                })
6286                            }),
6287                            alignment: desc.alignment.map(|a| {
6288                                Some(crate::model::AlignmentPatch {
6289                                    horizontal: a.horizontal.map(Some),
6290                                    vertical: a.vertical.map(Some),
6291                                    wrap_text: a.wrap_text.map(Some),
6292                                    text_rotation: a.text_rotation.map(Some),
6293                                })
6294                            }),
6295                            number_format: None,
6296                        });
6297                    }
6298
6299                    if v.is_some() || f.is_some() || fmt.is_some() || style_patch.is_some() {
6300                        cells.push(crate::model::GridCell {
6301                            offset: [row - min_row, col - min_col],
6302                            v,
6303                            f,
6304                            fmt,
6305                            style: style_patch,
6306                        });
6307                    }
6308                }
6309            }
6310            if !cells.is_empty() {
6311                rows.push(crate::model::GridRow { cells });
6312            }
6313        }
6314
6315        let anchor = format!(
6316            "{}{}",
6317            crate::utils::column_number_to_name(min_col),
6318            min_row
6319        );
6320
6321        Ok::<_, anyhow::Error>(crate::model::GridPayload {
6322            sheet: params.sheet_name.clone(),
6323            anchor,
6324            columns,
6325            merges,
6326            rows,
6327        })
6328    })??;
6329
6330    Ok(payload)
6331}
6332
6333#[derive(Debug, Deserialize, JsonSchema)]
6334pub struct LayoutPageParams {
6335    /// Workbook ID or fork ID
6336    #[serde(alias = "workbook_id")]
6337    pub workbook_or_fork_id: WorkbookId,
6338    /// Sheet name
6339    pub sheet_name: String,
6340    /// A1 range to render (e.g., "A1:F40"). Defaults to "A1:T50". Capped at 80 rows × 25 cols.
6341    #[serde(default)]
6342    pub range: Option<String>,
6343    /// Cell content mode: "values" (default) or "formulas"
6344    #[serde(default)]
6345    pub mode: Option<LayoutMode>,
6346    /// Maximum column width in character units before truncating content (default: 20)
6347    #[serde(default)]
6348    pub max_col_width: Option<u32>,
6349    /// Set column widths to the longest rendered value in each column (default: false)
6350    #[serde(default)]
6351    pub fit_columns: Option<bool>,
6352    /// Trim empty edge columns from the rendered range (default: true)
6353    #[serde(default)]
6354    pub trim_empty_columns: Option<bool>,
6355    /// Output format: "json" (default), "ascii", or "both"
6356    #[serde(default)]
6357    pub render: Option<LayoutRender>,
6358}
6359
6360const LAYOUT_MAX_ROWS: u32 = 80;
6361const LAYOUT_MAX_COLS: u32 = 25;
6362const LAYOUT_DEFAULT_COL_WIDTH: f64 = 8.43;
6363const LAYOUT_DEFAULT_MAX_COL_WIDTH: u32 = 20;
6364
6365pub async fn layout_page(
6366    state: Arc<AppState>,
6367    params: LayoutPageParams,
6368) -> Result<LayoutPageResponse> {
6369    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
6370
6371    let range_str = params.range.as_deref().unwrap_or("A1:T50");
6372    let ((min_col, min_row), (raw_max_col, raw_max_row)) =
6373        parse_range(range_str).ok_or_else(|| anyhow!("invalid range: {}", range_str))?;
6374
6375    let requested_max_col_width = params
6376        .max_col_width
6377        .unwrap_or(LAYOUT_DEFAULT_MAX_COL_WIDTH)
6378        .max(3) as f64;
6379    let fit_columns = params.fit_columns.unwrap_or(false);
6380    let trim_empty_columns = params.trim_empty_columns.unwrap_or(true);
6381    let mode = params.mode.unwrap_or_default();
6382    let render = params.render.unwrap_or_default();
6383
6384    // Cap to hard limits
6385    let max_col = raw_max_col.min(min_col + LAYOUT_MAX_COLS - 1);
6386    let max_row = raw_max_row.min(min_row + LAYOUT_MAX_ROWS - 1);
6387    let truncated = max_col < raw_max_col || max_row < raw_max_row;
6388
6389    let (mut columns, mut merged_cells, mut rows) =
6390        workbook.with_sheet(&params.sheet_name, |sheet| {
6391            // ── column widths ────────────────────────────────────────────────
6392            let columns: Vec<LayoutPageColumnInfo> = (min_col..=max_col)
6393                .map(|col_idx| {
6394                    let col_name = column_number_to_name(col_idx);
6395                    let (raw_width, is_default) =
6396                        match sheet.get_column_dimension_by_number(&col_idx) {
6397                            Some(dim) => {
6398                                let w = *dim.get_width();
6399                                if w > 0.0 {
6400                                    (w, false)
6401                                } else {
6402                                    (LAYOUT_DEFAULT_COL_WIDTH, true)
6403                                }
6404                            }
6405                            None => (LAYOUT_DEFAULT_COL_WIDTH, true),
6406                        };
6407                    LayoutPageColumnInfo {
6408                        col: col_name,
6409                        index: col_idx,
6410                        width_chars: raw_width,
6411                        is_default_width: is_default,
6412                    }
6413                })
6414                .collect();
6415
6416            // ── merged cells ─────────────────────────────────────────────────
6417            let merged_strings: Vec<String> = sheet
6418                .get_merge_cells()
6419                .iter()
6420                .map(|m| m.get_range())
6421                .collect();
6422
6423            // Build set of (col, row) that are top-left of a merge span
6424            let merge_starts: std::collections::HashSet<(u32, u32)> = merged_strings
6425                .iter()
6426                .filter_map(|r| parse_range(r).map(|((c, ro), _)| (c, ro)))
6427                .collect();
6428
6429            // ── cells ────────────────────────────────────────────────────────
6430            let mut cell_map: HashMap<(u32, u32), LayoutCellInfo> = HashMap::new();
6431
6432            for cell in sheet.get_cell_collection() {
6433                let address = cell.get_coordinate().get_coordinate().to_string();
6434                let Some((col, row)) = parse_address(&address) else {
6435                    continue;
6436                };
6437                if col < min_col || col > max_col || row < min_row || row > max_row {
6438                    continue;
6439                }
6440
6441                let text: String = match mode {
6442                    LayoutMode::Formulas => {
6443                        let formula = cell.get_formula();
6444                        if !formula.is_empty() {
6445                            format!("={formula}")
6446                        } else {
6447                            cell_display_string(cell)
6448                        }
6449                    }
6450                    LayoutMode::Values => cell_display_string(cell),
6451                };
6452
6453                let desc = crate::styles::descriptor_from_style(cell.get_style());
6454                let bold = desc.font.as_ref().and_then(|f| f.bold);
6455                let italic = desc.font.as_ref().and_then(|f| f.italic);
6456                let align_h = desc.alignment.as_ref().and_then(|a| a.horizontal.clone());
6457                let borders = desc.borders.as_ref().map(|b| LayoutCellBorders {
6458                    top: b.top.as_ref().and_then(|s| s.style.clone()),
6459                    bottom: b.bottom.as_ref().and_then(|s| s.style.clone()),
6460                    left: b.left.as_ref().and_then(|s| s.style.clone()),
6461                    right: b.right.as_ref().and_then(|s| s.style.clone()),
6462                });
6463                let borders = borders.and_then(|b| if b.is_empty() { None } else { Some(b) });
6464
6465                cell_map.insert(
6466                    (col, row),
6467                    LayoutCellInfo {
6468                        address,
6469                        value: if text.is_empty() { None } else { Some(text) },
6470                        bold,
6471                        italic,
6472                        align_h,
6473                        borders,
6474                        merge_start: if merge_starts.contains(&(col, row)) {
6475                            Some(true)
6476                        } else {
6477                            None
6478                        },
6479                    },
6480                );
6481            }
6482
6483            // ── build row structs ────────────────────────────────────────────
6484            let rows: Vec<LayoutRowInfo> = (min_row..=max_row)
6485                .map(|row| {
6486                    let cells = (min_col..=max_col)
6487                        .map(|col| {
6488                            cell_map
6489                                .remove(&(col, row))
6490                                .unwrap_or_else(|| LayoutCellInfo {
6491                                    address: format!("{}{}", column_number_to_name(col), row),
6492                                    value: None,
6493                                    bold: None,
6494                                    italic: None,
6495                                    align_h: None,
6496                                    borders: None,
6497                                    merge_start: None,
6498                                })
6499                        })
6500                        .collect();
6501                    LayoutRowInfo { row, cells }
6502                })
6503                .collect();
6504
6505            Ok::<_, anyhow::Error>((columns, merged_strings, rows))
6506        })??;
6507
6508    let mut notes = Vec::new();
6509    let mut render_min_col = min_col;
6510    let mut render_max_col = max_col;
6511
6512    if trim_empty_columns && !columns.is_empty() {
6513        let has_visible_content = |col_idx: usize| -> bool {
6514            rows.iter().any(|row| {
6515                let cell = &row.cells[col_idx];
6516                cell.value
6517                    .as_deref()
6518                    .map(|v| !v.is_empty())
6519                    .unwrap_or(false)
6520                    || cell.bold.unwrap_or(false)
6521                    || cell.italic.unwrap_or(false)
6522                    || cell.merge_start.unwrap_or(false)
6523                    || cell.borders.is_some()
6524            })
6525        };
6526
6527        let mut start = 0usize;
6528        while start < columns.len() && !has_visible_content(start) {
6529            start += 1;
6530        }
6531
6532        let mut end = columns.len();
6533        while end > start && !has_visible_content(end - 1) {
6534            end -= 1;
6535        }
6536
6537        if start > 0 || end < columns.len() {
6538            if start < end {
6539                let trimmed_left = start;
6540                let trimmed_right = columns.len() - end;
6541                render_min_col = columns[start].index;
6542                render_max_col = columns[end - 1].index;
6543                columns = columns[start..end].to_vec();
6544                for row in &mut rows {
6545                    row.cells = row.cells[start..end].to_vec();
6546                }
6547                notes.push(format!(
6548                    "Trimmed empty edge columns (left: {trimmed_left}, right: {trimmed_right})"
6549                ));
6550            } else {
6551                // All requested columns are empty; keep one column to avoid empty-grid output.
6552                render_min_col = columns[0].index;
6553                render_max_col = columns[0].index;
6554                columns = vec![columns[0].clone()];
6555                for row in &mut rows {
6556                    row.cells = vec![row.cells[0].clone()];
6557                }
6558                notes.push(
6559                    "All requested columns were empty; kept one placeholder column".to_string(),
6560                );
6561            }
6562        }
6563    }
6564
6565    // Filter merged cells to only those overlapping the rendered range
6566    merged_cells.retain(|mc| {
6567        parse_range(mc).is_some_and(|((c1, r1), (c2, r2))| {
6568            c1 <= render_max_col && c2 >= render_min_col && r1 <= max_row && r2 >= min_row
6569        })
6570    });
6571
6572    if fit_columns {
6573        for (ci, col) in columns.iter_mut().enumerate() {
6574            // Fit-to-content should be able to shrink overly wide workbook columns,
6575            // so start from a small floor instead of the sheet's stored width.
6576            let mut max_len = 3usize;
6577            for row in &rows {
6578                if let Some(cell) = row.cells.get(ci) {
6579                    let content_len = cell
6580                        .value
6581                        .as_deref()
6582                        .map(|s| s.chars().count())
6583                        .unwrap_or(0);
6584                    let marker_len = usize::from(cell.bold.unwrap_or(false)) * 2
6585                        + usize::from(cell.italic.unwrap_or(false)) * 2;
6586                    max_len = max_len.max(content_len + marker_len);
6587                }
6588            }
6589            col.width_chars = max_len as f64;
6590        }
6591    } else {
6592        for col in &mut columns {
6593            col.width_chars = col.width_chars.min(requested_max_col_width);
6594        }
6595    }
6596
6597    let effective_range = format!(
6598        "{}{}:{}{}",
6599        column_number_to_name(render_min_col),
6600        min_row,
6601        column_number_to_name(render_max_col),
6602        max_row,
6603    );
6604
6605    let ascii_render = match render {
6606        LayoutRender::Ascii | LayoutRender::Both => Some(render_layout_ascii(
6607            &columns,
6608            &rows,
6609            if fit_columns {
6610                None
6611            } else {
6612                Some(requested_max_col_width as usize)
6613            },
6614        )),
6615        LayoutRender::Json => None,
6616    };
6617
6618    if truncated {
6619        notes.push(format!(
6620            "Range capped to {LAYOUT_MAX_ROWS} rows × {LAYOUT_MAX_COLS} columns"
6621        ));
6622    }
6623
6624    Ok(LayoutPageResponse {
6625        workbook_id: workbook.id.clone(),
6626        sheet_name: params.sheet_name,
6627        range: effective_range,
6628        columns,
6629        merged_cells,
6630        rows,
6631        ascii_render,
6632        truncated,
6633        notes,
6634    })
6635}
6636
6637/// Format a cell's value as a display string for the layout render.
6638fn cell_display_string(cell: &umya_spreadsheet::Cell) -> String {
6639    use crate::workbook::cell_to_value;
6640    match cell_to_value(cell) {
6641        Some(CellValue::Text(s)) => s,
6642        Some(CellValue::Number(n)) => {
6643            if n.fract() == 0.0 && n.abs() < 1e15 {
6644                format!("{}", n as i64)
6645            } else {
6646                format!("{n}")
6647            }
6648        }
6649        Some(CellValue::Bool(b)) => if b { "TRUE" } else { "FALSE" }.to_string(),
6650        Some(CellValue::Error(e)) => e,
6651        Some(CellValue::Date(d)) => d,
6652        None => String::new(),
6653    }
6654}
6655
6656/// Map an Excel border style string to a render weight (0–3).
6657fn border_weight(style: Option<&str>) -> u8 {
6658    match style {
6659        None => 0,
6660        Some(s) => match s.to_ascii_lowercase().as_str() {
6661            "none" => 0,
6662            "hair" | "dotted" | "dashed" | "dashDot" | "dashDotDot" => 1,
6663            "thin" | "slantDashDot" | "mediumDashDot" | "mediumDashDotDot" => 1,
6664            "medium" | "mediumDashed" => 2,
6665            "thick" => 2,
6666            "double" => 3,
6667            _ => 1,
6668        },
6669    }
6670}
6671
6672/// Render a compact ASCII grid from layout data.
6673#[allow(
6674    clippy::unnecessary_map_or,
6675    clippy::if_same_then_else,
6676    clippy::needless_range_loop
6677)]
6678fn render_layout_ascii(
6679    columns: &[LayoutPageColumnInfo],
6680    rows: &[LayoutRowInfo],
6681    max_col_width: Option<usize>,
6682) -> String {
6683    use std::fmt::Write;
6684
6685    if rows.is_empty() || columns.is_empty() {
6686        return String::new();
6687    }
6688
6689    // Column display widths (capped, minimum 3 for truncation marker)
6690    let col_widths: Vec<usize> = columns
6691        .iter()
6692        .map(|c| {
6693            let raw = c.width_chars.ceil() as usize;
6694            let capped = max_col_width.map_or(raw, |cap| raw.min(cap));
6695            capped.max(3)
6696        })
6697        .collect();
6698
6699    let n_cols = columns.len();
6700    let n_rows = rows.len();
6701
6702    // Per-cell border weights indexed by (row_idx, col_idx)
6703    // We also need the border of the top edge of row 0 and left edge of col 0.
6704    let cell_borders = |ri: usize, ci: usize| -> (u8, u8, u8, u8) {
6705        // (top, bottom, left, right)
6706        if ri >= n_rows || ci >= n_cols {
6707            return (0, 0, 0, 0);
6708        }
6709        let cell = &rows[ri].cells[ci];
6710        let b = cell.borders.as_ref();
6711        (
6712            border_weight(b.and_then(|b| b.top.as_deref())),
6713            border_weight(b.and_then(|b| b.bottom.as_deref())),
6714            border_weight(b.and_then(|b| b.left.as_deref())),
6715            border_weight(b.and_then(|b| b.right.as_deref())),
6716        )
6717    };
6718
6719    // Horizontal separator weight between row `above` (row_idx) and the next row.
6720    // above = usize::MAX means the top frame edge (above row 0).
6721    // above = n_rows means the bottom frame edge.
6722    let h_sep_weight = |above: usize, ci: usize| -> u8 {
6723        let bottom_of_above = if above < n_rows {
6724            cell_borders(above, ci).1
6725        } else {
6726            0
6727        };
6728        let top_of_below = if above.checked_add(1).map_or(false, |r| r < n_rows) {
6729            cell_borders(above + 1, ci).0
6730        } else {
6731            0
6732        };
6733        bottom_of_above.max(top_of_below)
6734    };
6735
6736    // Vertical separator weight between col `left` and the next col.
6737    let v_sep_weight = |ri: usize, left: usize| -> u8 {
6738        let right_of_left = if left < n_cols {
6739            cell_borders(ri, left).3
6740        } else {
6741            0
6742        };
6743        let left_of_right = if left + 1 < n_cols {
6744            cell_borders(ri, left + 1).2
6745        } else {
6746            0
6747        };
6748        right_of_left.max(left_of_right)
6749    };
6750
6751    // Characters by weight
6752    let h_char = |w: u8| match w {
6753        0 => ' ',
6754        1 => '─',
6755        2 => '━',
6756        _ => '═',
6757    };
6758    let v_char = |w: u8| match w {
6759        0 => ' ',
6760        1 => '│',
6761        2 => '┃',
6762        _ => '║',
6763    };
6764
6765    // Junction character at intersection of horizontal line (weight hw) and vertical line (weight vw)
6766    let junction = |hw: u8, vw: u8| -> char {
6767        match (hw, vw) {
6768            (0, 0) => ' ',
6769            (0, _) => v_char(vw),
6770            (_, 0) => h_char(hw),
6771            (1, 1) => '┼',
6772            (1, 2) | (1, 3) => '╂',
6773            (2, 1) | (3, 1) => '┿',
6774            _ => '╋',
6775        }
6776    };
6777
6778    let mut out = String::new();
6779
6780    // Legend
6781    let _ = writeln!(
6782        out,
6783        "[*=bold  /=italic  border weight: ─thin ━medium ═double]"
6784    );
6785
6786    // Draw a horizontal separator line (top/bottom/between-row)
6787    // `above` is the row index above this separator (usize::MAX = before row 0, n_rows = after last row)
6788    let draw_h_line = |out: &mut String, above: usize, is_top: bool, is_bottom: bool| {
6789        // Left junction
6790        let left_v = if is_top || is_bottom { 0u8 } else { 0u8 }; // outer frame has no vertical sep
6791        let _ = write!(
6792            out,
6793            "{}",
6794            junction(
6795                h_sep_weight(above, 0).max(1)
6796                    * (is_top || is_bottom || h_sep_weight(above, 0) > 0) as u8,
6797                left_v
6798            )
6799        );
6800        for ci in 0..n_cols {
6801            let hw = h_sep_weight(above, ci).max(if is_top || is_bottom { 1 } else { 0 });
6802            for _ in 0..col_widths[ci] + 2 {
6803                let _ = write!(out, "{}", h_char(hw));
6804            }
6805            // Right junction or edge
6806            if ci + 1 < n_cols {
6807                let vw = v_sep_weight(if above < n_rows { above } else { n_rows - 1 }, ci);
6808                let _ = write!(out, "{}", junction(hw, vw));
6809            } else {
6810                let _ = write!(out, "{}", junction(hw, 0));
6811            }
6812        }
6813        let _ = writeln!(out);
6814    };
6815
6816    // Top border line
6817    draw_h_line(&mut out, usize::MAX, true, false);
6818
6819    for ri in 0..n_rows {
6820        // Content row
6821        // Left outer edge
6822        let _ = write!(out, "{}", v_char(1));
6823        for ci in 0..n_cols {
6824            let cell = &rows[ri].cells[ci];
6825            let w = col_widths[ci];
6826
6827            let content = cell.value.as_deref().unwrap_or("");
6828            // Truncate to fit (leaving room for bold/italic markers)
6829            let bold = cell.bold.unwrap_or(false);
6830            let italic = cell.italic.unwrap_or(false);
6831            let marker_chars = if bold && italic {
6832                4
6833            } else if bold || italic {
6834                2
6835            } else {
6836                0
6837            };
6838            let content_width = w.saturating_sub(marker_chars);
6839            let truncated_content: String = if content.chars().count() > content_width {
6840                let mut s: String = content
6841                    .chars()
6842                    .take(content_width.saturating_sub(1))
6843                    .collect();
6844                s.push('…');
6845                s
6846            } else {
6847                content.to_string()
6848            };
6849
6850            // Determine alignment: right-align if align_h is "right", or if it looks numeric and align_h is not explicitly left
6851            let is_numeric = content
6852                .trim_start_matches(['-', '$', '('])
6853                .chars()
6854                .next()
6855                .map(|c| c.is_ascii_digit())
6856                .unwrap_or(false);
6857            let right_align = cell.align_h.as_deref() == Some("right")
6858                || (is_numeric && cell.align_h.as_deref().map(|a| a != "left").unwrap_or(true));
6859
6860            // Build decorated content
6861            let decorated = {
6862                let mut s = String::new();
6863                if bold {
6864                    s.push('*');
6865                }
6866                if italic {
6867                    s.push('/');
6868                }
6869                s.push_str(&truncated_content);
6870                if italic {
6871                    s.push('/');
6872                }
6873                if bold {
6874                    s.push('*');
6875                }
6876                s
6877            };
6878
6879            // Pad to column width
6880            let decorated_len = decorated.chars().count();
6881            let padded = if right_align {
6882                format!(
6883                    " {:>width$} ",
6884                    decorated,
6885                    width = w.saturating_sub(decorated_len) + decorated_len
6886                )
6887            } else {
6888                format!(
6889                    " {:<width$} ",
6890                    decorated,
6891                    width = w.saturating_sub(decorated_len) + decorated_len
6892                )
6893            };
6894            let _ = write!(out, "{}", padded);
6895
6896            // Right separator
6897            if ci + 1 < n_cols {
6898                let vw = v_sep_weight(ri, ci).max(1); // always at least thin inside the frame
6899                let _ = write!(out, "{}", v_char(vw));
6900            } else {
6901                let _ = write!(out, "{}", v_char(1));
6902            }
6903        }
6904        let _ = writeln!(out);
6905
6906        // Separator after this row
6907        if ri + 1 < n_rows {
6908            // Only draw if any column has a border between these rows
6909            let max_w: u8 = (0..n_cols)
6910                .map(|ci| h_sep_weight(ri, ci))
6911                .max()
6912                .unwrap_or(0);
6913            if max_w > 0 {
6914                draw_h_line(&mut out, ri, false, false);
6915            }
6916        }
6917    }
6918
6919    // Bottom border
6920    draw_h_line(&mut out, n_rows - 1, false, true);
6921
6922    out
6923}