Skip to main content

spreadsheet_kit/tools/
fork.rs

1use super::param_enums::{BatchMode, FillDirection, FormulaRelativeMode, ReplaceMatchMode};
2use crate::config::RecalcBackendKind;
3use crate::fork::{ChangeSummary, EditOp, StagedChange, StagedOp};
4use crate::formula::pattern::{RelativeMode, parse_base_formula, shift_formula_ast};
5use crate::model::{
6    AlignmentPatch, BordersPatch, CommandClass, FORMULA_PARSE_FAILED_PREFIX, FillPatch, FontPatch,
7    FormulaParseDiagnostics, FormulaParseDiagnosticsBuilder, FormulaParsePolicy, PatternFillPatch,
8    StylePatch, Warning, WorkbookId, validate_formula,
9};
10use crate::recalc::RecalcBackend;
11#[cfg(not(target_arch = "wasm32"))]
12use crate::security::sanitize_filename_component;
13use crate::state::AppState;
14use crate::tools::write_normalize::{EditBatchParamsInput, normalize_edit_batch};
15use crate::utils::make_short_random_id;
16use anyhow::{Result, anyhow, bail};
17use chrono::Utc;
18use formualizer_parse::tokenizer::Tokenizer;
19use regex::Regex;
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize, de};
22use std::collections::{BTreeMap, BTreeSet};
23use std::fs;
24use std::io::{Read, Write};
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28fn set_recalc_needed_flag(summary: &mut ChangeSummary, recalc_needed: bool) {
29    summary
30        .flags
31        .insert("recalc_needed".to_string(), recalc_needed);
32}
33
34#[derive(Debug, Deserialize, JsonSchema)]
35pub struct CreateForkParams {
36    #[serde(alias = "workbook_id")]
37    pub workbook_or_fork_id: WorkbookId,
38}
39
40#[derive(Debug, Serialize, JsonSchema)]
41pub struct CreateForkResponse {
42    pub fork_id: String,
43    pub base_workbook: String,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub client_base_workbook: Option<String>,
46    pub ttl_seconds: u64,
47}
48
49pub async fn create_fork(
50    state: Arc<AppState>,
51    params: CreateForkParams,
52) -> Result<CreateForkResponse> {
53    let registry = state
54        .fork_registry()
55        .ok_or_else(|| anyhow!("fork registry not available (recalc disabled?)"))?;
56
57    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
58    let base_path = &workbook.path;
59    let config = state.config();
60    let workspace_root = &config.workspace_root;
61
62    let fork_id = registry.create_fork(base_path, workspace_root)?;
63
64    Ok(CreateForkResponse {
65        fork_id,
66        base_workbook: base_path.display().to_string(),
67        client_base_workbook: config
68            .map_path_for_client(base_path)
69            .map(|p| p.display().to_string()),
70        ttl_seconds: registry.ttl().as_secs(),
71    })
72}
73
74#[derive(Debug, Deserialize, JsonSchema)]
75pub struct EditBatchParams {
76    pub fork_id: String,
77    pub sheet_name: String,
78    pub edits: Vec<CellEdit>,
79}
80
81#[derive(Debug, Clone, Deserialize, JsonSchema)]
82pub struct CellEdit {
83    pub address: String,
84    pub value: String,
85    #[serde(default)]
86    pub is_formula: bool,
87}
88
89#[derive(Debug, Serialize, JsonSchema)]
90pub struct EditBatchResponse {
91    pub fork_id: String,
92    pub edits_applied: usize,
93    pub total_edits: usize,
94    pub recalc_needed: bool,
95    #[serde(skip_serializing_if = "Vec::is_empty")]
96    pub warnings: Vec<Warning>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
99}
100
101pub async fn edit_batch(
102    state: Arc<AppState>,
103    params: EditBatchParamsInput,
104) -> Result<EditBatchResponse> {
105    let policy =
106        params
107            .formula_parse_policy
108            .unwrap_or(FormulaParsePolicy::default_for_command_class(
109                CommandClass::BatchWrite,
110            ));
111    let (params, warnings) = normalize_edit_batch(params)?;
112
113    let (edits_to_write, formula_parse_diagnostics) = if policy == FormulaParsePolicy::Off {
114        (params.edits.clone(), None)
115    } else {
116        let mut builder = FormulaParseDiagnosticsBuilder::new(policy);
117        let mut valid_edits = Vec::new();
118        for edit in &params.edits {
119            if edit.is_formula {
120                match validate_formula(&edit.value) {
121                    Ok(()) => valid_edits.push(edit.clone()),
122                    Err(err_msg) => {
123                        if policy == FormulaParsePolicy::Fail {
124                            bail!(
125                                "{}edit at {} failed: {}",
126                                FORMULA_PARSE_FAILED_PREFIX,
127                                edit.address,
128                                err_msg
129                            );
130                        }
131                        builder.record_error(
132                            &params.sheet_name,
133                            &edit.address,
134                            &edit.value,
135                            &err_msg,
136                        );
137                    }
138                }
139            } else {
140                valid_edits.push(edit.clone());
141            }
142        }
143        let diagnostics = if builder.has_errors() {
144            Some(builder.build())
145        } else {
146            None
147        };
148        (valid_edits, diagnostics)
149    };
150
151    let registry = state
152        .fork_registry()
153        .ok_or_else(|| anyhow!("fork registry not available"))?;
154
155    let fork_ctx = registry.get_fork(&params.fork_id)?;
156    let work_path = fork_ctx.work_path.clone();
157
158    let edits_to_apply: Vec<_> = edits_to_write
159        .iter()
160        .map(|e| EditOp {
161            timestamp: Utc::now(),
162            sheet: params.sheet_name.clone(),
163            address: e.address.clone(),
164            value: e.value.clone(),
165            is_formula: e.is_formula,
166        })
167        .collect();
168
169    let edit_count = edits_to_apply.len();
170
171    tokio::task::spawn_blocking({
172        let sheet_name = params.sheet_name.clone();
173        let edits = edits_to_write.clone();
174        move || {
175            let core_edits = edits
176                .into_iter()
177                .map(|edit| crate::core::types::CellEdit {
178                    address: edit.address,
179                    value: edit.value,
180                    is_formula: edit.is_formula,
181                })
182                .collect::<Vec<_>>();
183            crate::core::write::apply_edits_to_file(&work_path, &sheet_name, &core_edits)
184        }
185    })
186    .await??;
187
188    let total = registry.with_fork_mut(&params.fork_id, |ctx| {
189        ctx.edits.extend(edits_to_apply);
190        ctx.recalc_needed = true;
191        Ok(ctx.edits.len())
192    })?;
193
194    let fork_workbook_id = WorkbookId(params.fork_id.clone());
195    let _ = state.close_workbook(&fork_workbook_id);
196
197    Ok(EditBatchResponse {
198        fork_id: params.fork_id,
199        edits_applied: edit_count,
200        total_edits: total,
201        recalc_needed: true,
202        warnings,
203        formula_parse_diagnostics,
204    })
205}
206
207fn default_clone_count() -> u32 {
208    1
209}
210
211fn default_clear_values() -> bool {
212    true
213}
214
215fn default_overwrite_formulas() -> bool {
216    false
217}
218
219fn default_replace_case_sensitive() -> bool {
220    true
221}
222
223#[derive(Debug, Deserialize, JsonSchema)]
224pub struct TransformBatchParams {
225    pub fork_id: String,
226    pub ops: Vec<TransformOp>,
227    #[serde(default)]
228    pub mode: Option<BatchMode>, // preview|apply (default apply)
229    pub label: Option<String>,
230    #[serde(default)]
231    pub formula_parse_policy: Option<FormulaParsePolicy>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
235pub enum MatrixCell {
236    #[serde(rename = "v")]
237    Value(serde_json::Value),
238    #[serde(rename = "f")]
239    Formula(String),
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
243#[serde(tag = "kind", rename_all = "snake_case")]
244pub enum TransformOp {
245    ClearRange {
246        sheet_name: String,
247        target: TransformTarget,
248        #[serde(default = "default_clear_values")]
249        clear_values: bool,
250        #[serde(default)]
251        clear_formulas: bool,
252    },
253    FillRange {
254        sheet_name: String,
255        target: TransformTarget,
256        value: String,
257        #[serde(default)]
258        is_formula: bool,
259        #[serde(default = "default_overwrite_formulas")]
260        overwrite_formulas: bool,
261    },
262    ReplaceInRange {
263        sheet_name: String,
264        target: TransformTarget,
265        find: String,
266        replace: String,
267        #[serde(default)]
268        match_mode: ReplaceMatchMode,
269        #[serde(default = "default_replace_case_sensitive")]
270        case_sensitive: bool,
271        #[serde(default)]
272        include_formulas: bool,
273    },
274    WriteMatrix {
275        sheet_name: String,
276        anchor: String,
277        rows: Vec<Vec<Option<MatrixCell>>>,
278        #[serde(default = "default_overwrite_formulas")]
279        overwrite_formulas: bool,
280    },
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
284#[serde(tag = "kind", rename_all = "snake_case")]
285pub enum TransformTarget {
286    Range { range: String },
287    Region { region_id: u32 },
288    Cells { cells: Vec<String> },
289}
290
291#[derive(Debug, Serialize, JsonSchema)]
292pub struct TransformBatchResponse {
293    pub fork_id: String,
294    pub mode: String,
295    pub change_id: Option<String>,
296    pub ops_applied: usize,
297    pub summary: ChangeSummary,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
300}
301
302#[derive(Debug, Serialize, Deserialize)]
303struct TransformBatchStagedPayload {
304    ops: Vec<TransformOp>,
305}
306
307pub(crate) fn resolve_transform_ops_for_workbook(
308    workbook: &crate::workbook::WorkbookContext,
309    ops: &[TransformOp],
310) -> Result<Vec<TransformOp>> {
311    let mut resolved_ops = Vec::with_capacity(ops.len());
312
313    for op in ops {
314        match op {
315            TransformOp::WriteMatrix { .. } => {
316                resolved_ops.push(op.clone());
317            }
318            TransformOp::ClearRange {
319                sheet_name, target, ..
320            }
321            | TransformOp::FillRange {
322                sheet_name, target, ..
323            }
324            | TransformOp::ReplaceInRange {
325                sheet_name, target, ..
326            } => {
327                let resolved_target = match target {
328                    TransformTarget::Region { region_id } => {
329                        let metrics = workbook.get_sheet_metrics(sheet_name)?;
330                        let regions = metrics.detected_regions();
331                        let region =
332                            regions.iter().find(|r| r.id == *region_id).ok_or_else(|| {
333                                anyhow!(
334                                    "region_id {} not found on sheet '{}'",
335                                    region_id,
336                                    sheet_name
337                                )
338                            })?;
339                        TransformTarget::Range {
340                            range: region.bounds.clone(),
341                        }
342                    }
343                    other => other.clone(),
344                };
345
346                match op {
347                    TransformOp::ClearRange {
348                        sheet_name,
349                        clear_values,
350                        clear_formulas,
351                        ..
352                    } => {
353                        resolved_ops.push(TransformOp::ClearRange {
354                            sheet_name: sheet_name.clone(),
355                            target: resolved_target,
356                            clear_values: *clear_values,
357                            clear_formulas: *clear_formulas,
358                        });
359                    }
360                    TransformOp::FillRange {
361                        sheet_name,
362                        value,
363                        is_formula,
364                        overwrite_formulas,
365                        ..
366                    } => {
367                        resolved_ops.push(TransformOp::FillRange {
368                            sheet_name: sheet_name.clone(),
369                            target: resolved_target,
370                            value: value.clone(),
371                            is_formula: *is_formula,
372                            overwrite_formulas: *overwrite_formulas,
373                        });
374                    }
375                    TransformOp::ReplaceInRange {
376                        sheet_name,
377                        find,
378                        replace,
379                        match_mode,
380                        case_sensitive,
381                        include_formulas,
382                        ..
383                    } => {
384                        resolved_ops.push(TransformOp::ReplaceInRange {
385                            sheet_name: sheet_name.clone(),
386                            target: resolved_target,
387                            find: find.clone(),
388                            replace: replace.clone(),
389                            match_mode: *match_mode,
390                            case_sensitive: *case_sensitive,
391                            include_formulas: *include_formulas,
392                        });
393                    }
394                    TransformOp::WriteMatrix { .. } => unreachable!(),
395                }
396            }
397        }
398    }
399
400    Ok(resolved_ops)
401}
402
403pub async fn transform_batch(
404    state: Arc<AppState>,
405    params: TransformBatchParams,
406) -> Result<TransformBatchResponse> {
407    let registry = state
408        .fork_registry()
409        .ok_or_else(|| anyhow!("fork registry not available"))?;
410
411    let fork_ctx = registry.get_fork(&params.fork_id)?;
412    let work_path = fork_ctx.work_path.clone();
413
414    let fork_workbook_id = WorkbookId(params.fork_id.clone());
415    let workbook = state.open_workbook(&fork_workbook_id).await?;
416
417    let resolved_ops = resolve_transform_ops_for_workbook(&workbook, &params.ops)?;
418
419    let policy =
420        params
421            .formula_parse_policy
422            .unwrap_or(FormulaParsePolicy::default_for_command_class(
423                CommandClass::BatchWrite,
424            ));
425
426    let (ops_to_apply, formula_parse_diagnostics) = if policy == FormulaParsePolicy::Off {
427        (resolved_ops, None)
428    } else {
429        let mut builder = FormulaParseDiagnosticsBuilder::new(policy);
430        let mut valid_ops = Vec::new();
431        for op in resolved_ops {
432            match &op {
433                TransformOp::FillRange {
434                    sheet_name,
435                    value,
436                    is_formula,
437                    ..
438                } if *is_formula => match validate_formula(value) {
439                    Ok(()) => valid_ops.push(op),
440                    Err(err_msg) => {
441                        if policy == FormulaParsePolicy::Fail {
442                            bail!(
443                                "{}FillRange formula failed: {}",
444                                FORMULA_PARSE_FAILED_PREFIX,
445                                err_msg
446                            );
447                        }
448                        builder.record_error(sheet_name, "FillRange", value, &err_msg);
449                    }
450                },
451                TransformOp::WriteMatrix {
452                    sheet_name,
453                    anchor,
454                    rows,
455                    overwrite_formulas,
456                } => {
457                    let mut has_errors = false;
458                    let mut valid_rows = Vec::new();
459
460                    let (anchor_col, anchor_row) = parse_cell_ref(anchor)?;
461
462                    for (r_idx, row) in rows.iter().enumerate() {
463                        let mut valid_row = Vec::new();
464                        let r = anchor_row + r_idx as u32;
465
466                        for (c_idx, cell_opt) in row.iter().enumerate() {
467                            let c = anchor_col + c_idx as u32;
468                            if let Some(MatrixCell::Formula(f)) = cell_opt {
469                                match validate_formula(f) {
470                                    Ok(()) => valid_row.push(cell_opt.clone()),
471                                    Err(err_msg) => {
472                                        if policy == FormulaParsePolicy::Fail {
473                                            bail!(
474                                                "{}WriteMatrix formula failed at {}: {}",
475                                                FORMULA_PARSE_FAILED_PREFIX,
476                                                crate::utils::cell_address(c, r),
477                                                err_msg
478                                            );
479                                        }
480                                        builder.record_error(
481                                            sheet_name,
482                                            &crate::utils::cell_address(c, r),
483                                            f,
484                                            &err_msg,
485                                        );
486                                        has_errors = true;
487                                        valid_row.push(None); // drop the invalid formula cell if warn
488                                    }
489                                }
490                            } else {
491                                valid_row.push(cell_opt.clone());
492                            }
493                        }
494                        valid_rows.push(valid_row);
495                    }
496
497                    if has_errors && policy == FormulaParsePolicy::Warn {
498                        valid_ops.push(TransformOp::WriteMatrix {
499                            sheet_name: sheet_name.clone(),
500                            anchor: anchor.clone(),
501                            rows: valid_rows,
502                            overwrite_formulas: *overwrite_formulas,
503                        });
504                    } else {
505                        valid_ops.push(op);
506                    }
507                }
508                _ => valid_ops.push(op),
509            }
510        }
511        let diagnostics = if builder.has_errors() {
512            Some(builder.build())
513        } else {
514            None
515        };
516        (valid_ops, diagnostics)
517    };
518
519    let mode = params.mode.unwrap_or_default();
520
521    if mode.is_preview() {
522        let change_id = make_short_random_id("chg", 12);
523        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
524        fs::create_dir_all(snapshot_path.parent().unwrap())?;
525        fs::copy(&work_path, &snapshot_path)?;
526
527        let snapshot_for_apply = snapshot_path.clone();
528        let apply_result = tokio::task::spawn_blocking({
529            let ops = ops_to_apply.clone();
530            move || apply_transform_ops_to_file(&snapshot_for_apply, &ops)
531        })
532        .await??;
533
534        let mut summary = apply_result.summary;
535        summary.op_kinds = vec!["transform_batch".to_string()];
536        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
537
538        let staged_op = StagedOp {
539            kind: "transform_batch".to_string(),
540            payload: serde_json::to_value(TransformBatchStagedPayload {
541                ops: ops_to_apply.clone(),
542            })?,
543        };
544
545        let staged = StagedChange {
546            change_id: change_id.clone(),
547            created_at: Utc::now(),
548            label: params.label.clone(),
549            ops: vec![staged_op],
550            summary: summary.clone(),
551            fork_path_snapshot: Some(snapshot_path),
552        };
553
554        registry.add_staged_change(&params.fork_id, staged)?;
555
556        Ok(TransformBatchResponse {
557            fork_id: params.fork_id,
558            mode: mode.as_str().to_string(),
559            change_id: Some(change_id),
560            ops_applied: apply_result.ops_applied,
561            summary,
562            formula_parse_diagnostics,
563        })
564    } else {
565        let apply_result = tokio::task::spawn_blocking({
566            let ops = ops_to_apply.clone();
567            let work_path = work_path.clone();
568            move || apply_transform_ops_to_file(&work_path, &ops)
569        })
570        .await??;
571
572        let mut summary = apply_result.summary;
573        summary.op_kinds = vec!["transform_batch".to_string()];
574
575        registry.with_fork_mut(&params.fork_id, |ctx| {
576            ctx.recalc_needed = true;
577            Ok(())
578        })?;
579        set_recalc_needed_flag(&mut summary, true);
580
581        let _ = state.close_workbook(&fork_workbook_id);
582
583        Ok(TransformBatchResponse {
584            fork_id: params.fork_id,
585            mode: mode.as_str().to_string(),
586            change_id: None,
587            ops_applied: apply_result.ops_applied,
588            summary,
589            formula_parse_diagnostics,
590        })
591    }
592}
593
594#[derive(Debug, Deserialize, JsonSchema)]
595pub struct StyleBatchParams {
596    pub fork_id: String,
597    pub ops: Vec<StyleOp>,
598    #[serde(default)]
599    pub mode: Option<BatchMode>, // preview|apply (default apply)
600    pub label: Option<String>,
601}
602
603#[derive(Debug, Deserialize, JsonSchema)]
604pub struct StyleBatchParamsInput {
605    pub fork_id: String,
606    pub ops: Vec<StyleOpInput>,
607    #[serde(default)]
608    pub mode: Option<BatchMode>,
609    pub label: Option<String>,
610}
611
612#[derive(Debug, Clone)]
613pub struct StyleOpInput {
614    op: StyleOp,
615    shorthand_used: bool,
616    fill_color_used: bool,
617    color_alpha_defaulted: bool,
618}
619
620impl From<StyleOp> for StyleOpInput {
621    fn from(op: StyleOp) -> Self {
622        Self {
623            op,
624            shorthand_used: false,
625            fill_color_used: false,
626            color_alpha_defaulted: false,
627        }
628    }
629}
630
631impl<'de> Deserialize<'de> for StyleOpInput {
632    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
633    where
634        D: serde::Deserializer<'de>,
635    {
636        let mut value = serde_json::Value::deserialize(deserializer)?;
637        let Some(obj) = value.as_object_mut() else {
638            return Err(de::Error::custom("style op must be an object"));
639        };
640
641        let mut shorthand_used = false;
642        let mut fill_color_used = false;
643        let mut color_alpha_defaulted = false;
644
645        if obj.get("target").is_none()
646            && let Some(range) = obj.remove("range")
647        {
648            shorthand_used = true;
649            obj.insert(
650                "target".to_string(),
651                serde_json::json!({ "kind": "range", "range": range }),
652            );
653        }
654
655        if obj.get("patch").is_none()
656            && let Some(style) = obj.remove("style")
657        {
658            shorthand_used = true;
659            obj.insert("patch".to_string(), style);
660        }
661
662        if let Some(patch_value) = obj.remove("patch") {
663            let patch_input: StylePatchInput =
664                serde_json::from_value(patch_value).map_err(de::Error::custom)?;
665            let (patch, used_fill_color, alpha_defaulted) =
666                normalize_style_patch_input(patch_input);
667            if used_fill_color {
668                fill_color_used = true;
669            }
670            if alpha_defaulted {
671                color_alpha_defaulted = true;
672            }
673            obj.insert(
674                "patch".to_string(),
675                serde_json::to_value(patch).map_err(de::Error::custom)?,
676            );
677        }
678
679        let op = serde_json::from_value(value).map_err(de::Error::custom)?;
680        Ok(StyleOpInput {
681            op,
682            shorthand_used,
683            fill_color_used,
684            color_alpha_defaulted,
685        })
686    }
687}
688
689impl schemars::JsonSchema for StyleOpInput {
690    fn schema_name() -> std::borrow::Cow<'static, str> {
691        "StyleOp".into()
692    }
693
694    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
695        StyleOp::json_schema(generator)
696    }
697}
698
699#[derive(Debug, Clone, Deserialize)]
700struct StylePatchInput {
701    #[serde(default)]
702    pub font: Option<Option<FontPatch>>,
703    #[serde(default)]
704    pub fill: Option<Option<FillPatchInput>>,
705    #[serde(default)]
706    pub borders: Option<Option<BordersPatch>>,
707    #[serde(default)]
708    pub alignment: Option<Option<AlignmentPatch>>,
709    #[serde(default)]
710    pub number_format: Option<Option<NumberFormatPatchInput>>,
711}
712
713#[derive(Debug, Clone, Deserialize)]
714#[serde(untagged)]
715enum NumberFormatPatchInput {
716    FormatCode(String),
717    Shorthand(NumberFormatShorthandInput),
718}
719
720#[derive(Debug, Clone, Deserialize)]
721struct NumberFormatShorthandInput {
722    pub kind: NumberFormatKind,
723    #[serde(default)]
724    pub format_code: Option<String>,
725}
726
727#[derive(Debug, Clone, Deserialize)]
728#[serde(rename_all = "snake_case")]
729enum NumberFormatKind {
730    Currency,
731    Percent,
732    DateIso,
733    Accounting,
734    Integer,
735}
736
737fn number_format_kind_to_format_code(kind: &NumberFormatKind) -> &'static str {
738    match kind {
739        NumberFormatKind::Currency => "$#,##0.00",
740        NumberFormatKind::Percent => "0.00%",
741        NumberFormatKind::DateIso => "yyyy-mm-dd",
742        NumberFormatKind::Accounting => "_($* #,##0.00_)",
743        NumberFormatKind::Integer => "0",
744    }
745}
746
747#[derive(Debug, Clone, Deserialize)]
748#[serde(untagged)]
749enum FillPatchInput {
750    Canonical(FillPatch),
751    Color(FillColorPatch),
752}
753
754#[derive(Debug, Clone, Deserialize)]
755struct FillColorPatch {
756    color: String,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
760pub struct StyleOp {
761    pub sheet_name: String,
762    pub target: StyleTarget,
763    pub patch: StylePatch,
764    #[serde(default)]
765    pub op_mode: Option<crate::styles::StylePatchMode>,
766}
767
768#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
769#[serde(tag = "kind", rename_all = "snake_case")]
770pub enum StyleTarget {
771    Range { range: String },
772    Region { region_id: u32 },
773    Cells { cells: Vec<String> },
774}
775
776#[derive(Debug, Serialize, JsonSchema)]
777pub struct StyleBatchResponse {
778    pub fork_id: String,
779    pub mode: String,
780    pub change_id: Option<String>,
781    pub ops_applied: usize,
782    pub summary: ChangeSummary,
783}
784
785#[derive(Debug, Deserialize, JsonSchema)]
786pub struct ColumnSizeBatchParamsInput {
787    pub fork_id: String,
788    pub sheet_name: String,
789    pub ops: Vec<ColumnSizeOpInput>,
790    pub mode: Option<BatchMode>, // preview|apply (default apply)
791    pub label: Option<String>,
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
795#[serde(tag = "kind", rename_all = "snake_case")]
796pub enum ColumnTarget {
797    Columns { range: String },
798}
799
800#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
801#[serde(tag = "kind", rename_all = "snake_case")]
802pub enum ColumnSizeSpec {
803    Auto {
804        #[serde(default)]
805        min_width_chars: Option<f64>,
806        #[serde(default)]
807        max_width_chars: Option<f64>,
808    },
809    Width {
810        width_chars: f64,
811    },
812}
813
814#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
815pub struct ColumnSizeOp {
816    pub target: ColumnTarget,
817    pub size: ColumnSizeSpec,
818}
819
820#[derive(Debug, Deserialize, JsonSchema)]
821#[serde(untagged)]
822pub enum ColumnSizeOpInput {
823    Canonical(ColumnSizeOp),
824    Shorthand { range: String, size: ColumnSizeSpec },
825}
826
827impl From<ColumnSizeOp> for ColumnSizeOpInput {
828    fn from(value: ColumnSizeOp) -> Self {
829        Self::Canonical(value)
830    }
831}
832
833#[derive(Debug, Clone)]
834struct ColumnSizeBatchParams {
835    fork_id: String,
836    sheet_name: String,
837    ops: Vec<ColumnSizeOp>,
838    mode: Option<BatchMode>,
839    label: Option<String>,
840}
841
842#[derive(Debug, Serialize, JsonSchema)]
843pub struct ColumnSizeBatchResponse {
844    pub fork_id: String,
845    pub sheet_name: String,
846    pub mode: String,
847    pub change_id: Option<String>,
848    pub ops_applied: usize,
849    pub summary: ChangeSummary,
850}
851
852#[derive(Debug, Serialize, Deserialize)]
853struct ColumnSizeBatchStagedPayload {
854    sheet_name: String,
855    ops: Vec<ColumnSizeOp>,
856}
857
858fn normalize_column_size_batch(
859    params: ColumnSizeBatchParamsInput,
860) -> Result<(ColumnSizeBatchParams, Vec<crate::model::Warning>)> {
861    let mut warnings = Vec::new();
862    let mut ops = Vec::with_capacity(params.ops.len());
863
864    for entry in params.ops {
865        match entry {
866            ColumnSizeOpInput::Canonical(op) => ops.push(op),
867            ColumnSizeOpInput::Shorthand { range, size } => {
868                warnings.push(crate::model::Warning {
869                    code: "WARN_COLUMN_SHORTHAND_TARGET".to_string(),
870                    message: "Used range shorthand; prefer target:{kind:'columns',range:'A:C'}"
871                        .to_string(),
872                });
873                ops.push(ColumnSizeOp {
874                    target: ColumnTarget::Columns { range },
875                    size,
876                });
877            }
878        }
879    }
880
881    Ok((
882        ColumnSizeBatchParams {
883            fork_id: params.fork_id,
884            sheet_name: params.sheet_name,
885            ops,
886            mode: params.mode,
887            label: params.label,
888        },
889        warnings,
890    ))
891}
892
893pub(crate) fn normalize_column_size_payload(
894    sheet_name: String,
895    ops: Vec<ColumnSizeOpInput>,
896) -> Result<(Vec<ColumnSizeOp>, Vec<Warning>)> {
897    let (params, warnings) = normalize_column_size_batch(ColumnSizeBatchParamsInput {
898        fork_id: String::new(),
899        sheet_name,
900        ops,
901        mode: None,
902        label: None,
903    })?;
904    Ok((params.ops, warnings))
905}
906
907pub async fn column_size_batch(
908    state: Arc<AppState>,
909    params: ColumnSizeBatchParamsInput,
910) -> Result<ColumnSizeBatchResponse> {
911    let (params, warnings) = normalize_column_size_batch(params)?;
912    let warning_messages: Vec<String> = warnings
913        .into_iter()
914        .map(|warning| format!("{}: {}", warning.code, warning.message))
915        .collect();
916    let registry = state
917        .fork_registry()
918        .ok_or_else(|| anyhow!("fork registry not available"))?;
919
920    let fork_ctx = registry.get_fork(&params.fork_id)?;
921    let work_path = fork_ctx.work_path.clone();
922
923    let fork_workbook_id = WorkbookId(params.fork_id.clone());
924    let workbook = state.open_workbook(&fork_workbook_id).await?;
925    let _ = workbook.with_sheet(&params.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
926
927    let mode = params.mode.unwrap_or_default();
928
929    if mode.is_preview() {
930        let change_id = make_short_random_id("chg", 12);
931        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
932        fs::create_dir_all(snapshot_path.parent().unwrap())?;
933        fs::copy(&work_path, &snapshot_path)?;
934
935        let snapshot_path_for_apply = snapshot_path.clone();
936        let apply_result = tokio::task::spawn_blocking({
937            let ops = params.ops.clone();
938            let sheet_name = params.sheet_name.clone();
939            move || apply_column_size_ops_to_file(&snapshot_path_for_apply, &sheet_name, &ops)
940        })
941        .await??;
942
943        let mut summary = apply_result.summary;
944        summary.op_kinds = vec!["column_size_batch".to_string()];
945        summary.warnings.extend(warning_messages.clone());
946        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
947
948        let staged_op = StagedOp {
949            kind: "column_size_batch".to_string(),
950            payload: serde_json::to_value(ColumnSizeBatchStagedPayload {
951                sheet_name: params.sheet_name.clone(),
952                ops: params.ops.clone(),
953            })?,
954        };
955
956        let staged = StagedChange {
957            change_id: change_id.clone(),
958            created_at: Utc::now(),
959            label: params.label.clone(),
960            ops: vec![staged_op],
961            summary: summary.clone(),
962            fork_path_snapshot: Some(snapshot_path),
963        };
964
965        registry.add_staged_change(&params.fork_id, staged)?;
966
967        Ok(ColumnSizeBatchResponse {
968            fork_id: params.fork_id,
969            sheet_name: params.sheet_name,
970            mode: mode.as_str().to_string(),
971            change_id: Some(change_id),
972            ops_applied: apply_result.ops_applied,
973            summary,
974        })
975    } else {
976        let apply_result = tokio::task::spawn_blocking({
977            let ops = params.ops.clone();
978            let sheet_name = params.sheet_name.clone();
979            let work_path = work_path.clone();
980            move || apply_column_size_ops_to_file(&work_path, &sheet_name, &ops)
981        })
982        .await??;
983
984        let mut summary = apply_result.summary;
985        summary.op_kinds = vec!["column_size_batch".to_string()];
986        summary.warnings.extend(warning_messages);
987        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
988
989        let _ = state.close_workbook(&fork_workbook_id);
990
991        Ok(ColumnSizeBatchResponse {
992            fork_id: params.fork_id,
993            sheet_name: params.sheet_name,
994            mode: mode.as_str().to_string(),
995            change_id: None,
996            ops_applied: apply_result.ops_applied,
997            summary,
998        })
999    }
1000}
1001
1002#[derive(Debug, Serialize, Deserialize)]
1003struct StyleBatchStagedPayload {
1004    ops: Vec<StyleOp>,
1005}
1006
1007pub fn normalize_style_batch(
1008    input: StyleBatchParamsInput,
1009) -> Result<(StyleBatchParams, Vec<Warning>)> {
1010    let mut warnings = Vec::new();
1011    let mut ops = Vec::with_capacity(input.ops.len());
1012
1013    for op_input in input.ops {
1014        if op_input.shorthand_used {
1015            warnings.push(Warning {
1016                code: "WARN_STYLE_SHORTHAND".to_string(),
1017                message: "Normalized style op shorthand to canonical form".to_string(),
1018            });
1019        }
1020        if op_input.fill_color_used {
1021            warnings.push(Warning {
1022                code: "WARN_FILL_COLOR".to_string(),
1023                message: "Normalized fill color shorthand to pattern fill".to_string(),
1024            });
1025        }
1026        if op_input.color_alpha_defaulted {
1027            warnings.push(Warning {
1028                code: "WARN_COLOR_ALPHA_DEFAULT".to_string(),
1029                message: "Normalized RGB hex to ARGB with default alpha".to_string(),
1030            });
1031        }
1032        ops.push(op_input.op);
1033    }
1034
1035    Ok((
1036        StyleBatchParams {
1037            fork_id: input.fork_id,
1038            ops,
1039            mode: input.mode,
1040            label: input.label,
1041        },
1042        warnings,
1043    ))
1044}
1045
1046fn normalize_style_patch_input(input: StylePatchInput) -> (StylePatch, bool, bool) {
1047    let mut fill_color_used = false;
1048    let mut color_alpha_defaulted = false;
1049    let fill = match input.fill {
1050        None => None,
1051        Some(None) => Some(None),
1052        Some(Some(fill_input)) => {
1053            let normalized = match fill_input {
1054                FillPatchInput::Canonical(fill) => fill,
1055                FillPatchInput::Color(color) => {
1056                    fill_color_used = true;
1057                    FillPatch::Pattern(PatternFillPatch {
1058                        pattern_type: Some(Some("solid".to_string())),
1059                        foreground_color: Some(Some(color.color)),
1060                        background_color: None,
1061                    })
1062                }
1063            };
1064            Some(Some(normalized))
1065        }
1066    };
1067
1068    let number_format: Option<Option<String>> = match input.number_format {
1069        None => None,
1070        Some(None) => Some(None),
1071        Some(Some(nf)) => match nf {
1072            NumberFormatPatchInput::FormatCode(code) => Some(Some(code)),
1073            NumberFormatPatchInput::Shorthand(sh) => {
1074                if let Some(code) = sh.format_code {
1075                    Some(Some(code))
1076                } else {
1077                    Some(Some(
1078                        number_format_kind_to_format_code(&sh.kind).to_string(),
1079                    ))
1080                }
1081            }
1082        },
1083    };
1084
1085    let mut patch = StylePatch {
1086        font: input.font,
1087        fill,
1088        borders: input.borders,
1089        alignment: input.alignment,
1090        number_format,
1091    };
1092    normalize_style_patch_colors(&mut patch, &mut color_alpha_defaulted);
1093
1094    (patch, fill_color_used, color_alpha_defaulted)
1095}
1096
1097fn normalize_style_patch_colors(patch: &mut StylePatch, alpha_defaulted: &mut bool) {
1098    if let Some(Some(font)) = patch.font.as_mut() {
1099        normalize_color_option(&mut font.color, alpha_defaulted);
1100    }
1101
1102    if let Some(Some(fill)) = patch.fill.as_mut() {
1103        normalize_fill_colors(fill, alpha_defaulted);
1104    }
1105
1106    if let Some(Some(borders)) = patch.borders.as_mut() {
1107        normalize_border_side_color(&mut borders.left, alpha_defaulted);
1108        normalize_border_side_color(&mut borders.right, alpha_defaulted);
1109        normalize_border_side_color(&mut borders.top, alpha_defaulted);
1110        normalize_border_side_color(&mut borders.bottom, alpha_defaulted);
1111        normalize_border_side_color(&mut borders.diagonal, alpha_defaulted);
1112        normalize_border_side_color(&mut borders.vertical, alpha_defaulted);
1113        normalize_border_side_color(&mut borders.horizontal, alpha_defaulted);
1114    }
1115}
1116
1117fn normalize_fill_colors(fill: &mut FillPatch, alpha_defaulted: &mut bool) {
1118    match fill {
1119        FillPatch::Pattern(pattern) => {
1120            normalize_color_option(&mut pattern.foreground_color, alpha_defaulted);
1121            normalize_color_option(&mut pattern.background_color, alpha_defaulted);
1122        }
1123        FillPatch::Gradient(gradient) => {
1124            if let Some(stops) = gradient.stops.as_mut() {
1125                for stop in stops {
1126                    if let Some((normalized, defaulted)) =
1127                        crate::styles::normalize_color_hex(&stop.color)
1128                    {
1129                        stop.color = normalized;
1130                        if defaulted {
1131                            *alpha_defaulted = true;
1132                        }
1133                    }
1134                }
1135            }
1136        }
1137    }
1138}
1139
1140fn normalize_border_side_color(
1141    side: &mut Option<Option<crate::model::BorderSidePatch>>,
1142    alpha_defaulted: &mut bool,
1143) {
1144    if let Some(Some(side_patch)) = side.as_mut() {
1145        normalize_color_option(&mut side_patch.color, alpha_defaulted);
1146    }
1147}
1148
1149fn normalize_color_option(value: &mut Option<Option<String>>, alpha_defaulted: &mut bool) {
1150    let Some(Some(color)) = value.as_mut() else {
1151        return;
1152    };
1153    if let Some((normalized, defaulted)) = crate::styles::normalize_color_hex(color) {
1154        *color = normalized;
1155        if defaulted {
1156            *alpha_defaulted = true;
1157        }
1158    }
1159}
1160
1161pub(crate) fn resolve_style_ops_for_workbook(
1162    workbook: &crate::workbook::WorkbookContext,
1163    ops: &[StyleOp],
1164) -> Result<Vec<StyleOp>> {
1165    let mut resolved_ops = Vec::with_capacity(ops.len());
1166    for op in ops {
1167        let mut resolved = op.clone();
1168        if let StyleTarget::Region { region_id } = &op.target {
1169            let metrics = workbook.get_sheet_metrics(&op.sheet_name)?;
1170            let regions = metrics.detected_regions();
1171            let region = regions.iter().find(|r| r.id == *region_id).ok_or_else(|| {
1172                anyhow!(
1173                    "region_id {} not found on sheet '{}'",
1174                    region_id,
1175                    op.sheet_name
1176                )
1177            })?;
1178            resolved.target = StyleTarget::Range {
1179                range: region.bounds.clone(),
1180            };
1181        }
1182        resolved_ops.push(resolved);
1183    }
1184    Ok(resolved_ops)
1185}
1186
1187pub async fn style_batch(
1188    state: Arc<AppState>,
1189    params: StyleBatchParamsInput,
1190) -> Result<StyleBatchResponse> {
1191    let (params, warnings) = normalize_style_batch(params)?;
1192    let warning_messages: Vec<String> = warnings
1193        .into_iter()
1194        .map(|warning| format!("{}: {}", warning.code, warning.message))
1195        .collect();
1196    let registry = state
1197        .fork_registry()
1198        .ok_or_else(|| anyhow!("fork registry not available"))?;
1199
1200    let fork_ctx = registry.get_fork(&params.fork_id)?;
1201    let work_path = fork_ctx.work_path.clone();
1202
1203    // Resolve any region targets against current fork regions.
1204    let fork_workbook_id = WorkbookId(params.fork_id.clone());
1205    let workbook = state.open_workbook(&fork_workbook_id).await?;
1206    let resolved_ops = resolve_style_ops_for_workbook(&workbook, &params.ops)?;
1207
1208    let mode = params.mode.unwrap_or_default();
1209
1210    if mode.is_preview() {
1211        let change_id = make_short_random_id("chg", 12);
1212        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
1213        fs::create_dir_all(snapshot_path.parent().unwrap())?;
1214        fs::copy(&work_path, &snapshot_path)?;
1215
1216        let snapshot_path_for_apply = snapshot_path.clone();
1217        let apply_result = tokio::task::spawn_blocking({
1218            let ops = resolved_ops.clone();
1219            move || apply_style_ops_to_file(&snapshot_path_for_apply, &ops)
1220        })
1221        .await??;
1222
1223        let mut summary = apply_result.summary;
1224        summary.op_kinds = vec!["style_batch".to_string()];
1225        summary.warnings.extend(warning_messages.clone());
1226        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1227
1228        let staged_op = StagedOp {
1229            kind: "style_batch".to_string(),
1230            payload: serde_json::to_value(StyleBatchStagedPayload {
1231                ops: resolved_ops.clone(),
1232            })?,
1233        };
1234
1235        let staged = StagedChange {
1236            change_id: change_id.clone(),
1237            created_at: Utc::now(),
1238            label: params.label.clone(),
1239            ops: vec![staged_op],
1240            summary: summary.clone(),
1241            fork_path_snapshot: Some(snapshot_path),
1242        };
1243
1244        registry.add_staged_change(&params.fork_id, staged)?;
1245
1246        Ok(StyleBatchResponse {
1247            fork_id: params.fork_id,
1248            mode: mode.as_str().to_string(),
1249            change_id: Some(change_id),
1250            ops_applied: resolved_ops.len(),
1251            summary,
1252        })
1253    } else {
1254        let apply_result = tokio::task::spawn_blocking({
1255            let ops = resolved_ops.clone();
1256            let work_path = work_path.clone();
1257            move || apply_style_ops_to_file(&work_path, &ops)
1258        })
1259        .await??;
1260
1261        let mut summary = apply_result.summary;
1262        summary.op_kinds = vec!["style_batch".to_string()];
1263        summary.warnings.extend(warning_messages);
1264        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1265
1266        let _ = state.close_workbook(&fork_workbook_id);
1267
1268        Ok(StyleBatchResponse {
1269            fork_id: params.fork_id,
1270            mode: mode.as_str().to_string(),
1271            change_id: None,
1272            ops_applied: apply_result.ops_applied,
1273            summary,
1274        })
1275    }
1276}
1277
1278#[derive(Debug, Deserialize, JsonSchema)]
1279pub struct ApplyFormulaPatternParams {
1280    pub fork_id: String,
1281    pub sheet_name: String,
1282    pub target_range: String,
1283    pub anchor_cell: String,
1284    pub base_formula: String,
1285    #[serde(default)]
1286    pub fill_direction: Option<FillDirection>, // down|right|both (default both)
1287    #[serde(default)]
1288    pub relative_mode: Option<FormulaRelativeMode>, // excel|abs_cols|abs_rows
1289    #[serde(default)]
1290    pub mode: Option<BatchMode>, // preview|apply (default apply)
1291    pub label: Option<String>,
1292}
1293
1294#[derive(Debug, Serialize, JsonSchema)]
1295pub struct ApplyFormulaPatternResponse {
1296    pub fork_id: String,
1297    pub sheet_name: String,
1298    pub target_range: String,
1299    pub mode: String,
1300    pub change_id: Option<String>,
1301    pub cells_filled: u64,
1302    pub summary: ChangeSummary,
1303}
1304
1305#[derive(Debug, Serialize, Deserialize)]
1306struct ApplyFormulaPatternStagedPayload {
1307    sheet_name: String,
1308    target_range: String,
1309    anchor_cell: String,
1310    base_formula: String,
1311    fill_direction: Option<FillDirection>,
1312    relative_mode: Option<FormulaRelativeMode>,
1313}
1314
1315#[derive(Debug, Clone, Deserialize, JsonSchema)]
1316pub struct ApplyFormulaPatternOpInput {
1317    pub sheet_name: String,
1318    pub target_range: String,
1319    pub anchor_cell: String,
1320    pub base_formula: String,
1321    #[serde(default)]
1322    pub fill_direction: Option<FillDirection>,
1323    #[serde(default)]
1324    pub relative_mode: Option<FormulaRelativeMode>,
1325}
1326
1327pub async fn apply_formula_pattern(
1328    state: Arc<AppState>,
1329    params: ApplyFormulaPatternParams,
1330) -> Result<ApplyFormulaPatternResponse> {
1331    let registry = state
1332        .fork_registry()
1333        .ok_or_else(|| anyhow!("fork registry not available"))?;
1334
1335    let fork_ctx = registry.get_fork(&params.fork_id)?;
1336    let work_path = fork_ctx.work_path.clone();
1337
1338    let bounds = parse_range_bounds(&params.target_range)?;
1339    let (anchor_col, anchor_row) = parse_cell_ref(&params.anchor_cell)?;
1340    let fill_direction = params.fill_direction.unwrap_or_default();
1341    validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
1342
1343    let fork_workbook_id = WorkbookId(params.fork_id.clone());
1344    let workbook = state.open_workbook(&fork_workbook_id).await?;
1345    let _ = workbook.with_sheet(&params.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
1346
1347    let relative_mode_param = params.relative_mode.unwrap_or_default();
1348    let relative_mode: RelativeMode = relative_mode_param.into();
1349    let mode = params.mode.unwrap_or_default();
1350
1351    if mode.is_preview() {
1352        let change_id = make_short_random_id("chg", 12);
1353        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
1354        fs::create_dir_all(snapshot_path.parent().unwrap())?;
1355        fs::copy(&work_path, &snapshot_path)?;
1356
1357        let sheet_name = params.sheet_name.clone();
1358        let target_range = params.target_range.clone();
1359        let anchor_cell = params.anchor_cell.clone();
1360        let base_formula = params.base_formula.clone();
1361        let fill_direction = Some(fill_direction);
1362        let relative_mode_param = Some(relative_mode_param);
1363        let snapshot_for_apply = snapshot_path.clone();
1364        let sheet_name_for_apply = sheet_name.clone();
1365        let target_range_for_apply = target_range.clone();
1366        let base_formula_for_apply = base_formula.clone();
1367
1368        let apply_result = tokio::task::spawn_blocking(move || {
1369            apply_formula_pattern_to_file(
1370                &snapshot_for_apply,
1371                &sheet_name_for_apply,
1372                &target_range_for_apply,
1373                anchor_col,
1374                anchor_row,
1375                &base_formula_for_apply,
1376                relative_mode,
1377            )
1378        })
1379        .await??;
1380
1381        let mut summary = apply_result.summary;
1382        summary.op_kinds = vec!["apply_formula_pattern".to_string()];
1383        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1384
1385        let staged_op = StagedOp {
1386            kind: "apply_formula_pattern".to_string(),
1387            payload: serde_json::to_value(ApplyFormulaPatternStagedPayload {
1388                sheet_name: sheet_name.clone(),
1389                target_range: target_range.clone(),
1390                anchor_cell: anchor_cell.clone(),
1391                base_formula: base_formula.clone(),
1392                fill_direction,
1393                relative_mode: relative_mode_param,
1394            })?,
1395        };
1396
1397        let staged = StagedChange {
1398            change_id: change_id.clone(),
1399            created_at: Utc::now(),
1400            label: params.label.clone(),
1401            ops: vec![staged_op],
1402            summary: summary.clone(),
1403            fork_path_snapshot: Some(snapshot_path),
1404        };
1405
1406        registry.add_staged_change(&params.fork_id, staged)?;
1407
1408        Ok(ApplyFormulaPatternResponse {
1409            fork_id: params.fork_id,
1410            sheet_name,
1411            target_range,
1412            mode: mode.as_str().to_string(),
1413            change_id: Some(change_id),
1414            cells_filled: apply_result.cells_filled,
1415            summary,
1416        })
1417    } else {
1418        let sheet_name = params.sheet_name.clone();
1419        let target_range = params.target_range.clone();
1420        let base_formula = params.base_formula.clone();
1421        let sheet_name_for_apply = sheet_name.clone();
1422        let target_range_for_apply = target_range.clone();
1423        let base_formula_for_apply = base_formula.clone();
1424        let apply_result = tokio::task::spawn_blocking(move || {
1425            apply_formula_pattern_to_file(
1426                &work_path,
1427                &sheet_name_for_apply,
1428                &target_range_for_apply,
1429                anchor_col,
1430                anchor_row,
1431                &base_formula_for_apply,
1432                relative_mode,
1433            )
1434        })
1435        .await??;
1436
1437        let mut summary = apply_result.summary;
1438        summary.op_kinds = vec!["apply_formula_pattern".to_string()];
1439
1440        registry.with_fork_mut(&params.fork_id, |ctx| {
1441            ctx.recalc_needed = true;
1442            Ok(())
1443        })?;
1444        set_recalc_needed_flag(&mut summary, true);
1445
1446        let _ = state.close_workbook(&fork_workbook_id);
1447
1448        Ok(ApplyFormulaPatternResponse {
1449            fork_id: params.fork_id,
1450            sheet_name,
1451            target_range,
1452            mode: mode.as_str().to_string(),
1453            change_id: None,
1454            cells_filled: apply_result.cells_filled,
1455            summary,
1456        })
1457    }
1458}
1459
1460struct FormulaPatternApplyResult {
1461    cells_filled: u64,
1462    summary: ChangeSummary,
1463}
1464
1465fn apply_formula_pattern_to_file(
1466    path: &Path,
1467    sheet_name: &str,
1468    target_range: &str,
1469    anchor_col: u32,
1470    anchor_row: u32,
1471    base_formula: &str,
1472    relative_mode: RelativeMode,
1473) -> Result<FormulaPatternApplyResult> {
1474    let ast = parse_base_formula(base_formula)?;
1475    let bounds = parse_range_bounds(target_range)?;
1476
1477    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
1478    let sheet = book
1479        .get_sheet_by_name_mut(sheet_name)
1480        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1481
1482    let mut cells_filled: u64 = 0;
1483    for row in bounds.min_row..=bounds.max_row {
1484        for col in bounds.min_col..=bounds.max_col {
1485            let delta_col = col as i32 - anchor_col as i32;
1486            let delta_row = row as i32 - anchor_row as i32;
1487            let shifted = shift_formula_ast(&ast, delta_col, delta_row, relative_mode)?;
1488            let shifted_for_umya = shifted.strip_prefix('=').unwrap_or(&shifted);
1489            let addr = crate::utils::cell_address(col, row);
1490            let cell = sheet.get_cell_mut(addr.as_str());
1491            cell.set_formula(shifted_for_umya.to_string());
1492            cell.set_formula_result_default("");
1493            cells_filled += 1;
1494        }
1495    }
1496
1497    umya_spreadsheet::writer::xlsx::write(&book, path)?;
1498
1499    let mut counts = BTreeMap::new();
1500    counts.insert("cells_filled".to_string(), cells_filled);
1501
1502    let summary = ChangeSummary {
1503        op_kinds: vec!["apply_formula_pattern".to_string()],
1504        affected_sheets: vec![sheet_name.to_string()],
1505        affected_bounds: vec![target_range.to_string()],
1506        counts,
1507        warnings: Vec::new(),
1508        ..Default::default()
1509    };
1510
1511    Ok(FormulaPatternApplyResult {
1512        cells_filled,
1513        summary,
1514    })
1515}
1516
1517pub(crate) struct FormulaPatternBatchApplyResult {
1518    pub(crate) ops_applied: usize,
1519    pub(crate) summary: ChangeSummary,
1520}
1521
1522pub(crate) fn apply_formula_pattern_ops_to_file(
1523    path: &Path,
1524    ops: &[ApplyFormulaPatternOpInput],
1525) -> Result<FormulaPatternBatchApplyResult> {
1526    struct PreparedFormulaPatternOp {
1527        sheet_name: String,
1528        target_range: String,
1529        anchor_col: u32,
1530        anchor_row: u32,
1531        base_formula: String,
1532        relative_mode: RelativeMode,
1533    }
1534
1535    let mut prepared_ops = Vec::with_capacity(ops.len());
1536    let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
1537    let mut affected_bounds: Vec<String> = Vec::with_capacity(ops.len());
1538
1539    for op in ops {
1540        let bounds = parse_range_bounds(&op.target_range)?;
1541        let (anchor_col, anchor_row) = parse_cell_ref(&op.anchor_cell)?;
1542        let fill_direction = op.fill_direction.unwrap_or_default();
1543        validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
1544        parse_base_formula(&op.base_formula)?;
1545
1546        let relative_mode: RelativeMode = op.relative_mode.unwrap_or_default().into();
1547
1548        affected_sheets.insert(op.sheet_name.clone());
1549        affected_bounds.push(op.target_range.clone());
1550
1551        prepared_ops.push(PreparedFormulaPatternOp {
1552            sheet_name: op.sheet_name.clone(),
1553            target_range: op.target_range.clone(),
1554            anchor_col,
1555            anchor_row,
1556            base_formula: op.base_formula.clone(),
1557            relative_mode,
1558        });
1559    }
1560
1561    let mut cells_filled = 0u64;
1562    for op in prepared_ops {
1563        let result = apply_formula_pattern_to_file(
1564            path,
1565            &op.sheet_name,
1566            &op.target_range,
1567            op.anchor_col,
1568            op.anchor_row,
1569            &op.base_formula,
1570            op.relative_mode,
1571        )?;
1572        cells_filled += result.cells_filled;
1573    }
1574
1575    let mut counts = BTreeMap::new();
1576    counts.insert("cells_filled".to_string(), cells_filled);
1577
1578    Ok(FormulaPatternBatchApplyResult {
1579        ops_applied: ops.len(),
1580        summary: ChangeSummary {
1581            op_kinds: vec!["apply_formula_pattern".to_string()],
1582            affected_sheets: affected_sheets.into_iter().collect(),
1583            affected_bounds,
1584            counts,
1585            warnings: Vec::new(),
1586            ..Default::default()
1587        },
1588    })
1589}
1590
1591fn validate_formula_pattern_bounds(
1592    bounds: &ScreenshotBounds,
1593    anchor_col: u32,
1594    anchor_row: u32,
1595    fill_direction: FillDirection,
1596) -> Result<()> {
1597    if anchor_col < bounds.min_col
1598        || anchor_col > bounds.max_col
1599        || anchor_row < bounds.min_row
1600        || anchor_row > bounds.max_row
1601    {
1602        let bounds_range = format!(
1603            "{}:{}",
1604            crate::utils::cell_address(bounds.min_col, bounds.min_row),
1605            crate::utils::cell_address(bounds.max_col, bounds.max_row)
1606        );
1607        bail!(
1608            "anchor_cell must be inside target_range (anchor {} not within {})",
1609            crate::utils::cell_address(anchor_col, anchor_row),
1610            bounds_range
1611        );
1612    }
1613
1614    if bounds.min_col != anchor_col || bounds.min_row != anchor_row {
1615        bail!("target_range must start at anchor_cell (anchor should be top-left of fill range)");
1616    }
1617
1618    match fill_direction {
1619        FillDirection::Down => {
1620            if bounds.min_col != bounds.max_col {
1621                bail!("fill_direction=down requires a single-column target_range");
1622            }
1623        }
1624        FillDirection::Right => {
1625            if bounds.min_row != bounds.max_row {
1626                bail!("fill_direction=right requires a single-row target_range");
1627            }
1628        }
1629        FillDirection::Both => {}
1630    }
1631    Ok(())
1632}
1633
1634#[derive(Debug, Deserialize, JsonSchema)]
1635pub struct StructureBatchParams {
1636    pub fork_id: String,
1637    pub ops: Vec<StructureOp>,
1638    #[serde(default)]
1639    pub mode: Option<BatchMode>, // preview|apply (default apply)
1640    pub label: Option<String>,
1641    #[serde(default)]
1642    pub formula_parse_policy: Option<FormulaParsePolicy>,
1643    #[serde(default)]
1644    pub impact_report: Option<bool>,
1645    #[serde(default)]
1646    pub show_formula_delta: Option<bool>,
1647}
1648
1649#[derive(Debug, Deserialize, JsonSchema)]
1650pub struct StructureBatchParamsInput {
1651    pub fork_id: String,
1652    pub ops: Vec<StructureOpInput>,
1653    #[serde(default)]
1654    pub mode: Option<BatchMode>,
1655    pub label: Option<String>,
1656    #[serde(default)]
1657    pub formula_parse_policy: Option<FormulaParsePolicy>,
1658    /// Request a structural impact report (shifted spans, absolute-ref warnings).
1659    /// Only honoured in preview mode.
1660    #[serde(default)]
1661    pub impact_report: Option<bool>,
1662    /// Request before/after formula delta samples.
1663    /// Only honoured in preview mode.
1664    #[serde(default)]
1665    pub show_formula_delta: Option<bool>,
1666}
1667
1668#[derive(Debug, Clone)]
1669pub struct StructureOpInput {
1670    op: StructureOp,
1671    alias_used: bool,
1672}
1673
1674impl From<StructureOp> for StructureOpInput {
1675    fn from(op: StructureOp) -> Self {
1676        Self {
1677            op,
1678            alias_used: false,
1679        }
1680    }
1681}
1682
1683impl<'de> Deserialize<'de> for StructureOpInput {
1684    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1685    where
1686        D: serde::Deserializer<'de>,
1687    {
1688        let mut value = serde_json::Value::deserialize(deserializer)?;
1689        let Some(obj) = value.as_object_mut() else {
1690            return Err(de::Error::custom("structure op must be an object"));
1691        };
1692
1693        let mut alias_used = false;
1694        let kind_value = if let Some(kind) = obj.get("kind") {
1695            kind.clone()
1696        } else if let Some(op) = obj.remove("op") {
1697            alias_used = true;
1698            op
1699        } else {
1700            return Err(de::Error::custom("structure op requires 'kind' or 'op'"));
1701        };
1702
1703        let Some(kind_str) = kind_value.as_str() else {
1704            return Err(de::Error::custom("structure op kind must be a string"));
1705        };
1706
1707        let normalized_kind = if kind_str == "add_sheet" {
1708            alias_used = true;
1709            "create_sheet"
1710        } else {
1711            kind_str
1712        };
1713
1714        obj.insert(
1715            "kind".to_string(),
1716            serde_json::Value::String(normalized_kind.to_string()),
1717        );
1718
1719        let op = serde_json::from_value(value).map_err(de::Error::custom)?;
1720        Ok(StructureOpInput { op, alias_used })
1721    }
1722}
1723
1724impl schemars::JsonSchema for StructureOpInput {
1725    fn schema_name() -> std::borrow::Cow<'static, str> {
1726        "StructureOp".into()
1727    }
1728
1729    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1730        StructureOp::json_schema(generator)
1731    }
1732}
1733
1734pub fn normalize_structure_batch(
1735    input: StructureBatchParamsInput,
1736) -> Result<(StructureBatchParams, Vec<Warning>)> {
1737    let mut warnings = Vec::new();
1738    let mut ops = Vec::with_capacity(input.ops.len());
1739
1740    for op_input in input.ops {
1741        if op_input.alias_used {
1742            warnings.push(Warning {
1743                code: "WARN_ALIAS_KIND".to_string(),
1744                message: "Normalized structure op alias to canonical kind".to_string(),
1745            });
1746        }
1747        ops.push(op_input.op);
1748    }
1749
1750    Ok((
1751        StructureBatchParams {
1752            fork_id: input.fork_id,
1753            ops,
1754            mode: input.mode,
1755            label: input.label,
1756            formula_parse_policy: input.formula_parse_policy,
1757            impact_report: input.impact_report,
1758            show_formula_delta: input.show_formula_delta,
1759        },
1760        warnings,
1761    ))
1762}
1763
1764#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1765#[serde(tag = "kind", rename_all = "snake_case")]
1766pub enum StructureOp {
1767    MergeCells {
1768        sheet_name: String,
1769        target_range: String,
1770    },
1771    UnmergeCells {
1772        sheet_name: String,
1773        target_range: String,
1774    },
1775    InsertRows {
1776        sheet_name: String,
1777        at_row: u32,
1778        count: u32,
1779        /// When true, simple SUM(Ax:Ay) formulas in the row directly adjacent
1780        /// (below) to the insertion band are expanded to include the new rows.
1781        /// Only unambiguous single-range SUM patterns are expanded; complex or
1782        /// ambiguous formulas produce a warning and are left untouched.
1783        #[serde(default)]
1784        expand_adjacent_sums: bool,
1785    },
1786    CloneRow {
1787        sheet_name: String,
1788        /// 1-based row to use as the template.
1789        source_row: u32,
1790        /// 1-based row position at which to insert new rows.
1791        insert_at: u32,
1792        /// Number of copies to insert (default 1).
1793        #[serde(default = "default_clone_count")]
1794        count: u32,
1795        /// When true, expand adjacent SUM formulas below the insertion band.
1796        #[serde(default)]
1797        expand_adjacent_sums: bool,
1798    },
1799    DeleteRows {
1800        sheet_name: String,
1801        start_row: u32,
1802        count: u32,
1803    },
1804    InsertCols {
1805        sheet_name: String,
1806        at_col: String,
1807        count: u32,
1808    },
1809    DeleteCols {
1810        sheet_name: String,
1811        start_col: String,
1812        count: u32,
1813    },
1814    RenameSheet {
1815        old_name: String,
1816        new_name: String,
1817    },
1818    CreateSheet {
1819        name: String,
1820        #[serde(default)]
1821        position: Option<u32>,
1822    },
1823    DeleteSheet {
1824        name: String,
1825    },
1826    CopyRange {
1827        sheet_name: String,
1828        #[serde(default)]
1829        dest_sheet_name: Option<String>,
1830        src_range: String,
1831        dest_anchor: String,
1832        include_styles: bool,
1833        include_formulas: bool,
1834    },
1835    MoveRange {
1836        sheet_name: String,
1837        #[serde(default)]
1838        dest_sheet_name: Option<String>,
1839        src_range: String,
1840        dest_anchor: String,
1841        include_styles: bool,
1842        include_formulas: bool,
1843    },
1844}
1845
1846fn structure_ops_require_recalc(ops: &[StructureOp]) -> bool {
1847    ops.iter().any(|op| {
1848        matches!(
1849            op,
1850            StructureOp::InsertRows { .. }
1851                | StructureOp::DeleteRows { .. }
1852                | StructureOp::InsertCols { .. }
1853                | StructureOp::DeleteCols { .. }
1854                | StructureOp::RenameSheet { .. }
1855                | StructureOp::CloneRow { .. }
1856                | StructureOp::CopyRange {
1857                    include_formulas: true,
1858                    ..
1859                }
1860                | StructureOp::MoveRange {
1861                    include_formulas: true,
1862                    ..
1863                }
1864        )
1865    })
1866}
1867
1868#[derive(Debug, Serialize, JsonSchema)]
1869pub struct StructureBatchResponse {
1870    pub fork_id: String,
1871    pub mode: String,
1872    pub change_id: Option<String>,
1873    pub ops_applied: usize,
1874    pub summary: ChangeSummary,
1875    #[serde(skip_serializing_if = "Option::is_none")]
1876    pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
1877    /// Structural impact report (only present when explicitly requested in preview mode).
1878    #[serde(skip_serializing_if = "Option::is_none")]
1879    pub impact_report: Option<crate::tools::structure_impact::StructureImpactReport>,
1880    /// Formula delta preview (only present when explicitly requested in preview mode).
1881    #[serde(skip_serializing_if = "Option::is_none")]
1882    pub formula_delta_preview: Option<Vec<crate::tools::structure_impact::FormulaDeltaItem>>,
1883}
1884
1885#[derive(Debug, Serialize, Deserialize)]
1886struct StructureBatchStagedPayload {
1887    ops: Vec<StructureOp>,
1888    #[serde(default)]
1889    formula_parse_policy: Option<FormulaParsePolicy>,
1890}
1891
1892pub async fn structure_batch(
1893    state: Arc<AppState>,
1894    params: StructureBatchParamsInput,
1895) -> Result<StructureBatchResponse> {
1896    let want_impact = params.impact_report.unwrap_or(false);
1897    let want_delta = params.show_formula_delta.unwrap_or(false);
1898    let (params, warnings) = normalize_structure_batch(params)?;
1899    let policy =
1900        params
1901            .formula_parse_policy
1902            .unwrap_or(FormulaParsePolicy::default_for_command_class(
1903                CommandClass::BatchWrite,
1904            ));
1905    let alias_warnings: Vec<String> = warnings
1906        .into_iter()
1907        .map(|warning| format!("{}: {}", warning.code, warning.message))
1908        .collect();
1909    let registry = state
1910        .fork_registry()
1911        .ok_or_else(|| anyhow!("fork registry not available"))?;
1912
1913    let fork_ctx = registry.get_fork(&params.fork_id)?;
1914    let work_path = fork_ctx.work_path.clone();
1915
1916    let will_need_recalc = fork_ctx.recalc_needed || structure_ops_require_recalc(&params.ops);
1917
1918    let mode = params.mode.unwrap_or_default();
1919
1920    if mode.is_preview() {
1921        let change_id = make_short_random_id("chg", 12);
1922        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
1923        fs::create_dir_all(snapshot_path.parent().unwrap())?;
1924        fs::copy(&work_path, &snapshot_path)?;
1925
1926        let snapshot_for_apply = snapshot_path.clone();
1927        let ops_for_apply = params.ops.clone();
1928
1929        let apply_result = tokio::task::spawn_blocking(move || {
1930            apply_structure_ops_to_file(&snapshot_for_apply, &ops_for_apply, policy)
1931        })
1932        .await??;
1933
1934        let mut summary = apply_result.summary;
1935        summary.op_kinds = vec!["structure_batch".to_string()];
1936        summary.warnings.extend(alias_warnings);
1937        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1938        // Best-effort preview diff size: compare current fork to preview snapshot.
1939        // This is intentionally summarized as a count to avoid large payloads.
1940        if let Ok(change_count) = tokio::task::spawn_blocking({
1941            let base_path = work_path.clone();
1942            let preview_path = snapshot_path.clone();
1943            move || {
1944                crate::core::diff::calculate_changeset(&base_path, &preview_path, None)
1945                    .map(|changes| changes.len() as u64)
1946            }
1947        })
1948        .await?
1949        {
1950            summary
1951                .counts
1952                .insert("preview_change_items".to_string(), change_count);
1953        } else {
1954            summary.warnings.push(
1955                "Preview diff computation failed; run get_changeset after applying to inspect changes."
1956                    .to_string(),
1957            );
1958        }
1959
1960        let staged_op = StagedOp {
1961            kind: "structure_batch".to_string(),
1962            payload: serde_json::to_value(StructureBatchStagedPayload {
1963                ops: params.ops.clone(),
1964                formula_parse_policy: Some(policy),
1965            })?,
1966        };
1967
1968        let staged = StagedChange {
1969            change_id: change_id.clone(),
1970            created_at: Utc::now(),
1971            label: params.label.clone(),
1972            ops: vec![staged_op],
1973            summary: summary.clone(),
1974            fork_path_snapshot: Some(snapshot_path),
1975        };
1976
1977        registry.add_staged_change(&params.fork_id, staged)?;
1978
1979        // Compute impact report / formula delta preview if requested in preview mode.
1980        let (ir, fdp) = if want_impact || want_delta {
1981            let ops_for_impact = params.ops.clone();
1982            let base_path = work_path.clone();
1983            let (report, delta) = tokio::task::spawn_blocking(move || {
1984                crate::tools::structure_impact::compute_structure_impact(
1985                    &base_path,
1986                    &ops_for_impact,
1987                    want_delta,
1988                )
1989            })
1990            .await??;
1991            (if want_impact { Some(report) } else { None }, delta)
1992        } else {
1993            (None, None)
1994        };
1995
1996        Ok(StructureBatchResponse {
1997            fork_id: params.fork_id,
1998            mode: mode.as_str().to_string(),
1999            change_id: Some(change_id),
2000            ops_applied: apply_result.ops_applied,
2001            summary,
2002            formula_parse_diagnostics: apply_result.formula_parse_diagnostics,
2003            impact_report: ir,
2004            formula_delta_preview: fdp,
2005        })
2006    } else {
2007        let ops_for_apply = params.ops.clone();
2008        let apply_result = tokio::task::spawn_blocking(move || {
2009            apply_structure_ops_to_file(&work_path, &ops_for_apply, policy)
2010        })
2011        .await??;
2012
2013        let mut summary = apply_result.summary;
2014        summary.op_kinds = vec!["structure_batch".to_string()];
2015        summary.warnings.extend(alias_warnings);
2016
2017        if will_need_recalc {
2018            registry.with_fork_mut(&params.fork_id, |ctx| {
2019                ctx.recalc_needed = true;
2020                Ok(())
2021            })?;
2022        }
2023        set_recalc_needed_flag(&mut summary, will_need_recalc);
2024
2025        let fork_workbook_id = WorkbookId(params.fork_id.clone());
2026        let _ = state.close_workbook(&fork_workbook_id);
2027
2028        Ok(StructureBatchResponse {
2029            fork_id: params.fork_id,
2030            mode: mode.as_str().to_string(),
2031            change_id: None,
2032            ops_applied: apply_result.ops_applied,
2033            summary,
2034            formula_parse_diagnostics: apply_result.formula_parse_diagnostics,
2035            impact_report: None,
2036            formula_delta_preview: None,
2037        })
2038    }
2039}
2040
2041pub(crate) struct StructureApplyResult {
2042    pub(crate) ops_applied: usize,
2043    pub(crate) summary: ChangeSummary,
2044    pub(crate) formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
2045}
2046
2047pub(crate) fn apply_structure_ops_to_file(
2048    path: &Path,
2049    ops: &[StructureOp],
2050    policy: FormulaParsePolicy,
2051) -> Result<StructureApplyResult> {
2052    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
2053    let mut formula_parse_diagnostics_builder = FormulaParseDiagnosticsBuilder::new(policy);
2054
2055    let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
2056    let affected_bounds: Vec<String> = Vec::new();
2057    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
2058    let mut warnings: Vec<String> = vec![
2059        "Structural edits may not fully rewrite formulas/named ranges like Excel. After apply, run recalculate and review get_changeset.".to_string(),
2060    ];
2061
2062    for op in ops {
2063        match op {
2064            StructureOp::MergeCells {
2065                sheet_name,
2066                target_range,
2067            } => {
2068                let sheet = book
2069                    .get_sheet_by_name_mut(sheet_name)
2070                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2071                sheet.add_merge_cells(target_range.clone());
2072                affected_sheets.insert(sheet_name.clone());
2073                *counts.entry("cells_merged".to_string()).or_insert(0) += 1;
2074            }
2075            StructureOp::UnmergeCells {
2076                sheet_name,
2077                target_range,
2078            } => {
2079                let sheet = book
2080                    .get_sheet_by_name_mut(sheet_name)
2081                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2082
2083                let original_len = sheet.get_merge_cells().len();
2084                if let Ok(target_bounds) = parse_range_bounds(target_range) {
2085                    let merges = sheet.get_merge_cells_mut();
2086                    merges.retain(|m| {
2087                        let m_range = m.get_range();
2088                        if let Ok(m_bounds) = parse_range_bounds(&m_range) {
2089                            !(m_bounds.min_col <= target_bounds.max_col
2090                                && m_bounds.max_col >= target_bounds.min_col
2091                                && m_bounds.min_row <= target_bounds.max_row
2092                                && m_bounds.max_row >= target_bounds.min_row)
2093                        } else {
2094                            true
2095                        }
2096                    });
2097                }
2098                *counts.entry("cells_unmerged".to_string()).or_insert(0) +=
2099                    (original_len - sheet.get_merge_cells().len()) as u64;
2100
2101                affected_sheets.insert(sheet_name.clone());
2102            }
2103            StructureOp::InsertRows {
2104                sheet_name,
2105                at_row,
2106                count,
2107                expand_adjacent_sums,
2108            } => {
2109                if *at_row == 0 || *count == 0 {
2110                    bail!("insert_rows requires at_row>=1 and count>=1");
2111                }
2112                {
2113                    let sheet = book
2114                        .get_sheet_by_name_mut(sheet_name)
2115                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2116                    sheet.insert_new_row(at_row, count);
2117                }
2118                rewrite_formulas_for_sheet_row_insert(
2119                    &mut book,
2120                    sheet_name,
2121                    *at_row,
2122                    *count,
2123                    policy,
2124                    &mut formula_parse_diagnostics_builder,
2125                )?;
2126                rewrite_defined_name_formulas_for_sheet_row_insert(
2127                    &mut book,
2128                    sheet_name,
2129                    *at_row,
2130                    *count,
2131                    policy,
2132                    &mut formula_parse_diagnostics_builder,
2133                )?;
2134                if *expand_adjacent_sums {
2135                    let (expansion_warnings, expanded_count) =
2136                        expand_adjacent_sum_formulas(&mut book, sheet_name, *at_row, *count)?;
2137                    warnings.extend(expansion_warnings);
2138                    if expanded_count > 0 {
2139                        counts
2140                            .entry("sums_expanded".to_string())
2141                            .and_modify(|v| *v += expanded_count)
2142                            .or_insert(expanded_count);
2143                    }
2144                }
2145                affected_sheets.insert(sheet_name.clone());
2146                counts
2147                    .entry("rows_inserted".to_string())
2148                    .and_modify(|v| *v += *count as u64)
2149                    .or_insert(*count as u64);
2150            }
2151            StructureOp::CloneRow {
2152                sheet_name,
2153                source_row,
2154                insert_at,
2155                count,
2156                expand_adjacent_sums,
2157            } => {
2158                if *source_row == 0 || *insert_at == 0 || *count == 0 {
2159                    bail!("clone_row requires source_row>=1, insert_at>=1, and count>=1");
2160                }
2161                // Step 1: Capture template row cells before insertion (pre-shift).
2162                let template_cells = {
2163                    let sheet = book
2164                        .get_sheet_by_name_mut(sheet_name)
2165                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2166                    capture_row_template(sheet, *source_row)?
2167                };
2168                if template_cells.is_empty() {
2169                    warnings.push(format!(
2170                        "WARN_CLONE_TEMPLATE_EMPTY: {} row {} has no template cells to clone.",
2171                        sheet_name, source_row
2172                    ));
2173                }
2174
2175                // Step 2: Insert blank rows.
2176                {
2177                    let sheet = book
2178                        .get_sheet_by_name_mut(sheet_name)
2179                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2180                    sheet.insert_new_row(insert_at, count);
2181                }
2182                rewrite_formulas_for_sheet_row_insert(
2183                    &mut book,
2184                    sheet_name,
2185                    *insert_at,
2186                    *count,
2187                    policy,
2188                    &mut formula_parse_diagnostics_builder,
2189                )?;
2190                rewrite_defined_name_formulas_for_sheet_row_insert(
2191                    &mut book,
2192                    sheet_name,
2193                    *insert_at,
2194                    *count,
2195                    policy,
2196                    &mut formula_parse_diagnostics_builder,
2197                )?;
2198
2199                // Step 3: Fill inserted rows from the template.
2200                {
2201                    let sheet = book
2202                        .get_sheet_by_name_mut(sheet_name)
2203                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2204                    let clone_warnings = stamp_template_rows(
2205                        sheet,
2206                        &template_cells,
2207                        *source_row,
2208                        *insert_at,
2209                        *count,
2210                    )?;
2211                    warnings.extend(clone_warnings);
2212                }
2213
2214                // Step 4: Optionally expand adjacent SUMs.
2215                if *expand_adjacent_sums {
2216                    let (expansion_warnings, expanded_count) =
2217                        expand_adjacent_sum_formulas(&mut book, sheet_name, *insert_at, *count)?;
2218                    warnings.extend(expansion_warnings);
2219                    if expanded_count > 0 {
2220                        counts
2221                            .entry("sums_expanded".to_string())
2222                            .and_modify(|v| *v += expanded_count)
2223                            .or_insert(expanded_count);
2224                    }
2225                }
2226
2227                affected_sheets.insert(sheet_name.clone());
2228                counts
2229                    .entry("rows_inserted".to_string())
2230                    .and_modify(|v| *v += *count as u64)
2231                    .or_insert(*count as u64);
2232                counts
2233                    .entry("rows_cloned".to_string())
2234                    .and_modify(|v| *v += *count as u64)
2235                    .or_insert(*count as u64);
2236            }
2237            StructureOp::DeleteRows {
2238                sheet_name,
2239                start_row,
2240                count,
2241            } => {
2242                if *start_row == 0 || *count == 0 {
2243                    bail!("delete_rows requires start_row>=1 and count>=1");
2244                }
2245                {
2246                    let sheet = book
2247                        .get_sheet_by_name_mut(sheet_name)
2248                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2249                    sheet.remove_row(start_row, count);
2250                }
2251                rewrite_formulas_for_sheet_row_delete(
2252                    &mut book,
2253                    sheet_name,
2254                    *start_row,
2255                    *count,
2256                    policy,
2257                    &mut formula_parse_diagnostics_builder,
2258                )?;
2259                rewrite_defined_name_formulas_for_sheet_row_delete(
2260                    &mut book,
2261                    sheet_name,
2262                    *start_row,
2263                    *count,
2264                    policy,
2265                    &mut formula_parse_diagnostics_builder,
2266                )?;
2267                affected_sheets.insert(sheet_name.clone());
2268                counts
2269                    .entry("rows_deleted".to_string())
2270                    .and_modify(|v| *v += *count as u64)
2271                    .or_insert(*count as u64);
2272            }
2273            StructureOp::InsertCols {
2274                sheet_name,
2275                at_col,
2276                count,
2277            } => {
2278                if at_col.trim().is_empty() || *count == 0 {
2279                    bail!("insert_cols requires at_col and count>=1");
2280                }
2281                let col_letters = normalize_col_letters(at_col)?;
2282                let root_col =
2283                    umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
2284                {
2285                    let sheet = book
2286                        .get_sheet_by_name_mut(sheet_name)
2287                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2288                    sheet.insert_new_column(&col_letters, count);
2289                }
2290                rewrite_formulas_for_sheet_col_insert(
2291                    &mut book,
2292                    sheet_name,
2293                    root_col,
2294                    *count,
2295                    policy,
2296                    &mut formula_parse_diagnostics_builder,
2297                )?;
2298                rewrite_defined_name_formulas_for_sheet_col_insert(
2299                    &mut book,
2300                    sheet_name,
2301                    root_col,
2302                    *count,
2303                    policy,
2304                    &mut formula_parse_diagnostics_builder,
2305                )?;
2306                affected_sheets.insert(sheet_name.clone());
2307                counts
2308                    .entry("cols_inserted".to_string())
2309                    .and_modify(|v| *v += *count as u64)
2310                    .or_insert(*count as u64);
2311            }
2312            StructureOp::DeleteCols {
2313                sheet_name,
2314                start_col,
2315                count,
2316            } => {
2317                if start_col.trim().is_empty() || *count == 0 {
2318                    bail!("delete_cols requires start_col and count>=1");
2319                }
2320                let col_letters = normalize_col_letters(start_col)?;
2321                let root_col =
2322                    umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
2323                {
2324                    let sheet = book
2325                        .get_sheet_by_name_mut(sheet_name)
2326                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2327                    sheet.remove_column(&col_letters, count);
2328                }
2329                rewrite_formulas_for_sheet_col_delete(
2330                    &mut book,
2331                    sheet_name,
2332                    root_col,
2333                    *count,
2334                    policy,
2335                    &mut formula_parse_diagnostics_builder,
2336                )?;
2337                rewrite_defined_name_formulas_for_sheet_col_delete(
2338                    &mut book,
2339                    sheet_name,
2340                    root_col,
2341                    *count,
2342                    policy,
2343                    &mut formula_parse_diagnostics_builder,
2344                )?;
2345                affected_sheets.insert(sheet_name.clone());
2346                counts
2347                    .entry("cols_deleted".to_string())
2348                    .and_modify(|v| *v += *count as u64)
2349                    .or_insert(*count as u64);
2350            }
2351            StructureOp::RenameSheet { old_name, new_name } => {
2352                let old_name = old_name.trim();
2353                let new_name = new_name.trim();
2354                if old_name.is_empty() || new_name.is_empty() {
2355                    bail!("rename_sheet requires non-empty old_name and new_name");
2356                }
2357
2358                let sheet_index = book
2359                    .get_sheet_collection_no_check()
2360                    .iter()
2361                    .position(|s| s.get_name() == old_name)
2362                    .ok_or_else(|| anyhow!("sheet '{}' not found", old_name))?;
2363                book.set_sheet_name(sheet_index, new_name.to_string())
2364                    .map_err(|e| anyhow!("failed to rename sheet '{}': {}", old_name, e))?;
2365
2366                rewrite_formulas_for_sheet_rename(
2367                    &mut book,
2368                    old_name,
2369                    new_name,
2370                    policy,
2371                    &mut formula_parse_diagnostics_builder,
2372                )?;
2373                rewrite_defined_name_formulas_for_sheet_rename(
2374                    &mut book,
2375                    old_name,
2376                    new_name,
2377                    policy,
2378                    &mut formula_parse_diagnostics_builder,
2379                )?;
2380
2381                affected_sheets.insert(old_name.to_string());
2382                affected_sheets.insert(new_name.to_string());
2383                counts
2384                    .entry("sheets_renamed".to_string())
2385                    .and_modify(|v| *v += 1)
2386                    .or_insert(1);
2387            }
2388            StructureOp::CreateSheet { name, position } => {
2389                let name_trimmed = name.trim();
2390                if name_trimmed.is_empty() {
2391                    bail!("create_sheet requires non-empty name");
2392                }
2393                let requested_position = *position;
2394                book.new_sheet(name_trimmed.to_string())
2395                    .map_err(|e| anyhow!("failed to create sheet '{}': {}", name_trimmed, e))?;
2396
2397                if let Some(pos) = requested_position {
2398                    let desired = pos as usize;
2399                    let len = book.get_sheet_collection_no_check().len();
2400                    if desired >= len {
2401                        warnings.push(format!(
2402                            "create_sheet position {} is out of range (sheet_count {}). Appended at end.",
2403                            desired, len
2404                        ));
2405                    } else if desired != len - 1 {
2406                        let sheets = book.get_sheet_collection_mut();
2407                        let created = sheets.remove(len - 1);
2408                        sheets.insert(desired, created);
2409                    }
2410                }
2411
2412                affected_sheets.insert(name_trimmed.to_string());
2413                counts
2414                    .entry("sheets_created".to_string())
2415                    .and_modify(|v| *v += 1)
2416                    .or_insert(1);
2417            }
2418            StructureOp::DeleteSheet { name } => {
2419                let name_trimmed = name.trim();
2420                if name_trimmed.is_empty() {
2421                    bail!("delete_sheet requires non-empty name");
2422                }
2423                if book.get_sheet_collection_no_check().len() <= 1 {
2424                    bail!("cannot delete the last remaining sheet");
2425                }
2426                book.remove_sheet_by_name(name_trimmed)
2427                    .map_err(|e| anyhow!("failed to delete sheet '{}': {}", name_trimmed, e))?;
2428                affected_sheets.insert(name_trimmed.to_string());
2429                counts
2430                    .entry("sheets_deleted".to_string())
2431                    .and_modify(|v| *v += 1)
2432                    .or_insert(1);
2433            }
2434            StructureOp::CopyRange {
2435                sheet_name,
2436                dest_sheet_name,
2437                src_range,
2438                dest_anchor,
2439                include_styles,
2440                include_formulas,
2441            } => {
2442                let dest_sheet_name = dest_sheet_name.as_deref().unwrap_or(sheet_name);
2443                let result = copy_or_move_range(
2444                    &mut book,
2445                    sheet_name,
2446                    dest_sheet_name,
2447                    src_range,
2448                    dest_anchor,
2449                    *include_styles,
2450                    *include_formulas,
2451                    false,
2452                    policy,
2453                    &mut formula_parse_diagnostics_builder,
2454                )?;
2455                affected_sheets.insert(sheet_name.clone());
2456                affected_sheets.insert(dest_sheet_name.to_string());
2457                counts
2458                    .entry("cells_copied".to_string())
2459                    .and_modify(|v| *v += result.cells_written)
2460                    .or_insert(result.cells_written);
2461                counts
2462                    .entry("ranges_copied".to_string())
2463                    .and_modify(|v| *v += 1)
2464                    .or_insert(1);
2465                warnings.extend(result.warnings);
2466            }
2467            StructureOp::MoveRange {
2468                sheet_name,
2469                dest_sheet_name,
2470                src_range,
2471                dest_anchor,
2472                include_styles,
2473                include_formulas,
2474            } => {
2475                let dest_sheet_name = dest_sheet_name.as_deref().unwrap_or(sheet_name);
2476                let result = copy_or_move_range(
2477                    &mut book,
2478                    sheet_name,
2479                    dest_sheet_name,
2480                    src_range,
2481                    dest_anchor,
2482                    *include_styles,
2483                    *include_formulas,
2484                    true,
2485                    policy,
2486                    &mut formula_parse_diagnostics_builder,
2487                )?;
2488                affected_sheets.insert(sheet_name.clone());
2489                affected_sheets.insert(dest_sheet_name.to_string());
2490                counts
2491                    .entry("cells_moved".to_string())
2492                    .and_modify(|v| *v += result.cells_written)
2493                    .or_insert(result.cells_written);
2494                counts
2495                    .entry("ranges_moved".to_string())
2496                    .and_modify(|v| *v += 1)
2497                    .or_insert(1);
2498                warnings.extend(result.warnings);
2499            }
2500        }
2501    }
2502
2503    let clamped_defined_names = clamp_out_of_bounds_defined_name_rows(&mut book);
2504    if clamped_defined_names > 0 {
2505        warnings.push(format!(
2506            "Clamped {} defined name reference(s) to Excel max row 1048576 after structural edits.",
2507            clamped_defined_names
2508        ));
2509    }
2510
2511    umya_spreadsheet::writer::xlsx::write(&book, path)?;
2512
2513    // Temporary guardrail: patch overflowing workbook-scoped defined-name row references
2514    // directly in workbook.xml after structural writes. Remove once Formualizer/Umya
2515    // perform named-range row-bound clamping during ingest/mutation.
2516    let clamped_workbook_xml_defined_names = sanitize_workbook_xml_defined_name_rows(path)?;
2517    if clamped_workbook_xml_defined_names > 0 {
2518        warnings.push(format!(
2519            "Clamped {} workbook.xml defined name reference(s) to Excel max row 1048576 after structural edits.",
2520            clamped_workbook_xml_defined_names
2521        ));
2522    }
2523
2524    let summary = ChangeSummary {
2525        op_kinds: vec!["structure_batch".to_string()],
2526        affected_sheets: affected_sheets.into_iter().collect(),
2527        affected_bounds,
2528        counts,
2529        warnings,
2530        ..Default::default()
2531    };
2532
2533    let formula_parse_diagnostics = if formula_parse_diagnostics_builder.has_errors() {
2534        Some(formula_parse_diagnostics_builder.build())
2535    } else {
2536        None
2537    };
2538
2539    Ok(StructureApplyResult {
2540        ops_applied: ops.len(),
2541        summary,
2542        formula_parse_diagnostics,
2543    })
2544}
2545
2546fn normalize_col_letters(col: &str) -> Result<String> {
2547    let letters = col.trim().to_ascii_uppercase();
2548    if letters.is_empty() || !letters.chars().all(|c| c.is_ascii_alphabetic()) {
2549        bail!("invalid column reference: {}", col);
2550    }
2551    Ok(letters)
2552}
2553
2554struct CopyMoveApplyResult {
2555    cells_written: u64,
2556    warnings: Vec<String>,
2557}
2558
2559#[allow(clippy::too_many_arguments)]
2560fn ranges_intersect(
2561    a_min_col: u32,
2562    a_min_row: u32,
2563    a_max_col: u32,
2564    a_max_row: u32,
2565    b_min_col: u32,
2566    b_min_row: u32,
2567    b_max_col: u32,
2568    b_max_row: u32,
2569) -> bool {
2570    !(a_max_col < b_min_col
2571        || b_max_col < a_min_col
2572        || a_max_row < b_min_row
2573        || b_max_row < a_min_row)
2574}
2575
2576#[allow(clippy::too_many_arguments)]
2577fn copy_or_move_range(
2578    book: &mut umya_spreadsheet::Spreadsheet,
2579    src_sheet_name: &str,
2580    dest_sheet_name: &str,
2581    src_range: &str,
2582    dest_anchor: &str,
2583    include_styles: bool,
2584    include_formulas: bool,
2585    clear_source: bool,
2586    policy: FormulaParsePolicy,
2587    builder: &mut FormulaParseDiagnosticsBuilder,
2588) -> Result<CopyMoveApplyResult> {
2589    let src_bounds = parse_range_bounds(src_range)?;
2590    let (dest_start_col, dest_start_row) = parse_cell_ref(dest_anchor)?;
2591
2592    let width = src_bounds.cols;
2593    let height = src_bounds.rows;
2594
2595    let dest_end_col = dest_start_col
2596        .checked_add(width.saturating_sub(1))
2597        .ok_or_else(|| anyhow!("destination range overflows column bounds"))?;
2598    let dest_end_row = dest_start_row
2599        .checked_add(height.saturating_sub(1))
2600        .ok_or_else(|| anyhow!("destination range overflows row bounds"))?;
2601
2602    let same_sheet = src_sheet_name == dest_sheet_name;
2603
2604    if same_sheet
2605        && ranges_intersect(
2606            src_bounds.min_col,
2607            src_bounds.min_row,
2608            src_bounds.max_col,
2609            src_bounds.max_row,
2610            dest_start_col,
2611            dest_start_row,
2612            dest_end_col,
2613            dest_end_row,
2614        )
2615    {
2616        let dest_range = if width == 1 && height == 1 {
2617            crate::utils::cell_address(dest_start_col, dest_start_row)
2618        } else {
2619            format!(
2620                "{}:{}",
2621                crate::utils::cell_address(dest_start_col, dest_start_row),
2622                crate::utils::cell_address(dest_end_col, dest_end_row)
2623            )
2624        };
2625        bail!(
2626            "copy/move destination overlaps source (src {}, dest {})",
2627            src_range,
2628            dest_range
2629        );
2630    }
2631
2632    let delta_col = dest_start_col as i32 - src_bounds.min_col as i32;
2633    let delta_row = dest_start_row as i32 - src_bounds.min_row as i32;
2634
2635    let mut warnings: Vec<String> = Vec::new();
2636    let mut formula_value_copies: u64 = 0;
2637
2638    let (src_sheet_index, dest_sheet_index) = {
2639        let sheets = book.get_sheet_collection_no_check();
2640        let src = sheets
2641            .iter()
2642            .position(|s| s.get_name() == src_sheet_name)
2643            .ok_or_else(|| anyhow!("sheet '{}' not found", src_sheet_name))?;
2644        let dest = sheets
2645            .iter()
2646            .position(|s| s.get_name() == dest_sheet_name)
2647            .ok_or_else(|| anyhow!("sheet '{}' not found", dest_sheet_name))?;
2648        (src, dest)
2649    };
2650
2651    let sheets = book.get_sheet_collection_mut();
2652
2653    if src_sheet_index == dest_sheet_index {
2654        let sheet = &mut sheets[src_sheet_index];
2655
2656        for row in 0..height {
2657            for col in 0..width {
2658                let src_col = src_bounds.min_col + col;
2659                let src_row = src_bounds.min_row + row;
2660                let dest_col = dest_start_col + col;
2661                let dest_row = dest_start_row + row;
2662
2663                let Some(src_cell) = sheet.get_cell((src_col, src_row)) else {
2664                    sheet.remove_cell((dest_col, dest_row));
2665                    continue;
2666                };
2667
2668                let mut set_value = true;
2669                let mut dest_formula: Option<String> = None;
2670
2671                if include_formulas && src_cell.is_formula() {
2672                    let src_formula = src_cell.get_formula().to_string();
2673                    if policy == FormulaParsePolicy::Off {
2674                        dest_formula = Some(src_formula);
2675                        set_value = false;
2676                    } else {
2677                        match parse_base_formula(&src_formula).and_then(|ast| {
2678                            shift_formula_ast(&ast, delta_col, delta_row, RelativeMode::Excel)
2679                        }) {
2680                            Ok(shifted) => {
2681                                let shifted =
2682                                    shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
2683                                dest_formula = Some(shifted);
2684                                set_value = false;
2685                            }
2686                            Err(err) => {
2687                                let src_address = crate::utils::cell_address(src_col, src_row);
2688                                let dest_address = crate::utils::cell_address(dest_col, dest_row);
2689                                if policy == FormulaParsePolicy::Fail {
2690                                    bail!(
2691                                        "{}formula shift failed in {}!{} -> {}!{}: {}",
2692                                        FORMULA_PARSE_FAILED_PREFIX,
2693                                        src_sheet_name,
2694                                        src_address,
2695                                        dest_sheet_name,
2696                                        dest_address,
2697                                        err
2698                                    );
2699                                }
2700                                builder.record_error(
2701                                    dest_sheet_name,
2702                                    &dest_address,
2703                                    &src_formula,
2704                                    &err.to_string(),
2705                                );
2706                                dest_formula = Some(src_formula);
2707                                set_value = false;
2708                            }
2709                        }
2710                    }
2711                } else if !include_formulas && src_cell.is_formula() {
2712                    formula_value_copies += 1;
2713                }
2714
2715                let src_value = src_cell.get_value().to_string();
2716                let src_style = src_cell.get_style().clone();
2717
2718                let dest_cell = sheet.get_cell_mut((dest_col, dest_row));
2719                if include_styles {
2720                    dest_cell.set_style(src_style);
2721                }
2722
2723                dest_cell.get_cell_value_mut().remove_formula();
2724                if let Some(formula) = dest_formula {
2725                    dest_cell.set_formula(formula);
2726                    dest_cell.set_formula_result_default("");
2727                }
2728                if set_value {
2729                    dest_cell.set_value(src_value);
2730                }
2731            }
2732        }
2733
2734        if clear_source {
2735            for row in 0..height {
2736                for col in 0..width {
2737                    let src_col = src_bounds.min_col + col;
2738                    let src_row = src_bounds.min_row + row;
2739                    sheet.remove_cell((src_col, src_row));
2740                }
2741            }
2742        }
2743    } else {
2744        let (src_sheet, dest_sheet) = if src_sheet_index < dest_sheet_index {
2745            let (left, right) = sheets.split_at_mut(dest_sheet_index);
2746            (&mut left[src_sheet_index], &mut right[0])
2747        } else {
2748            let (left, right) = sheets.split_at_mut(src_sheet_index);
2749            (&mut right[0], &mut left[dest_sheet_index])
2750        };
2751
2752        for row in 0..height {
2753            for col in 0..width {
2754                let src_col = src_bounds.min_col + col;
2755                let src_row = src_bounds.min_row + row;
2756                let dest_col = dest_start_col + col;
2757                let dest_row = dest_start_row + row;
2758
2759                let Some(src_cell) = src_sheet.get_cell((src_col, src_row)) else {
2760                    dest_sheet.remove_cell((dest_col, dest_row));
2761                    continue;
2762                };
2763
2764                let mut set_value = true;
2765                let mut dest_formula: Option<String> = None;
2766
2767                if include_formulas && src_cell.is_formula() {
2768                    let src_formula = src_cell.get_formula().to_string();
2769                    if policy == FormulaParsePolicy::Off {
2770                        dest_formula = Some(src_formula);
2771                        set_value = false;
2772                    } else {
2773                        match parse_base_formula(&src_formula).and_then(|ast| {
2774                            shift_formula_ast(&ast, delta_col, delta_row, RelativeMode::Excel)
2775                        }) {
2776                            Ok(shifted) => {
2777                                let shifted =
2778                                    shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
2779                                dest_formula = Some(shifted);
2780                                set_value = false;
2781                            }
2782                            Err(err) => {
2783                                let src_address = crate::utils::cell_address(src_col, src_row);
2784                                let dest_address = crate::utils::cell_address(dest_col, dest_row);
2785                                if policy == FormulaParsePolicy::Fail {
2786                                    bail!(
2787                                        "{}formula shift failed in {}!{} -> {}!{}: {}",
2788                                        FORMULA_PARSE_FAILED_PREFIX,
2789                                        src_sheet_name,
2790                                        src_address,
2791                                        dest_sheet_name,
2792                                        dest_address,
2793                                        err
2794                                    );
2795                                }
2796                                builder.record_error(
2797                                    dest_sheet_name,
2798                                    &dest_address,
2799                                    &src_formula,
2800                                    &err.to_string(),
2801                                );
2802                                dest_formula = Some(src_formula);
2803                                set_value = false;
2804                            }
2805                        }
2806                    }
2807                } else if !include_formulas && src_cell.is_formula() {
2808                    formula_value_copies += 1;
2809                }
2810
2811                let src_value = src_cell.get_value().to_string();
2812                let src_style = src_cell.get_style().clone();
2813
2814                let dest_cell = dest_sheet.get_cell_mut((dest_col, dest_row));
2815                if include_styles {
2816                    dest_cell.set_style(src_style);
2817                }
2818
2819                dest_cell.get_cell_value_mut().remove_formula();
2820                if let Some(formula) = dest_formula {
2821                    dest_cell.set_formula(formula);
2822                    dest_cell.set_formula_result_default("");
2823                }
2824                if set_value {
2825                    dest_cell.set_value(src_value);
2826                }
2827            }
2828        }
2829
2830        if clear_source {
2831            for row in 0..height {
2832                for col in 0..width {
2833                    let src_col = src_bounds.min_col + col;
2834                    let src_row = src_bounds.min_row + row;
2835                    src_sheet.remove_cell((src_col, src_row));
2836                }
2837            }
2838        }
2839    }
2840
2841    if !include_formulas && formula_value_copies > 0 {
2842        warnings.push(format!(
2843            "Copied cached values for {} formula cell(s) (include_formulas=false); run recalculate for fresh results.",
2844            formula_value_copies
2845        ));
2846    }
2847
2848    Ok(CopyMoveApplyResult {
2849        cells_written: width as u64 * height as u64,
2850        warnings,
2851    })
2852}
2853
2854fn rewrite_formulas_for_sheet_rename(
2855    book: &mut umya_spreadsheet::Spreadsheet,
2856    old_name: &str,
2857    new_name: &str,
2858    policy: FormulaParsePolicy,
2859    builder: &mut FormulaParseDiagnosticsBuilder,
2860) -> Result<()> {
2861    let new_prefix = format_sheet_prefix_for_formula(new_name);
2862
2863    for sheet in book.get_sheet_collection_mut().iter_mut() {
2864        let sheet_name = sheet.get_name().to_string();
2865        for cell in sheet.get_cell_collection_mut() {
2866            if !cell.is_formula() {
2867                continue;
2868            }
2869            let formula_text = cell.get_formula();
2870            if formula_text.is_empty() {
2871                continue;
2872            }
2873            let formula_with_equals = if formula_text.starts_with('=') {
2874                formula_text.to_string()
2875            } else {
2876                format!("={}", formula_text)
2877            };
2878
2879            if policy == FormulaParsePolicy::Off {
2880                continue;
2881            }
2882
2883            let cell_address = cell.get_coordinate().get_coordinate().to_string();
2884            let context_description = format!("{}!{}", sheet_name, cell_address);
2885
2886            let tokens = match Tokenizer::new(&formula_with_equals) {
2887                Ok(tokenizer) => tokenizer.items,
2888                Err(e) => {
2889                    if policy == FormulaParsePolicy::Fail {
2890                        bail!(
2891                            "{}tokenizer error in {}: {}",
2892                            FORMULA_PARSE_FAILED_PREFIX,
2893                            context_description,
2894                            e.message
2895                        );
2896                    }
2897                    builder.record_error(&sheet_name, &cell_address, formula_text, &e.message);
2898                    continue;
2899                }
2900            };
2901            let mut out = String::with_capacity(formula_with_equals.len());
2902            let mut cursor = 0usize;
2903
2904            for token in &tokens {
2905                if token.start > cursor {
2906                    out.push_str(&formula_with_equals[cursor..token.start]);
2907                }
2908
2909                let mut value = token.value.clone();
2910                if token.subtype == formualizer_parse::TokenSubType::Range
2911                    && value.contains('!')
2912                    && let Some((sheet_part, tail)) = value.split_once('!')
2913                    && sheet_part_matches(sheet_part, old_name)
2914                {
2915                    value = format!("{}{}", new_prefix, tail);
2916                }
2917
2918                out.push_str(&value);
2919                cursor = token.end;
2920            }
2921
2922            if cursor < formula_with_equals.len() {
2923                out.push_str(&formula_with_equals[cursor..]);
2924            }
2925
2926            let new_formula = out.strip_prefix('=').unwrap_or(&out);
2927            cell.set_formula(new_formula.to_string());
2928            cell.set_formula_result_default("");
2929        }
2930    }
2931
2932    Ok(())
2933}
2934
2935fn rewrite_defined_name_formulas_for_sheet_rename(
2936    book: &mut umya_spreadsheet::Spreadsheet,
2937    old_name: &str,
2938    new_name: &str,
2939    policy: FormulaParsePolicy,
2940    builder: &mut FormulaParseDiagnosticsBuilder,
2941) -> Result<()> {
2942    let new_prefix = format_sheet_prefix_for_formula(new_name);
2943
2944    for defined in book.get_defined_names_mut() {
2945        let refers_to = defined.get_address();
2946        let trimmed = refers_to.trim();
2947        let had_equals = trimmed.starts_with('=');
2948        let looks_like_formula = had_equals || trimmed.contains('(');
2949        if !looks_like_formula {
2950            continue;
2951        }
2952
2953        let formula_in = if had_equals {
2954            trimmed.to_string()
2955        } else {
2956            format!("={}", trimmed)
2957        };
2958
2959        if policy == FormulaParsePolicy::Off {
2960            continue;
2961        }
2962
2963        let defined_name = defined.get_name().to_string();
2964        let context_description = format!("defined name '{}'", defined_name);
2965
2966        let tokens = match Tokenizer::new(&formula_in) {
2967            Ok(tokenizer) => tokenizer.items,
2968            Err(e) => {
2969                if policy == FormulaParsePolicy::Fail {
2970                    bail!(
2971                        "{}tokenizer error in {}: {}",
2972                        FORMULA_PARSE_FAILED_PREFIX,
2973                        context_description,
2974                        e.message
2975                    );
2976                }
2977                builder.record_error("[DefinedName]", &defined_name, trimmed, &e.message);
2978                continue;
2979            }
2980        };
2981
2982        let mut out = String::with_capacity(formula_in.len());
2983        let mut cursor = 0usize;
2984        let mut changed = false;
2985
2986        for token in &tokens {
2987            if token.start > cursor {
2988                out.push_str(&formula_in[cursor..token.start]);
2989            }
2990
2991            let mut value = token.value.clone();
2992            if token.subtype == formualizer_parse::TokenSubType::Range
2993                && value.contains('!')
2994                && let Some((sheet_part, tail)) = value.split_once('!')
2995                && sheet_part_matches(sheet_part, old_name)
2996            {
2997                value = format!("{}{}", new_prefix, tail);
2998                changed = true;
2999            }
3000
3001            out.push_str(&value);
3002            cursor = token.end;
3003        }
3004
3005        if cursor < formula_in.len() {
3006            out.push_str(&formula_in[cursor..]);
3007        }
3008
3009        if changed {
3010            let out_final = if had_equals {
3011                out
3012            } else {
3013                out.strip_prefix('=').unwrap_or(&out).to_string()
3014            };
3015            defined.set_address(out_final);
3016        }
3017    }
3018
3019    Ok(())
3020}
3021
3022fn rewrite_defined_name_formulas_for_sheet_col_insert(
3023    book: &mut umya_spreadsheet::Spreadsheet,
3024    sheet_name: &str,
3025    at_col: u32,
3026    count: u32,
3027    policy: FormulaParsePolicy,
3028    builder: &mut FormulaParseDiagnosticsBuilder,
3029) -> Result<()> {
3030    rewrite_defined_name_formulas_for_sheet_structure_change(
3031        book,
3032        sheet_name,
3033        StructureAxis::Col,
3034        StructureEdit::Insert { at: at_col, count },
3035        policy,
3036        builder,
3037    )
3038}
3039
3040fn rewrite_defined_name_formulas_for_sheet_col_delete(
3041    book: &mut umya_spreadsheet::Spreadsheet,
3042    sheet_name: &str,
3043    start_col: u32,
3044    count: u32,
3045    policy: FormulaParsePolicy,
3046    builder: &mut FormulaParseDiagnosticsBuilder,
3047) -> Result<()> {
3048    rewrite_defined_name_formulas_for_sheet_structure_change(
3049        book,
3050        sheet_name,
3051        StructureAxis::Col,
3052        StructureEdit::Delete {
3053            start: start_col,
3054            count,
3055        },
3056        policy,
3057        builder,
3058    )
3059}
3060
3061fn rewrite_defined_name_formulas_for_sheet_row_insert(
3062    book: &mut umya_spreadsheet::Spreadsheet,
3063    sheet_name: &str,
3064    at_row: u32,
3065    count: u32,
3066    policy: FormulaParsePolicy,
3067    builder: &mut FormulaParseDiagnosticsBuilder,
3068) -> Result<()> {
3069    rewrite_defined_name_formulas_for_sheet_structure_change(
3070        book,
3071        sheet_name,
3072        StructureAxis::Row,
3073        StructureEdit::Insert { at: at_row, count },
3074        policy,
3075        builder,
3076    )
3077}
3078
3079fn rewrite_defined_name_formulas_for_sheet_row_delete(
3080    book: &mut umya_spreadsheet::Spreadsheet,
3081    sheet_name: &str,
3082    start_row: u32,
3083    count: u32,
3084    policy: FormulaParsePolicy,
3085    builder: &mut FormulaParseDiagnosticsBuilder,
3086) -> Result<()> {
3087    rewrite_defined_name_formulas_for_sheet_structure_change(
3088        book,
3089        sheet_name,
3090        StructureAxis::Row,
3091        StructureEdit::Delete {
3092            start: start_row,
3093            count,
3094        },
3095        policy,
3096        builder,
3097    )
3098}
3099
3100fn rewrite_defined_name_formulas_for_sheet_structure_change(
3101    book: &mut umya_spreadsheet::Spreadsheet,
3102    sheet_name: &str,
3103    axis: StructureAxis,
3104    edit: StructureEdit,
3105    policy: FormulaParsePolicy,
3106    builder: &mut FormulaParseDiagnosticsBuilder,
3107) -> Result<()> {
3108    for defined in book.get_defined_names_mut() {
3109        let refers_to = defined.get_address();
3110        let trimmed = refers_to.trim();
3111        let had_equals = trimmed.starts_with('=');
3112        let looks_like_formula = had_equals || trimmed.contains('(');
3113        if !looks_like_formula {
3114            continue;
3115        }
3116
3117        let formula_in = if had_equals {
3118            trimmed.to_string()
3119        } else {
3120            format!("={}", trimmed)
3121        };
3122
3123        if policy == FormulaParsePolicy::Off {
3124            continue;
3125        }
3126
3127        let defined_name = defined.get_name().to_string();
3128        let context_description = format!("defined name '{}'", defined_name);
3129
3130        let tokens = match Tokenizer::new(&formula_in) {
3131            Ok(tokenizer) => tokenizer.items,
3132            Err(e) => {
3133                if policy == FormulaParsePolicy::Fail {
3134                    bail!(
3135                        "{}tokenizer error in {}: {}",
3136                        FORMULA_PARSE_FAILED_PREFIX,
3137                        context_description,
3138                        e.message
3139                    );
3140                }
3141                builder.record_error("[DefinedName]", &defined_name, trimmed, &e.message);
3142                continue;
3143            }
3144        };
3145
3146        let mut out = String::with_capacity(formula_in.len());
3147        let mut cursor = 0usize;
3148        let mut changed = false;
3149
3150        for token in &tokens {
3151            if token.start > cursor {
3152                out.push_str(&formula_in[cursor..token.start]);
3153            }
3154
3155            let mut value = token.value.clone();
3156            if token.subtype == formualizer_parse::TokenSubType::Range
3157                && value.contains('!')
3158                && let Some((sheet_part, coord_part)) = value.split_once('!')
3159                && sheet_part_matches(sheet_part, sheet_name)
3160            {
3161                let adjusted = adjust_ref_coord_part(coord_part, axis, edit)?;
3162                value = format!("{sheet_part}!{adjusted}");
3163                changed = true;
3164            }
3165
3166            out.push_str(&value);
3167            cursor = token.end;
3168        }
3169
3170        if cursor < formula_in.len() {
3171            out.push_str(&formula_in[cursor..]);
3172        }
3173
3174        if changed {
3175            let out_final = if had_equals {
3176                out
3177            } else {
3178                out.strip_prefix('=').unwrap_or(&out).to_string()
3179            };
3180            defined.set_address(out_final);
3181        }
3182    }
3183
3184    Ok(())
3185}
3186
3187fn clamp_defined_name_refers_to_max_row(refers_to: &str) -> Option<String> {
3188    const EXCEL_MAX_ROW: u32 = 1_048_576;
3189
3190    let trimmed = refers_to.trim();
3191    if trimmed.is_empty() {
3192        return None;
3193    }
3194
3195    let had_equals = trimmed.starts_with('=');
3196    let formula_in = if had_equals {
3197        trimmed.to_string()
3198    } else {
3199        format!("={trimmed}")
3200    };
3201
3202    let tokens = Tokenizer::new(&formula_in).ok()?.items;
3203
3204    let mut out = String::with_capacity(formula_in.len());
3205    let mut cursor = 0usize;
3206    let mut changed = false;
3207
3208    for token in &tokens {
3209        if token.start > cursor {
3210            out.push_str(&formula_in[cursor..token.start]);
3211        }
3212
3213        let mut value = token.value.clone();
3214        if token.subtype == formualizer_parse::TokenSubType::Range {
3215            let clamped = clamp_range_token_max_row(&value, EXCEL_MAX_ROW);
3216            if clamped != value {
3217                value = clamped;
3218                changed = true;
3219            }
3220        }
3221
3222        out.push_str(&value);
3223        cursor = token.end;
3224    }
3225
3226    if cursor < formula_in.len() {
3227        out.push_str(&formula_in[cursor..]);
3228    }
3229
3230    if !changed {
3231        return None;
3232    }
3233
3234    Some(if had_equals {
3235        out
3236    } else {
3237        out.strip_prefix('=').unwrap_or(&out).to_string()
3238    })
3239}
3240
3241fn clamp_range_token_max_row(token_value: &str, max_row: u32) -> String {
3242    if token_value == "#REF!" {
3243        return token_value.to_string();
3244    }
3245
3246    if let Some((sheet_part, coord_part)) = token_value.split_once('!') {
3247        let clamped = clamp_coord_part_max_row(coord_part, max_row);
3248        if clamped == coord_part {
3249            token_value.to_string()
3250        } else {
3251            format!("{sheet_part}!{clamped}")
3252        }
3253    } else {
3254        clamp_coord_part_max_row(token_value, max_row)
3255    }
3256}
3257
3258fn clamp_coord_part_max_row(coord_part: &str, max_row: u32) -> String {
3259    let mut changed = false;
3260    let mut union_out = Vec::new();
3261
3262    for union_piece in coord_part.split(',') {
3263        let mut range_out = Vec::new();
3264        let mut local_changed = false;
3265
3266        for segment in union_piece.split(':') {
3267            let clamped = clamp_ref_segment_max_row(segment, max_row);
3268            if clamped != segment {
3269                local_changed = true;
3270            }
3271            range_out.push(clamped);
3272        }
3273
3274        if local_changed {
3275            changed = true;
3276        }
3277        union_out.push(range_out.join(":"));
3278    }
3279
3280    if changed {
3281        union_out.join(",")
3282    } else {
3283        coord_part.to_string()
3284    }
3285}
3286
3287fn clamp_ref_segment_max_row(segment: &str, max_row: u32) -> String {
3288    use umya_spreadsheet::helper::coordinate::{
3289        coordinate_from_index_with_lock, index_from_coordinate, string_from_column_index,
3290    };
3291
3292    let (col, row, col_lock, row_lock) = index_from_coordinate(segment);
3293
3294    // Not a coordinate-like segment (e.g., structured reference); leave untouched.
3295    if col.is_none() && row.is_none() {
3296        return segment.to_string();
3297    }
3298
3299    let row = row.map(|value| value.min(max_row));
3300
3301    match (col, row) {
3302        (Some(c), Some(r)) => coordinate_from_index_with_lock(
3303            &c,
3304            &r,
3305            &col_lock.unwrap_or(false),
3306            &row_lock.unwrap_or(false),
3307        ),
3308        (Some(c), None) => {
3309            let col_str = string_from_column_index(&c);
3310            format!(
3311                "{}{}",
3312                if col_lock.unwrap_or(false) { "$" } else { "" },
3313                col_str
3314            )
3315        }
3316        (None, Some(r)) => format!("{}{}", if row_lock.unwrap_or(false) { "$" } else { "" }, r),
3317        (None, None) => segment.to_string(),
3318    }
3319}
3320
3321fn clamp_out_of_bounds_defined_name_rows(book: &mut umya_spreadsheet::Spreadsheet) -> usize {
3322    #[derive(Debug)]
3323    struct Patch {
3324        idx: usize,
3325        name: String,
3326        clamped_address: String,
3327        hidden: bool,
3328        local_sheet_id: Option<u32>,
3329        sheet_hint: Option<String>,
3330    }
3331
3332    fn extract_first_sheet_name(refers_to: &str) -> Option<String> {
3333        let first = refers_to.split(',').next()?.trim();
3334        let (sheet_part, _) = first.split_once('!')?;
3335        let sheet_part = sheet_part.trim();
3336        if let Some(inner) = sheet_part
3337            .strip_prefix('\'')
3338            .and_then(|value| value.strip_suffix('\''))
3339        {
3340            Some(inner.replace("''", "'"))
3341        } else {
3342            Some(sheet_part.to_string())
3343        }
3344    }
3345
3346    let mut patches = Vec::new();
3347    for (idx, defined) in book.get_defined_names().iter().enumerate() {
3348        let original = defined.get_address();
3349        let trimmed = original.trim();
3350
3351        // Keep formula-like names on the tokenizer path; clamp plain address unions only.
3352        if trimmed.starts_with('=') || trimmed.contains('(') || trimmed.is_empty() {
3353            continue;
3354        }
3355
3356        if let Some(clamped_address) = clamp_defined_name_refers_to_max_row(trimmed) {
3357            patches.push(Patch {
3358                idx,
3359                name: defined.get_name().to_string(),
3360                clamped_address,
3361                hidden: *defined.get_hidden(),
3362                local_sheet_id: if defined.has_local_sheet_id() {
3363                    Some(*defined.get_local_sheet_id())
3364                } else {
3365                    None
3366                },
3367                sheet_hint: extract_first_sheet_name(trimmed),
3368            });
3369        }
3370    }
3371
3372    let mut applied = 0usize;
3373    for patch in patches {
3374        let target_sheet = patch
3375            .sheet_hint
3376            .as_ref()
3377            .filter(|name| book.get_sheet_by_name(name).is_some())
3378            .cloned()
3379            .or_else(|| {
3380                book.get_sheet_collection_no_check()
3381                    .first()
3382                    .map(|sheet| sheet.get_name().to_string())
3383            });
3384
3385        let Some(target_sheet) = target_sheet else {
3386            continue;
3387        };
3388
3389        let mut replacement = {
3390            let Some(sheet) = book.get_sheet_by_name_mut(&target_sheet) else {
3391                continue;
3392            };
3393            let before = sheet.get_defined_names().len();
3394            if sheet
3395                .add_defined_name(patch.name.clone(), patch.clamped_address.clone())
3396                .is_err()
3397            {
3398                continue;
3399            }
3400            let Some(candidate) = sheet.get_defined_names().last().cloned() else {
3401                sheet.get_defined_names_mut().truncate(before);
3402                continue;
3403            };
3404            sheet.get_defined_names_mut().truncate(before);
3405            candidate
3406        };
3407
3408        replacement.set_hidden(patch.hidden);
3409        if let Some(local_sheet_id) = patch.local_sheet_id {
3410            replacement.set_local_sheet_id(local_sheet_id);
3411        }
3412
3413        if let Some(slot) = book.get_defined_names_mut().get_mut(patch.idx) {
3414            *slot = replacement;
3415            applied += 1;
3416        }
3417    }
3418
3419    applied
3420}
3421
3422fn clamp_defined_name_rows_in_workbook_xml(xml: &str) -> (String, usize) {
3423    let defined_name_re =
3424        Regex::new(r"(?s)(<definedName[^>]*>)([^<]*)(</definedName>)").expect("valid xml regex");
3425
3426    let mut clamped_entries = 0usize;
3427    let rewritten = defined_name_re
3428        .replace_all(xml, |caps: &regex::Captures<'_>| {
3429            let full = caps.get(0).map(|m| m.as_str()).unwrap_or_default();
3430            let prefix = caps.get(1).map(|m| m.as_str()).unwrap_or_default();
3431            let body = caps.get(2).map(|m| m.as_str()).unwrap_or_default();
3432            let suffix = caps.get(3).map(|m| m.as_str()).unwrap_or_default();
3433
3434            if body.trim_start().starts_with('=') || body.contains('(') {
3435                return full.to_string();
3436            }
3437
3438            if let Some(clamped_body) = clamp_defined_name_refers_to_max_row(body) {
3439                clamped_entries += 1;
3440                format!("{prefix}{clamped_body}{suffix}")
3441            } else {
3442                full.to_string()
3443            }
3444        })
3445        .to_string();
3446
3447    (rewritten, clamped_entries)
3448}
3449
3450fn sanitize_workbook_xml_defined_name_rows(path: &Path) -> Result<usize> {
3451    use zip::{ZipArchive, ZipWriter, write::FileOptions};
3452
3453    let input_file = fs::File::open(path)?;
3454    let mut archive = ZipArchive::new(input_file)?;
3455
3456    #[derive(Debug)]
3457    struct ZipEntry {
3458        name: String,
3459        is_dir: bool,
3460        data: Vec<u8>,
3461        compression: zip::CompressionMethod,
3462        unix_mode: Option<u32>,
3463        modified: zip::DateTime,
3464    }
3465
3466    let mut entries: Vec<ZipEntry> = Vec::with_capacity(archive.len());
3467    let mut clamped_defined_names = 0usize;
3468
3469    for idx in 0..archive.len() {
3470        let mut file = archive.by_index(idx)?;
3471        let name = file.name().to_string();
3472        let is_dir = file.is_dir();
3473        let compression = file.compression();
3474        let unix_mode = file.unix_mode();
3475        let modified = file.last_modified();
3476
3477        let mut data = Vec::new();
3478        if !is_dir {
3479            file.read_to_end(&mut data)?;
3480            if name == "xl/workbook.xml" {
3481                let xml = String::from_utf8_lossy(&data);
3482                let (rewritten, changed) = clamp_defined_name_rows_in_workbook_xml(&xml);
3483                clamped_defined_names = changed;
3484                if changed > 0 {
3485                    data = rewritten.into_bytes();
3486                }
3487            }
3488        }
3489
3490        entries.push(ZipEntry {
3491            name,
3492            is_dir,
3493            data,
3494            compression,
3495            unix_mode,
3496            modified,
3497        });
3498    }
3499
3500    if clamped_defined_names == 0 {
3501        return Ok(0);
3502    }
3503
3504    let temp_path = path.with_extension("xlsx.tmp");
3505    let output_file = fs::File::create(&temp_path)?;
3506    let mut writer = ZipWriter::new(output_file);
3507
3508    for entry in entries {
3509        let mut options = FileOptions::default()
3510            .compression_method(entry.compression)
3511            .last_modified_time(entry.modified);
3512        if let Some(mode) = entry.unix_mode {
3513            options = options.unix_permissions(mode);
3514        }
3515
3516        if entry.is_dir {
3517            writer.add_directory(entry.name, options)?;
3518        } else {
3519            writer.start_file(entry.name, options)?;
3520            writer.write_all(&entry.data)?;
3521        }
3522    }
3523
3524    writer.finish()?;
3525    fs::rename(temp_path, path)?;
3526    Ok(clamped_defined_names)
3527}
3528
3529fn rewrite_formulas_for_sheet_col_insert(
3530    book: &mut umya_spreadsheet::Spreadsheet,
3531    sheet_name: &str,
3532    at_col: u32,
3533    count: u32,
3534    policy: FormulaParsePolicy,
3535    builder: &mut FormulaParseDiagnosticsBuilder,
3536) -> Result<()> {
3537    rewrite_formulas_for_sheet_structure_change(
3538        book,
3539        sheet_name,
3540        StructureAxis::Col,
3541        StructureEdit::Insert { at: at_col, count },
3542        policy,
3543        builder,
3544    )
3545}
3546
3547fn rewrite_formulas_for_sheet_col_delete(
3548    book: &mut umya_spreadsheet::Spreadsheet,
3549    sheet_name: &str,
3550    start_col: u32,
3551    count: u32,
3552    policy: FormulaParsePolicy,
3553    builder: &mut FormulaParseDiagnosticsBuilder,
3554) -> Result<()> {
3555    rewrite_formulas_for_sheet_structure_change(
3556        book,
3557        sheet_name,
3558        StructureAxis::Col,
3559        StructureEdit::Delete {
3560            start: start_col,
3561            count,
3562        },
3563        policy,
3564        builder,
3565    )
3566}
3567
3568fn rewrite_formulas_for_sheet_row_insert(
3569    book: &mut umya_spreadsheet::Spreadsheet,
3570    sheet_name: &str,
3571    at_row: u32,
3572    count: u32,
3573    policy: FormulaParsePolicy,
3574    builder: &mut FormulaParseDiagnosticsBuilder,
3575) -> Result<()> {
3576    rewrite_formulas_for_sheet_structure_change(
3577        book,
3578        sheet_name,
3579        StructureAxis::Row,
3580        StructureEdit::Insert { at: at_row, count },
3581        policy,
3582        builder,
3583    )
3584}
3585
3586fn rewrite_formulas_for_sheet_row_delete(
3587    book: &mut umya_spreadsheet::Spreadsheet,
3588    sheet_name: &str,
3589    start_row: u32,
3590    count: u32,
3591    policy: FormulaParsePolicy,
3592    builder: &mut FormulaParseDiagnosticsBuilder,
3593) -> Result<()> {
3594    rewrite_formulas_for_sheet_structure_change(
3595        book,
3596        sheet_name,
3597        StructureAxis::Row,
3598        StructureEdit::Delete {
3599            start: start_row,
3600            count,
3601        },
3602        policy,
3603        builder,
3604    )
3605}
3606
3607#[derive(Debug, Clone, Copy)]
3608enum StructureAxis {
3609    Row,
3610    Col,
3611}
3612
3613#[derive(Debug, Clone, Copy)]
3614enum StructureEdit {
3615    Insert { at: u32, count: u32 },
3616    Delete { start: u32, count: u32 },
3617}
3618
3619fn rewrite_formulas_for_sheet_structure_change(
3620    book: &mut umya_spreadsheet::Spreadsheet,
3621    sheet_name: &str,
3622    axis: StructureAxis,
3623    edit: StructureEdit,
3624    policy: FormulaParsePolicy,
3625    builder: &mut FormulaParseDiagnosticsBuilder,
3626) -> Result<()> {
3627    for sheet in book.get_sheet_collection_mut().iter_mut() {
3628        if sheet.get_name() == sheet_name {
3629            continue;
3630        }
3631        let current_sheet_name = sheet.get_name().to_string();
3632        for cell in sheet.get_cell_collection_mut() {
3633            if !cell.is_formula() {
3634                continue;
3635            }
3636            let formula_text = cell.get_formula();
3637            if formula_text.is_empty() {
3638                continue;
3639            }
3640            let formula_with_equals = if formula_text.starts_with('=') {
3641                formula_text.to_string()
3642            } else {
3643                format!("={}", formula_text)
3644            };
3645            if policy == FormulaParsePolicy::Off {
3646                continue;
3647            }
3648
3649            let cell_address = cell.get_coordinate().get_coordinate().to_string();
3650            let context_description = format!("{}!{}", current_sheet_name, cell_address);
3651
3652            let tokens = match Tokenizer::new(&formula_with_equals) {
3653                Ok(tokenizer) => tokenizer.items,
3654                Err(e) => {
3655                    if policy == FormulaParsePolicy::Fail {
3656                        bail!(
3657                            "{}tokenizer error in {}: {}",
3658                            FORMULA_PARSE_FAILED_PREFIX,
3659                            context_description,
3660                            e.message
3661                        );
3662                    }
3663                    builder.record_error(
3664                        &current_sheet_name,
3665                        &cell_address,
3666                        formula_text,
3667                        &e.message,
3668                    );
3669                    continue;
3670                }
3671            };
3672
3673            let mut out = String::with_capacity(formula_with_equals.len());
3674            let mut cursor = 0usize;
3675            let mut changed = false;
3676
3677            for token in &tokens {
3678                if token.start > cursor {
3679                    out.push_str(&formula_with_equals[cursor..token.start]);
3680                }
3681
3682                let mut value = token.value.clone();
3683                if token.subtype == formualizer_parse::TokenSubType::Range
3684                    && value.contains('!')
3685                    && let Some((sheet_part, coord_part)) = value.split_once('!')
3686                    && sheet_part_matches(sheet_part, sheet_name)
3687                {
3688                    let adjusted = adjust_ref_coord_part(coord_part, axis, edit)?;
3689                    value = format!("{sheet_part}!{adjusted}");
3690                    changed = true;
3691                }
3692
3693                out.push_str(&value);
3694                cursor = token.end;
3695            }
3696
3697            if cursor < formula_with_equals.len() {
3698                out.push_str(&formula_with_equals[cursor..]);
3699            }
3700
3701            if changed {
3702                let new_formula = out.strip_prefix('=').unwrap_or(&out);
3703                cell.set_formula(new_formula.to_string());
3704                cell.set_formula_result_default("");
3705            }
3706        }
3707    }
3708    Ok(())
3709}
3710
3711fn adjust_ref_coord_part(
3712    coord_part: &str,
3713    axis: StructureAxis,
3714    edit: StructureEdit,
3715) -> Result<String> {
3716    if coord_part == "#REF!" {
3717        return Ok(coord_part.to_string());
3718    }
3719    if let Some((start, end)) = coord_part.split_once(':') {
3720        let start_adj = adjust_ref_segment(start, axis, edit)?;
3721        let end_adj = adjust_ref_segment(end, axis, edit)?;
3722        if start_adj == "#REF!" || end_adj == "#REF!" {
3723            return Ok("#REF!".to_string());
3724        }
3725        Ok(format!("{start_adj}:{end_adj}"))
3726    } else {
3727        Ok(adjust_ref_segment(coord_part, axis, edit)?)
3728    }
3729}
3730
3731fn adjust_ref_segment(segment: &str, axis: StructureAxis, edit: StructureEdit) -> Result<String> {
3732    use umya_spreadsheet::helper::coordinate::{
3733        coordinate_from_index_with_lock, index_from_coordinate, string_from_column_index,
3734    };
3735
3736    const EXCEL_MAX_ROW: u32 = 1_048_576;
3737
3738    let (col, row, col_lock, row_lock) = index_from_coordinate(segment);
3739    let mut col = col;
3740    let mut row = row;
3741
3742    match axis {
3743        StructureAxis::Col => {
3744            if let Some(c) = col {
3745                col = match edit {
3746                    StructureEdit::Insert { at, count } => Some(adjust_insert(c, at, count)),
3747                    StructureEdit::Delete { start, count } => adjust_delete(c, start, count),
3748                };
3749            }
3750        }
3751        StructureAxis::Row => {
3752            if let Some(r) = row {
3753                row = match edit {
3754                    StructureEdit::Insert { at, count } => {
3755                        Some(adjust_insert_bounded(r, at, count, EXCEL_MAX_ROW))
3756                    }
3757                    StructureEdit::Delete { start, count } => adjust_delete(r, start, count),
3758                };
3759            }
3760        }
3761    }
3762
3763    if col.is_none() && row.is_none() {
3764        return Ok("#REF!".to_string());
3765    }
3766
3767    match (col, row) {
3768        (Some(c), Some(r)) => Ok(coordinate_from_index_with_lock(
3769            &c,
3770            &r,
3771            &col_lock.unwrap_or(false),
3772            &row_lock.unwrap_or(false),
3773        )),
3774        (Some(c), None) => {
3775            let col_str = string_from_column_index(&c);
3776            Ok(format!(
3777                "{}{}",
3778                if col_lock.unwrap_or(false) { "$" } else { "" },
3779                col_str
3780            ))
3781        }
3782        (None, Some(r)) => Ok(format!(
3783            "{}{}",
3784            if row_lock.unwrap_or(false) { "$" } else { "" },
3785            r
3786        )),
3787        (None, None) => Ok("#REF!".to_string()),
3788    }
3789}
3790
3791fn adjust_insert(value: u32, at: u32, count: u32) -> u32 {
3792    if value >= at {
3793        value.saturating_add(count)
3794    } else {
3795        value
3796    }
3797}
3798
3799fn adjust_insert_bounded(value: u32, at: u32, count: u32, max_value: u32) -> u32 {
3800    adjust_insert(value, at, count).min(max_value)
3801}
3802
3803fn adjust_delete(value: u32, start: u32, count: u32) -> Option<u32> {
3804    let end = start.saturating_add(count.saturating_sub(1));
3805    if value >= start && value <= end {
3806        None
3807    } else if value > end {
3808        Some(value - count)
3809    } else {
3810        Some(value)
3811    }
3812}
3813
3814// ---------------------------------------------------------------------------
3815// Adjacent SUM expansion helpers (ticket 4104)
3816// ---------------------------------------------------------------------------
3817
3818/// Regex for simple `SUM(ColRow:ColRow)` patterns (A1-style, no sheet prefix).
3819/// Captures: full match, col1, row1, col2, row2.
3820fn simple_sum_range_regex() -> Regex {
3821    Regex::new(r"(?i)^SUM\(([A-Z]{1,3})(\d+):([A-Z]{1,3})(\d+)\)$").expect("valid regex")
3822}
3823
3824/// After inserting `count` rows at `at_row`, scan the subtotal row immediately
3825/// below the insertion band (row `at_row + count`) for simple `SUM(Ax:Ay)`
3826/// formulas whose range end was adjacent to the insertion point. Expand those
3827/// ranges to include the newly inserted rows.
3828///
3829/// **Important**: `insert_new_row` shifts cell positions but the same-sheet
3830/// formula rewriter is intentionally skipped (it only rewrites cross-sheet
3831/// references). So the formula text at the subtotal row still contains
3832/// pre-insert row references. If the range ended at `at_row - 1`, it was
3833/// adjacent and should be expanded to `subtotal_row - 1`.
3834///
3835/// Only unambiguous single-range `SUM(Ax:Ay)` patterns are touched.
3836/// Returns a list of warning strings for any skipped/ambiguous formulas.
3837fn expand_adjacent_sum_formulas(
3838    book: &mut umya_spreadsheet::Spreadsheet,
3839    sheet_name: &str,
3840    at_row: u32,
3841    count: u32,
3842) -> Result<(Vec<String>, u64)> {
3843    let mut warnings: Vec<String> = Vec::new();
3844    let mut expanded_count: u64 = 0;
3845    // The cell that was originally at `at_row` is now at `at_row + count`.
3846    let subtotal_row = at_row + count;
3847    let sum_re = simple_sum_range_regex();
3848
3849    let sheet = book
3850        .get_sheet_by_name_mut(sheet_name)
3851        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
3852
3853    let max_col = sheet.get_highest_column();
3854    if max_col == 0 {
3855        return Ok((warnings, expanded_count));
3856    }
3857
3858    for col in 1..=max_col {
3859        let Some(cell) = sheet.get_cell((col, subtotal_row)) else {
3860            continue;
3861        };
3862        if !cell.is_formula() {
3863            continue;
3864        }
3865        let formula_text = cell.get_formula().to_string();
3866        if formula_text.is_empty() {
3867            continue;
3868        }
3869        // Strip leading = if present (umya stores without it, but be safe).
3870        let formula_bare = formula_text.strip_prefix('=').unwrap_or(&formula_text);
3871
3872        let Some(caps) = sum_re.captures(formula_bare) else {
3873            // Not a simple SUM(range) pattern.
3874            if formula_bare.to_ascii_uppercase().contains("SUM") {
3875                warnings.push(format!(
3876                    "WARN_SUM_EXPANSION_SKIPPED: {}!{} has complex SUM formula '{}'; skipped.",
3877                    sheet_name,
3878                    crate::utils::cell_address(col, subtotal_row),
3879                    formula_bare
3880                ));
3881            }
3882            continue;
3883        };
3884
3885        let col1_str = caps.get(1).unwrap().as_str().to_ascii_uppercase();
3886        let row1: u32 = caps.get(2).unwrap().as_str().parse().unwrap_or(0);
3887        let col2_str = caps.get(3).unwrap().as_str().to_ascii_uppercase();
3888        let row2: u32 = caps.get(4).unwrap().as_str().parse().unwrap_or(0);
3889
3890        // Only expand single-column SUM ranges.
3891        if col1_str != col2_str {
3892            warnings.push(format!(
3893                "WARN_SUM_EXPANSION_SKIPPED: {}!{} SUM spans columns {}:{} — not a single-column range; skipped.",
3894                sheet_name,
3895                crate::utils::cell_address(col, subtotal_row),
3896                col1_str,
3897                col2_str,
3898            ));
3899            continue;
3900        }
3901
3902        // The formula text still has pre-insert references because the
3903        // same-sheet rewriter was skipped. Check adjacency: the range end
3904        // (row2) should be at_row - 1 (immediately above the insertion point).
3905        if at_row == 0 || row2 + 1 != at_row {
3906            // Range end wasn't immediately before the insertion point.
3907            continue;
3908        }
3909        if row1 > row2 || row1 == 0 {
3910            continue;
3911        }
3912
3913        // Expand the range end to include the newly inserted rows.
3914        // New end = subtotal_row - 1 = at_row + count - 1.
3915        let desired_end = subtotal_row - 1;
3916
3917        // Build expanded formula.
3918        let new_formula = format!("SUM({}{}:{}{})", col1_str, row1, col2_str, desired_end);
3919
3920        let cell = sheet.get_cell_mut((col, subtotal_row));
3921        cell.set_formula(new_formula);
3922        cell.set_formula_result_default("");
3923        expanded_count += 1;
3924    }
3925
3926    Ok((warnings, expanded_count))
3927}
3928
3929// ---------------------------------------------------------------------------
3930// Clone-row helpers (ticket 4104)
3931// ---------------------------------------------------------------------------
3932
3933/// Captured cell data from a template row.
3934#[derive(Debug, Clone)]
3935struct TemplateCellData {
3936    col: u32,
3937    value: String,
3938    formula: Option<String>,
3939    style: umya_spreadsheet::Style,
3940}
3941
3942/// Capture every non-empty cell in `source_row` as template data.
3943fn capture_row_template(
3944    sheet: &umya_spreadsheet::Worksheet,
3945    source_row: u32,
3946) -> Result<Vec<TemplateCellData>> {
3947    let max_col = sheet.get_highest_column();
3948    let mut cells = Vec::new();
3949    for col in 1..=max_col {
3950        let Some(cell) = sheet.get_cell((col, source_row)) else {
3951            continue;
3952        };
3953        let value = cell.get_value().to_string();
3954        let formula = if cell.is_formula() {
3955            Some(cell.get_formula().to_string())
3956        } else {
3957            None
3958        };
3959        let style = cell.get_style().clone();
3960        cells.push(TemplateCellData {
3961            col,
3962            value,
3963            formula,
3964            style,
3965        });
3966    }
3967    Ok(cells)
3968}
3969
3970/// Stamp template data into each inserted row, shifting formulas by row delta.
3971fn stamp_template_rows(
3972    sheet: &mut umya_spreadsheet::Worksheet,
3973    template: &[TemplateCellData],
3974    source_row: u32,
3975    insert_at: u32,
3976    count: u32,
3977) -> Result<Vec<String>> {
3978    let mut warnings = Vec::new();
3979    for copy_idx in 0..count {
3980        let dest_row = insert_at + copy_idx;
3981        let delta_row = dest_row as i32 - source_row as i32;
3982        for tpl in template {
3983            let dest_cell = sheet.get_cell_mut((tpl.col, dest_row));
3984            dest_cell.set_style(tpl.style.clone());
3985
3986            if let Some(formula) = &tpl.formula {
3987                // Shift formula references by delta_row rows.
3988                match parse_base_formula(formula)
3989                    .and_then(|ast| shift_formula_ast(&ast, 0, delta_row, RelativeMode::Excel))
3990                {
3991                    Ok(shifted) => {
3992                        let shifted = shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
3993                        dest_cell.set_formula(shifted);
3994                        dest_cell.set_formula_result_default("");
3995                    }
3996                    Err(err) => {
3997                        warnings.push(format!(
3998                            "WARN_CLONE_FORMULA_SHIFT: could not shift formula '{}' for row {}: {}; copied verbatim.",
3999                            formula, dest_row, err
4000                        ));
4001                        dest_cell.set_formula(formula.clone());
4002                        dest_cell.set_formula_result_default("");
4003                    }
4004                }
4005            } else {
4006                dest_cell.set_value(tpl.value.clone());
4007            }
4008        }
4009    }
4010    Ok(warnings)
4011}
4012
4013fn sheet_part_matches(sheet_part: &str, old_name: &str) -> bool {
4014    let trimmed = sheet_part.trim();
4015    if let Some(stripped) = trimmed.strip_prefix('\'')
4016        && let Some(inner) = stripped.strip_suffix('\'')
4017    {
4018        return inner.replace("''", "'") == old_name;
4019    }
4020    trimmed == old_name
4021}
4022
4023fn format_sheet_prefix_for_formula(sheet_name: &str) -> String {
4024    if sheet_name_needs_quoting_for_formula(sheet_name) {
4025        let escaped = sheet_name.replace('\'', "''");
4026        format!("'{escaped}'!")
4027    } else {
4028        format!("{sheet_name}!")
4029    }
4030}
4031
4032fn sheet_name_needs_quoting_for_formula(name: &str) -> bool {
4033    if name.is_empty() {
4034        return false;
4035    }
4036    let bytes = name.as_bytes();
4037    if bytes[0].is_ascii_digit() {
4038        return true;
4039    }
4040    for &byte in bytes {
4041        match byte {
4042            b' ' | b'!' | b'"' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+'
4043            | b',' | b'-' | b'.' | b'/' | b':' | b';' | b'<' | b'=' | b'>' | b'?' | b'@' | b'['
4044            | b'\\' | b']' | b'^' | b'`' | b'{' | b'|' | b'}' | b'~' => return true,
4045            _ => {}
4046        }
4047    }
4048    let upper = name.to_uppercase();
4049    matches!(
4050        upper.as_str(),
4051        "TRUE" | "FALSE" | "NULL" | "REF" | "DIV" | "NAME" | "NUM" | "VALUE" | "N/A"
4052    )
4053}
4054
4055pub(crate) struct StyleApplyResult {
4056    pub(crate) ops_applied: usize,
4057    pub(crate) summary: ChangeSummary,
4058}
4059
4060pub(crate) fn stage_snapshot_path(fork_id: &str, change_id: &str) -> PathBuf {
4061    PathBuf::from("/tmp/mcp-staged").join(format!("{fork_id}_{change_id}.xlsx"))
4062}
4063
4064pub(crate) struct ColumnSizeApplyResult {
4065    pub(crate) ops_applied: usize,
4066    pub(crate) summary: ChangeSummary,
4067}
4068
4069fn parse_column_span(spec: &str) -> Result<(u32, u32)> {
4070    let raw = spec.trim();
4071    if raw.is_empty() {
4072        return Err(anyhow!("column range is empty"));
4073    }
4074
4075    let raw = raw.replace(' ', "");
4076    let (start, end) = if let Some((a, b)) = raw.split_once(':') {
4077        (a, b)
4078    } else if let Some((a, b)) = raw.split_once('-') {
4079        (a, b)
4080    } else {
4081        (raw.as_str(), raw.as_str())
4082    };
4083
4084    let start_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(start);
4085    let end_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(end);
4086    if start_idx == 0 || end_idx == 0 {
4087        return Err(anyhow!("invalid column span '{spec}'"));
4088    }
4089    let (min, max) = if start_idx <= end_idx {
4090        (start_idx, end_idx)
4091    } else {
4092        (end_idx, start_idx)
4093    };
4094    Ok((min, max))
4095}
4096
4097pub(crate) fn apply_column_size_ops_to_file(
4098    path: &Path,
4099    sheet_name: &str,
4100    ops: &[ColumnSizeOp],
4101) -> Result<ColumnSizeApplyResult> {
4102    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
4103    let sheet = book
4104        .get_sheet_by_name_mut(sheet_name)
4105        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4106
4107    let mut affected_bounds: Vec<String> = Vec::new();
4108    let mut warnings: Vec<String> = Vec::new();
4109
4110    let mut columns_sized: u64 = 0;
4111    let mut auto_ops: u64 = 0;
4112    let mut width_ops: u64 = 0;
4113
4114    for op in ops {
4115        let ColumnTarget::Columns { range } = &op.target;
4116        let (start_col, end_col) = parse_column_span(range)?;
4117        affected_bounds.push(range.clone());
4118
4119        match &op.size {
4120            ColumnSizeSpec::Width { width_chars } => {
4121                width_ops += 1;
4122                for col in start_col..=end_col {
4123                    let col_dim = sheet.get_column_dimension_by_number_mut(&col);
4124                    col_dim.set_width(*width_chars);
4125                    col_dim.set_best_fit(false);
4126                    col_dim.set_auto_width(false);
4127                    columns_sized += 1;
4128                }
4129            }
4130            ColumnSizeSpec::Auto {
4131                min_width_chars,
4132                max_width_chars,
4133            } => {
4134                auto_ops += 1;
4135
4136                let mut saw_formula_without_cached = false;
4137                for cell in sheet.get_cell_collection() {
4138                    let col_num = *cell.get_coordinate().get_col_num();
4139                    if col_num < start_col || col_num > end_col {
4140                        continue;
4141                    }
4142                    if cell.is_formula() && cell.get_value().is_empty() {
4143                        saw_formula_without_cached = true;
4144                        break;
4145                    }
4146                }
4147                if saw_formula_without_cached {
4148                    warnings.push(
4149                        "WARN_AUTOWIDTH_FORMULA_NO_CACHED: Autosize measured empty values for some formula cells; results may be too narrow. Recalc the sheet before autosize for best results."
4150                            .to_string(),
4151                    );
4152                }
4153
4154                for col in start_col..=end_col {
4155                    sheet
4156                        .get_column_dimension_by_number_mut(&col)
4157                        .set_auto_width(true);
4158                }
4159                sheet.calculation_auto_width();
4160
4161                for col in start_col..=end_col {
4162                    let col_dim = sheet.get_column_dimension_by_number_mut(&col);
4163                    col_dim.set_auto_width(false);
4164                    col_dim.set_best_fit(true);
4165
4166                    let mut width = *col_dim.get_width();
4167                    if let Some(min_width) = min_width_chars
4168                        && width < *min_width
4169                    {
4170                        width = *min_width;
4171                    }
4172                    if let Some(max_width) = max_width_chars
4173                        && width > *max_width
4174                    {
4175                        width = *max_width;
4176                    }
4177                    col_dim.set_width(width);
4178                    columns_sized += 1;
4179                }
4180            }
4181        }
4182    }
4183
4184    umya_spreadsheet::writer::xlsx::write(&book, path)?;
4185
4186    let mut counts = BTreeMap::new();
4187    counts.insert("columns_sized".to_string(), columns_sized);
4188    counts.insert("auto_ops".to_string(), auto_ops);
4189    counts.insert("width_ops".to_string(), width_ops);
4190
4191    Ok(ColumnSizeApplyResult {
4192        ops_applied: ops.len(),
4193        summary: ChangeSummary {
4194            op_kinds: vec!["column_size_batch".to_string()],
4195            affected_sheets: vec![sheet_name.to_string()],
4196            affected_bounds,
4197            counts,
4198            warnings,
4199            ..Default::default()
4200        },
4201    })
4202}
4203
4204pub(crate) struct TransformApplyResult {
4205    pub(crate) ops_applied: usize,
4206    pub(crate) summary: ChangeSummary,
4207}
4208
4209pub(crate) fn apply_transform_ops_to_file(
4210    path: &Path,
4211    ops: &[TransformOp],
4212) -> Result<TransformApplyResult> {
4213    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
4214
4215    let mut sheets: BTreeSet<String> = BTreeSet::new();
4216    let mut affected_bounds: Vec<String> = Vec::new();
4217
4218    let mut cells_touched: u64 = 0;
4219    let mut cells_value_cleared: u64 = 0;
4220    let mut cells_formula_cleared: u64 = 0;
4221    let mut cells_skipped_keep_formulas: u64 = 0;
4222
4223    let mut cells_value_set: u64 = 0;
4224    let mut cells_formula_set: u64 = 0;
4225    let mut cells_value_replaced: u64 = 0;
4226    let mut cells_formula_replaced: u64 = 0;
4227
4228    for op in ops {
4229        match op {
4230            TransformOp::ClearRange {
4231                sheet_name,
4232                target,
4233                clear_values,
4234                clear_formulas,
4235            } => {
4236                let sheet = book
4237                    .get_sheet_by_name_mut(sheet_name)
4238                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4239                sheets.insert(sheet_name.clone());
4240
4241                match target {
4242                    TransformTarget::Range { range } => {
4243                        let bounds = parse_range_bounds(range)?;
4244                        affected_bounds.push(range.clone());
4245
4246                        for row in bounds.min_row..=bounds.max_row {
4247                            for col in bounds.min_col..=bounds.max_col {
4248                                let exists = sheet.get_cell((col, row)).is_some();
4249                                if !exists {
4250                                    continue;
4251                                }
4252
4253                                let cell = sheet.get_cell_mut((col, row));
4254                                let was_formula = cell.is_formula();
4255                                cells_touched += 1;
4256
4257                                if *clear_formulas && was_formula {
4258                                    cell.set_formula(String::new());
4259                                    cells_formula_cleared += 1;
4260                                }
4261
4262                                if *clear_values {
4263                                    if was_formula && !*clear_formulas {
4264                                        cells_skipped_keep_formulas += 1;
4265                                    } else {
4266                                        if !cell.get_value().is_empty() {
4267                                            cells_value_cleared += 1;
4268                                        }
4269                                        cell.set_value(String::new());
4270                                    }
4271                                }
4272                            }
4273                        }
4274                    }
4275                    TransformTarget::Cells { cells } => {
4276                        affected_bounds.extend(cells.iter().cloned());
4277                        for addr in cells {
4278                            let exists = sheet.get_cell(addr.as_str()).is_some();
4279                            if !exists {
4280                                continue;
4281                            }
4282
4283                            let cell = sheet.get_cell_mut(addr.as_str());
4284                            let was_formula = cell.is_formula();
4285                            cells_touched += 1;
4286
4287                            if *clear_formulas && was_formula {
4288                                cell.set_formula(String::new());
4289                                cells_formula_cleared += 1;
4290                            }
4291
4292                            if *clear_values {
4293                                if was_formula && !*clear_formulas {
4294                                    cells_skipped_keep_formulas += 1;
4295                                } else {
4296                                    if !cell.get_value().is_empty() {
4297                                        cells_value_cleared += 1;
4298                                    }
4299                                    cell.set_value(String::new());
4300                                }
4301                            }
4302                        }
4303                    }
4304                    TransformTarget::Region { .. } => {
4305                        return Err(anyhow!(
4306                            "region_id targets must be resolved before apply_transform_ops_to_file"
4307                        ));
4308                    }
4309                }
4310            }
4311            TransformOp::FillRange {
4312                sheet_name,
4313                target,
4314                value,
4315                is_formula,
4316                overwrite_formulas,
4317            } => {
4318                let sheet = book
4319                    .get_sheet_by_name_mut(sheet_name)
4320                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4321                sheets.insert(sheet_name.clone());
4322
4323                match target {
4324                    TransformTarget::Range { range } => {
4325                        let bounds = parse_range_bounds(range)?;
4326                        affected_bounds.push(range.clone());
4327
4328                        for row in bounds.min_row..=bounds.max_row {
4329                            for col in bounds.min_col..=bounds.max_col {
4330                                let cell = sheet.get_cell_mut((col, row));
4331                                cells_touched += 1;
4332
4333                                if !*is_formula && cell.is_formula() {
4334                                    if !*overwrite_formulas {
4335                                        cells_skipped_keep_formulas += 1;
4336                                        continue;
4337                                    }
4338                                    cell.set_formula(String::new());
4339                                    cells_formula_cleared += 1;
4340                                }
4341
4342                                if *is_formula {
4343                                    cell.set_formula(value.clone());
4344                                    cell.set_formula_result_default("");
4345                                    cells_formula_set += 1;
4346                                } else {
4347                                    cell.set_value(value.clone());
4348                                    cells_value_set += 1;
4349                                }
4350                            }
4351                        }
4352                    }
4353                    TransformTarget::Cells { cells } => {
4354                        affected_bounds.extend(cells.iter().cloned());
4355                        for addr in cells {
4356                            let cell = sheet.get_cell_mut(addr.as_str());
4357                            cells_touched += 1;
4358
4359                            if !*is_formula && cell.is_formula() {
4360                                if !*overwrite_formulas {
4361                                    cells_skipped_keep_formulas += 1;
4362                                    continue;
4363                                }
4364                                cell.set_formula(String::new());
4365                                cells_formula_cleared += 1;
4366                            }
4367
4368                            if *is_formula {
4369                                cell.set_formula(value.clone());
4370                                cell.set_formula_result_default("");
4371                                cells_formula_set += 1;
4372                            } else {
4373                                cell.set_value(value.clone());
4374                                cells_value_set += 1;
4375                            }
4376                        }
4377                    }
4378                    TransformTarget::Region { .. } => {
4379                        return Err(anyhow!(
4380                            "region_id targets must be resolved before apply_transform_ops_to_file"
4381                        ));
4382                    }
4383                }
4384            }
4385            TransformOp::ReplaceInRange {
4386                sheet_name,
4387                target,
4388                find,
4389                replace,
4390                match_mode,
4391                case_sensitive,
4392                include_formulas,
4393            } => {
4394                let sheet = book
4395                    .get_sheet_by_name_mut(sheet_name)
4396                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4397                sheets.insert(sheet_name.clone());
4398
4399                if *match_mode == ReplaceMatchMode::Contains && !*case_sensitive {
4400                    return Err(anyhow!(
4401                        "match_mode 'contains' requires case_sensitive=true"
4402                    ));
4403                }
4404
4405                let replace_value = |input: &str| -> Option<String> {
4406                    if *match_mode == ReplaceMatchMode::Exact {
4407                        if *case_sensitive {
4408                            (input == find).then(|| replace.clone())
4409                        } else {
4410                            input.eq_ignore_ascii_case(find).then(|| replace.clone())
4411                        }
4412                    } else if input.contains(find) {
4413                        Some(input.replace(find, replace))
4414                    } else {
4415                        None
4416                    }
4417                };
4418
4419                match target {
4420                    TransformTarget::Range { range } => {
4421                        let bounds = parse_range_bounds(range)?;
4422                        affected_bounds.push(range.clone());
4423
4424                        for row in bounds.min_row..=bounds.max_row {
4425                            for col in bounds.min_col..=bounds.max_col {
4426                                let exists = sheet.get_cell((col, row)).is_some();
4427                                if !exists {
4428                                    continue;
4429                                }
4430
4431                                let cell = sheet.get_cell_mut((col, row));
4432                                cells_touched += 1;
4433
4434                                if cell.is_formula() {
4435                                    if !*include_formulas {
4436                                        cells_skipped_keep_formulas += 1;
4437                                        continue;
4438                                    }
4439
4440                                    let formula = cell.get_formula().to_string();
4441                                    if formula.is_empty() {
4442                                        continue;
4443                                    }
4444                                    if let Some(next) = replace_value(&formula) {
4445                                        cell.set_formula(next);
4446                                        cell.set_formula_result_default("");
4447                                        cells_formula_replaced += 1;
4448                                    }
4449                                    continue;
4450                                }
4451
4452                                let value = cell.get_value().to_string();
4453                                if value.is_empty() {
4454                                    continue;
4455                                }
4456                                if let Some(next) = replace_value(&value) {
4457                                    cell.set_value(next);
4458                                    cells_value_replaced += 1;
4459                                }
4460                            }
4461                        }
4462                    }
4463                    TransformTarget::Cells { cells } => {
4464                        affected_bounds.extend(cells.iter().cloned());
4465                        for addr in cells {
4466                            let exists = sheet.get_cell(addr.as_str()).is_some();
4467                            if !exists {
4468                                continue;
4469                            }
4470
4471                            let cell = sheet.get_cell_mut(addr.as_str());
4472                            cells_touched += 1;
4473
4474                            if cell.is_formula() {
4475                                if !*include_formulas {
4476                                    cells_skipped_keep_formulas += 1;
4477                                    continue;
4478                                }
4479
4480                                let formula = cell.get_formula().to_string();
4481                                if formula.is_empty() {
4482                                    continue;
4483                                }
4484                                if let Some(next) = replace_value(&formula) {
4485                                    cell.set_formula(next);
4486                                    cell.set_formula_result_default("");
4487                                    cells_formula_replaced += 1;
4488                                }
4489                                continue;
4490                            }
4491
4492                            let value = cell.get_value().to_string();
4493                            if value.is_empty() {
4494                                continue;
4495                            }
4496                            if let Some(next) = replace_value(&value) {
4497                                cell.set_value(next);
4498                                cells_value_replaced += 1;
4499                            }
4500                        }
4501                    }
4502                    TransformTarget::Region { .. } => {
4503                        return Err(anyhow!(
4504                            "region_id targets must be resolved before apply_transform_ops_to_file"
4505                        ));
4506                    }
4507                }
4508            }
4509            TransformOp::WriteMatrix {
4510                sheet_name,
4511                anchor,
4512                rows,
4513                overwrite_formulas,
4514            } => {
4515                let sheet = book
4516                    .get_sheet_by_name_mut(sheet_name)
4517                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
4518                sheets.insert(sheet_name.clone());
4519
4520                let (anchor_col, anchor_row) = parse_cell_ref(anchor)?;
4521
4522                let mut max_row = anchor_row;
4523                let mut max_col = anchor_col;
4524
4525                for (r_idx, row) in rows.iter().enumerate() {
4526                    let r = anchor_row + r_idx as u32;
4527                    if r > max_row {
4528                        max_row = r;
4529                    }
4530                    for (c_idx, cell_opt) in row.iter().enumerate() {
4531                        let c = anchor_col + c_idx as u32;
4532                        if c > max_col {
4533                            max_col = c;
4534                        }
4535
4536                        let Some(cell_data) = cell_opt else {
4537                            continue;
4538                        };
4539
4540                        let cell = sheet.get_cell_mut((c, r));
4541                        cells_touched += 1;
4542
4543                        if cell.is_formula() {
4544                            if !*overwrite_formulas {
4545                                cells_skipped_keep_formulas += 1;
4546                                continue;
4547                            }
4548                            cell.set_formula(String::new());
4549                            cells_formula_cleared += 1;
4550                        }
4551
4552                        match cell_data {
4553                            MatrixCell::Value(v) => {
4554                                let val_str = match v {
4555                                    serde_json::Value::Null => String::new(),
4556                                    serde_json::Value::Bool(b) => b.to_string(),
4557                                    serde_json::Value::Number(n) => n.to_string(),
4558                                    serde_json::Value::String(s) => s.clone(),
4559                                    serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
4560                                        v.to_string()
4561                                    }
4562                                };
4563                                cell.set_value(val_str);
4564                                cells_value_set += 1;
4565                            }
4566                            MatrixCell::Formula(f) => {
4567                                let f_str = f.strip_prefix('=').unwrap_or(f);
4568                                cell.set_formula(f_str);
4569                                cell.set_formula_result_default("");
4570                                cells_formula_set += 1;
4571                            }
4572                        }
4573                    }
4574                }
4575
4576                affected_bounds.push(format!(
4577                    "{}:{}",
4578                    crate::utils::cell_address(anchor_col, anchor_row),
4579                    crate::utils::cell_address(max_col, max_row)
4580                ));
4581            }
4582        }
4583    }
4584
4585    umya_spreadsheet::writer::xlsx::write(&book, path)?;
4586
4587    let mut counts = BTreeMap::new();
4588    counts.insert("cells_touched".to_string(), cells_touched);
4589    counts.insert("cells_value_cleared".to_string(), cells_value_cleared);
4590    counts.insert("cells_formula_cleared".to_string(), cells_formula_cleared);
4591    counts.insert(
4592        "cells_skipped_keep_formulas".to_string(),
4593        cells_skipped_keep_formulas,
4594    );
4595
4596    counts.insert("cells_value_set".to_string(), cells_value_set);
4597    counts.insert("cells_formula_set".to_string(), cells_formula_set);
4598    counts.insert("cells_value_replaced".to_string(), cells_value_replaced);
4599    counts.insert("cells_formula_replaced".to_string(), cells_formula_replaced);
4600
4601    let summary = ChangeSummary {
4602        op_kinds: vec!["transform_batch".to_string()],
4603        affected_sheets: sheets.into_iter().collect(),
4604        affected_bounds,
4605        counts,
4606        warnings: Vec::new(),
4607        ..Default::default()
4608    };
4609
4610    Ok(TransformApplyResult {
4611        ops_applied: ops.len(),
4612        summary,
4613    })
4614}
4615
4616// ── replace_in_formulas core ───────────────────────────────────────────────
4617
4618#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
4619pub struct ReplaceInFormulasOp {
4620    pub sheet_name: String,
4621    pub find: String,
4622    pub replace: String,
4623    /// Optional A1 range to scope the replacement; defaults to the used range.
4624    #[serde(default)]
4625    pub range: Option<String>,
4626    /// Enable regex mode (default: false).
4627    #[serde(default)]
4628    pub regex: bool,
4629    /// Case-sensitive matching (default: true).
4630    #[serde(default = "default_replace_case_sensitive")]
4631    pub case_sensitive: bool,
4632}
4633
4634#[derive(Debug, Clone, Serialize, JsonSchema)]
4635pub struct FormulaReplaceSample {
4636    pub address: String,
4637    pub before: String,
4638    pub after: String,
4639}
4640
4641#[derive(Debug)]
4642pub struct ReplaceInFormulasApplyResult {
4643    pub formulas_checked: u64,
4644    pub formulas_changed: u64,
4645    pub samples: Vec<FormulaReplaceSample>,
4646    pub warnings: Vec<String>,
4647    pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
4648}
4649
4650const REPLACE_SAMPLE_LIMIT: usize = 20;
4651
4652pub fn apply_replace_in_formulas_to_file(
4653    path: &Path,
4654    op: &ReplaceInFormulasOp,
4655    policy: FormulaParsePolicy,
4656) -> Result<ReplaceInFormulasApplyResult> {
4657    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
4658
4659    let sheet = book
4660        .get_sheet_by_name_mut(&op.sheet_name)
4661        .ok_or_else(|| anyhow!("sheet '{}' not found", op.sheet_name))?;
4662
4663    // Determine bounds (optional range or used range).
4664    let (min_col, min_row, max_col, max_row) = if let Some(range) = &op.range {
4665        let bounds = parse_range_bounds(range)?;
4666        (
4667            bounds.min_col,
4668            bounds.min_row,
4669            bounds.max_col,
4670            bounds.max_row,
4671        )
4672    } else {
4673        let (hc, hr) = sheet.get_highest_column_and_row();
4674        (1, 1, hc.max(1), hr.max(1))
4675    };
4676
4677    // Build matcher.
4678    let compiled_regex: Option<Regex> = if op.regex {
4679        let pattern = if op.case_sensitive {
4680            op.find.clone()
4681        } else {
4682            format!("(?i){}", op.find)
4683        };
4684        Some(Regex::new(&pattern).map_err(|e| anyhow!("invalid regex pattern: {}", e))?)
4685    } else {
4686        None
4687    };
4688
4689    let replace_formula = |formula: &str| -> Option<String> {
4690        if let Some(re) = &compiled_regex {
4691            let result = re.replace_all(formula, op.replace.as_str());
4692            if result != formula {
4693                Some(result.into_owned())
4694            } else {
4695                None
4696            }
4697        } else if op.case_sensitive {
4698            if formula.contains(&op.find) {
4699                Some(formula.replace(&op.find, &op.replace))
4700            } else {
4701                None
4702            }
4703        } else {
4704            // Case-insensitive plain text replacement.
4705            let find_lower = op.find.to_ascii_lowercase();
4706            let formula_lower = formula.to_ascii_lowercase();
4707            if !formula_lower.contains(&find_lower) {
4708                return None;
4709            }
4710            // Rebuild with original casing for non-matched parts.
4711            let mut result = String::with_capacity(formula.len());
4712            let mut cursor = 0usize;
4713            while let Some(pos) = formula_lower[cursor..].find(&find_lower) {
4714                result.push_str(&formula[cursor..cursor + pos]);
4715                result.push_str(&op.replace);
4716                cursor += pos + op.find.len();
4717            }
4718            result.push_str(&formula[cursor..]);
4719            Some(result)
4720        }
4721    };
4722
4723    let mut formulas_checked: u64 = 0;
4724    let mut formulas_changed: u64 = 0;
4725    let mut samples: Vec<FormulaReplaceSample> = Vec::new();
4726    let mut warnings: Vec<String> = Vec::new();
4727    let mut formula_parse_diagnostics_builder = FormulaParseDiagnosticsBuilder::new(policy);
4728
4729    for row in min_row..=max_row {
4730        for col in min_col..=max_col {
4731            let exists = sheet.get_cell((col, row)).is_some();
4732            if !exists {
4733                continue;
4734            }
4735            let cell = sheet.get_cell_mut((col, row));
4736            if !cell.is_formula() {
4737                continue;
4738            }
4739            let formula = cell.get_formula().to_string();
4740            if formula.is_empty() {
4741                continue;
4742            }
4743            formulas_checked += 1;
4744
4745            if let Some(next) = replace_formula(&formula) {
4746                let address = crate::utils::cell_address(col, row);
4747
4748                if policy != FormulaParsePolicy::Off
4749                    && let Err(err_msg) = validate_formula(&next)
4750                {
4751                    if policy == FormulaParsePolicy::Fail {
4752                        bail!(
4753                            "{}replaced formula at {} failed parse: {}",
4754                            FORMULA_PARSE_FAILED_PREFIX,
4755                            address,
4756                            err_msg
4757                        );
4758                    }
4759                    formula_parse_diagnostics_builder.record_error(
4760                        &op.sheet_name,
4761                        &address,
4762                        &next,
4763                        &err_msg,
4764                    );
4765                    // Warn mode: keep the original formula untouched.
4766                    continue;
4767                }
4768
4769                if samples.len() < REPLACE_SAMPLE_LIMIT {
4770                    samples.push(FormulaReplaceSample {
4771                        address,
4772                        before: formula.clone(),
4773                        after: next.clone(),
4774                    });
4775                }
4776                cell.set_formula(next);
4777                cell.set_formula_result_default("");
4778                formulas_changed += 1;
4779            }
4780        }
4781    }
4782
4783    if formulas_changed == 0 {
4784        warnings.push("WARN_NO_MATCH: no formula text matched the find pattern".to_string());
4785    }
4786
4787    umya_spreadsheet::writer::xlsx::write(&book, path)?;
4788
4789    let formula_parse_diagnostics = if formula_parse_diagnostics_builder.has_errors() {
4790        Some(formula_parse_diagnostics_builder.build())
4791    } else {
4792        None
4793    };
4794
4795    Ok(ReplaceInFormulasApplyResult {
4796        formulas_checked,
4797        formulas_changed,
4798        samples,
4799        warnings,
4800        formula_parse_diagnostics,
4801    })
4802}
4803
4804// ── replace_in_formulas MCP fork tool ─────────────────────────────────────
4805
4806#[derive(Debug, Deserialize, JsonSchema)]
4807pub struct ReplaceInFormulasParams {
4808    pub fork_id: String,
4809    pub sheet_name: String,
4810    pub find: String,
4811    pub replace: String,
4812    /// Optional A1 range; defaults to the used range.
4813    #[serde(default)]
4814    pub range: Option<String>,
4815    /// Enable regex mode (default: false).
4816    #[serde(default)]
4817    pub regex: bool,
4818    /// Case-sensitive matching (default: true).
4819    #[serde(default = "default_replace_case_sensitive")]
4820    pub case_sensitive: bool,
4821    #[serde(default)]
4822    pub mode: Option<BatchMode>,
4823    #[serde(default)]
4824    pub label: Option<String>,
4825    /// Formula parse policy: fail, warn (default), or off.
4826    #[serde(default)]
4827    pub formula_parse_policy: Option<FormulaParsePolicy>,
4828}
4829
4830#[derive(Debug, Serialize, JsonSchema)]
4831pub struct ReplaceInFormulasResponse {
4832    pub fork_id: String,
4833    pub mode: String,
4834    #[serde(skip_serializing_if = "Option::is_none")]
4835    pub change_id: Option<String>,
4836    pub formulas_checked: u64,
4837    pub formulas_changed: u64,
4838    pub recalc_needed: bool,
4839    pub samples: Vec<FormulaReplaceSample>,
4840    pub warnings: Vec<String>,
4841    #[serde(skip_serializing_if = "Option::is_none")]
4842    pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
4843}
4844
4845#[derive(Debug, Serialize, Deserialize)]
4846struct ReplaceInFormulasStagedPayload {
4847    op: ReplaceInFormulasOp,
4848}
4849
4850pub async fn replace_in_formulas(
4851    state: Arc<AppState>,
4852    params: ReplaceInFormulasParams,
4853) -> Result<ReplaceInFormulasResponse> {
4854    let registry = state
4855        .fork_registry()
4856        .ok_or_else(|| anyhow!("fork registry not available"))?;
4857
4858    let fork_ctx = registry.get_fork(&params.fork_id)?;
4859    let work_path = fork_ctx.work_path.clone();
4860
4861    let op = ReplaceInFormulasOp {
4862        sheet_name: params.sheet_name.clone(),
4863        find: params.find.clone(),
4864        replace: params.replace.clone(),
4865        range: params.range.clone(),
4866        regex: params.regex,
4867        case_sensitive: params.case_sensitive,
4868    };
4869
4870    let policy =
4871        params
4872            .formula_parse_policy
4873            .unwrap_or(FormulaParsePolicy::default_for_command_class(
4874                CommandClass::BatchWrite,
4875            ));
4876
4877    let mode = params.mode.unwrap_or_default();
4878
4879    if mode.is_preview() {
4880        let change_id = make_short_random_id("chg", 12);
4881        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
4882        std::fs::create_dir_all(snapshot_path.parent().unwrap())?;
4883        std::fs::copy(&work_path, &snapshot_path)?;
4884
4885        let snapshot_for_apply = snapshot_path.clone();
4886        let op_clone = op.clone();
4887        let result = tokio::task::spawn_blocking(move || {
4888            apply_replace_in_formulas_to_file(&snapshot_for_apply, &op_clone, policy)
4889        })
4890        .await??;
4891
4892        let staged_op = StagedOp {
4893            kind: "replace_in_formulas".to_string(),
4894            payload: serde_json::to_value(ReplaceInFormulasStagedPayload { op: op.clone() })?,
4895        };
4896
4897        let summary = ChangeSummary {
4898            op_kinds: vec!["replace_in_formulas".to_string()],
4899            affected_sheets: vec![op.sheet_name.clone()],
4900            affected_bounds: op.range.clone().into_iter().collect(),
4901            counts: {
4902                let mut c = BTreeMap::new();
4903                c.insert("formulas_checked".to_string(), result.formulas_checked);
4904                c.insert("formulas_changed".to_string(), result.formulas_changed);
4905                c
4906            },
4907            warnings: result.warnings.clone(),
4908            ..Default::default()
4909        };
4910
4911        let staged = StagedChange {
4912            change_id: change_id.clone(),
4913            created_at: Utc::now(),
4914            label: params.label.clone(),
4915            ops: vec![staged_op],
4916            summary,
4917            fork_path_snapshot: Some(snapshot_path),
4918        };
4919
4920        registry.add_staged_change(&params.fork_id, staged)?;
4921
4922        Ok(ReplaceInFormulasResponse {
4923            fork_id: params.fork_id,
4924            mode: mode.as_str().to_string(),
4925            change_id: Some(change_id),
4926            formulas_checked: result.formulas_checked,
4927            formulas_changed: result.formulas_changed,
4928            recalc_needed: fork_ctx.recalc_needed || result.formulas_changed > 0,
4929            samples: result.samples,
4930            warnings: result.warnings,
4931            formula_parse_diagnostics: result.formula_parse_diagnostics,
4932        })
4933    } else {
4934        let prior_recalc_needed = fork_ctx.recalc_needed;
4935        let op_clone = op.clone();
4936        let work_path_for_apply = work_path.clone();
4937        let result = tokio::task::spawn_blocking(move || {
4938            apply_replace_in_formulas_to_file(&work_path_for_apply, &op_clone, policy)
4939        })
4940        .await??;
4941
4942        if result.formulas_changed > 0 {
4943            registry.with_fork_mut(&params.fork_id, |ctx| {
4944                ctx.recalc_needed = true;
4945                Ok(())
4946            })?;
4947            let fork_workbook_id = WorkbookId(params.fork_id.clone());
4948            let _ = state.close_workbook(&fork_workbook_id);
4949        }
4950
4951        Ok(ReplaceInFormulasResponse {
4952            fork_id: params.fork_id,
4953            mode: mode.as_str().to_string(),
4954            change_id: None,
4955            formulas_checked: result.formulas_checked,
4956            formulas_changed: result.formulas_changed,
4957            recalc_needed: prior_recalc_needed || result.formulas_changed > 0,
4958            samples: result.samples,
4959            warnings: result.warnings,
4960            formula_parse_diagnostics: result.formula_parse_diagnostics,
4961        })
4962    }
4963}
4964
4965pub(crate) fn apply_style_ops_to_file(path: &Path, ops: &[StyleOp]) -> Result<StyleApplyResult> {
4966    use crate::styles::{
4967        StylePatchMode, apply_style_patch, descriptor_from_style, stable_style_id,
4968    };
4969
4970    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
4971
4972    let mut sheets: BTreeSet<String> = BTreeSet::new();
4973    let mut affected_bounds: Vec<String> = Vec::new();
4974    let mut cells_touched: u64 = 0;
4975    let mut cells_style_changed: u64 = 0;
4976
4977    for op in ops {
4978        let sheet = book
4979            .get_sheet_by_name_mut(&op.sheet_name)
4980            .ok_or_else(|| anyhow!("sheet '{}' not found", op.sheet_name))?;
4981        sheets.insert(op.sheet_name.clone());
4982
4983        let op_mode = op.op_mode.unwrap_or(StylePatchMode::Merge);
4984
4985        match &op.target {
4986            StyleTarget::Range { range } => {
4987                let bounds = parse_range_bounds(range)?;
4988                affected_bounds.push(range.clone());
4989                for row in bounds.min_row..=bounds.max_row {
4990                    for col in bounds.min_col..=bounds.max_col {
4991                        let addr = crate::utils::cell_address(col, row);
4992                        let cell = sheet.get_cell_mut(addr.as_str());
4993                        let before = stable_style_id(&descriptor_from_style(cell.get_style()));
4994                        let next_style = apply_style_patch(cell.get_style(), &op.patch, op_mode);
4995                        cell.set_style(next_style);
4996                        let after = stable_style_id(&descriptor_from_style(cell.get_style()));
4997                        cells_touched += 1;
4998                        if before != after {
4999                            cells_style_changed += 1;
5000                        }
5001                    }
5002                }
5003            }
5004            StyleTarget::Cells { cells } => {
5005                affected_bounds.extend(cells.iter().cloned());
5006                for addr in cells {
5007                    let cell = sheet.get_cell_mut(addr.as_str());
5008                    let before = stable_style_id(&descriptor_from_style(cell.get_style()));
5009                    let next_style = apply_style_patch(cell.get_style(), &op.patch, op_mode);
5010                    cell.set_style(next_style);
5011                    let after = stable_style_id(&descriptor_from_style(cell.get_style()));
5012                    cells_touched += 1;
5013                    if before != after {
5014                        cells_style_changed += 1;
5015                    }
5016                }
5017            }
5018            StyleTarget::Region { .. } => {
5019                return Err(anyhow!(
5020                    "region_id targets must be resolved before apply_style_ops_to_file"
5021                ));
5022            }
5023        }
5024    }
5025
5026    umya_spreadsheet::writer::xlsx::write(&book, path)?;
5027
5028    let mut counts = BTreeMap::new();
5029    counts.insert("cells_touched".to_string(), cells_touched);
5030    counts.insert("cells_style_changed".to_string(), cells_style_changed);
5031
5032    let summary = ChangeSummary {
5033        op_kinds: vec!["style_batch".to_string()],
5034        affected_sheets: sheets.into_iter().collect(),
5035        affected_bounds,
5036        counts,
5037        warnings: Vec::new(),
5038        ..Default::default()
5039    };
5040
5041    Ok(StyleApplyResult {
5042        ops_applied: ops.len(),
5043        summary,
5044    })
5045}
5046
5047#[derive(Debug, Deserialize, JsonSchema)]
5048pub struct GetEditsParams {
5049    pub fork_id: String,
5050}
5051
5052#[derive(Debug, Serialize, JsonSchema)]
5053pub struct GetEditsResponse {
5054    pub fork_id: String,
5055    pub edits: Vec<EditRecord>,
5056}
5057
5058#[derive(Debug, Serialize, JsonSchema)]
5059pub struct EditRecord {
5060    pub timestamp: String,
5061    pub sheet: String,
5062    pub address: String,
5063    pub value: String,
5064    pub is_formula: bool,
5065}
5066
5067pub async fn get_edits(state: Arc<AppState>, params: GetEditsParams) -> Result<GetEditsResponse> {
5068    let registry = state
5069        .fork_registry()
5070        .ok_or_else(|| anyhow!("fork registry not available"))?;
5071
5072    let fork_ctx = registry.get_fork(&params.fork_id)?;
5073
5074    let edits: Vec<EditRecord> = fork_ctx
5075        .edits
5076        .iter()
5077        .map(|e| EditRecord {
5078            timestamp: e.timestamp.to_rfc3339(),
5079            sheet: e.sheet.clone(),
5080            address: e.address.clone(),
5081            value: e.value.clone(),
5082            is_formula: e.is_formula,
5083        })
5084        .collect();
5085
5086    Ok(GetEditsResponse {
5087        fork_id: params.fork_id,
5088        edits,
5089    })
5090}
5091
5092fn default_get_changeset_limit() -> u32 {
5093    200
5094}
5095
5096#[derive(Debug, Deserialize, JsonSchema)]
5097pub struct GetChangesetParams {
5098    pub fork_id: String,
5099    pub sheet_name: Option<String>,
5100    #[serde(default = "default_get_changeset_limit")]
5101    pub limit: u32,
5102    #[serde(default)]
5103    pub offset: u32,
5104    #[serde(default)]
5105    pub summary_only: bool,
5106    #[serde(default)]
5107    pub include_types: Option<Vec<String>>,
5108    #[serde(default)]
5109    pub exclude_types: Option<Vec<String>>,
5110    #[serde(default)]
5111    pub include_subtypes: Option<Vec<String>>,
5112    #[serde(default)]
5113    pub exclude_subtypes: Option<Vec<String>>,
5114}
5115
5116impl Default for GetChangesetParams {
5117    fn default() -> Self {
5118        Self {
5119            fork_id: String::new(),
5120            sheet_name: None,
5121            limit: default_get_changeset_limit(),
5122            offset: 0,
5123            summary_only: false,
5124            include_types: None,
5125            exclude_types: None,
5126            include_subtypes: None,
5127            exclude_subtypes: None,
5128        }
5129    }
5130}
5131
5132#[derive(Debug, Serialize, JsonSchema)]
5133pub struct ChangesetSummary {
5134    pub total_changes: u32,
5135    pub returned_changes: u32,
5136    pub truncated: bool,
5137    pub next_offset: Option<u32>,
5138    pub counts_by_kind: BTreeMap<String, u32>,
5139    pub counts_by_type: BTreeMap<String, u32>,
5140    pub counts_by_subtype: BTreeMap<String, u32>,
5141    pub affected_sheets: Vec<String>,
5142}
5143
5144#[derive(Debug, Serialize, JsonSchema)]
5145pub struct GetChangesetResponse {
5146    pub fork_id: String,
5147    pub base_workbook: String,
5148    #[serde(skip_serializing_if = "Option::is_none")]
5149    pub client_base_workbook: Option<String>,
5150    pub changes: Vec<crate::diff::Change>,
5151    pub summary: ChangesetSummary,
5152}
5153
5154fn normalize_filter(values: &Option<Vec<String>>) -> Option<BTreeSet<String>> {
5155    values.as_ref().map(|items| {
5156        items
5157            .iter()
5158            .map(|s| s.to_ascii_lowercase())
5159            .collect::<BTreeSet<_>>()
5160    })
5161}
5162
5163fn change_kind_key(change: &crate::diff::Change) -> &'static str {
5164    match change {
5165        crate::diff::Change::Cell(_) => "cell",
5166        crate::diff::Change::Table(_) => "table",
5167        crate::diff::Change::Name(_) => "name",
5168    }
5169}
5170
5171fn change_type_key(change: &crate::diff::Change) -> &'static str {
5172    use crate::diff::merge::CellDiff;
5173    match change {
5174        crate::diff::Change::Cell(cell) => match &cell.diff {
5175            CellDiff::Added { .. } => "added",
5176            CellDiff::Deleted { .. } => "deleted",
5177            CellDiff::Modified { .. } => "modified",
5178        },
5179        crate::diff::Change::Table(table) => match table {
5180            crate::diff::tables::TableDiff::TableAdded { .. } => "table_added",
5181            crate::diff::tables::TableDiff::TableDeleted { .. } => "table_deleted",
5182            crate::diff::tables::TableDiff::TableModified { .. } => "table_modified",
5183        },
5184        crate::diff::Change::Name(name) => match name {
5185            crate::diff::names::NameDiff::NameAdded { .. } => "name_added",
5186            crate::diff::names::NameDiff::NameDeleted { .. } => "name_deleted",
5187            crate::diff::names::NameDiff::NameModified { .. } => "name_modified",
5188        },
5189    }
5190}
5191
5192fn change_subtype_key(change: &crate::diff::Change) -> Option<&'static str> {
5193    use crate::diff::merge::{CellDiff, ModificationType};
5194    match change {
5195        crate::diff::Change::Cell(cell) => match &cell.diff {
5196            CellDiff::Modified { subtype, .. } => Some(match subtype {
5197                ModificationType::FormulaEdit => "formula_edit",
5198                ModificationType::RecalcResult => "recalc_result",
5199                ModificationType::ValueEdit => "value_edit",
5200                ModificationType::StyleEdit => "style_edit",
5201            }),
5202            _ => None,
5203        },
5204        _ => None,
5205    }
5206}
5207
5208fn change_sheet_name(change: &crate::diff::Change) -> Option<&str> {
5209    match change {
5210        crate::diff::Change::Cell(cell) => Some(cell.sheet.as_str()),
5211        crate::diff::Change::Table(table) => match table {
5212            crate::diff::tables::TableDiff::TableAdded { sheet, .. }
5213            | crate::diff::tables::TableDiff::TableDeleted { sheet, .. }
5214            | crate::diff::tables::TableDiff::TableModified { sheet, .. } => Some(sheet.as_str()),
5215        },
5216        crate::diff::Change::Name(name) => match name {
5217            crate::diff::names::NameDiff::NameAdded { scope_sheet, .. }
5218            | crate::diff::names::NameDiff::NameDeleted { scope_sheet, .. }
5219            | crate::diff::names::NameDiff::NameModified { scope_sheet, .. } => {
5220                scope_sheet.as_deref()
5221            }
5222        },
5223    }
5224}
5225
5226fn change_passes_filters(
5227    change: &crate::diff::Change,
5228    include_types: &Option<BTreeSet<String>>,
5229    exclude_types: &Option<BTreeSet<String>>,
5230    include_subtypes: &Option<BTreeSet<String>>,
5231    exclude_subtypes: &Option<BTreeSet<String>>,
5232) -> bool {
5233    let type_key = change_type_key(change);
5234    let subtype_key = change_subtype_key(change);
5235
5236    if let Some(include) = include_types
5237        && !include.contains(type_key)
5238    {
5239        return false;
5240    }
5241    if let Some(exclude) = exclude_types
5242        && exclude.contains(type_key)
5243    {
5244        return false;
5245    }
5246
5247    if let Some(include) = include_subtypes
5248        && subtype_key.is_none_or(|subtype| !include.contains(subtype))
5249    {
5250        return false;
5251    }
5252    if let Some(exclude) = exclude_subtypes
5253        && subtype_key.is_some_and(|subtype| exclude.contains(subtype))
5254    {
5255        return false;
5256    }
5257
5258    true
5259}
5260
5261pub async fn get_changeset(
5262    state: Arc<AppState>,
5263    params: GetChangesetParams,
5264) -> Result<GetChangesetResponse> {
5265    let registry = state
5266        .fork_registry()
5267        .ok_or_else(|| anyhow!("fork registry not available"))?;
5268
5269    let fork_ctx = registry.get_fork(&params.fork_id)?;
5270
5271    let raw_changes = tokio::task::spawn_blocking({
5272        let base_path = fork_ctx.base_path.clone();
5273        let work_path = fork_ctx.work_path.clone();
5274        let sheet_filter = params.sheet_name.clone();
5275        move || {
5276            crate::core::diff::calculate_changeset(&base_path, &work_path, sheet_filter.as_deref())
5277        }
5278    })
5279    .await??;
5280
5281    let include_types = normalize_filter(&params.include_types);
5282    let exclude_types = normalize_filter(&params.exclude_types);
5283    let include_subtypes = normalize_filter(&params.include_subtypes);
5284    let exclude_subtypes = normalize_filter(&params.exclude_subtypes);
5285
5286    let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
5287    let mut counts_by_kind: BTreeMap<String, u32> = BTreeMap::new();
5288    let mut counts_by_type: BTreeMap<String, u32> = BTreeMap::new();
5289    let mut counts_by_subtype: BTreeMap<String, u32> = BTreeMap::new();
5290
5291    let mut filtered: Vec<crate::diff::Change> = Vec::new();
5292    for change in raw_changes {
5293        if !change_passes_filters(
5294            &change,
5295            &include_types,
5296            &exclude_types,
5297            &include_subtypes,
5298            &exclude_subtypes,
5299        ) {
5300            continue;
5301        }
5302
5303        *counts_by_kind
5304            .entry(change_kind_key(&change).to_string())
5305            .or_default() += 1;
5306        *counts_by_type
5307            .entry(change_type_key(&change).to_string())
5308            .or_default() += 1;
5309        if let Some(subtype) = change_subtype_key(&change) {
5310            *counts_by_subtype.entry(subtype.to_string()).or_default() += 1;
5311        }
5312        if let Some(sheet) = change_sheet_name(&change) {
5313            affected_sheets.insert(sheet.to_string());
5314        }
5315
5316        filtered.push(change);
5317    }
5318
5319    let limit = params.limit.clamp(1, 2000) as usize;
5320    let offset = params.offset as usize;
5321    let total = filtered.len();
5322
5323    let (returned_changes, changes, truncated, next_offset) = if params.summary_only {
5324        (0u32, Vec::new(), false, None)
5325    } else {
5326        let end = offset.saturating_add(limit);
5327        let truncated = end < total;
5328        let next_offset = truncated.then_some(end as u32);
5329        let changes: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
5330        (changes.len() as u32, changes, truncated, next_offset)
5331    };
5332
5333    let summary = ChangesetSummary {
5334        total_changes: total as u32,
5335        returned_changes,
5336        truncated,
5337        next_offset,
5338        counts_by_kind,
5339        counts_by_type,
5340        counts_by_subtype,
5341        affected_sheets: affected_sheets.into_iter().collect(),
5342    };
5343
5344    Ok(GetChangesetResponse {
5345        fork_id: params.fork_id,
5346        base_workbook: fork_ctx.base_path.display().to_string(),
5347        client_base_workbook: state
5348            .config()
5349            .map_path_for_client(&fork_ctx.base_path)
5350            .map(|p| p.display().to_string()),
5351        changes,
5352        summary,
5353    })
5354}
5355
5356#[derive(Debug, Deserialize, JsonSchema)]
5357pub struct RecalculateParams {
5358    pub fork_id: String,
5359    #[serde(default = "default_timeout")]
5360    pub timeout_ms: u64,
5361    #[serde(default)]
5362    pub backend: Option<RecalcBackendKind>,
5363}
5364
5365fn default_timeout() -> u64 {
5366    30_000
5367}
5368
5369#[derive(Debug, Serialize, JsonSchema)]
5370pub struct RecalculateResponse {
5371    pub fork_id: String,
5372    pub duration_ms: u64,
5373    pub backend: String,
5374    #[serde(skip_serializing_if = "Option::is_none")]
5375    pub cells_evaluated: Option<u64>,
5376    #[serde(skip_serializing_if = "Option::is_none")]
5377    pub eval_errors: Option<Vec<String>>,
5378}
5379
5380pub async fn recalculate(
5381    state: Arc<AppState>,
5382    params: RecalculateParams,
5383) -> Result<RecalculateResponse> {
5384    let backend = state
5385        .recalc_backend(params.backend)
5386        .ok_or_else(|| anyhow!("requested recalc backend not available"))?;
5387
5388    recalculate_with_backend(state, params, backend).await
5389}
5390
5391pub async fn recalculate_with_backend(
5392    state: Arc<AppState>,
5393    params: RecalculateParams,
5394    backend: Arc<dyn RecalcBackend>,
5395) -> Result<RecalculateResponse> {
5396    let registry = state
5397        .fork_registry()
5398        .ok_or_else(|| anyhow!("fork registry not available"))?;
5399
5400    let semaphore = state
5401        .recalc_semaphore()
5402        .ok_or_else(|| anyhow!("recalc semaphore not available"))?;
5403
5404    let fork_ctx = registry.get_fork(&params.fork_id)?;
5405
5406    let _permit = semaphore
5407        .0
5408        .acquire()
5409        .await
5410        .map_err(|e| anyhow!("failed to acquire recalc permit: {}", e))?;
5411
5412    let timeout_ms = if params.timeout_ms == 0 {
5413        None
5414    } else {
5415        Some(params.timeout_ms)
5416    };
5417    let result =
5418        crate::core::recalc::execute_with_backend(&fork_ctx.work_path, timeout_ms, backend).await?;
5419
5420    registry.with_fork_mut(&params.fork_id, |ctx| {
5421        ctx.recalc_needed = false;
5422        Ok(())
5423    })?;
5424
5425    let fork_workbook_id = WorkbookId(params.fork_id.clone());
5426    let _ = state.close_workbook(&fork_workbook_id);
5427
5428    Ok(RecalculateResponse {
5429        fork_id: params.fork_id,
5430        duration_ms: result.duration_ms,
5431        backend: result.backend,
5432        cells_evaluated: result.cells_evaluated,
5433        eval_errors: result.eval_errors,
5434    })
5435}
5436
5437#[derive(Debug, Deserialize, JsonSchema)]
5438pub struct ListForksParams {}
5439
5440#[derive(Debug, Serialize, JsonSchema)]
5441pub struct ListForksResponse {
5442    pub forks: Vec<ForkSummary>,
5443}
5444
5445#[derive(Debug, Serialize, JsonSchema)]
5446pub struct ForkSummary {
5447    pub fork_id: String,
5448    pub base_path: String,
5449    #[serde(skip_serializing_if = "Option::is_none")]
5450    pub client_base_path: Option<String>,
5451    pub age_seconds: u64,
5452    pub edit_count: usize,
5453    pub recalc_needed: bool,
5454}
5455
5456pub async fn list_forks(
5457    state: Arc<AppState>,
5458    _params: ListForksParams,
5459) -> Result<ListForksResponse> {
5460    let registry = state
5461        .fork_registry()
5462        .ok_or_else(|| anyhow!("fork registry not available"))?;
5463
5464    let config = state.config();
5465    let forks: Vec<ForkSummary> = registry
5466        .list_forks()
5467        .into_iter()
5468        .map(|f| {
5469            let base_path = f.base_path;
5470            let client_base_path = if config.path_mappings.is_empty() {
5471                None
5472            } else {
5473                config
5474                    .map_path_for_client(PathBuf::from(&base_path))
5475                    .map(|p| p.display().to_string())
5476            };
5477            ForkSummary {
5478                fork_id: f.fork_id,
5479                base_path,
5480                client_base_path,
5481                age_seconds: f.created_at.elapsed().as_secs(),
5482                edit_count: f.edit_count,
5483                recalc_needed: f.recalc_needed,
5484            }
5485        })
5486        .collect();
5487
5488    Ok(ListForksResponse { forks })
5489}
5490
5491#[derive(Debug, Deserialize, JsonSchema)]
5492pub struct DiscardForkParams {
5493    pub fork_id: String,
5494}
5495
5496#[derive(Debug, Serialize, JsonSchema)]
5497pub struct DiscardForkResponse {
5498    pub fork_id: String,
5499    pub discarded: bool,
5500}
5501
5502pub async fn discard_fork(
5503    state: Arc<AppState>,
5504    params: DiscardForkParams,
5505) -> Result<DiscardForkResponse> {
5506    let registry = state
5507        .fork_registry()
5508        .ok_or_else(|| anyhow!("fork registry not available"))?;
5509
5510    registry.discard_fork(&params.fork_id)?;
5511
5512    Ok(DiscardForkResponse {
5513        fork_id: params.fork_id,
5514        discarded: true,
5515    })
5516}
5517
5518#[derive(Debug, Deserialize, JsonSchema)]
5519pub struct SaveForkParams {
5520    pub fork_id: String,
5521    /// Target path to save to. If omitted, saves to original location (requires --allow-overwrite).
5522    pub target_path: Option<String>,
5523    /// If true, discard the fork after saving. If false, fork remains active for further edits.
5524    #[serde(default = "default_drop_fork")]
5525    pub drop_fork: bool,
5526}
5527
5528fn default_drop_fork() -> bool {
5529    true
5530}
5531
5532#[derive(Debug, Serialize, JsonSchema)]
5533pub struct SaveForkResponse {
5534    pub fork_id: String,
5535    pub saved_to: String,
5536    #[serde(skip_serializing_if = "Option::is_none")]
5537    pub client_saved_to: Option<String>,
5538    pub fork_dropped: bool,
5539}
5540
5541pub async fn save_fork(state: Arc<AppState>, params: SaveForkParams) -> Result<SaveForkResponse> {
5542    let registry = state
5543        .fork_registry()
5544        .ok_or_else(|| anyhow!("fork registry not available"))?;
5545
5546    let fork_ctx = registry.get_fork(&params.fork_id)?;
5547    let config = state.config();
5548    let workspace_root = &config.workspace_root;
5549
5550    let (target, is_overwrite) = match params.target_path {
5551        Some(p) => {
5552            let resolved = config.resolve_user_path(&p);
5553            let is_overwrite = if resolved.exists() {
5554                let base_canon = fork_ctx.base_path.canonicalize().map_err(|e| {
5555                    anyhow!("failed to canonicalize base_path for overwrite check: {e}")
5556                })?;
5557                let target_canon = resolved.canonicalize().map_err(|e| {
5558                    anyhow!("failed to canonicalize target_path for overwrite check: {e}")
5559                })?;
5560                target_canon == base_canon
5561            } else {
5562                false
5563            };
5564            (resolved, is_overwrite)
5565        }
5566        None => (fork_ctx.base_path.clone(), true),
5567    };
5568
5569    if is_overwrite && !config.allow_overwrite {
5570        return Err(anyhow!(
5571            "overwriting original file is disabled. Use --allow-overwrite flag or specify a different target_path"
5572        ));
5573    }
5574
5575    let base_path = fork_ctx.base_path.clone();
5576    registry.save_fork(&params.fork_id, &target, workspace_root, params.drop_fork)?;
5577
5578    if is_overwrite {
5579        state.evict_by_path(&base_path);
5580    }
5581
5582    Ok(SaveForkResponse {
5583        fork_id: params.fork_id,
5584        saved_to: target.display().to_string(),
5585        client_saved_to: config
5586            .map_path_for_client(&target)
5587            .map(|p| p.display().to_string()),
5588        fork_dropped: params.drop_fork,
5589    })
5590}
5591
5592#[derive(Debug, Deserialize, JsonSchema)]
5593pub struct CheckpointForkParams {
5594    pub fork_id: String,
5595    pub label: Option<String>,
5596}
5597
5598#[derive(Debug, Serialize, JsonSchema)]
5599pub struct CheckpointInfo {
5600    pub checkpoint_id: String,
5601    pub created_at: String,
5602    pub label: Option<String>,
5603}
5604
5605#[derive(Debug, Serialize, JsonSchema)]
5606pub struct CheckpointForkResponse {
5607    pub fork_id: String,
5608    pub checkpoint: CheckpointInfo,
5609    pub total_checkpoints: usize,
5610}
5611
5612pub async fn checkpoint_fork(
5613    state: Arc<AppState>,
5614    params: CheckpointForkParams,
5615) -> Result<CheckpointForkResponse> {
5616    let registry = state
5617        .fork_registry()
5618        .ok_or_else(|| anyhow!("fork registry not available"))?;
5619
5620    registry.get_fork(&params.fork_id)?;
5621    let checkpoint = registry.create_checkpoint(&params.fork_id, params.label.clone())?;
5622    let total = registry.list_checkpoints(&params.fork_id)?.len();
5623
5624    Ok(CheckpointForkResponse {
5625        fork_id: params.fork_id,
5626        checkpoint: CheckpointInfo {
5627            checkpoint_id: checkpoint.checkpoint_id,
5628            created_at: checkpoint.created_at.to_rfc3339(),
5629            label: checkpoint.label,
5630        },
5631        total_checkpoints: total,
5632    })
5633}
5634
5635#[derive(Debug, Deserialize, JsonSchema)]
5636pub struct ListCheckpointsParams {
5637    pub fork_id: String,
5638}
5639
5640#[derive(Debug, Serialize, JsonSchema)]
5641pub struct ListCheckpointsResponse {
5642    pub fork_id: String,
5643    pub checkpoints: Vec<CheckpointInfo>,
5644}
5645
5646pub async fn list_checkpoints(
5647    state: Arc<AppState>,
5648    params: ListCheckpointsParams,
5649) -> Result<ListCheckpointsResponse> {
5650    let registry = state
5651        .fork_registry()
5652        .ok_or_else(|| anyhow!("fork registry not available"))?;
5653
5654    let checkpoints = registry.list_checkpoints(&params.fork_id)?;
5655    let checkpoints = checkpoints
5656        .into_iter()
5657        .map(|cp| CheckpointInfo {
5658            checkpoint_id: cp.checkpoint_id,
5659            created_at: cp.created_at.to_rfc3339(),
5660            label: cp.label,
5661        })
5662        .collect();
5663
5664    Ok(ListCheckpointsResponse {
5665        fork_id: params.fork_id,
5666        checkpoints,
5667    })
5668}
5669
5670#[derive(Debug, Deserialize, JsonSchema)]
5671pub struct RestoreCheckpointParams {
5672    pub fork_id: String,
5673    pub checkpoint_id: String,
5674}
5675
5676#[derive(Debug, Serialize, JsonSchema)]
5677pub struct RestoreCheckpointResponse {
5678    pub fork_id: String,
5679    pub restored_checkpoint: CheckpointInfo,
5680}
5681
5682pub async fn restore_checkpoint(
5683    state: Arc<AppState>,
5684    params: RestoreCheckpointParams,
5685) -> Result<RestoreCheckpointResponse> {
5686    let registry = state
5687        .fork_registry()
5688        .ok_or_else(|| anyhow!("fork registry not available"))?;
5689
5690    let checkpoint = registry.restore_checkpoint(&params.fork_id, &params.checkpoint_id)?;
5691    let fork_workbook_id = WorkbookId(params.fork_id.clone());
5692    let _ = state.close_workbook(&fork_workbook_id);
5693
5694    Ok(RestoreCheckpointResponse {
5695        fork_id: params.fork_id,
5696        restored_checkpoint: CheckpointInfo {
5697            checkpoint_id: checkpoint.checkpoint_id,
5698            created_at: checkpoint.created_at.to_rfc3339(),
5699            label: checkpoint.label,
5700        },
5701    })
5702}
5703
5704#[derive(Debug, Deserialize, JsonSchema)]
5705pub struct DeleteCheckpointParams {
5706    pub fork_id: String,
5707    pub checkpoint_id: String,
5708}
5709
5710#[derive(Debug, Serialize, JsonSchema)]
5711pub struct DeleteCheckpointResponse {
5712    pub fork_id: String,
5713    pub checkpoint_id: String,
5714    pub deleted: bool,
5715}
5716
5717pub async fn delete_checkpoint(
5718    state: Arc<AppState>,
5719    params: DeleteCheckpointParams,
5720) -> Result<DeleteCheckpointResponse> {
5721    let registry = state
5722        .fork_registry()
5723        .ok_or_else(|| anyhow!("fork registry not available"))?;
5724
5725    registry.delete_checkpoint(&params.fork_id, &params.checkpoint_id)?;
5726
5727    Ok(DeleteCheckpointResponse {
5728        fork_id: params.fork_id,
5729        checkpoint_id: params.checkpoint_id,
5730        deleted: true,
5731    })
5732}
5733
5734#[derive(Debug, Deserialize, JsonSchema)]
5735pub struct ListStagedChangesParams {
5736    pub fork_id: String,
5737}
5738
5739#[derive(Debug, Serialize, JsonSchema)]
5740pub struct StagedChangeInfo {
5741    pub change_id: String,
5742    pub created_at: String,
5743    pub label: Option<String>,
5744    pub summary: ChangeSummary,
5745}
5746
5747#[derive(Debug, Serialize, JsonSchema)]
5748pub struct ListStagedChangesResponse {
5749    pub fork_id: String,
5750    pub staged_changes: Vec<StagedChangeInfo>,
5751}
5752
5753pub async fn list_staged_changes(
5754    state: Arc<AppState>,
5755    params: ListStagedChangesParams,
5756) -> Result<ListStagedChangesResponse> {
5757    let registry = state
5758        .fork_registry()
5759        .ok_or_else(|| anyhow!("fork registry not available"))?;
5760
5761    let staged = registry.list_staged_changes(&params.fork_id)?;
5762    let staged_changes = staged
5763        .into_iter()
5764        .map(|c| StagedChangeInfo {
5765            change_id: c.change_id,
5766            created_at: c.created_at.to_rfc3339(),
5767            label: c.label,
5768            summary: c.summary,
5769        })
5770        .collect();
5771
5772    Ok(ListStagedChangesResponse {
5773        fork_id: params.fork_id,
5774        staged_changes,
5775    })
5776}
5777
5778#[derive(Debug, Deserialize, JsonSchema)]
5779pub struct ApplyStagedChangeParams {
5780    pub fork_id: String,
5781    pub change_id: String,
5782}
5783
5784#[derive(Debug, Serialize, JsonSchema)]
5785pub struct ApplyStagedChangeResponse {
5786    pub fork_id: String,
5787    pub change_id: String,
5788    pub ops_applied: usize,
5789    pub summary: ChangeSummary,
5790}
5791
5792#[derive(Debug, Deserialize)]
5793struct EditBatchStagedPayload {
5794    sheet_name: String,
5795    edits: Vec<CellEdit>,
5796}
5797
5798pub async fn apply_staged_change(
5799    state: Arc<AppState>,
5800    params: ApplyStagedChangeParams,
5801) -> Result<ApplyStagedChangeResponse> {
5802    let registry = state
5803        .fork_registry()
5804        .ok_or_else(|| anyhow!("fork registry not available"))?;
5805
5806    let staged_list = registry.list_staged_changes(&params.fork_id)?;
5807    let staged = staged_list
5808        .iter()
5809        .find(|c| c.change_id == params.change_id)
5810        .cloned()
5811        .ok_or_else(|| anyhow!("staged change not found: {}", params.change_id))?;
5812
5813    let fork_ctx = registry.get_fork(&params.fork_id)?;
5814    let work_path = fork_ctx.work_path.clone();
5815
5816    let initial_recalc_needed = fork_ctx.recalc_needed;
5817    let mut recalc_triggered = false;
5818
5819    let mut ops_applied = 0usize;
5820
5821    for op in &staged.ops {
5822        match op.kind.as_str() {
5823            "edit_batch" => {
5824                recalc_triggered = true;
5825                let payload: EditBatchStagedPayload = serde_json::from_value(op.payload.clone())
5826                    .map_err(|e| anyhow!("invalid edit_batch payload: {}", e))?;
5827
5828                let edits_to_apply: Vec<_> = payload
5829                    .edits
5830                    .iter()
5831                    .map(|e| EditOp {
5832                        timestamp: Utc::now(),
5833                        sheet: payload.sheet_name.clone(),
5834                        address: e.address.clone(),
5835                        value: e.value.clone(),
5836                        is_formula: e.is_formula,
5837                    })
5838                    .collect();
5839
5840                tokio::task::spawn_blocking({
5841                    let sheet_name = payload.sheet_name.clone();
5842                    let edits = payload.edits.clone();
5843                    let work_path = work_path.clone();
5844                    move || {
5845                        let core_edits = edits
5846                            .into_iter()
5847                            .map(|edit| crate::core::types::CellEdit {
5848                                address: edit.address,
5849                                value: edit.value,
5850                                is_formula: edit.is_formula,
5851                            })
5852                            .collect::<Vec<_>>();
5853                        crate::core::write::apply_edits_to_file(
5854                            &work_path,
5855                            &sheet_name,
5856                            &core_edits,
5857                        )
5858                    }
5859                })
5860                .await??;
5861
5862                registry.with_fork_mut(&params.fork_id, |ctx| {
5863                    ctx.edits.extend(edits_to_apply);
5864                    ctx.recalc_needed = true;
5865                    Ok(())
5866                })?;
5867
5868                ops_applied += 1;
5869            }
5870            "style_batch" => {
5871                let payload: StyleBatchStagedPayload =
5872                    serde_json::from_value(op.payload.clone())
5873                        .map_err(|e| anyhow!("invalid style_batch payload: {}", e))?;
5874
5875                tokio::task::spawn_blocking({
5876                    let ops = payload.ops.clone();
5877                    let work_path = work_path.clone();
5878                    move || apply_style_ops_to_file(&work_path, &ops)
5879                })
5880                .await??;
5881
5882                ops_applied += 1;
5883            }
5884            "column_size_batch" => {
5885                let payload: ColumnSizeBatchStagedPayload =
5886                    serde_json::from_value(op.payload.clone())
5887                        .map_err(|e| anyhow!("invalid column_size_batch payload: {}", e))?;
5888
5889                tokio::task::spawn_blocking({
5890                    let sheet_name = payload.sheet_name.clone();
5891                    let ops = payload.ops.clone();
5892                    let work_path = work_path.clone();
5893                    move || apply_column_size_ops_to_file(&work_path, &sheet_name, &ops)
5894                })
5895                .await??;
5896
5897                ops_applied += 1;
5898            }
5899            "transform_batch" => {
5900                recalc_triggered = true;
5901                let payload: TransformBatchStagedPayload =
5902                    serde_json::from_value(op.payload.clone())
5903                        .map_err(|e| anyhow!("invalid transform_batch payload: {}", e))?;
5904
5905                tokio::task::spawn_blocking({
5906                    let ops = payload.ops.clone();
5907                    let work_path = work_path.clone();
5908                    move || apply_transform_ops_to_file(&work_path, &ops)
5909                })
5910                .await??;
5911
5912                ops_applied += 1;
5913            }
5914            "grid_import" => {
5915                recalc_triggered = true;
5916                let payload: GridImportStagedPayload =
5917                    serde_json::from_value(op.payload.clone())
5918                        .map_err(|e| anyhow!("invalid grid_import payload: {}", e))?;
5919
5920                grid_import(
5921                    state.clone(),
5922                    GridImportParams {
5923                        fork_id: params.fork_id.clone(),
5924                        sheet_name: payload.sheet_name,
5925                        anchor: payload.anchor,
5926                        grid: payload.grid,
5927                        clear_target: payload.clear_target,
5928                        mode: Some(BatchMode::Apply),
5929                        label: None,
5930                        formula_parse_policy: None,
5931                    },
5932                )
5933                .await?;
5934
5935                ops_applied += 1;
5936            }
5937            "apply_formula_pattern" => {
5938                recalc_triggered = true;
5939                let payload: ApplyFormulaPatternStagedPayload =
5940                    serde_json::from_value(op.payload.clone())
5941                        .map_err(|e| anyhow!("invalid apply_formula_pattern payload: {}", e))?;
5942
5943                let bounds = parse_range_bounds(&payload.target_range)?;
5944                let (anchor_col, anchor_row) = parse_cell_ref(&payload.anchor_cell)?;
5945                let fill_direction = payload.fill_direction.unwrap_or_default();
5946                validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
5947                let relative_mode: RelativeMode = payload.relative_mode.unwrap_or_default().into();
5948
5949                tokio::task::spawn_blocking({
5950                    let sheet_name = payload.sheet_name.clone();
5951                    let target_range = payload.target_range.clone();
5952                    let base_formula = payload.base_formula.clone();
5953                    let work_path = work_path.clone();
5954                    move || {
5955                        apply_formula_pattern_to_file(
5956                            &work_path,
5957                            &sheet_name,
5958                            &target_range,
5959                            anchor_col,
5960                            anchor_row,
5961                            &base_formula,
5962                            relative_mode,
5963                        )
5964                    }
5965                })
5966                .await??;
5967
5968                ops_applied += 1;
5969            }
5970            "structure_batch" => {
5971                let payload: StructureBatchStagedPayload =
5972                    serde_json::from_value(op.payload.clone())
5973                        .map_err(|e| anyhow!("invalid structure_batch payload: {}", e))?;
5974
5975                if structure_ops_require_recalc(&payload.ops) {
5976                    recalc_triggered = true;
5977                }
5978
5979                tokio::task::spawn_blocking({
5980                    let ops = payload.ops.clone();
5981                    let work_path = work_path.clone();
5982                    let policy = payload.formula_parse_policy.unwrap_or(
5983                        FormulaParsePolicy::default_for_command_class(CommandClass::BatchWrite),
5984                    );
5985                    move || apply_structure_ops_to_file(&work_path, &ops, policy)
5986                })
5987                .await??;
5988
5989                ops_applied += 1;
5990            }
5991            "sheet_layout_batch" => {
5992                let payload: crate::tools::sheet_layout::SheetLayoutBatchStagedPayload =
5993                    serde_json::from_value(op.payload.clone())
5994                        .map_err(|e| anyhow!("invalid sheet_layout_batch payload: {}", e))?;
5995
5996                tokio::task::spawn_blocking({
5997                    let ops = payload.ops.clone();
5998                    let work_path = work_path.clone();
5999                    move || {
6000                        crate::tools::sheet_layout::apply_sheet_layout_ops_to_file(&work_path, &ops)
6001                    }
6002                })
6003                .await??;
6004
6005                ops_applied += 1;
6006            }
6007            "rules_batch" => {
6008                let payload: crate::tools::rules_batch::RulesBatchStagedPayload =
6009                    serde_json::from_value(op.payload.clone())
6010                        .map_err(|e| anyhow!("invalid rules_batch payload: {}", e))?;
6011
6012                tokio::task::spawn_blocking({
6013                    let ops = payload.ops.clone();
6014                    let work_path = work_path.clone();
6015                    let policy = payload.formula_parse_policy.unwrap_or(
6016                        FormulaParsePolicy::default_for_command_class(CommandClass::BatchWrite),
6017                    );
6018                    move || {
6019                        crate::tools::rules_batch::apply_rules_ops_to_file(&work_path, &ops, policy)
6020                    }
6021                })
6022                .await??;
6023
6024                ops_applied += 1;
6025            }
6026            other => {
6027                return Err(anyhow!("unsupported staged op kind: {}", other));
6028            }
6029        }
6030    }
6031
6032    let recalc_needed_now = initial_recalc_needed || recalc_triggered;
6033    if recalc_needed_now {
6034        registry.with_fork_mut(&params.fork_id, |ctx| {
6035            ctx.recalc_needed = true;
6036            Ok(())
6037        })?;
6038    }
6039
6040    registry.discard_staged_change(&params.fork_id, &params.change_id)?;
6041    let fork_workbook_id = WorkbookId(params.fork_id.clone());
6042    let _ = state.close_workbook(&fork_workbook_id);
6043
6044    let mut summary = staged.summary;
6045    set_recalc_needed_flag(&mut summary, recalc_needed_now);
6046
6047    Ok(ApplyStagedChangeResponse {
6048        fork_id: params.fork_id,
6049        change_id: params.change_id,
6050        ops_applied,
6051        summary,
6052    })
6053}
6054
6055#[derive(Debug, Deserialize, JsonSchema)]
6056pub struct DiscardStagedChangeParams {
6057    pub fork_id: String,
6058    pub change_id: String,
6059}
6060
6061#[derive(Debug, Serialize, JsonSchema)]
6062pub struct DiscardStagedChangeResponse {
6063    pub fork_id: String,
6064    pub change_id: String,
6065    pub discarded: bool,
6066}
6067
6068pub async fn discard_staged_change(
6069    state: Arc<AppState>,
6070    params: DiscardStagedChangeParams,
6071) -> Result<DiscardStagedChangeResponse> {
6072    let registry = state
6073        .fork_registry()
6074        .ok_or_else(|| anyhow!("fork registry not available"))?;
6075
6076    registry.discard_staged_change(&params.fork_id, &params.change_id)?;
6077
6078    Ok(DiscardStagedChangeResponse {
6079        fork_id: params.fork_id,
6080        change_id: params.change_id,
6081        discarded: true,
6082    })
6083}
6084
6085#[cfg(not(target_arch = "wasm32"))]
6086const MAX_SCREENSHOT_ROWS: u32 = 100;
6087#[cfg(not(target_arch = "wasm32"))]
6088const MAX_SCREENSHOT_COLS: u32 = 30;
6089#[cfg(not(target_arch = "wasm32"))]
6090const DEFAULT_SCREENSHOT_RANGE: &str = "A1:M40";
6091#[cfg(feature = "recalc-libreoffice")]
6092const DEFAULT_MAX_PNG_DIM_PX: u32 = 4096;
6093#[cfg(feature = "recalc-libreoffice")]
6094const DEFAULT_MAX_PNG_AREA_PX: u64 = 12_000_000;
6095
6096#[derive(Debug, Deserialize, JsonSchema)]
6097pub struct ScreenshotSheetParams {
6098    #[serde(alias = "workbook_id")]
6099    pub workbook_or_fork_id: WorkbookId,
6100    pub sheet_name: String,
6101    #[serde(default)]
6102    pub range: Option<String>,
6103}
6104
6105#[derive(Debug, Serialize, JsonSchema)]
6106pub struct ScreenshotSheetResponse {
6107    pub workbook_id: String,
6108    pub sheet_name: String,
6109    pub range: String,
6110    pub output_path: String,
6111    #[serde(skip_serializing_if = "Option::is_none")]
6112    pub client_output_path: Option<String>,
6113    pub size_bytes: u64,
6114    pub duration_ms: u64,
6115}
6116
6117#[cfg(not(target_arch = "wasm32"))]
6118pub async fn screenshot_sheet(
6119    state: Arc<AppState>,
6120    params: ScreenshotSheetParams,
6121) -> Result<ScreenshotSheetResponse> {
6122    let range = params.range.as_deref().unwrap_or(DEFAULT_SCREENSHOT_RANGE);
6123    let bounds = validate_screenshot_range(range)?;
6124
6125    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
6126    let workbook_path = workbook.path.clone();
6127
6128    let _ = workbook.with_sheet(&params.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
6129
6130    let safe_range = sanitize_filename_component(&range.replace(':', "-"));
6131    let safe_sheet = sanitize_filename_component(&params.sheet_name).replace(' ', "_");
6132    let safe_slug = sanitize_filename_component(&workbook.slug);
6133    let filename = format!("{}_{}_{}.png", safe_slug, safe_sheet, safe_range);
6134
6135    let config = state.config();
6136    let screenshot_dir = config.screenshot_dir.clone();
6137    tokio::fs::create_dir_all(&screenshot_dir).await?;
6138    let output_path = screenshot_dir.join(&filename);
6139
6140    let semaphore = state
6141        .screenshot_semaphore()
6142        .ok_or_else(|| anyhow!("screenshot semaphore not available"))?;
6143
6144    // LibreOffice profile/macro export is not concurrency-safe. Serialize screenshot calls.
6145    let _permit = semaphore
6146        .0
6147        .acquire()
6148        .await
6149        .map_err(|e| anyhow!("failed to acquire screenshot permit: {}", e))?;
6150
6151    #[cfg(not(feature = "recalc-libreoffice"))]
6152    {
6153        let _ = workbook_path;
6154        let _ = output_path;
6155        let _ = bounds;
6156        Err(anyhow!(
6157            "screenshot backend unavailable (build without recalc-libreoffice feature)"
6158        ))
6159    }
6160
6161    #[cfg(feature = "recalc-libreoffice")]
6162    {
6163        let executor =
6164            crate::recalc::ScreenshotExecutor::new(&crate::recalc::RecalcConfig::default());
6165        let result = executor
6166            .screenshot(
6167                &workbook_path,
6168                &output_path,
6169                &params.sheet_name,
6170                Some(range),
6171            )
6172            .await?;
6173
6174        enforce_png_pixel_limits(&result.output_path, range, &bounds).await?;
6175
6176        Ok(ScreenshotSheetResponse {
6177            workbook_id: params.workbook_or_fork_id.0,
6178            sheet_name: params.sheet_name,
6179            range: range.to_string(),
6180            output_path: format!("file://{}", result.output_path.display()),
6181            client_output_path: config
6182                .map_path_for_client(&result.output_path)
6183                .map(|p| format!("file://{}", p.display())),
6184            size_bytes: result.size_bytes,
6185            duration_ms: result.duration_ms,
6186        })
6187    }
6188}
6189
6190#[derive(Debug, Clone, Copy)]
6191struct ScreenshotBounds {
6192    min_col: u32,
6193    max_col: u32,
6194    min_row: u32,
6195    max_row: u32,
6196    rows: u32,
6197    cols: u32,
6198}
6199
6200#[cfg(not(target_arch = "wasm32"))]
6201fn validate_screenshot_range(range: &str) -> Result<ScreenshotBounds> {
6202    let bounds = parse_range_bounds(range)?;
6203
6204    if bounds.rows > MAX_SCREENSHOT_ROWS || bounds.cols > MAX_SCREENSHOT_COLS {
6205        let row_tiles = div_ceil(bounds.rows, MAX_SCREENSHOT_ROWS);
6206        let col_tiles = div_ceil(bounds.cols, MAX_SCREENSHOT_COLS);
6207        let total_tiles = row_tiles * col_tiles;
6208
6209        let display_limit = 50usize;
6210        let display_ranges = suggest_tiled_ranges(
6211            &bounds,
6212            MAX_SCREENSHOT_ROWS,
6213            MAX_SCREENSHOT_COLS,
6214            Some(display_limit),
6215        );
6216
6217        let mut msg = format!(
6218            "Requested range {range} is too large for a single screenshot ({} rows x {} cols; max {} x {}). \
6219Split into {} tile(s) ({} row tiles x {} col tiles). Suggested ranges: {}",
6220            bounds.rows,
6221            bounds.cols,
6222            MAX_SCREENSHOT_ROWS,
6223            MAX_SCREENSHOT_COLS,
6224            total_tiles,
6225            row_tiles,
6226            col_tiles,
6227            display_ranges.join(", ")
6228        );
6229        if total_tiles as usize > display_limit {
6230            msg.push_str(&format!(
6231                " ... and {} more.",
6232                total_tiles as usize - display_limit
6233            ));
6234        }
6235        return Err(anyhow!(msg));
6236    }
6237
6238    Ok(bounds)
6239}
6240
6241fn parse_cell_ref(cell: &str) -> Result<(u32, u32)> {
6242    use umya_spreadsheet::helper::coordinate::index_from_coordinate;
6243    let (col, row, _, _) = index_from_coordinate(cell);
6244    match (col, row) {
6245        (Some(c), Some(r)) => Ok((c, r)),
6246        _ => Err(anyhow!("Invalid cell reference: {}", cell)),
6247    }
6248}
6249
6250fn parse_range_bounds(range: &str) -> Result<ScreenshotBounds> {
6251    let parts: Vec<&str> = range.split(':').collect();
6252    if parts.is_empty() || parts.len() > 2 {
6253        return Err(anyhow!("Invalid range format. Expected 'A1' or 'A1:Z99'"));
6254    }
6255
6256    let start = parse_cell_ref(parts[0])?;
6257    let end = if parts.len() == 2 {
6258        parse_cell_ref(parts[1])?
6259    } else {
6260        start
6261    };
6262
6263    let min_col = start.0.min(end.0);
6264    let max_col = start.0.max(end.0);
6265    let min_row = start.1.min(end.1);
6266    let max_row = start.1.max(end.1);
6267
6268    let rows = max_row - min_row + 1;
6269    let cols = max_col - min_col + 1;
6270
6271    Ok(ScreenshotBounds {
6272        min_col,
6273        max_col,
6274        min_row,
6275        max_row,
6276        rows,
6277        cols,
6278    })
6279}
6280
6281#[cfg(not(target_arch = "wasm32"))]
6282fn div_ceil(n: u32, d: u32) -> u32 {
6283    n.div_ceil(d)
6284}
6285
6286#[cfg(not(target_arch = "wasm32"))]
6287fn suggest_tiled_ranges(
6288    bounds: &ScreenshotBounds,
6289    max_rows: u32,
6290    max_cols: u32,
6291    limit: Option<usize>,
6292) -> Vec<String> {
6293    use umya_spreadsheet::helper::coordinate::coordinate_from_index;
6294
6295    let mut out = Vec::new();
6296    let mut row_start = bounds.min_row;
6297    while row_start <= bounds.max_row {
6298        let row_end = (row_start + max_rows - 1).min(bounds.max_row);
6299        let mut col_start = bounds.min_col;
6300        while col_start <= bounds.max_col {
6301            let col_end = (col_start + max_cols - 1).min(bounds.max_col);
6302            let start_cell = coordinate_from_index(&col_start, &row_start);
6303            let end_cell = coordinate_from_index(&col_end, &row_end);
6304            out.push(format!("{start_cell}:{end_cell}"));
6305            if let Some(lim) = limit
6306                && out.len() >= lim
6307            {
6308                return out;
6309            }
6310            col_start = col_end + 1;
6311        }
6312        row_start = row_end + 1;
6313        if let Some(lim) = limit
6314            && out.len() >= lim
6315        {
6316            return out;
6317        }
6318    }
6319    out
6320}
6321
6322#[cfg(feature = "recalc-libreoffice")]
6323fn suggest_split_single_tile(bounds: &ScreenshotBounds) -> Vec<String> {
6324    use umya_spreadsheet::helper::coordinate::coordinate_from_index;
6325
6326    if bounds.rows >= bounds.cols && bounds.rows > 1 {
6327        let mid_row = bounds.min_row + (bounds.rows / 2) - 1;
6328        let start1 = coordinate_from_index(&bounds.min_col, &bounds.min_row);
6329        let end1 = coordinate_from_index(&bounds.max_col, &mid_row);
6330        let start2 = coordinate_from_index(&bounds.min_col, &(mid_row + 1));
6331        let end2 = coordinate_from_index(&bounds.max_col, &bounds.max_row);
6332        vec![format!("{start1}:{end1}"), format!("{start2}:{end2}")]
6333    } else if bounds.cols > 1 {
6334        let mid_col = bounds.min_col + (bounds.cols / 2) - 1;
6335        let start1 = coordinate_from_index(&bounds.min_col, &bounds.min_row);
6336        let end1 = coordinate_from_index(&mid_col, &bounds.max_row);
6337        let start2 = coordinate_from_index(&(mid_col + 1), &bounds.min_row);
6338        let end2 = coordinate_from_index(&bounds.max_col, &bounds.max_row);
6339        vec![format!("{start1}:{end1}"), format!("{start2}:{end2}")]
6340    } else {
6341        vec![range_from_bounds(bounds)]
6342    }
6343}
6344
6345#[cfg(feature = "recalc-libreoffice")]
6346fn range_from_bounds(bounds: &ScreenshotBounds) -> String {
6347    use umya_spreadsheet::helper::coordinate::coordinate_from_index;
6348    let start = coordinate_from_index(&bounds.min_col, &bounds.min_row);
6349    let end = coordinate_from_index(&bounds.max_col, &bounds.max_row);
6350    format!("{start}:{end}")
6351}
6352
6353#[cfg(feature = "recalc-libreoffice")]
6354async fn enforce_png_pixel_limits(
6355    path: &std::path::Path,
6356    range: &str,
6357    bounds: &ScreenshotBounds,
6358) -> Result<()> {
6359    use image::GenericImageView;
6360    use image::ImageReader;
6361
6362    let max_dim_px = std::env::var("SPREADSHEET_MCP_MAX_PNG_DIM_PX")
6363        .ok()
6364        .and_then(|v| v.parse::<u32>().ok())
6365        .unwrap_or(DEFAULT_MAX_PNG_DIM_PX);
6366    let max_area_px = std::env::var("SPREADSHEET_MCP_MAX_PNG_AREA_PX")
6367        .ok()
6368        .and_then(|v| v.parse::<u64>().ok())
6369        .unwrap_or(DEFAULT_MAX_PNG_AREA_PX);
6370
6371    let reader = ImageReader::open(path)
6372        .map_err(|e| anyhow!("failed to read png {}: {}", path.display(), e))?;
6373    let reader = reader
6374        .with_guessed_format()
6375        .map_err(|e| anyhow!("failed to sniff png {}: {}", path.display(), e))?;
6376    let img = reader
6377        .decode()
6378        .map_err(|e| anyhow!("failed to decode png {}: {}", path.display(), e))?;
6379    let (w, h) = img.dimensions();
6380    let area = (w as u64) * (h as u64);
6381
6382    if w > max_dim_px || h > max_dim_px || area > max_area_px {
6383        let _ = tokio::fs::remove_file(path).await;
6384
6385        let mut suggestions =
6386            suggest_tiled_ranges(bounds, MAX_SCREENSHOT_ROWS, MAX_SCREENSHOT_COLS, Some(50));
6387        let row_tiles = div_ceil(bounds.rows, MAX_SCREENSHOT_ROWS);
6388        let col_tiles = div_ceil(bounds.cols, MAX_SCREENSHOT_COLS);
6389        let total_tiles = row_tiles * col_tiles;
6390        if total_tiles == 1 {
6391            suggestions = suggest_split_single_tile(bounds);
6392        }
6393
6394        return Err(anyhow!(
6395            "Rendered PNG for range {range} is {w}x{h}px (area {area}px), exceeding limits (max_dim={max_dim_px}px, max_area={max_area_px}px). \
6396Try smaller ranges. Suggested ranges: {}",
6397            suggestions.join(", ")
6398        ));
6399    }
6400
6401    Ok(())
6402}
6403
6404#[derive(Debug, Deserialize, JsonSchema)]
6405pub struct GridImportParams {
6406    #[serde(alias = "workbook_or_fork_id")]
6407    pub fork_id: String,
6408    pub sheet_name: String,
6409    pub anchor: String,
6410    pub grid: crate::model::GridPayload,
6411    #[serde(default)]
6412    pub clear_target: bool,
6413    #[serde(default)]
6414    pub mode: Option<BatchMode>,
6415    pub label: Option<String>,
6416    #[serde(default)]
6417    pub formula_parse_policy: Option<FormulaParsePolicy>,
6418}
6419
6420#[derive(Debug, Serialize, JsonSchema)]
6421pub struct GridImportResponse {
6422    pub fork_id: String,
6423    pub mode: String,
6424    pub change_id: Option<String>,
6425    pub summary: ChangeSummary,
6426    #[serde(skip_serializing_if = "Option::is_none")]
6427    pub formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
6428}
6429
6430#[derive(Debug, Serialize, Deserialize)]
6431struct GridImportStagedPayload {
6432    sheet_name: String,
6433    anchor: String,
6434    clear_target: bool,
6435    grid: crate::model::GridPayload,
6436}
6437
6438pub async fn grid_import(
6439    state: Arc<AppState>,
6440    params: GridImportParams,
6441) -> Result<GridImportResponse> {
6442    let registry = state
6443        .fork_registry()
6444        .ok_or_else(|| anyhow!("fork registry not available"))?;
6445
6446    let fork_ctx = registry.get_fork(&params.fork_id)?;
6447    let work_path = fork_ctx.work_path.clone();
6448
6449    let (anchor_col, anchor_row) = parse_cell_ref(&params.anchor)?;
6450    let mut max_col = anchor_col;
6451    let mut max_row = anchor_row;
6452
6453    let mut write_rows: Vec<Vec<Option<MatrixCell>>> = Vec::new();
6454    let mut style_ops_map: BTreeMap<String, (StyleOp, Vec<String>)> = BTreeMap::new(); // keyed by json of style patch
6455
6456    for grid_row in &params.grid.rows {
6457        for cell in &grid_row.cells {
6458            let r = anchor_row + cell.offset[0];
6459            let c = anchor_col + cell.offset[1];
6460            if r > max_row {
6461                max_row = r;
6462            }
6463            if c > max_col {
6464                max_col = c;
6465            }
6466        }
6467    }
6468
6469    let footprint_range = format!(
6470        "{}:{}",
6471        crate::utils::cell_address(anchor_col, anchor_row),
6472        crate::utils::cell_address(max_col, max_row)
6473    );
6474
6475    for grid_row in &params.grid.rows {
6476        for cell in &grid_row.cells {
6477            let row_idx = cell.offset[0] as usize;
6478            let col_idx = cell.offset[1] as usize;
6479            while write_rows.len() <= row_idx {
6480                write_rows.push(Vec::new());
6481            }
6482            while write_rows[row_idx].len() <= col_idx {
6483                write_rows[row_idx].push(None);
6484            }
6485
6486            let mut mc = None;
6487            if let Some(f) = &cell.f {
6488                mc = Some(MatrixCell::Formula(f.clone()));
6489            } else if let Some(v) = &cell.v {
6490                mc = Some(MatrixCell::Value(v.clone()));
6491            }
6492            if mc.is_some() {
6493                write_rows[row_idx][col_idx] = mc;
6494            }
6495
6496            let mut has_style = false;
6497            let mut style_patch = crate::model::StylePatch::default();
6498            if let Some(st) = &cell.style {
6499                style_patch = st.clone();
6500                has_style = true;
6501            }
6502            if let Some(fmt) = &cell.fmt {
6503                style_patch.number_format = Some(Some(fmt.clone()));
6504                has_style = true;
6505            }
6506
6507            if has_style {
6508                let key = serde_json::to_string(&style_patch).unwrap();
6509                let addr = crate::utils::cell_address(
6510                    anchor_col + cell.offset[1],
6511                    anchor_row + cell.offset[0],
6512                );
6513
6514                let entry = style_ops_map.entry(key).or_insert_with(|| {
6515                    (
6516                        StyleOp {
6517                            sheet_name: params.sheet_name.clone(),
6518                            target: StyleTarget::Cells { cells: Vec::new() }, // filled later
6519                            patch: style_patch.clone(),
6520                            op_mode: None,
6521                        },
6522                        Vec::new(),
6523                    )
6524                });
6525                entry.1.push(addr);
6526            }
6527        }
6528    }
6529
6530    let mut style_ops = Vec::new();
6531    if params.clear_target {
6532        style_ops.push(StyleOp {
6533            sheet_name: params.sheet_name.clone(),
6534            target: StyleTarget::Range {
6535                range: footprint_range.clone(),
6536            },
6537            patch: crate::model::StylePatch {
6538                font: Some(None),
6539                fill: Some(None),
6540                borders: Some(None),
6541                alignment: Some(None),
6542                number_format: Some(None),
6543            },
6544            op_mode: None,
6545        });
6546    }
6547    for mut group in style_ops_map.into_values() {
6548        group.0.target = StyleTarget::Cells { cells: group.1 };
6549        style_ops.push(group.0);
6550    }
6551
6552    let mut transform_ops = Vec::new();
6553    if params.clear_target {
6554        transform_ops.push(TransformOp::ClearRange {
6555            sheet_name: params.sheet_name.clone(),
6556            target: TransformTarget::Range {
6557                range: footprint_range.clone(),
6558            },
6559            clear_values: true,
6560            clear_formulas: true,
6561        });
6562    }
6563    transform_ops.push(TransformOp::WriteMatrix {
6564        sheet_name: params.sheet_name.clone(),
6565        anchor: params.anchor.clone(),
6566        rows: write_rows,
6567        overwrite_formulas: true,
6568    });
6569
6570    let mut column_ops = Vec::new();
6571    for col in &params.grid.columns {
6572        let col_name = crate::utils::column_number_to_name(anchor_col + col.offset);
6573        column_ops.push(ColumnSizeOp {
6574            target: ColumnTarget::Columns {
6575                range: format!("{}:{}", col_name, col_name),
6576            },
6577            size: ColumnSizeSpec::Width {
6578                width_chars: col.width_chars,
6579            },
6580        });
6581    }
6582
6583    let mut structure_ops = Vec::new();
6584    if !params.grid.merges.is_empty() {
6585        if params.clear_target {
6586            structure_ops.push(StructureOp::UnmergeCells {
6587                sheet_name: params.sheet_name.clone(),
6588                target_range: footprint_range.clone(),
6589            });
6590        }
6591        for merge in &params.grid.merges {
6592            structure_ops.push(StructureOp::MergeCells {
6593                sheet_name: params.sheet_name.clone(),
6594                target_range: merge.clone(),
6595            });
6596        }
6597    }
6598
6599    // Prepare ops for applying (same formula parsing as transform_batch)
6600    let fork_workbook_id = WorkbookId(params.fork_id.clone());
6601    let workbook = state.open_workbook(&fork_workbook_id).await?;
6602    let resolved_transform_ops = resolve_transform_ops_for_workbook(&workbook, &transform_ops)?;
6603    let resolved_style_ops = resolve_style_ops_for_workbook(&workbook, &style_ops)?;
6604
6605    let policy =
6606        params
6607            .formula_parse_policy
6608            .unwrap_or(FormulaParsePolicy::default_for_command_class(
6609                CommandClass::BatchWrite,
6610            ));
6611    let (ops_to_apply, formula_parse_diagnostics) = if policy == FormulaParsePolicy::Off {
6612        (resolved_transform_ops, None)
6613    } else {
6614        let mut builder = FormulaParseDiagnosticsBuilder::new(policy);
6615        let mut valid_ops = Vec::new();
6616        for op in resolved_transform_ops {
6617            match &op {
6618                TransformOp::WriteMatrix {
6619                    sheet_name,
6620                    anchor,
6621                    rows,
6622                    overwrite_formulas,
6623                } => {
6624                    let mut has_errors = false;
6625                    let mut valid_rows = Vec::new();
6626                    let (a_col, a_row) = parse_cell_ref(anchor)?;
6627                    for (r_idx, row) in rows.iter().enumerate() {
6628                        let mut valid_row = Vec::new();
6629                        let r = a_row + r_idx as u32;
6630                        for (c_idx, cell_opt) in row.iter().enumerate() {
6631                            let c = a_col + c_idx as u32;
6632                            if let Some(MatrixCell::Formula(f)) = cell_opt {
6633                                match validate_formula(f) {
6634                                    Ok(()) => valid_row.push(cell_opt.clone()),
6635                                    Err(err_msg) => {
6636                                        if policy == FormulaParsePolicy::Fail {
6637                                            bail!(
6638                                                "{}grid_import formula failed at {}: {}",
6639                                                FORMULA_PARSE_FAILED_PREFIX,
6640                                                crate::utils::cell_address(c, r),
6641                                                err_msg
6642                                            );
6643                                        }
6644                                        builder.record_error(
6645                                            sheet_name,
6646                                            &crate::utils::cell_address(c, r),
6647                                            f,
6648                                            &err_msg,
6649                                        );
6650                                        has_errors = true;
6651                                        valid_row.push(None);
6652                                    }
6653                                }
6654                            } else {
6655                                valid_row.push(cell_opt.clone());
6656                            }
6657                        }
6658                        valid_rows.push(valid_row);
6659                    }
6660                    if has_errors && policy == FormulaParsePolicy::Warn {
6661                        valid_ops.push(TransformOp::WriteMatrix {
6662                            sheet_name: sheet_name.clone(),
6663                            anchor: anchor.clone(),
6664                            rows: valid_rows,
6665                            overwrite_formulas: *overwrite_formulas,
6666                        });
6667                    } else {
6668                        valid_ops.push(op);
6669                    }
6670                }
6671                _ => valid_ops.push(op),
6672            }
6673        }
6674        let diagnostics = if builder.has_errors() {
6675            Some(builder.build())
6676        } else {
6677            None
6678        };
6679        (valid_ops, diagnostics)
6680    };
6681
6682    let mode = params.mode.unwrap_or_default();
6683
6684    if mode.is_preview() {
6685        let change_id = make_short_random_id("chg", 12);
6686        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
6687        fs::create_dir_all(snapshot_path.parent().unwrap())?;
6688        fs::copy(&work_path, &snapshot_path)?;
6689
6690        let snapshot_for_apply = snapshot_path.clone();
6691        let sheet_name_for_col = params.sheet_name.clone();
6692        let apply_result = tokio::task::spawn_blocking(move || {
6693            let mut summary = ChangeSummary::default();
6694            summary.op_kinds.push("grid_import".to_string());
6695
6696            if !structure_ops.is_empty() {
6697                let res = apply_structure_ops_to_file(
6698                    &snapshot_for_apply,
6699                    &structure_ops,
6700                    FormulaParsePolicy::Off,
6701                )?;
6702                merge_summary_counts(&mut summary, &res.summary);
6703            }
6704            if !column_ops.is_empty() {
6705                let res = apply_column_size_ops_to_file(
6706                    &snapshot_for_apply,
6707                    &sheet_name_for_col,
6708                    &column_ops,
6709                )?;
6710                merge_summary_counts(&mut summary, &res.summary);
6711            }
6712            if !ops_to_apply.is_empty() {
6713                let res = apply_transform_ops_to_file(&snapshot_for_apply, &ops_to_apply)?;
6714                merge_summary_counts(&mut summary, &res.summary);
6715            }
6716            if !resolved_style_ops.is_empty() {
6717                let res = apply_style_ops_to_file(&snapshot_for_apply, &resolved_style_ops)?;
6718                merge_summary_counts(&mut summary, &res.summary);
6719            }
6720
6721            Ok::<_, anyhow::Error>(summary)
6722        })
6723        .await??;
6724
6725        let mut summary = apply_result;
6726        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
6727
6728        let staged_op = StagedOp {
6729            kind: "grid_import".to_string(),
6730            payload: serde_json::to_value(GridImportStagedPayload {
6731                sheet_name: params.sheet_name.clone(),
6732                anchor: params.anchor.clone(),
6733                clear_target: params.clear_target,
6734                grid: params.grid.clone(),
6735            })?,
6736        };
6737
6738        let staged = StagedChange {
6739            change_id: change_id.clone(),
6740            created_at: Utc::now(),
6741            label: params.label.clone(),
6742            ops: vec![staged_op],
6743            summary: summary.clone(),
6744            fork_path_snapshot: Some(snapshot_path),
6745        };
6746
6747        registry.add_staged_change(&params.fork_id, staged)?;
6748
6749        Ok(GridImportResponse {
6750            fork_id: params.fork_id,
6751            mode: mode.as_str().to_string(),
6752            change_id: Some(change_id),
6753            summary,
6754            formula_parse_diagnostics,
6755        })
6756    } else {
6757        let sheet_name_for_col = params.sheet_name.clone();
6758        let work_path_for_apply = work_path.clone();
6759        let apply_result = tokio::task::spawn_blocking(move || {
6760            let mut summary = ChangeSummary::default();
6761            summary.op_kinds.push("grid_import".to_string());
6762
6763            if !structure_ops.is_empty() {
6764                let res = apply_structure_ops_to_file(
6765                    &work_path_for_apply,
6766                    &structure_ops,
6767                    FormulaParsePolicy::Off,
6768                )?;
6769                merge_summary_counts(&mut summary, &res.summary);
6770            }
6771            if !column_ops.is_empty() {
6772                let res = apply_column_size_ops_to_file(
6773                    &work_path_for_apply,
6774                    &sheet_name_for_col,
6775                    &column_ops,
6776                )?;
6777                merge_summary_counts(&mut summary, &res.summary);
6778            }
6779            if !ops_to_apply.is_empty() {
6780                let res = apply_transform_ops_to_file(&work_path_for_apply, &ops_to_apply)?;
6781                merge_summary_counts(&mut summary, &res.summary);
6782            }
6783            if !resolved_style_ops.is_empty() {
6784                let res = apply_style_ops_to_file(&work_path_for_apply, &resolved_style_ops)?;
6785                merge_summary_counts(&mut summary, &res.summary);
6786            }
6787
6788            Ok::<_, anyhow::Error>(summary)
6789        })
6790        .await??;
6791
6792        let mut summary = apply_result;
6793        registry.with_fork_mut(&params.fork_id, |ctx| {
6794            ctx.recalc_needed = true;
6795            Ok(())
6796        })?;
6797        set_recalc_needed_flag(&mut summary, true);
6798
6799        let _ = state.close_workbook(&fork_workbook_id);
6800
6801        Ok(GridImportResponse {
6802            fork_id: params.fork_id,
6803            mode: mode.as_str().to_string(),
6804            change_id: None,
6805            summary,
6806            formula_parse_diagnostics,
6807        })
6808    }
6809}
6810
6811fn merge_summary_counts(dest: &mut ChangeSummary, src: &ChangeSummary) {
6812    for sheet in &src.affected_sheets {
6813        if !dest.affected_sheets.contains(sheet) {
6814            dest.affected_sheets.push(sheet.clone());
6815        }
6816    }
6817    for bounds in &src.affected_bounds {
6818        if !dest.affected_bounds.contains(bounds) {
6819            dest.affected_bounds.push(bounds.clone());
6820        }
6821    }
6822    for (k, v) in &src.counts {
6823        *dest.counts.entry(k.clone()).or_insert(0) += v;
6824    }
6825}
6826
6827#[cfg(test)]
6828mod tests {
6829    use super::*;
6830
6831    #[test]
6832    fn adjust_ref_coord_part_row_insert_clamps_excel_max_row() {
6833        let adjusted = adjust_ref_coord_part(
6834            "$C$2:$P$1048576",
6835            StructureAxis::Row,
6836            StructureEdit::Insert { at: 1791, count: 7 },
6837        )
6838        .expect("adjust range");
6839
6840        assert_eq!(adjusted, "$C$2:$P$1048576");
6841    }
6842
6843    #[test]
6844    fn clamp_out_of_bounds_defined_name_rows_clamps_plain_ranges() {
6845        let mut workbook = umya_spreadsheet::new_file();
6846        workbook
6847            .set_sheet_name(0, "GL Data".to_string())
6848            .expect("rename default sheet");
6849
6850        let workbook_scoped = {
6851            let sheet = workbook
6852                .get_sheet_by_name_mut("GL Data")
6853                .expect("GL Data sheet");
6854            sheet
6855                .add_defined_name("GLDATA", "'GL Data'!$C$2:$P$1048583")
6856                .expect("add defined name");
6857            let cloned = sheet
6858                .get_defined_names()
6859                .first()
6860                .expect("sheet defined name")
6861                .clone();
6862            sheet.get_defined_names_mut().clear();
6863            cloned
6864        };
6865        {
6866            let defs = workbook.get_defined_names_mut();
6867            defs.clear();
6868            defs.push(workbook_scoped);
6869        }
6870
6871        let changed = clamp_out_of_bounds_defined_name_rows(&mut workbook);
6872        assert!(changed >= 1, "expected at least one clamped defined name");
6873
6874        let global_refers_to = workbook
6875            .get_defined_names()
6876            .iter()
6877            .find(|item| item.get_name() == "GLDATA")
6878            .map(|item| item.get_address().to_string())
6879            .expect("global GLDATA defined name");
6880        assert!(
6881            global_refers_to.contains("$P$1048576"),
6882            "expected clamped max-row bound, got: {global_refers_to}"
6883        );
6884        assert!(
6885            !global_refers_to.contains("104858"),
6886            "defined name should not overflow Excel max rows: {global_refers_to}"
6887        );
6888    }
6889
6890    #[test]
6891    fn clamp_defined_name_rows_in_workbook_xml_clamps_overflow_rows() {
6892        let xml = r#"<workbook><definedNames><definedName name="GLDATA" hidden="0">'GL Data'!$C$2:$P$1048583</definedName><definedName name="Calc">=SUM(Sheet1!A1:A10)</definedName></definedNames></workbook>"#;
6893        let (rewritten, changed) = clamp_defined_name_rows_in_workbook_xml(xml);
6894
6895        assert_eq!(changed, 1);
6896        assert!(rewritten.contains("'GL Data'!$C$2:$P$1048576"));
6897        assert!(!rewritten.contains("1048583"));
6898        assert!(
6899            rewritten.contains("<definedName name=\"Calc\">=SUM(Sheet1!A1:A10)</definedName>"),
6900            "formula-like defined names should not be rewritten"
6901        );
6902    }
6903}