//! Public orchestration service for the research subsystem.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use chrono::{Datelike, Utc};
use serde_json::{json, Value};
use tauri::{AppHandle, Runtime};

use crate::agent::{default_task_defaults, AgentSettingsService, TaskProfile};
use crate::research::ai::{build_model_info, LiveResearchAiGateway, ResearchAiGateway};
use crate::research::errors::{ResearchError, Result};
use crate::research::events::ResearchEventEmitter;
use crate::research::export::export_bundle;
use crate::research::ghosts::{generate_ghost_notes, GhostResolvedFileContext};
use crate::research::grounding::{
    build_source_record, build_source_reference_note, preview_link, refresh_source_metadata,
    source_excerpt_from_input,
};
use crate::research::heuristics::{classify_note, derive_title, detect_urls, extract_tickers};
use crate::research::links::infer_links;
use crate::research::model_formula::evaluate_workbook;
use crate::research::pipeline::ResearchPipeline;
use crate::research::projections::{build_memo_blocks, build_workspace_projection};
use crate::research::repository::ResearchRepository;
use crate::research::types::{
    ArchiveResearchNoteRequest, ArchiveResearchWorkspaceRequest, AuditActor, AuditEntityKind,
    AuditEvent, CaptureMethod, CaptureResearchNoteRequest, CreateFinancialModelRequest,
    CreateResearchWorkspaceRequest, EvaluateFinancialModelRequest, EvaluateFinancialModelResult,
    EvidenceStatus, ExportFinancialModelXlsxRequest, ExportFinancialModelXlsxResult,
    ExportResearchBundleRequest, FinancialModel, GetFinancialModelRequest,
    GetNoteAuditTrailRequest, GetNotesByTickerRequest, GetResearchFileTextPreviewRequest,
    GetWorkspaceProjectionRequest, GhostLifecycleState, GhostRecommendedAction,
    GhostResolutionStatus, GhostReviewAction, GhostStatus, LinkNoteToModelCellRequest, LinkOrigin,
    ListFinancialModelsRequest, ListModelCellAnnotationsRequest, ListModelDataBindingsRequest,
    ListNoteLinksRequest, ListResearchFilesRequest, ListResearchJobsRequest,
    ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, ModelCellAnnotation,
    ModelDataBinding, ModelEvidenceStatus, ModelTemplateType, NoteAuditTrail, NoteCaptureResult,
    NoteEmbed, NoteEmbedInput, NotePriority, NoteType, PreviewLinkRequest, PreviewLinkResult,
    PromoteNoteToThesisRequest, ProvenanceActor, ResearchBundleExport, ResearchFile,
    ResearchFilePreviewKind, ResearchFileTextPreview, ResearchNote, ResearchWorkspace,
    ResolveGhostInterventionRequest, RetryResearchJobsRequest, ReviewGhostNoteRequest,
    SaveFinancialModelRequest, SaveModelDataBindingRequest, SearchResearchNotesRequest, SourceKind,
    SourceReferenceInput, ThesisStatus, UnlinkNoteFromModelCellRequest,
    UpdateModelCellAnnotationRequest, UpdateResearchNoteRequest, UpdateResearchWorkspaceRequest,
    UploadResearchFilesRequest, WorkspaceProjection, WorkspaceScope,
};
use crate::research::util::{generate_id, normalize_text, now_rfc3339, sha256_hex};

pub struct ResearchService<R: Runtime> {
    repository: Arc<ResearchRepository>,
    files_root: PathBuf,
    pipeline: Arc<ResearchPipeline>,
    ai_gateway: Arc<dyn ResearchAiGateway>,
    emitter: ResearchEventEmitter<R>,
    settings: AgentSettingsService<R>,
    job_processor_lock: Arc<tokio::sync::Mutex<()>>,
}

struct AuditRecord<'a> {
    entity_id: &'a str,
    entity_kind: AuditEntityKind,
    action: &'a str,
    actor: AuditActor,
    prior_revision: Option<u32>,
    new_revision: Option<u32>,
    job_id: Option<String>,
    source_ids: Vec<String>,
}

impl<R: Runtime> Clone for ResearchService<R> {
    fn clone(&self) -> Self {
        Self {
            repository: self.repository.clone(),
            files_root: self.files_root.clone(),
            pipeline: self.pipeline.clone(),
            ai_gateway: self.ai_gateway.clone(),
            emitter: self.emitter.clone(),
            settings: self.settings.clone(),
            job_processor_lock: self.job_processor_lock.clone(),
        }
    }
}

impl<R: Runtime + 'static> ResearchService<R> {
    pub fn new(app_handle: &AppHandle<R>, db_path: std::path::PathBuf) -> Result<Self> {
        let settings = AgentSettingsService::new(app_handle);
        let live_gateway = LiveResearchAiGateway::new(settings);
        Self::new_with_ai_gateway(app_handle, db_path, Arc::new(live_gateway))
    }

    pub fn new_with_ai_gateway(
        app_handle: &AppHandle<R>,
        db_path: std::path::PathBuf,
        ai_gateway: Arc<dyn ResearchAiGateway>,
    ) -> Result<Self> {
        let files_root = db_path
            .parent()
            .map(|parent| parent.join("files"))
            .unwrap_or_else(|| PathBuf::from("files"));
        std::fs::create_dir_all(&files_root)?;
        let repository = Arc::new(ResearchRepository::new(db_path)?);
        Ok(Self {
            pipeline: Arc::new(ResearchPipeline::new(repository.clone())),
            repository,
            files_root,
            ai_gateway,
            emitter: ResearchEventEmitter::new(app_handle),
            settings: AgentSettingsService::new(app_handle),
            job_processor_lock: Arc::new(tokio::sync::Mutex::new(())),
        })
    }

    pub async fn create_workspace(
        &self,
        request: CreateResearchWorkspaceRequest,
    ) -> Result<ResearchWorkspace> {
        let now = now_rfc3339();
        let workspace = ResearchWorkspace {
            id: generate_id("workspace"),
            name: normalize_non_empty(&request.name, "workspace name")?,
            primary_ticker: normalize_ticker(&request.primary_ticker)?,
            scope: WorkspaceScope::SingleCompany,
            stage: request.stage,
            default_view: request.default_view,
            pinned_note_ids: Vec::new(),
            archived: false,
            created_at: now.clone(),
            updated_at: now,
        };
        let saved = self.repository.create_workspace(workspace.clone()).await?;
        self.record_audit(
            &saved.id,
            AuditRecord {
                entity_id: &saved.id,
                entity_kind: AuditEntityKind::Workspace,
                action: "workspace_created",
                actor: AuditActor::Analyst,
                prior_revision: None,
                new_revision: None,
                job_id: None,
                source_ids: Vec::new(),
            },
        )
        .await?;
        self.emitter.workspace_updated(&saved);
        Ok(saved)
    }

    pub async fn list_workspaces(&self) -> Result<Vec<ResearchWorkspace>> {
        self.repository.list_workspaces().await
    }

    pub async fn get_workspace(&self, workspace_id: &str) -> Result<ResearchWorkspace> {
        self.repository.get_workspace(workspace_id).await
    }

    pub async fn list_financial_models(
        &self,
        request: ListFinancialModelsRequest,
    ) -> Result<Vec<FinancialModel>> {
        self.repository
            .list_financial_models(&request.workspace_id)
            .await
    }

    pub async fn get_financial_model(
        &self,
        request: GetFinancialModelRequest,
    ) -> Result<FinancialModel> {
        self.repository.get_financial_model(&request.model_id).await
    }

    pub async fn create_financial_model(
        &self,
        request: CreateFinancialModelRequest,
    ) -> Result<FinancialModel> {
        let workspace = self.repository.get_workspace(&request.workspace_id).await?;
        let now = now_rfc3339();
        let model = FinancialModel {
            id: generate_id("model"),
            workspace_id: workspace.id,
            name: normalize_non_empty(&request.name, "model name")?,
            ticker: request
                .ticker
                .or_else(|| Some(workspace.primary_ticker.clone()))
                .map(|ticker| ticker.trim().to_ascii_uppercase())
                .filter(|ticker| !ticker.is_empty()),
            template_type: request.template_type,
            scenario_group_id: None,
            workbook_json: build_model_template_workbook(request.template_type),
            annotations: build_model_template_annotations(request.template_type, &now),
            created_at: now.clone(),
            updated_at: now,
        };
        self.repository.save_financial_model(model).await
    }

    pub async fn save_financial_model(
        &self,
        request: SaveFinancialModelRequest,
    ) -> Result<FinancialModel> {
        let mut model = self
            .repository
            .get_financial_model(&request.model_id)
            .await?;
        let now = now_rfc3339();
        model.workbook_json = request.workbook_json;
        model.annotations = request
            .annotations
            .into_iter()
            .map(|mut annotation| {
                annotation.updated_at = now.clone();
                annotation
            })
            .collect();
        model.updated_at = now;
        self.repository.save_financial_model(model).await
    }

    pub async fn evaluate_financial_model(
        &self,
        request: EvaluateFinancialModelRequest,
    ) -> Result<EvaluateFinancialModelResult> {
        let workbook_json = if let Some(workbook_json) = request.workbook_json {
            workbook_json
        } else if let Some(model_id) = request.model_id {
            self.repository
                .get_financial_model(&model_id)
                .await?
                .workbook_json
        } else {
            return Err(crate::research::errors::ResearchError::Validation(
                "evaluate_financial_model requires modelId or workbookJson".to_string(),
            ));
        };
        Ok(evaluate_workbook(&workbook_json))
    }

    pub async fn list_model_data_bindings(
        &self,
        request: ListModelDataBindingsRequest,
    ) -> Result<Vec<ModelDataBinding>> {
        self.repository
            .list_model_data_bindings(&request.model_id)
            .await
    }

    pub async fn save_model_data_binding(
        &self,
        mut request: SaveModelDataBindingRequest,
    ) -> Result<ModelDataBinding> {
        let now = now_rfc3339();
        if request.binding.id.trim().is_empty() {
            request.binding.id = generate_id("binding");
        }
        request.binding.updated_at = now.clone();
        if request.binding.created_at.trim().is_empty() {
            request.binding.created_at = now;
        }
        self.repository
            .save_model_data_binding(request.binding)
            .await
    }

    pub async fn list_model_cell_annotations(
        &self,
        request: ListModelCellAnnotationsRequest,
    ) -> Result<Vec<ModelCellAnnotation>> {
        self.repository
            .list_model_cell_annotations(&request.model_id)
            .await
    }

    pub async fn update_model_cell_annotation(
        &self,
        request: UpdateModelCellAnnotationRequest,
    ) -> Result<ModelCellAnnotation> {
        let mut model = self
            .repository
            .get_financial_model(&request.model_id)
            .await?;
        let now = now_rfc3339();
        let mut annotation = request.annotation;
        annotation.updated_at = now.clone();
        model.annotations.retain(|existing| {
            !(existing.sheet_id == annotation.sheet_id && existing.address == annotation.address)
                && existing.cell_id != annotation.cell_id
        });
        model.annotations.push(annotation.clone());
        model.updated_at = now;
        self.repository.save_financial_model(model).await?;
        Ok(annotation)
    }

    pub async fn link_note_to_model_cell(
        &self,
        request: LinkNoteToModelCellRequest,
    ) -> Result<ModelCellAnnotation> {
        let mut model = self
            .repository
            .get_financial_model(&request.model_id)
            .await?;
        let _note = self.repository.get_note(&request.note_id).await?;
        let now = now_rfc3339();
        let annotation = upsert_annotation_link(
            &mut model.annotations,
            &request.sheet_id,
            &request.address,
            &request.note_id,
            true,
            &now,
        );
        model.updated_at = now;
        self.repository.save_financial_model(model).await?;
        Ok(annotation)
    }

    pub async fn unlink_note_from_model_cell(
        &self,
        request: UnlinkNoteFromModelCellRequest,
    ) -> Result<ModelCellAnnotation> {
        let mut model = self
            .repository
            .get_financial_model(&request.model_id)
            .await?;
        let now = now_rfc3339();
        let annotation = upsert_annotation_link(
            &mut model.annotations,
            &request.sheet_id,
            &request.address,
            &request.note_id,
            false,
            &now,
        );
        model.updated_at = now;
        self.repository.save_financial_model(model).await?;
        Ok(annotation)
    }

    pub async fn export_financial_model_xlsx(
        &self,
        request: ExportFinancialModelXlsxRequest,
    ) -> Result<ExportFinancialModelXlsxResult> {
        let model = self
            .repository
            .get_financial_model(&request.model_id)
            .await?;
        let export_dir = self.files_root.join("exports");
        std::fs::create_dir_all(&export_dir)?;
        let file_path = export_dir.join(format!(
            "{}-{}.xlsx",
            model.id,
            chrono::Utc::now().timestamp()
        ));
        write_model_xlsx(&model, &file_path)?;
        Ok(ExportFinancialModelXlsxResult {
            file_path: file_path.to_string_lossy().to_string(),
            model_id: model.id,
            sheet_count: model
                .workbook_json
                .get("sheets")
                .and_then(serde_json::Value::as_array)
                .map_or(0, Vec::len),
            exported_at: now_rfc3339(),
        })
    }

    pub async fn capture_note(
        &self,
        request: CaptureResearchNoteRequest,
    ) -> Result<NoteCaptureResult> {
        let workspace = self.repository.get_workspace(&request.workspace_id).await?;
        let raw_text = normalize_non_empty(&request.raw_text, "note text")?;
        let cleaned_text = normalize_text(&raw_text);
        let source_kind = request
            .source_ref
            .as_ref()
            .map(|source| source.kind)
            .unwrap_or_else(|| {
                if detect_urls(&raw_text).is_empty() {
                    SourceKind::Manual
                } else {
                    SourceKind::Article
                }
            });
        let typing = classify_note(&cleaned_text, source_kind, request.user_note_type_override);
        let now = now_rfc3339();
        let ticker = request
            .ticker
            .as_deref()
            .map(normalize_ticker)
            .transpose()?
            .or_else(|| extract_tickers(&cleaned_text).into_iter().next())
            .or_else(|| Some(workspace.primary_ticker.clone()));

        let detected_source_ref = detect_urls(&raw_text).first().map(|url| {
            crate::research::types::SourceReferenceInput {
                kind: SourceKind::Article,
                title: None,
                url: Some(url.clone()),
                published_at: None,
                filing_accession: None,
                form_type: None,
                excerpt_text: None,
                location_label: None,
            }
        });

        let mut source_id = None;
        if let Some(source_ref) = request.source_ref.as_ref().or(detected_source_ref.as_ref()) {
            let checksum = source_ref.url.as_deref().map(sha256_hex);
            let existing = self
                .repository
                .find_source_by_checksum_or_accession(
                    &workspace.id,
                    checksum.as_deref(),
                    source_ref.filing_accession.as_deref(),
                )
                .await?;
            let source = if let Some(existing) = existing {
                existing
            } else {
                self.repository
                    .save_source(build_source_record(
                        &workspace.id,
                        ticker.as_deref(),
                        source_ref,
                    ))
                    .await?
            };
            source_id = Some(source.id.clone());
            if self
                .repository
                .find_source_reference_note(&workspace.id, &source.id)
                .await?
                .is_none()
            {
                let source_note = build_source_reference_note(
                    &workspace.id,
                    ticker.as_deref(),
                    &source,
                    source_excerpt_from_input(&source.id, source_ref).as_ref(),
                );
                self.repository.create_note(source_note.clone()).await?;
                self.record_audit(
                    &workspace.id,
                    AuditRecord {
                        entity_id: &source_note.id,
                        entity_kind: AuditEntityKind::Note,
                        action: "source_reference_note_created",
                        actor: AuditActor::System,
                        prior_revision: None,
                        new_revision: Some(source_note.revision),
                        job_id: None,
                        source_ids: source_note.source_id.iter().cloned().collect(),
                    },
                )
                .await?;
                self.emitter.note_updated(&source_note);
            }
        }
        let embeds = self
            .resolve_note_embeds(&workspace.id, ticker.as_deref(), &request.embeds)
            .await?;
        let file_ids = self
            .validate_workspace_file_ids(&workspace.id, &request.file_ids)
            .await?;
        let note_source_ids = note_source_ids(source_id.as_ref(), &embeds);

        let note = ResearchNote {
            id: generate_id("note"),
            workspace_id: workspace.id.clone(),
            company_id: request.company_id,
            ticker,
            source_id: source_id.clone(),
            raw_text: raw_text.clone(),
            cleaned_text: cleaned_text.clone(),
            body_html: request.body_html.filter(|value| !value.trim().is_empty()),
            embeds,
            file_ids,
            title: derive_title(&cleaned_text, typing.note_type),
            note_type: typing.note_type,
            subtype: typing.subtype,
            analyst_status: crate::research::types::AnalystStatus::Captured,
            ai_annotation: None,
            confidence: typing.confidence,
            evidence_status: match source_kind {
                SourceKind::Transcript if matches!(typing.note_type, NoteType::Quote) => {
                    EvidenceStatus::Quoted
                }
                SourceKind::Manual if source_id.is_none() => EvidenceStatus::Unsourced,
                _ if source_id.is_some() => EvidenceStatus::SourceLinked,
                _ => EvidenceStatus::Unsourced,
            },
            inferred_links: Vec::new(),
            ghost_status: GhostStatus::CandidateInput,
            thesis_status: ThesisStatus::None,
            created_at: now.clone(),
            updated_at: now.clone(),
            provenance: crate::research::types::NoteProvenance {
                created_by: ProvenanceActor::Manual,
                capture_method: capture_method_from_source_kind(source_kind),
                source_kind,
                origin_note_id: None,
                origin_ghost_id: None,
                model_info: None,
                created_at: now,
                raw_input_hash: sha256_hex(&raw_text),
            },
            tags: typing.tags,
            catalysts: typing.catalysts,
            risks: typing.risks,
            valuation_refs: typing.valuation_refs,
            time_horizon: typing.time_horizon,
            scenario: typing.scenario,
            priority: typing.priority,
            pinned: false,
            archived: false,
            revision: 1,
            source_excerpt: request.source_ref.as_ref().and_then(|source_ref| {
                source_excerpt_from_input(source_id.as_deref().unwrap_or_default(), source_ref)
            }),
            last_enriched_at: None,
            last_linked_at: None,
            stale_reason: None,
            superseded_by_note_id: None,
        };

        let saved = self.repository.create_note(note).await?;
        let queued_jobs = self
            .pipeline
            .enqueue_capture_jobs(&workspace.id, &saved.id, saved.revision, source_id.clone())
            .await?;
        self.record_audit(
            &workspace.id,
            AuditRecord {
                entity_id: &saved.id,
                entity_kind: AuditEntityKind::Note,
                action: "note_captured",
                actor: AuditActor::Analyst,
                prior_revision: None,
                new_revision: Some(saved.revision),
                job_id: None,
                source_ids: note_source_ids,
            },
        )
        .await?;
        for job in &queued_jobs {
            self.record_audit(
                &workspace.id,
                AuditRecord {
                    entity_id: &job.id,
                    entity_kind: AuditEntityKind::Job,
                    action: "job_enqueued",
                    actor: AuditActor::System,
                    prior_revision: None,
                    new_revision: None,
                    job_id: Some(job.id.clone()),
                    source_ids: Vec::new(),
                },
            )
            .await?;
            self.emitter.job_updated(job);
        }

        let placement_hint = crate::research::types::PlacementHint {
            tile_lane: placement_lane(saved.priority),
            kanban_column: format!("{:?}", saved.note_type).to_ascii_lowercase(),
            graph_anchor_note_id: source_id,
        };
        self.try_touch_workspace(&saved.workspace_id).await;
        self.emitter.note_updated(&saved);
        self.kick_job_processor();

        Ok(NoteCaptureResult {
            note: saved,
            placement_hint,
            queued_jobs,
        })
    }

    pub async fn preview_note_link(
        &self,
        request: PreviewLinkRequest,
    ) -> Result<PreviewLinkResult> {
        preview_link(request).await
    }

    pub async fn update_note(&self, request: UpdateResearchNoteRequest) -> Result<ResearchNote> {
        let mut note = self.repository.get_note(&request.note_id).await?;
        let prior_revision = note.revision;

        if let Some(raw_text) = request.raw_text.as_deref() {
            let raw_text = normalize_non_empty(raw_text, "note text")?;
            note.raw_text = raw_text.clone();
            note.cleaned_text = normalize_text(&raw_text);
            let typing = classify_note(
                &note.cleaned_text,
                note.provenance.source_kind,
                request.note_type.or(Some(note.note_type)),
            );
            note.note_type = request.note_type.unwrap_or(typing.note_type);
            note.subtype = request.subtype.or(typing.subtype);
            note.title = derive_title(&note.cleaned_text, note.note_type);
            note.tags = typing.tags;
            note.catalysts = typing.catalysts;
            note.risks = typing.risks;
            note.valuation_refs = typing.valuation_refs;
            note.time_horizon = typing.time_horizon;
            note.scenario = typing.scenario;
            note.priority = request.priority.unwrap_or(typing.priority);
            note.stale_reason = Some(crate::research::types::StaleReason::NoteUpdated);
            note.ai_annotation = None;
        }
        if let Some(body_html) = request.body_html {
            note.body_html = if body_html.trim().is_empty() {
                None
            } else {
                Some(body_html)
            };
        }
        if let Some(embed_inputs) = request.embeds {
            note.embeds = self
                .resolve_note_embeds(&note.workspace_id, note.ticker.as_deref(), &embed_inputs)
                .await?;
        }
        if let Some(file_ids) = request.file_ids {
            note.file_ids = self
                .validate_workspace_file_ids(&note.workspace_id, &file_ids)
                .await?;
        }

        if let Some(title) = request.title {
            note.title = Some(title);
        }
        if let Some(note_type) = request.note_type {
            note.note_type = note_type;
        }
        if let Some(analyst_status) = request.analyst_status {
            note.analyst_status = analyst_status;
        }
        if let Some(pinned) = request.pinned {
            note.pinned = pinned;
        }
        if let Some(priority) = request.priority {
            note.priority = priority;
        }
        if let Some(thesis_status) = request.thesis_status {
            note.thesis_status = thesis_status;
        }

        note.revision += 1;
        note.updated_at = now_rfc3339();
        note.last_enriched_at = None;
        note.last_linked_at = None;
        let saved = self.repository.save_note(note).await?;
        let queued_jobs = self
            .pipeline
            .enqueue_capture_jobs(&saved.workspace_id, &saved.id, saved.revision, None)
            .await?;
        self.record_audit(
            &saved.workspace_id,
            AuditRecord {
                entity_id: &saved.id,
                entity_kind: AuditEntityKind::Note,
                action: "note_updated",
                actor: AuditActor::Analyst,
                prior_revision: Some(prior_revision),
                new_revision: Some(saved.revision),
                job_id: None,
                source_ids: note_source_ids(saved.source_id.as_ref(), &saved.embeds),
            },
        )
        .await?;
        self.try_touch_workspace(&saved.workspace_id).await;
        self.emitter.note_updated(&saved);
        for job in &queued_jobs {
            self.emitter.job_updated(job);
        }
        self.kick_job_processor();
        Ok(saved)
    }

    pub async fn archive_note(&self, request: ArchiveResearchNoteRequest) -> Result<ResearchNote> {
        let note = self
            .repository
            .archive_note(&request.note_id, request.archived)
            .await?;
        self.record_audit(
            &note.workspace_id,
            AuditRecord {
                entity_id: &note.id,
                entity_kind: AuditEntityKind::Note,
                action: if request.archived {
                    "note_archived"
                } else {
                    "note_unarchived"
                },
                actor: AuditActor::Analyst,
                prior_revision: Some(note.revision),
                new_revision: Some(note.revision),
                job_id: None,
                source_ids: note.source_id.iter().cloned().collect(),
            },
        )
        .await?;
        self.try_touch_workspace(&note.workspace_id).await;
        self.emitter.note_updated(&note);
        Ok(note)
    }

    pub async fn list_notes(&self, request: ListResearchNotesRequest) -> Result<Vec<ResearchNote>> {
        self.repository
            .list_notes(
                &request.workspace_id,
                request.include_archived.unwrap_or(false),
                request.note_type,
            )
            .await
    }

    pub async fn get_notes_by_ticker(
        &self,
        request: GetNotesByTickerRequest,
    ) -> Result<Vec<ResearchNote>> {
        let normalized_ticker = request.ticker.to_uppercase();

        // Get all workspaces that match this ticker
        let workspaces = self.repository.list_workspaces().await?;
        let matching_workspaces: Vec<_> = workspaces
            .into_iter()
            .filter(|ws| {
                ws.primary_ticker.to_uppercase() == normalized_ticker
                    || ws.primary_ticker.to_uppercase() == format!("{}-US", normalized_ticker)
            })
            .collect();

        if matching_workspaces.is_empty() {
            return Ok(Vec::new());
        }

        // Collect notes from all matching workspaces
        let mut all_notes = Vec::new();
        for workspace in matching_workspaces {
            let workspace_notes = self
                .repository
                .list_notes(&workspace.id, false, None)
                .await?;

            // Filter for non-archived notes only
            let notes: Vec<ResearchNote> = workspace_notes
                .into_iter()
                .filter(|note| !note.archived)
                .collect();

            all_notes.extend(notes);
        }

        // Sort by updated_at (most recent first)
        all_notes.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));

        // Apply limit if specified
        if let Some(limit) = request.limit {
            all_notes.truncate(limit);
        }

        Ok(all_notes)
    }

    pub async fn search_notes(
        &self,
        request: SearchResearchNotesRequest,
    ) -> Result<Vec<ResearchNote>> {
        let query = request.query.trim().to_string();
        if query.is_empty() {
            return self
                .repository
                .list_notes(
                    &request.workspace_id,
                    request.include_archived.unwrap_or(false),
                    request.note_type,
                )
                .await;
        }
        self.repository
            .search_notes(
                &request.workspace_id,
                &query,
                request.include_archived.unwrap_or(false),
                request.note_type,
                request.limit,
            )
            .await
    }

    pub async fn update_workspace(
        &self,
        request: UpdateResearchWorkspaceRequest,
    ) -> Result<ResearchWorkspace> {
        let mut workspace = self.repository.get_workspace(&request.workspace_id).await?;
        if let Some(name) = request.name {
            workspace.name = name;
        }
        if let Some(stage) = request.stage {
            workspace.stage = stage;
        }
        if let Some(default_view) = request.default_view {
            workspace.default_view = default_view;
        }
        if let Some(pinned_note_ids) = request.pinned_note_ids {
            workspace.pinned_note_ids = pinned_note_ids;
        }
        workspace.updated_at = now_rfc3339();
        let updated = self.repository.save_workspace(workspace).await?;
        self.emitter.workspace_updated(&updated);
        Ok(updated)
    }

    pub async fn archive_workspace(
        &self,
        request: ArchiveResearchWorkspaceRequest,
    ) -> Result<ResearchWorkspace> {
        let mut workspace = self.repository.get_workspace(&request.workspace_id).await?;
        workspace.archived = request.archived;
        workspace.updated_at = now_rfc3339();
        let updated = self.repository.save_workspace(workspace).await?;
        self.emitter.workspace_updated(&updated);
        Ok(updated)
    }

    pub async fn get_workspace_projection(
        &self,
        request: GetWorkspaceProjectionRequest,
    ) -> Result<WorkspaceProjection> {
        let parts = self
            .repository
            .load_workspace_projection_parts(&request.workspace_id, false)
            .await?;
        let workspace = parts.workspace;
        let active_view = request.view.unwrap_or(workspace.default_view);
        Ok(build_workspace_projection(
            workspace,
            active_view,
            parts.notes,
            parts.links,
            parts.ghosts,
        ))
    }

    pub async fn list_note_links(
        &self,
        request: ListNoteLinksRequest,
    ) -> Result<Vec<crate::research::types::NoteLink>> {
        self.repository
            .list_links(&request.workspace_id, request.note_id.as_deref())
            .await
    }

    pub async fn list_workspace_ghost_notes(
        &self,
        request: ListWorkspaceGhostNotesRequest,
    ) -> Result<Vec<crate::research::types::GhostNote>> {
        self.repository
            .list_ghosts(
                &request.workspace_id,
                request.include_hidden.unwrap_or(false),
            )
            .await
    }

    pub async fn review_ghost_note(
        &self,
        request: ReviewGhostNoteRequest,
    ) -> Result<crate::research::types::GhostNote> {
        let mut ghost = self.repository.get_ghost(&request.ghost_note_id).await?;
        ghost.updated_at = now_rfc3339();
        match request.action {
            GhostReviewAction::Accept => {
                ghost.state = GhostLifecycleState::Accepted;
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Visible;
                ghost.resolution_status = GhostResolutionStatus::Strengthened;
            }
            GhostReviewAction::Ignore => {
                ghost.state = GhostLifecycleState::Ignored;
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Collapsed;
                ghost.resolution_status = GhostResolutionStatus::Blocked;
            }
            GhostReviewAction::Dismiss => {
                ghost.state = GhostLifecycleState::Dismissed;
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Hidden;
                ghost.resolution_status = GhostResolutionStatus::Blocked;
            }
            GhostReviewAction::Pin => {
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Pinned;
                ghost.resolution_status = GhostResolutionStatus::InProgress;
            }
        }
        let saved = self.repository.save_ghost(ghost).await?;
        self.record_audit(
            &saved.workspace_id,
            AuditRecord {
                entity_id: &saved.id,
                entity_kind: AuditEntityKind::Ghost,
                action: "ghost_reviewed",
                actor: AuditActor::Analyst,
                prior_revision: None,
                new_revision: None,
                job_id: None,
                source_ids: saved.source_ids.clone(),
            },
        )
        .await?;
        self.try_touch_workspace(&saved.workspace_id).await;
        self.emitter.ghost_updated(&saved);
        Ok(saved)
    }

    pub async fn resolve_ghost_intervention(
        &self,
        request: ResolveGhostInterventionRequest,
    ) -> Result<crate::research::types::GhostNote> {
        let mut ghost = self.repository.get_ghost(&request.ghost_note_id).await?;
        if ghost.workspace_id != request.workspace_id {
            return Err(ResearchError::Validation(
                "ghost does not belong to the requested workspace".to_string(),
            ));
        }

        ghost.updated_at = now_rfc3339();
        ghost.resolution_status = request.resolution_status;
        let new_linked_note_ids = request
            .linked_note_ids
            .into_iter()
            .filter(|note_id| !ghost.supporting_note_ids.contains(note_id))
            .collect::<Vec<_>>();
        ghost.supporting_note_ids.extend(new_linked_note_ids);
        if let Some(action) = request.action_taken {
            ghost.recommended_action = action;
        }
        if let Some(notes) = request.notes.filter(|value| !value.trim().is_empty()) {
            ghost.recommended_action_rationale = notes;
        }

        match request.resolution_status {
            GhostResolutionStatus::Open => {
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Visible;
            }
            GhostResolutionStatus::InProgress => {
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Pinned;
            }
            GhostResolutionStatus::Strengthened => {
                ghost.state = GhostLifecycleState::Accepted;
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Visible;
                ghost.support_strength = crate::research::types::GhostSupportStrength::Strong;
            }
            GhostResolutionStatus::Weakened => {
                ghost.state = GhostLifecycleState::Ignored;
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Collapsed;
            }
            GhostResolutionStatus::Waived => {
                ghost.state = GhostLifecycleState::Ignored;
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Collapsed;
                ghost.recommended_action = GhostRecommendedAction::MarkWaived;
            }
            GhostResolutionStatus::Blocked => {
                ghost.state = GhostLifecycleState::Ignored;
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Collapsed;
            }
        }

        let saved = self.repository.save_ghost(ghost).await?;
        self.record_audit(
            &saved.workspace_id,
            AuditRecord {
                entity_id: &saved.id,
                entity_kind: AuditEntityKind::Ghost,
                action: "ghost_intervention_resolved",
                actor: AuditActor::Analyst,
                prior_revision: None,
                new_revision: None,
                job_id: None,
                source_ids: saved.source_ids.clone(),
            },
        )
        .await?;
        self.try_touch_workspace(&saved.workspace_id).await;
        self.emitter.ghost_updated(&saved);
        Ok(saved)
    }

    pub async fn promote_note_to_thesis(
        &self,
        request: PromoteNoteToThesisRequest,
    ) -> Result<ResearchNote> {
        let mut note = self.repository.get_note(&request.note_id).await?;
        let prior_revision = note.revision;
        note.note_type = request.note_type.unwrap_or(NoteType::Thesis);
        note.thesis_status = request.thesis_status.unwrap_or(ThesisStatus::AcceptedCore);
        note.analyst_status = crate::research::types::AnalystStatus::Accepted;
        note.revision += 1;
        note.updated_at = now_rfc3339();
        let saved = self.repository.save_note(note).await?;
        self.record_audit(
            &saved.workspace_id,
            AuditRecord {
                entity_id: &saved.id,
                entity_kind: AuditEntityKind::Note,
                action: "note_promoted_to_thesis",
                actor: AuditActor::Analyst,
                prior_revision: Some(prior_revision),
                new_revision: Some(saved.revision),
                job_id: None,
                source_ids: saved.source_id.iter().cloned().collect(),
            },
        )
        .await?;
        self.try_touch_workspace(&saved.workspace_id).await;
        self.emitter.note_updated(&saved);
        Ok(saved)
    }

    pub async fn retry_research_jobs(
        &self,
        request: RetryResearchJobsRequest,
    ) -> Result<Vec<crate::research::types::PipelineJob>> {
        let jobs = self
            .repository
            .retry_failed_jobs(&request.workspace_id, request.job_kind)
            .await?;
        for job in &jobs {
            self.emitter.job_updated(job);
        }
        self.kick_job_processor();
        Ok(jobs)
    }

    pub async fn list_jobs(
        &self,
        request: ListResearchJobsRequest,
    ) -> Result<Vec<crate::research::types::PipelineJob>> {
        self.repository
            .list_jobs(&request.workspace_id, request.job_kind)
            .await
    }

    pub async fn list_files(&self, request: ListResearchFilesRequest) -> Result<Vec<ResearchFile>> {
        self.repository.get_workspace(&request.workspace_id).await?;
        self.repository.list_files(&request.workspace_id).await
    }

    pub async fn upload_files(
        &self,
        request: UploadResearchFilesRequest,
    ) -> Result<Vec<ResearchFile>> {
        self.repository.get_workspace(&request.workspace_id).await?;
        if request.files.is_empty() {
            return Err(ResearchError::Validation(
                "at least one file is required".to_string(),
            ));
        }

        let workspace_dir = self.files_root.join(&request.workspace_id);
        std::fs::create_dir_all(&workspace_dir)?;
        let mut uploaded = Vec::with_capacity(request.files.len());

        for input in request.files {
            let original_name = sanitize_original_file_name(&input.name)?;
            if input.bytes.is_empty() {
                return Err(ResearchError::Validation(format!(
                    "{original_name} is empty"
                )));
            }
            if input.bytes.len() > MAX_RESEARCH_FILE_BYTES {
                return Err(ResearchError::Validation(format!(
                    "{original_name} exceeds the 25 MB max upload size"
                )));
            }

            let checksum = sha256_hex(&input.bytes);
            if let Some(existing) = self
                .repository
                .find_file_by_checksum_and_name(&request.workspace_id, &checksum, &original_name)
                .await?
            {
                let existing = self.merge_extracted_file_text(existing, &input).await?;
                uploaded.push(existing);
                continue;
            }

            let id = generate_id("file");
            let safe_name = sanitize_stored_file_name(&original_name);
            let stored_path = workspace_dir
                .join(format!("{id}--{safe_name}"))
                .to_string_lossy()
                .to_string();
            std::fs::write(&stored_path, &input.bytes)?;
            let now = now_rfc3339();
            let file = ResearchFile {
                id,
                workspace_id: request.workspace_id.clone(),
                original_name: original_name.clone(),
                stored_path,
                mime_type: input
                    .mime_type
                    .clone()
                    .filter(|value| !value.trim().is_empty()),
                size_bytes: input.bytes.len() as u64,
                checksum,
                preview_kind: detect_file_preview_kind(input.mime_type.as_deref(), &original_name),
                extracted_text: normalize_extracted_text(input.extracted_text.as_deref()),
                extracted_text_source: normalize_extracted_text_source(
                    input.extracted_text_source.as_deref(),
                ),
                extracted_text_truncated: input.extracted_text_truncated,
                created_at: now.clone(),
                updated_at: now,
            };
            uploaded.push(self.repository.save_file(file).await?);
        }

        self.try_touch_workspace(&request.workspace_id).await;
        Ok(uploaded)
    }

    pub async fn get_file_text_preview(
        &self,
        request: GetResearchFileTextPreviewRequest,
    ) -> Result<ResearchFileTextPreview> {
        let max_bytes = request.max_bytes.unwrap_or(DEFAULT_TEXT_PREVIEW_BYTES);
        let max_bytes = max_bytes.clamp(1, DEFAULT_TEXT_PREVIEW_BYTES);
        let file = self.repository.get_file(&request.file_id).await?;
        if let Some(extracted_text) = file
            .extracted_text
            .as_deref()
            .filter(|value| !value.trim().is_empty())
        {
            let (text, response_truncated) = truncate_to_byte_boundary(extracted_text, max_bytes);
            return Ok(ResearchFileTextPreview {
                file_id: file.id,
                text,
                truncated: file.extracted_text_truncated.unwrap_or(false) || response_truncated,
            });
        }

        if !matches!(file.preview_kind, ResearchFilePreviewKind::Text) {
            return Err(ResearchError::Validation(
                "file does not support text preview".to_string(),
            ));
        }

        let bytes = std::fs::read(&file.stored_path)?;
        let truncated = bytes.len() > max_bytes;
        let preview_bytes = &bytes[..bytes.len().min(max_bytes)];
        let text = String::from_utf8_lossy(preview_bytes).to_string();
        Ok(ResearchFileTextPreview {
            file_id: file.id,
            text,
            truncated,
        })
    }

    async fn merge_extracted_file_text(
        &self,
        mut existing: ResearchFile,
        input: &crate::research::types::UploadResearchFileInput,
    ) -> Result<ResearchFile> {
        if existing
            .extracted_text
            .as_deref()
            .is_some_and(|value| !value.trim().is_empty())
        {
            return Ok(existing);
        }

        let Some(extracted_text) = normalize_extracted_text(input.extracted_text.as_deref()) else {
            return Ok(existing);
        };

        existing.extracted_text = Some(extracted_text);
        existing.extracted_text_source =
            normalize_extracted_text_source(input.extracted_text_source.as_deref());
        existing.extracted_text_truncated = input.extracted_text_truncated;
        existing.updated_at = now_rfc3339();
        self.repository.save_file(existing).await
    }

    pub async fn get_note_audit_trail(
        &self,
        request: GetNoteAuditTrailRequest,
    ) -> Result<NoteAuditTrail> {
        let note = self.repository.get_note(&request.note_id).await?;
        let (links, ghosts, audit_events) = tokio::try_join!(
            self.repository
                .list_links(&note.workspace_id, Some(&note.id)),
            self.repository.list_ghosts(&note.workspace_id, true),
            self.repository.list_audit_events_for_entity(&note.id),
        )?;
        let ghosts = ghosts
            .into_iter()
            .filter(|ghost| {
                ghost.supporting_note_ids.contains(&note.id)
                    || ghost.contradicting_note_ids.contains(&note.id)
            })
            .collect::<Vec<_>>();
        let source_ids = dedupe_ids(
            note.source_id.iter().cloned().chain(
                note.embeds
                    .iter()
                    .filter_map(|embed| embed.source_id.clone())
                    .chain(
                        ghosts
                            .iter()
                            .flat_map(|ghost| ghost.source_ids.iter().cloned()),
                    ),
            ),
        );
        let sources = self.repository.list_sources_by_ids(&source_ids).await?;
        let files = self.repository.list_files_by_ids(&note.file_ids).await?;
        let memo_blocks = build_memo_blocks(std::slice::from_ref(&note), &ghosts);

        Ok(NoteAuditTrail {
            note,
            links,
            related_ghosts: ghosts,
            sources,
            files,
            audit_events,
            memo_blocks,
        })
    }

    pub async fn export_research_bundle(
        &self,
        request: ExportResearchBundleRequest,
    ) -> Result<ResearchBundleExport> {
        let workspace = self.repository.get_workspace(&request.workspace_id).await?;
        let notes = self
            .repository
            .list_notes(&workspace.id, true, None)
            .await?;
        let links = self.repository.list_links(&workspace.id, None).await?;
        let ghosts = self.repository.list_ghosts(&workspace.id, true).await?;
        let sources = self.repository.list_sources(&workspace.id).await?;
        let audit_events = self
            .repository
            .list_audit_events_for_workspace(&workspace.id)
            .await?;
        Ok(export_bundle(
            workspace,
            notes,
            links,
            ghosts,
            sources,
            audit_events,
        ))
    }

    async fn resolve_note_embeds(
        &self,
        workspace_id: &str,
        ticker: Option<&str>,
        inputs: &[NoteEmbedInput],
    ) -> Result<Vec<NoteEmbed>> {
        let mut embeds = Vec::with_capacity(inputs.len());
        for input in inputs {
            let source_ref = source_ref_from_embed(input)?;
            let checksum = source_ref.url.as_deref().map(sha256_hex);
            let existing = self
                .repository
                .find_source_by_checksum_or_accession(
                    workspace_id,
                    checksum.as_deref(),
                    source_ref.filing_accession.as_deref(),
                )
                .await?;
            let source = if let Some(existing) = existing {
                existing
            } else {
                self.repository
                    .save_source(build_source_record(workspace_id, ticker, &source_ref))
                    .await?
            };

            embeds.push(NoteEmbed {
                id: generate_id("embed"),
                source_id: Some(source.id),
                url: input.url.trim().to_string(),
                canonical_url: input.canonical_url.clone(),
                title: normalize_optional_title(&input.title)
                    .unwrap_or_else(|| input.url.trim().to_string()),
                publisher: input.publisher.clone(),
                excerpt: input
                    .excerpt
                    .clone()
                    .filter(|value| !value.trim().is_empty()),
                kind: input.kind,
                image_url: input.image_url.clone(),
            });
        }
        Ok(embeds)
    }

    async fn validate_workspace_file_ids(
        &self,
        workspace_id: &str,
        file_ids: &[String],
    ) -> Result<Vec<String>> {
        validate_files_belong_to_workspace(&self.repository, workspace_id, file_ids).await
    }

    pub async fn process_due_jobs(&self) -> Result<()> {
        let Ok(_guard) = self.job_processor_lock.try_lock() else {
            return Ok(());
        };

        loop {
            let jobs = self.pipeline.claim_due_jobs(16).await?;
            if jobs.is_empty() {
                break;
            }

            for running in jobs {
                self.emitter.job_updated(&running);
                let result = self.process_job(&running).await;
                match result {
                    Ok(()) => {
                        let completed = self.pipeline.mark_completed(running).await?;
                        self.emitter.job_updated(&completed);
                    }
                    Err(error) => {
                        let failed = if running.attempt_count >= running.max_attempts {
                            self.pipeline
                                .mark_skipped(running, &error.to_string())
                                .await?
                        } else {
                            self.pipeline
                                .mark_failed(running, &error.to_string())
                                .await?
                        };
                        self.emitter.job_updated(&failed);
                    }
                }
            }
        }

        Ok(())
    }

    fn kick_job_processor(&self) {
        if tokio::runtime::Handle::try_current().is_err() {
            return;
        }

        let service = self.clone();
        tauri::async_runtime::spawn(async move {
            if let Err(e) = service.process_due_jobs().await {
                eprintln!("research job processor failed: {e}");
            }
        });
    }

    async fn process_job(&self, job: &crate::research::types::PipelineJob) -> Result<()> {
        match job.job_kind {
            crate::research::types::JobKind::EnrichNote => self.process_enrich_note(job).await,
            crate::research::types::JobKind::InferLinks => self.process_infer_links(job).await,
            crate::research::types::JobKind::EvaluateDuplicates => {
                self.process_evaluate_duplicates(job).await
            }
            crate::research::types::JobKind::EvaluateGhosts => {
                self.process_evaluate_ghosts(job).await
            }
            crate::research::types::JobKind::RefreshSourceMetadata => {
                self.process_refresh_source(job).await
            }
            crate::research::types::JobKind::RecalculateStaleness => {
                self.process_recalculate_staleness(job).await
            }
        }
    }

    async fn process_enrich_note(&self, job: &crate::research::types::PipelineJob) -> Result<()> {
        let note_id = payload_required_str(&job.payload_json, "noteId")?;
        let expected_revision = payload_required_u32(&job.payload_json, "expectedRevision")?;
        let mut note = self.repository.get_note(&note_id).await?;
        if note.revision != expected_revision {
            return Err(ResearchError::Validation(format!(
                "note revision changed from {expected_revision} to {}",
                note.revision
            )));
        }

        let model_info = self.model_info_for(TaskProfile::NoteEnrichment);
        let enrichment = self
            .ai_gateway
            .enrich_note(note.clone(), model_info.clone())
            .await;
        if let Some(note_type) = enrichment.note_type {
            note.note_type = note_type;
        }
        note.ai_annotation = enrichment.annotation;
        note.tags = merge_unique(note.tags, enrichment.tags);
        note.risks = merge_unique(note.risks, enrichment.risks);
        note.catalysts = merge_unique(note.catalysts, enrichment.catalysts);
        note.valuation_refs = if note.valuation_refs.is_empty() {
            enrichment.valuation_refs
        } else {
            note.valuation_refs
        };
        note.last_enriched_at = Some(now_rfc3339());
        note.provenance.model_info = model_info;
        if note.source_id.is_some() && matches!(note.evidence_status, EvidenceStatus::Unsourced) {
            note.evidence_status = EvidenceStatus::SourceLinked;
        } else if enrichment.missing_evidence {
            note.evidence_status = EvidenceStatus::Inferred;
        }
        let saved = self.repository.save_note(note).await?;
        self.record_audit(
            &saved.workspace_id,
            AuditRecord {
                entity_id: &saved.id,
                entity_kind: AuditEntityKind::Note,
                action: "note_enriched",
                actor: AuditActor::Ai,
                prior_revision: Some(expected_revision),
                new_revision: Some(saved.revision),
                job_id: Some(job.id.clone()),
                source_ids: saved.source_id.iter().cloned().collect(),
            },
        )
        .await?;
        self.emitter.note_updated(&saved);
        Ok(())
    }

    async fn process_infer_links(&self, job: &crate::research::types::PipelineJob) -> Result<()> {
        let workspace_id = payload_required_str(&job.payload_json, "workspaceId")?;
        let notes = self
            .repository
            .list_notes(&workspace_id, false, None)
            .await?;
        let links = infer_links(&notes);
        let saved_links = self
            .repository
            .replace_links_for_workspace(&workspace_id, links.clone())
            .await?;
        self.try_touch_workspace(&workspace_id).await;
        let now = now_rfc3339();
        let changed_notes = notes
            .into_iter()
            .filter_map(|mut note| {
                let inferred_links = saved_links
                    .iter()
                    .filter(|link| link.from_note_id == note.id || link.to_note_id == note.id)
                    .map(|link| link.id.clone())
                    .collect::<Vec<_>>();
                if note.inferred_links == inferred_links {
                    return None;
                }

                note.inferred_links = inferred_links;
                note.last_linked_at = Some(now.clone());
                Some(note)
            })
            .collect::<Vec<_>>();
        if !changed_notes.is_empty() {
            self.repository
                .save_notes_batch(changed_notes.clone())
                .await?;
            for note in &changed_notes {
                self.emitter.note_updated(note);
            }
        }
        for link in &saved_links {
            self.record_audit(
                &workspace_id,
                AuditRecord {
                    entity_id: &link.id,
                    entity_kind: AuditEntityKind::Link,
                    action: "link_inferred",
                    actor: match link.created_by {
                        LinkOrigin::Ai => AuditActor::Ai,
                        _ => AuditActor::System,
                    },
                    prior_revision: None,
                    new_revision: None,
                    job_id: Some(job.id.clone()),
                    source_ids: Vec::new(),
                },
            )
            .await?;
        }
        Ok(())
    }

    async fn process_evaluate_ghosts(
        &self,
        job: &crate::research::types::PipelineJob,
    ) -> Result<()> {
        let workspace_id = payload_required_str(&job.payload_json, "workspaceId")?;
        let workspace = self.repository.get_workspace(&workspace_id).await?;
        let notes = self
            .repository
            .list_notes(&workspace_id, false, None)
            .await?;
        let links = self.repository.list_links(&workspace_id, None).await?;
        let files_by_id = self.build_workspace_file_context(&notes).await?;
        let mut ghosts = generate_ghost_notes(&workspace, &notes, &links);
        crate::research::ghosts::enhance_ghosts_with_ai(
            &self.ai_gateway,
            &mut ghosts,
            &notes,
            &files_by_id,
        )
        .await;
        let saved_ghosts = self
            .repository
            .replace_ghosts_for_workspace(&workspace_id, ghosts)
            .await?;
        for ghost in &saved_ghosts {
            self.record_audit(
                &workspace_id,
                AuditRecord {
                    entity_id: &ghost.id,
                    entity_kind: AuditEntityKind::Ghost,
                    action: "ghost_generated",
                    actor: AuditActor::Ai,
                    prior_revision: None,
                    new_revision: None,
                    job_id: Some(job.id.clone()),
                    source_ids: ghost.source_ids.clone(),
                },
            )
            .await?;
            self.emitter.ghost_updated(ghost);
        }
        self.try_touch_workspace(&workspace.id).await;
        Ok(())
    }

    async fn build_workspace_file_context(
        &self,
        notes: &[ResearchNote],
    ) -> Result<HashMap<String, GhostResolvedFileContext>> {
        let file_ids = dedupe_ids(notes.iter().flat_map(|note| note.file_ids.iter().cloned()));
        let files = self.repository.list_files_by_ids(&file_ids).await?;
        Ok(files
            .into_iter()
            .map(|file| {
                (
                    file.id.clone(),
                    GhostResolvedFileContext {
                        file_id: file.id,
                        original_name: file.original_name,
                        preview_kind: file.preview_kind,
                        extracted_text: file.extracted_text,
                        extracted_text_truncated: file.extracted_text_truncated.unwrap_or(false),
                    },
                )
            })
            .collect())
    }

    async fn process_refresh_source(
        &self,
        job: &crate::research::types::PipelineJob,
    ) -> Result<()> {
        let source_id = payload_required_str(&job.payload_json, "sourceId")?;
        let source = self
            .repository
            .list_sources_by_ids(std::slice::from_ref(&source_id))
            .await?
            .into_iter()
            .next()
            .ok_or_else(|| ResearchError::Validation(format!("unknown source {source_id}")))?;
        let refreshed = refresh_source_metadata(&source).await?;
        self.repository.save_source(refreshed.clone()).await?;
        self.record_audit(
            &refreshed.workspace_id,
            AuditRecord {
                entity_id: &refreshed.id,
                entity_kind: AuditEntityKind::Source,
                action: "source_metadata_refreshed",
                actor: AuditActor::System,
                prior_revision: None,
                new_revision: None,
                job_id: Some(job.id.clone()),
                source_ids: vec![refreshed.id.clone()],
            },
        )
        .await?;
        Ok(())
    }

    async fn process_evaluate_duplicates(
        &self,
        job: &crate::research::types::PipelineJob,
    ) -> Result<()> {
        let workspace_id = payload_required_str(&job.payload_json, "workspaceId")?;
        let notes = self
            .repository
            .list_notes(&workspace_id, false, None)
            .await?;
        let groups = crate::research::duplicates::find_duplicate_groups(&notes, 0.85);
        for group in &groups {
            self.record_audit(
                &workspace_id,
                AuditRecord {
                    entity_id: &group[0],
                    entity_kind: AuditEntityKind::Note,
                    action: "duplicate_group_detected",
                    actor: AuditActor::System,
                    prior_revision: None,
                    new_revision: None,
                    job_id: Some(job.id.clone()),
                    source_ids: Vec::new(),
                },
            )
            .await?;
        }
        Ok(())
    }

    async fn process_recalculate_staleness(
        &self,
        job: &crate::research::types::PipelineJob,
    ) -> Result<()> {
        let workspace_id = payload_required_str(&job.payload_json, "workspaceId")?;
        let notes = self
            .repository
            .list_notes(&workspace_id, true, None)
            .await?;
        let sources = self.repository.list_sources(&workspace_id).await?;
        let verdicts = crate::research::staleness::recalculate_staleness(&notes, &sources);
        for (note_id, verdict) in &verdicts {
            if !verdict.fresh {
                let mut note = self.repository.get_note(note_id).await?;
                note.stale_reason = verdict.reason.clone();
                let saved = self.repository.save_note(note).await?;
                self.emitter.note_updated(&saved);
                self.record_audit(
                    &workspace_id,
                    AuditRecord {
                        entity_id: &saved.id,
                        entity_kind: AuditEntityKind::Note,
                        action: "note_marked_stale",
                        actor: AuditActor::System,
                        prior_revision: Some(saved.revision),
                        new_revision: Some(saved.revision),
                        job_id: Some(job.id.clone()),
                        source_ids: saved.source_id.iter().cloned().collect(),
                    },
                )
                .await?;
            }
        }
        Ok(())
    }

    fn model_info_for(
        &self,
        task_profile: TaskProfile,
    ) -> Option<crate::research::types::ModelInfo> {
        let Ok(settings) = self.settings.load() else {
            return None;
        };
        let defaults = if settings.task_defaults.is_empty() {
            default_task_defaults()
        } else {
            settings.task_defaults
        };
        defaults.get(&task_profile).and_then(|route| {
            let model = if route.model.trim().is_empty() {
                match route.provider {
                    crate::agent::AgentProviderKind::Remote => {
                        settings.remote.default_model.as_str()
                    }
                    crate::agent::AgentProviderKind::Ollama => {
                        settings.ollama.default_model.as_str()
                    }
                }
            } else {
                route.model.as_str()
            };
            let provider = match route.provider {
                crate::agent::AgentProviderKind::Remote => "remote",
                crate::agent::AgentProviderKind::Ollama => "ollama",
            };

            build_model_info(model, &format!("{task_profile:?}"), provider)
        })
    }

    async fn record_audit(&self, workspace_id: &str, record: AuditRecord<'_>) -> Result<()> {
        self.repository
            .append_audit_event(AuditEvent {
                id: generate_id("audit"),
                workspace_id: workspace_id.to_string(),
                entity_id: record.entity_id.to_string(),
                entity_kind: record.entity_kind,
                action: record.action.to_string(),
                actor: record.actor,
                prior_revision: record.prior_revision,
                new_revision: record.new_revision,
                job_id: record.job_id,
                source_ids: record.source_ids,
                detail: None,
                created_at: now_rfc3339(),
            })
            .await?;
        Ok(())
    }

    async fn try_touch_workspace(&self, workspace_id: &str) {
        match self
            .repository
            .touch_workspace_updated_at(workspace_id, now_rfc3339())
            .await
        {
            Ok(workspace) => self.emitter.workspace_updated(&workspace),
            Err(error) => eprintln!("research workspace touch failed for {workspace_id}: {error}"),
        }
    }
}

#[must_use]
fn dedupe_ids<I>(ids: I) -> Vec<String>
where
    I: IntoIterator<Item = String>,
{
    let mut seen = HashSet::new();
    ids.into_iter()
        .filter(|id| seen.insert(id.clone()))
        .collect()
}

fn payload_required_str(payload: &Value, key: &str) -> Result<String> {
    payload
        .get(key)
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
        .ok_or_else(|| ResearchError::Validation(format!("missing payload field {key}")))
}

const MAX_RESEARCH_FILE_BYTES: usize = 25 * 1024 * 1024;
const DEFAULT_TEXT_PREVIEW_BYTES: usize = 128 * 1024;

fn normalize_extracted_text(input: Option<&str>) -> Option<String> {
    input
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn normalize_extracted_text_source(input: Option<&str>) -> Option<String> {
    input
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn truncate_to_byte_boundary(input: &str, max_bytes: usize) -> (String, bool) {
    if input.len() <= max_bytes {
        return (input.to_string(), false);
    }

    let mut boundary = max_bytes;
    while boundary > 0 && !input.is_char_boundary(boundary) {
        boundary -= 1;
    }
    (input[..boundary].to_string(), true)
}

fn sanitize_original_file_name(input: &str) -> Result<String> {
    let name = input
        .trim()
        .rsplit(['/', '\\'])
        .next()
        .unwrap_or_default()
        .trim();
    if name.is_empty() || name == "." || name == ".." || name.contains('\0') {
        return Err(ResearchError::Validation(
            "file name is invalid".to_string(),
        ));
    }
    Ok(name.to_string())
}

fn sanitize_stored_file_name(input: &str) -> String {
    let sanitized = input
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>();
    let sanitized = sanitized.trim_matches('_');
    if sanitized.is_empty() {
        "file".to_string()
    } else {
        sanitized.to_string()
    }
}

fn file_extension(name: &str) -> Option<String> {
    name.rsplit_once('.')
        .map(|(_, extension)| extension.to_ascii_lowercase())
        .filter(|extension| !extension.is_empty())
}

fn detect_file_preview_kind(mime_type: Option<&str>, file_name: &str) -> ResearchFilePreviewKind {
    let mime = mime_type.unwrap_or_default().to_ascii_lowercase();
    if mime.starts_with("image/") {
        return ResearchFilePreviewKind::Image;
    }
    if mime == "application/pdf" {
        return ResearchFilePreviewKind::Pdf;
    }
    if mime.starts_with("text/") {
        return ResearchFilePreviewKind::Text;
    }

    match file_extension(file_name).as_deref() {
        Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg") => {
            ResearchFilePreviewKind::Image
        }
        Some("pdf") => ResearchFilePreviewKind::Pdf,
        Some(
            "txt" | "md" | "csv" | "tsv" | "json" | "yaml" | "yml" | "xml" | "log" | "py" | "rs"
            | "ts" | "tsx" | "js" | "jsx" | "css" | "html",
        ) => ResearchFilePreviewKind::Text,
        _ => ResearchFilePreviewKind::None,
    }
}

async fn validate_files_belong_to_workspace(
    repository: &ResearchRepository,
    workspace_id: &str,
    file_ids: &[String],
) -> Result<Vec<String>> {
    let ids = dedupe_ids(file_ids.iter().filter_map(|id| {
        let trimmed = id.trim();
        (!trimmed.is_empty()).then(|| trimmed.to_string())
    }));
    if ids.is_empty() {
        return Ok(Vec::new());
    }

    let files = repository.list_files_by_ids(&ids).await?;
    let found_ids = files
        .iter()
        .map(|file| file.id.clone())
        .collect::<HashSet<_>>();
    if let Some(missing_id) = ids.iter().find(|id| !found_ids.contains(*id)) {
        return Err(ResearchError::Validation(format!(
            "research file not found: {missing_id}"
        )));
    }
    if let Some(file) = files.iter().find(|file| file.workspace_id != workspace_id) {
        return Err(ResearchError::Validation(format!(
            "research file {} does not belong to workspace {}",
            file.id, workspace_id
        )));
    }
    Ok(ids)
}

fn payload_required_u32(payload: &Value, key: &str) -> Result<u32> {
    payload
        .get(key)
        .and_then(Value::as_u64)
        .and_then(|value| u32::try_from(value).ok())
        .ok_or_else(|| ResearchError::Validation(format!("missing payload field {key}")))
}

fn normalize_non_empty(input: &str, field_name: &str) -> Result<String> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(ResearchError::Validation(format!(
            "{field_name} cannot be empty"
        )));
    }
    Ok(trimmed.to_string())
}

fn normalize_ticker(input: &str) -> Result<String> {
    normalize_non_empty(input, "ticker").map(|value| value.to_ascii_uppercase())
}

fn build_model_template_workbook(template_type: ModelTemplateType) -> Value {
    match template_type {
        ModelTemplateType::ThreeStatement => build_three_statement_template_workbook(),
        ModelTemplateType::Dcf => json!({
            "version": 1,
            "sheets": [
                {
                    "id": "historicals",
                    "name": "Historical Financials",
                    "cells": {
                        "A1": {"value": "Historical Financials"},
                        "A3": {"value": "Metric"},
                        "B3": {"value": "Year -4"},
                        "C3": {"value": "Year -3"},
                        "D3": {"value": "Year -2"},
                        "E3": {"value": "Year -1"},
                        "F3": {"value": "Latest"},
                        "A4": {"value": "Revenue", "binding": {"kind": "sec_edgar", "field": "total_revenues"}},
                        "A5": {"value": "EBIT", "binding": {"kind": "sec_edgar", "field": "operating_income"}},
                        "A6": {"value": "Free Cash Flow", "binding": {"kind": "sec_edgar", "field": "free_cash_flow"}},
                        "A8": {"value": "Revenue CAGR"},
                        "F8": {"formula": "=CAGR(B4,F4,4)"}
                    }
                },
                {
                    "id": "dcf",
                    "name": "DCF",
                    "cells": {
                        "A1": {"value": "DCF Valuation"},
                        "A3": {"value": "Assumption"},
                        "B3": {"value": "Base"},
                        "A4": {"value": "Revenue Growth"},
                        "B4": {"value": 0.06, "format": "percent", "cellId": "dcf-b4-revenue-growth"},
                        "A5": {"value": "EBIT Margin"},
                        "B5": {"value": 0.24, "format": "percent", "cellId": "dcf-b5-ebit-margin"},
                        "A6": {"value": "Tax Rate"},
                        "B6": {"value": 0.18, "format": "percent", "cellId": "dcf-b6-tax-rate"},
                        "A7": {"value": "WACC"},
                        "B7": {"value": 0.09, "format": "percent", "cellId": "dcf-b7-wacc"},
                        "A8": {"value": "Terminal Growth"},
                        "B8": {"value": 0.025, "format": "percent", "cellId": "dcf-b8-terminal-growth"},
                        "D3": {"value": "Output"},
                        "D4": {"value": "Terminal Multiple Check"},
                        "E4": {"formula": "=1/(B7-B8)"},
                        "D5": {"value": "Implied Share Price"},
                        "E5": {"formula": "=NPV(B7,100,106,112,119,126)+126*(1+B8)/(B7-B8)"}
                    }
                },
                {
                    "id": "sensitivity",
                    "name": "Sensitivity",
                    "cells": {
                        "A1": {"value": "WACC vs Terminal Growth"},
                        "B2": {"value": 0.02, "format": "percent"},
                        "C2": {"value": 0.025, "format": "percent"},
                        "D2": {"value": 0.03, "format": "percent"},
                        "A3": {"value": 0.08, "format": "percent"},
                        "A4": {"value": 0.09, "format": "percent"},
                        "A5": {"value": 0.10, "format": "percent"}
                    }
                }
            ]
        }),
        _ => json!({
            "version": 1,
            "sheets": [
                {
                    "id": "sheet1",
                    "name": "Sheet1",
                    "cells": {
                        "A1": {"value": "Metric"},
                        "B1": {"value": "Value"}
                    }
                }
            ]
        }),
    }
}

fn build_three_statement_template_workbook() -> Value {
    let latest_historical_year = Utc::now().year() - 1;
    let period_headers = [
        format!("FY {}", latest_historical_year - 2),
        format!("FY {}", latest_historical_year - 1),
        format!("FY {}", latest_historical_year),
        format!("FY {}", latest_historical_year + 1),
        format!("FY {}", latest_historical_year + 2),
        format!("FY {}", latest_historical_year + 3),
        format!("FY {}", latest_historical_year + 4),
        format!("FY {}", latest_historical_year + 5),
    ];

    json!({
        "version": 1,
        "activeSheetId": "revenue_build",
        "metadata": {
            "mosaiciq": {
                "template": "three_statement",
                "historicalYears": 3,
                "forecastYears": 5
            }
        },
        "sheets": [
            {
                "id": "revenue_build",
                "name": "Revenue Build",
                "cells": {
                    "A1": {"value": "Revenue Build"},
                    "A3": {"value": "Metric"},
                    "B3": {"value": period_headers[0].as_str()},
                    "C3": {"value": period_headers[1].as_str()},
                    "D3": {"value": period_headers[2].as_str()},
                    "E3": {"value": period_headers[3].as_str()},
                    "F3": {"value": period_headers[4].as_str()},
                    "G3": {"value": period_headers[5].as_str()},
                    "H3": {"value": period_headers[6].as_str()},
                    "I3": {"value": period_headers[7].as_str()},
                    "A5": {"value": "Product Revenue"},
                    "B5": {"value": 600, "format": "currency"},
                    "C5": {"value": 660, "format": "currency"},
                    "D5": {"value": 726, "format": "currency"},
                    "E5": {"formula": "=D5*(1+E13)", "format": "currency"},
                    "F5": {"formula": "=E5*(1+F13)", "format": "currency"},
                    "G5": {"formula": "=F5*(1+G13)", "format": "currency"},
                    "H5": {"formula": "=G5*(1+H13)", "format": "currency"},
                    "I5": {"formula": "=H5*(1+I13)", "format": "currency"},
                    "A6": {"value": "Service Revenue"},
                    "B6": {"value": 300, "format": "currency"},
                    "C6": {"value": 330, "format": "currency"},
                    "D6": {"value": 363, "format": "currency"},
                    "E6": {"formula": "=D6*(1+E14)", "format": "currency"},
                    "F6": {"formula": "=E6*(1+F14)", "format": "currency"},
                    "G6": {"formula": "=F6*(1+G14)", "format": "currency"},
                    "H6": {"formula": "=G6*(1+H14)", "format": "currency"},
                    "I6": {"formula": "=H6*(1+I14)", "format": "currency"},
                    "A7": {"value": "Other Revenue"},
                    "B7": {"value": 100, "format": "currency"},
                    "C7": {"value": 110, "format": "currency"},
                    "D7": {"value": 121, "format": "currency"},
                    "E7": {"formula": "=D7*(1+E15)", "format": "currency"},
                    "F7": {"formula": "=E7*(1+F15)", "format": "currency"},
                    "G7": {"formula": "=F7*(1+G15)", "format": "currency"},
                    "H7": {"formula": "=G7*(1+H15)", "format": "currency"},
                    "I7": {"formula": "=H7*(1+I15)", "format": "currency"},
                    "A8": {"value": "Total Revenue", "binding": {"kind": "sec_edgar", "field": "total_revenues"}},
                    "B8": {"value": 1000, "format": "currency"},
                    "C8": {"value": 1100, "format": "currency"},
                    "D8": {"value": 1210, "format": "currency"},
                    "E8": {"formula": "=SUM(E5:E7)", "format": "currency"},
                    "F8": {"formula": "=SUM(F5:F7)", "format": "currency"},
                    "G8": {"formula": "=SUM(G5:G7)", "format": "currency"},
                    "H8": {"formula": "=SUM(H5:H7)", "format": "currency"},
                    "I8": {"formula": "=SUM(I5:I7)", "format": "currency"},
                    "A9": {"value": "Revenue Growth %"},
                    "C9": {"formula": "=C8/B8-1", "format": "percent"},
                    "D9": {"formula": "=D8/C8-1", "format": "percent"},
                    "E9": {"formula": "=E8/D8-1", "format": "percent"},
                    "F9": {"formula": "=F8/E8-1", "format": "percent"},
                    "G9": {"formula": "=G8/F8-1", "format": "percent"},
                    "H9": {"formula": "=H8/G8-1", "format": "percent"},
                    "I9": {"formula": "=I8/H8-1", "format": "percent"},
                    "A10": {"value": "Gross Margin %"},
                    "B10": {"formula": "=B12/B8", "format": "percent"},
                    "C10": {"formula": "=C12/C8", "format": "percent"},
                    "D10": {"formula": "=D12/D8", "format": "percent"},
                    "E10": {"value": 0.60, "format": "percent", "cellId": "three-statement-gross-margin-fy1"},
                    "F10": {"value": 0.61, "format": "percent", "cellId": "three-statement-gross-margin-fy2"},
                    "G10": {"value": 0.62, "format": "percent", "cellId": "three-statement-gross-margin-fy3"},
                    "H10": {"value": 0.62, "format": "percent", "cellId": "three-statement-gross-margin-fy4"},
                    "I10": {"value": 0.63, "format": "percent", "cellId": "three-statement-gross-margin-fy5"},
                    "A11": {"value": "COGS"},
                    "B11": {"formula": "=B8-B12", "format": "currency"},
                    "C11": {"formula": "=C8-C12", "format": "currency"},
                    "D11": {"formula": "=D8-D12", "format": "currency"},
                    "E11": {"formula": "=E8*(1-E10)", "format": "currency"},
                    "F11": {"formula": "=F8*(1-F10)", "format": "currency"},
                    "G11": {"formula": "=G8*(1-G10)", "format": "currency"},
                    "H11": {"formula": "=H8*(1-H10)", "format": "currency"},
                    "I11": {"formula": "=I8*(1-I10)", "format": "currency"},
                    "A12": {"value": "Gross Profit", "binding": {"kind": "sec_edgar", "field": "gross_profit"}},
                    "B12": {"value": 600, "format": "currency"},
                    "C12": {"value": 660, "format": "currency"},
                    "D12": {"value": 726, "format": "currency"},
                    "E12": {"formula": "=E8*E10", "format": "currency"},
                    "F12": {"formula": "=F8*F10", "format": "currency"},
                    "G12": {"formula": "=G8*G10", "format": "currency"},
                    "H12": {"formula": "=H8*H10", "format": "currency"},
                    "I12": {"formula": "=I8*I10", "format": "currency"},
                    "A13": {"value": "Product Revenue Growth"},
                    "E13": {"value": 0.08, "format": "percent", "cellId": "three-statement-product-growth-fy1"},
                    "F13": {"value": 0.07, "format": "percent", "cellId": "three-statement-product-growth-fy2"},
                    "G13": {"value": 0.06, "format": "percent", "cellId": "three-statement-product-growth-fy3"},
                    "H13": {"value": 0.05, "format": "percent", "cellId": "three-statement-product-growth-fy4"},
                    "I13": {"value": 0.04, "format": "percent", "cellId": "three-statement-product-growth-fy5"},
                    "A14": {"value": "Service Revenue Growth"},
                    "E14": {"value": 0.10, "format": "percent", "cellId": "three-statement-service-growth-fy1"},
                    "F14": {"value": 0.09, "format": "percent", "cellId": "three-statement-service-growth-fy2"},
                    "G14": {"value": 0.08, "format": "percent", "cellId": "three-statement-service-growth-fy3"},
                    "H14": {"value": 0.07, "format": "percent", "cellId": "three-statement-service-growth-fy4"},
                    "I14": {"value": 0.06, "format": "percent", "cellId": "three-statement-service-growth-fy5"},
                    "A15": {"value": "Other Revenue Growth"},
                    "E15": {"value": 0.03, "format": "percent", "cellId": "three-statement-other-growth-fy1"},
                    "F15": {"value": 0.03, "format": "percent", "cellId": "three-statement-other-growth-fy2"},
                    "G15": {"value": 0.02, "format": "percent", "cellId": "three-statement-other-growth-fy3"},
                    "H15": {"value": 0.02, "format": "percent", "cellId": "three-statement-other-growth-fy4"},
                    "I15": {"value": 0.02, "format": "percent", "cellId": "three-statement-other-growth-fy5"},
                    "A17": {"value": "SG&A % Revenue"},
                    "E17": {"value": 0.18, "format": "percent", "cellId": "three-statement-sga-percent-fy1"},
                    "F17": {"value": 0.18, "format": "percent", "cellId": "three-statement-sga-percent-fy2"},
                    "G17": {"value": 0.17, "format": "percent", "cellId": "three-statement-sga-percent-fy3"},
                    "H17": {"value": 0.17, "format": "percent", "cellId": "three-statement-sga-percent-fy4"},
                    "I17": {"value": 0.16, "format": "percent", "cellId": "three-statement-sga-percent-fy5"},
                    "A18": {"value": "R&D % Revenue"},
                    "E18": {"value": 0.10, "format": "percent", "cellId": "three-statement-rd-percent-fy1"},
                    "F18": {"value": 0.10, "format": "percent", "cellId": "three-statement-rd-percent-fy2"},
                    "G18": {"value": 0.10, "format": "percent", "cellId": "three-statement-rd-percent-fy3"},
                    "H18": {"value": 0.09, "format": "percent", "cellId": "three-statement-rd-percent-fy4"},
                    "I18": {"value": 0.09, "format": "percent", "cellId": "three-statement-rd-percent-fy5"},
                    "A19": {"value": "Other OpEx % Revenue"},
                    "E19": {"value": 0.03, "format": "percent", "cellId": "three-statement-other-opex-percent-fy1"},
                    "F19": {"value": 0.03, "format": "percent", "cellId": "three-statement-other-opex-percent-fy2"},
                    "G19": {"value": 0.03, "format": "percent", "cellId": "three-statement-other-opex-percent-fy3"},
                    "H19": {"value": 0.03, "format": "percent", "cellId": "three-statement-other-opex-percent-fy4"},
                    "I19": {"value": 0.03, "format": "percent", "cellId": "three-statement-other-opex-percent-fy5"},
                    "A20": {"value": "EBIT / Operating Income", "binding": {"kind": "sec_edgar", "field": "operating_income"}},
                    "B20": {"value": 250, "format": "currency"},
                    "C20": {"value": 275, "format": "currency"},
                    "D20": {"value": 303, "format": "currency"},
                    "E20": {"formula": "=E12-E8*E17-E8*E18-E8*E19", "format": "currency"},
                    "F20": {"formula": "=F12-F8*F17-F8*F18-F8*F19", "format": "currency"},
                    "G20": {"formula": "=G12-G8*G17-G8*G18-G8*G19", "format": "currency"},
                    "H20": {"formula": "=H12-H8*H17-H8*H18-H8*H19", "format": "currency"},
                    "I20": {"formula": "=I12-I8*I17-I8*I18-I8*I19", "format": "currency"},
                    "A21": {"value": "EBIT Margin %"},
                    "B21": {"formula": "=B20/B8", "format": "percent"},
                    "C21": {"formula": "=C20/C8", "format": "percent"},
                    "D21": {"formula": "=D20/D8", "format": "percent"},
                    "E21": {"formula": "=E20/E8", "format": "percent"},
                    "F21": {"formula": "=F20/F8", "format": "percent"},
                    "G21": {"formula": "=G20/G8", "format": "percent"},
                    "H21": {"formula": "=H20/H8", "format": "percent"},
                    "I21": {"formula": "=I20/I8", "format": "percent"}
                }
            },
            {
                "id": "income_statement",
                "name": "Income Statement",
                "cells": {
                    "A1": {"value": "Income Statement"},
                    "A3": {"value": "Metric"},
                    "B3": {"value": period_headers[0].as_str()},
                    "C3": {"value": period_headers[1].as_str()},
                    "D3": {"value": period_headers[2].as_str()},
                    "E3": {"value": period_headers[3].as_str()},
                    "F3": {"value": period_headers[4].as_str()},
                    "G3": {"value": period_headers[5].as_str()},
                    "H3": {"value": period_headers[6].as_str()},
                    "I3": {"value": period_headers[7].as_str()},
                    "A5": {"value": "Revenue", "binding": {"kind": "sec_edgar", "field": "total_revenues"}},
                    "B5": {"value": 1000, "format": "currency"},
                    "C5": {"value": 1100, "format": "currency"},
                    "D5": {"value": 1210, "format": "currency"},
                    "E5": {"formula": "='Revenue Build'!E8", "format": "currency"},
                    "F5": {"formula": "='Revenue Build'!F8", "format": "currency"},
                    "G5": {"formula": "='Revenue Build'!G8", "format": "currency"},
                    "H5": {"formula": "='Revenue Build'!H8", "format": "currency"},
                    "I5": {"formula": "='Revenue Build'!I8", "format": "currency"},
                    "A6": {"value": "COGS"},
                    "E6": {"formula": "='Revenue Build'!E11", "format": "currency"},
                    "F6": {"formula": "='Revenue Build'!F11", "format": "currency"},
                    "G6": {"formula": "='Revenue Build'!G11", "format": "currency"},
                    "H6": {"formula": "='Revenue Build'!H11", "format": "currency"},
                    "I6": {"formula": "='Revenue Build'!I11", "format": "currency"},
                    "A7": {"value": "Gross Profit", "binding": {"kind": "sec_edgar", "field": "gross_profit"}},
                    "B7": {"value": 600, "format": "currency"},
                    "C7": {"value": 660, "format": "currency"},
                    "D7": {"value": 726, "format": "currency"},
                    "E7": {"formula": "='Revenue Build'!E12", "format": "currency"},
                    "F7": {"formula": "='Revenue Build'!F12", "format": "currency"},
                    "G7": {"formula": "='Revenue Build'!G12", "format": "currency"},
                    "H7": {"formula": "='Revenue Build'!H12", "format": "currency"},
                    "I7": {"formula": "='Revenue Build'!I12", "format": "currency"},
                    "A8": {"value": "SG&A", "binding": {"kind": "sec_edgar", "field": "selling_general_and_administrative"}},
                    "E8": {"formula": "=E5*'Revenue Build'!E17", "format": "currency"},
                    "F8": {"formula": "=F5*'Revenue Build'!F17", "format": "currency"},
                    "G8": {"formula": "=G5*'Revenue Build'!G17", "format": "currency"},
                    "H8": {"formula": "=H5*'Revenue Build'!H17", "format": "currency"},
                    "I8": {"formula": "=I5*'Revenue Build'!I17", "format": "currency"},
                    "A9": {"value": "R&D", "binding": {"kind": "sec_edgar", "field": "research_and_development"}},
                    "E9": {"formula": "=E5*'Revenue Build'!E18", "format": "currency"},
                    "F9": {"formula": "=F5*'Revenue Build'!F18", "format": "currency"},
                    "G9": {"formula": "=G5*'Revenue Build'!G18", "format": "currency"},
                    "H9": {"formula": "=H5*'Revenue Build'!H18", "format": "currency"},
                    "I9": {"formula": "=I5*'Revenue Build'!I18", "format": "currency"},
                    "A10": {"value": "Other OpEx"},
                    "E10": {"formula": "=E5*'Revenue Build'!E19", "format": "currency"},
                    "F10": {"formula": "=F5*'Revenue Build'!F19", "format": "currency"},
                    "G10": {"formula": "=G5*'Revenue Build'!G19", "format": "currency"},
                    "H10": {"formula": "=H5*'Revenue Build'!H19", "format": "currency"},
                    "I10": {"formula": "=I5*'Revenue Build'!I19", "format": "currency"},
                    "A11": {"value": "EBIT / Operating Income", "binding": {"kind": "sec_edgar", "field": "operating_income"}},
                    "B11": {"value": 250, "format": "currency"},
                    "C11": {"value": 275, "format": "currency"},
                    "D11": {"value": 303, "format": "currency"},
                    "E11": {"formula": "=E7-E8-E9-E10", "format": "currency"},
                    "F11": {"formula": "=F7-F8-F9-F10", "format": "currency"},
                    "G11": {"formula": "=G7-G8-G9-G10", "format": "currency"},
                    "H11": {"formula": "=H7-H8-H9-H10", "format": "currency"},
                    "I11": {"formula": "=I7-I8-I9-I10", "format": "currency"},
                    "A12": {"value": "Interest Expense"},
                    "E12": {"formula": "=('Balance Sheet'!E16+'Balance Sheet'!E17)*E18", "format": "currency"},
                    "F12": {"formula": "=('Balance Sheet'!F16+'Balance Sheet'!F17)*F18", "format": "currency"},
                    "G12": {"formula": "=('Balance Sheet'!G16+'Balance Sheet'!G17)*G18", "format": "currency"},
                    "H12": {"formula": "=('Balance Sheet'!H16+'Balance Sheet'!H17)*H18", "format": "currency"},
                    "I12": {"formula": "=('Balance Sheet'!I16+'Balance Sheet'!I17)*I18", "format": "currency"},
                    "A13": {"value": "Pre-tax Income"},
                    "E13": {"formula": "=E11-E12", "format": "currency"},
                    "F13": {"formula": "=F11-F12", "format": "currency"},
                    "G13": {"formula": "=G11-G12", "format": "currency"},
                    "H13": {"formula": "=H11-H12", "format": "currency"},
                    "I13": {"formula": "=I11-I12", "format": "currency"},
                    "A14": {"value": "Tax Expense", "binding": {"kind": "sec_edgar", "field": "provision_for_income_taxes"}},
                    "E14": {"formula": "=E13*E19", "format": "currency"},
                    "F14": {"formula": "=F13*F19", "format": "currency"},
                    "G14": {"formula": "=G13*G19", "format": "currency"},
                    "H14": {"formula": "=H13*H19", "format": "currency"},
                    "I14": {"formula": "=I13*I19", "format": "currency"},
                    "A15": {"value": "Net Income", "binding": {"kind": "sec_edgar", "field": "net_income"}},
                    "B15": {"value": 180, "format": "currency"},
                    "C15": {"value": 200, "format": "currency"},
                    "D15": {"value": 220, "format": "currency"},
                    "E15": {"formula": "=E13-E14", "format": "currency"},
                    "F15": {"formula": "=F13-F14", "format": "currency"},
                    "G15": {"formula": "=G13-G14", "format": "currency"},
                    "H15": {"formula": "=H13-H14", "format": "currency"},
                    "I15": {"formula": "=I13-I14", "format": "currency"},
                    "A16": {"value": "Diluted Shares", "binding": {"kind": "sec_edgar", "field": "diluted_weighted_average_shares_outstanding"}},
                    "B16": {"value": 100, "format": "number"},
                    "C16": {"value": 99, "format": "number"},
                    "D16": {"value": 98, "format": "number"},
                    "E16": {"formula": "=D16*(1+E20)", "format": "number"},
                    "F16": {"formula": "=E16*(1+F20)", "format": "number"},
                    "G16": {"formula": "=F16*(1+G20)", "format": "number"},
                    "H16": {"formula": "=G16*(1+H20)", "format": "number"},
                    "I16": {"formula": "=H16*(1+I20)", "format": "number"},
                    "A17": {"value": "EPS", "binding": {"kind": "sec_edgar", "field": "diluted_eps"}},
                    "B17": {"formula": "=B15/B16", "format": "number"},
                    "C17": {"formula": "=C15/C16", "format": "number"},
                    "D17": {"formula": "=D15/D16", "format": "number"},
                    "E17": {"formula": "=E15/E16", "format": "number"},
                    "F17": {"formula": "=F15/F16", "format": "number"},
                    "G17": {"formula": "=G15/G16", "format": "number"},
                    "H17": {"formula": "=H15/H16", "format": "number"},
                    "I17": {"formula": "=I15/I16", "format": "number"},
                    "A18": {"value": "Interest Rate"},
                    "E18": {"value": 0.04, "format": "percent", "cellId": "three-statement-interest-rate-fy1"},
                    "F18": {"value": 0.04, "format": "percent", "cellId": "three-statement-interest-rate-fy2"},
                    "G18": {"value": 0.04, "format": "percent", "cellId": "three-statement-interest-rate-fy3"},
                    "H18": {"value": 0.04, "format": "percent", "cellId": "three-statement-interest-rate-fy4"},
                    "I18": {"value": 0.04, "format": "percent", "cellId": "three-statement-interest-rate-fy5"},
                    "A19": {"value": "Tax Rate"},
                    "E19": {"value": 0.22, "format": "percent", "cellId": "three-statement-tax-rate-fy1"},
                    "F19": {"value": 0.22, "format": "percent", "cellId": "three-statement-tax-rate-fy2"},
                    "G19": {"value": 0.22, "format": "percent", "cellId": "three-statement-tax-rate-fy3"},
                    "H19": {"value": 0.22, "format": "percent", "cellId": "three-statement-tax-rate-fy4"},
                    "I19": {"value": 0.22, "format": "percent", "cellId": "three-statement-tax-rate-fy5"},
                    "A20": {"value": "Share Count Growth"},
                    "E20": {"value": -0.01, "format": "percent", "cellId": "three-statement-share-growth-fy1"},
                    "F20": {"value": -0.01, "format": "percent", "cellId": "three-statement-share-growth-fy2"},
                    "G20": {"value": -0.01, "format": "percent", "cellId": "three-statement-share-growth-fy3"},
                    "H20": {"value": -0.01, "format": "percent", "cellId": "three-statement-share-growth-fy4"},
                    "I20": {"value": -0.01, "format": "percent", "cellId": "three-statement-share-growth-fy5"}
                }
            },
            {
                "id": "balance_sheet",
                "name": "Balance Sheet",
                "cells": {
                    "A1": {"value": "Balance Sheet"},
                    "A3": {"value": "Metric"},
                    "B3": {"value": period_headers[0].as_str()},
                    "C3": {"value": period_headers[1].as_str()},
                    "D3": {"value": period_headers[2].as_str()},
                    "E3": {"value": period_headers[3].as_str()},
                    "F3": {"value": period_headers[4].as_str()},
                    "G3": {"value": period_headers[5].as_str()},
                    "H3": {"value": period_headers[6].as_str()},
                    "I3": {"value": period_headers[7].as_str()},
                    "A5": {"value": "Cash & Equivalents", "binding": {"kind": "sec_edgar", "field": "cash_and_equivalents"}},
                    "B5": {"value": 150, "format": "currency"},
                    "C5": {"value": 175, "format": "currency"},
                    "D5": {"value": 200, "format": "currency"},
                    "E5": {"formula": "='Cashflow Statement'!E18", "format": "currency"},
                    "F5": {"formula": "='Cashflow Statement'!F18", "format": "currency"},
                    "G5": {"formula": "='Cashflow Statement'!G18", "format": "currency"},
                    "H5": {"formula": "='Cashflow Statement'!H18", "format": "currency"},
                    "I5": {"formula": "='Cashflow Statement'!I18", "format": "currency"},
                    "A6": {"value": "Accounts Receivable", "binding": {"kind": "sec_edgar", "field": "accounts_receivable_net"}},
                    "B6": {"value": 100, "format": "currency"},
                    "C6": {"value": 110, "format": "currency"},
                    "D6": {"value": 121, "format": "currency"},
                    "E6": {"formula": "='Income Statement'!E5*E23/365", "format": "currency"},
                    "F6": {"formula": "='Income Statement'!F5*F23/365", "format": "currency"},
                    "G6": {"formula": "='Income Statement'!G5*G23/365", "format": "currency"},
                    "H6": {"formula": "='Income Statement'!H5*H23/365", "format": "currency"},
                    "I6": {"formula": "='Income Statement'!I5*I23/365", "format": "currency"},
                    "A7": {"value": "Inventory", "binding": {"kind": "sec_edgar", "field": "inventory"}},
                    "E7": {"formula": "='Income Statement'!E6*E24/365", "format": "currency"},
                    "F7": {"formula": "='Income Statement'!F6*F24/365", "format": "currency"},
                    "G7": {"formula": "='Income Statement'!G6*G24/365", "format": "currency"},
                    "H7": {"formula": "='Income Statement'!H6*H24/365", "format": "currency"},
                    "I7": {"formula": "='Income Statement'!I6*I24/365", "format": "currency"},
                    "A8": {"value": "Other Current Assets", "binding": {"kind": "sec_edgar", "field": "other_current_assets"}},
                    "E8": {"formula": "='Income Statement'!E5*0.03", "format": "currency"},
                    "F8": {"formula": "='Income Statement'!F5*0.03", "format": "currency"},
                    "G8": {"formula": "='Income Statement'!G5*0.03", "format": "currency"},
                    "H8": {"formula": "='Income Statement'!H5*0.03", "format": "currency"},
                    "I8": {"formula": "='Income Statement'!I5*0.03", "format": "currency"},
                    "A9": {"value": "Total Current Assets", "binding": {"kind": "sec_edgar", "field": "total_current_assets"}},
                    "E9": {"formula": "=SUM(E5:E8)", "format": "currency"},
                    "F9": {"formula": "=SUM(F5:F8)", "format": "currency"},
                    "G9": {"formula": "=SUM(G5:G8)", "format": "currency"},
                    "H9": {"formula": "=SUM(H5:H8)", "format": "currency"},
                    "I9": {"formula": "=SUM(I5:I8)", "format": "currency"},
                    "A10": {"value": "PP&E, Net", "binding": {"kind": "sec_edgar", "field": "property_plant_and_equipment_net"}},
                    "B10": {"value": 400, "format": "currency"},
                    "C10": {"value": 425, "format": "currency"},
                    "D10": {"value": 450, "format": "currency"},
                    "E10": {"formula": "=D10+'Cashflow Statement'!E11-'Cashflow Statement'!E6", "format": "currency"},
                    "F10": {"formula": "=E10+'Cashflow Statement'!F11-'Cashflow Statement'!F6", "format": "currency"},
                    "G10": {"formula": "=F10+'Cashflow Statement'!G11-'Cashflow Statement'!G6", "format": "currency"},
                    "H10": {"formula": "=G10+'Cashflow Statement'!H11-'Cashflow Statement'!H6", "format": "currency"},
                    "I10": {"formula": "=H10+'Cashflow Statement'!I11-'Cashflow Statement'!I6", "format": "currency"},
                    "A11": {"value": "Goodwill / Intangibles", "binding": {"kind": "sec_edgar", "field": "goodwill"}},
                    "E11": {"formula": "=D11", "format": "currency"},
                    "F11": {"formula": "=E11", "format": "currency"},
                    "G11": {"formula": "=F11", "format": "currency"},
                    "H11": {"formula": "=G11", "format": "currency"},
                    "I11": {"formula": "=H11", "format": "currency"},
                    "A12": {"value": "Other Non-current Assets", "binding": {"kind": "sec_edgar", "field": "other_non_current_assets"}},
                    "E12": {"formula": "=D12", "format": "currency"},
                    "F12": {"formula": "=E12", "format": "currency"},
                    "G12": {"formula": "=F12", "format": "currency"},
                    "H12": {"formula": "=G12", "format": "currency"},
                    "I12": {"formula": "=H12", "format": "currency"},
                    "A13": {"value": "Total Assets", "binding": {"kind": "sec_edgar", "field": "total_assets"}},
                    "E13": {"formula": "=SUM(E9:E12)", "format": "currency"},
                    "F13": {"formula": "=SUM(F9:F12)", "format": "currency"},
                    "G13": {"formula": "=SUM(G9:G12)", "format": "currency"},
                    "H13": {"formula": "=SUM(H9:H12)", "format": "currency"},
                    "I13": {"formula": "=SUM(I9:I12)", "format": "currency"},
                    "A15": {"value": "Accounts Payable", "binding": {"kind": "sec_edgar", "field": "accounts_payable"}},
                    "E15": {"formula": "='Income Statement'!E6*E25/365", "format": "currency"},
                    "F15": {"formula": "='Income Statement'!F6*F25/365", "format": "currency"},
                    "G15": {"formula": "='Income Statement'!G6*G25/365", "format": "currency"},
                    "H15": {"formula": "='Income Statement'!H6*H25/365", "format": "currency"},
                    "I15": {"formula": "='Income Statement'!I6*I25/365", "format": "currency"},
                    "A16": {"value": "Short-term Debt", "binding": {"kind": "sec_edgar", "field": "short_term_debt"}},
                    "E16": {"formula": "=D16", "format": "currency"},
                    "F16": {"formula": "=E16", "format": "currency"},
                    "G16": {"formula": "=F16", "format": "currency"},
                    "H16": {"formula": "=G16", "format": "currency"},
                    "I16": {"formula": "=H16", "format": "currency"},
                    "A17": {"value": "Long-term Debt", "binding": {"kind": "sec_edgar", "field": "long_term_debt"}},
                    "E17": {"formula": "=D17+'Cashflow Statement'!E13", "format": "currency"},
                    "F17": {"formula": "=E17+'Cashflow Statement'!F13", "format": "currency"},
                    "G17": {"formula": "=F17+'Cashflow Statement'!G13", "format": "currency"},
                    "H17": {"formula": "=G17+'Cashflow Statement'!H13", "format": "currency"},
                    "I17": {"formula": "=H17+'Cashflow Statement'!I13", "format": "currency"},
                    "A18": {"value": "Total Liabilities", "binding": {"kind": "sec_edgar", "field": "total_liabilities"}},
                    "E18": {"formula": "=SUM(E15:E17)", "format": "currency"},
                    "F18": {"formula": "=SUM(F15:F17)", "format": "currency"},
                    "G18": {"formula": "=SUM(G15:G17)", "format": "currency"},
                    "H18": {"formula": "=SUM(H15:H17)", "format": "currency"},
                    "I18": {"formula": "=SUM(I15:I17)", "format": "currency"},
                    "A19": {"value": "Retained Earnings", "binding": {"kind": "sec_edgar", "field": "retained_earnings"}},
                    "E19": {"formula": "=D19+'Income Statement'!E15-'Cashflow Statement'!E14", "format": "currency"},
                    "F19": {"formula": "=E19+'Income Statement'!F15-'Cashflow Statement'!F14", "format": "currency"},
                    "G19": {"formula": "=F19+'Income Statement'!G15-'Cashflow Statement'!G14", "format": "currency"},
                    "H19": {"formula": "=G19+'Income Statement'!H15-'Cashflow Statement'!H14", "format": "currency"},
                    "I19": {"formula": "=H19+'Income Statement'!I15-'Cashflow Statement'!I14", "format": "currency"},
                    "A20": {"value": "Total Equity", "binding": {"kind": "sec_edgar", "field": "total_equity"}},
                    "E20": {"formula": "=D20+'Income Statement'!E15-'Cashflow Statement'!E14", "format": "currency"},
                    "F20": {"formula": "=E20+'Income Statement'!F15-'Cashflow Statement'!F14", "format": "currency"},
                    "G20": {"formula": "=F20+'Income Statement'!G15-'Cashflow Statement'!G14", "format": "currency"},
                    "H20": {"formula": "=G20+'Income Statement'!H15-'Cashflow Statement'!H14", "format": "currency"},
                    "I20": {"formula": "=H20+'Income Statement'!I15-'Cashflow Statement'!I14", "format": "currency"},
                    "A21": {"value": "Total Liabilities + Equity"},
                    "E21": {"formula": "=E18+E20", "format": "currency"},
                    "F21": {"formula": "=F18+F20", "format": "currency"},
                    "G21": {"formula": "=G18+G20", "format": "currency"},
                    "H21": {"formula": "=H18+H20", "format": "currency"},
                    "I21": {"formula": "=I18+I20", "format": "currency"},
                    "A22": {"value": "Balance Check"},
                    "E22": {"formula": "=E13-E21", "format": "currency"},
                    "F22": {"formula": "=F13-F21", "format": "currency"},
                    "G22": {"formula": "=G13-G21", "format": "currency"},
                    "H22": {"formula": "=H13-H21", "format": "currency"},
                    "I22": {"formula": "=I13-I21", "format": "currency"},
                    "A23": {"value": "AR Days"},
                    "E23": {"value": 35, "format": "number", "cellId": "three-statement-ar-days-fy1"},
                    "F23": {"value": 35, "format": "number", "cellId": "three-statement-ar-days-fy2"},
                    "G23": {"value": 34, "format": "number", "cellId": "three-statement-ar-days-fy3"},
                    "H23": {"value": 34, "format": "number", "cellId": "three-statement-ar-days-fy4"},
                    "I23": {"value": 33, "format": "number", "cellId": "three-statement-ar-days-fy5"},
                    "A24": {"value": "Inventory Days"},
                    "E24": {"value": 30, "format": "number", "cellId": "three-statement-inventory-days-fy1"},
                    "F24": {"value": 30, "format": "number", "cellId": "three-statement-inventory-days-fy2"},
                    "G24": {"value": 29, "format": "number", "cellId": "three-statement-inventory-days-fy3"},
                    "H24": {"value": 29, "format": "number", "cellId": "three-statement-inventory-days-fy4"},
                    "I24": {"value": 28, "format": "number", "cellId": "three-statement-inventory-days-fy5"},
                    "A25": {"value": "AP Days"},
                    "E25": {"value": 45, "format": "number", "cellId": "three-statement-ap-days-fy1"},
                    "F25": {"value": 45, "format": "number", "cellId": "three-statement-ap-days-fy2"},
                    "G25": {"value": 44, "format": "number", "cellId": "three-statement-ap-days-fy3"},
                    "H25": {"value": 44, "format": "number", "cellId": "three-statement-ap-days-fy4"},
                    "I25": {"value": 43, "format": "number", "cellId": "three-statement-ap-days-fy5"}
                }
            },
            {
                "id": "cashflow_statement",
                "name": "Cashflow Statement",
                "cells": {
                    "A1": {"value": "Cashflow Statement"},
                    "A3": {"value": "Metric"},
                    "B3": {"value": period_headers[0].as_str()},
                    "C3": {"value": period_headers[1].as_str()},
                    "D3": {"value": period_headers[2].as_str()},
                    "E3": {"value": period_headers[3].as_str()},
                    "F3": {"value": period_headers[4].as_str()},
                    "G3": {"value": period_headers[5].as_str()},
                    "H3": {"value": period_headers[6].as_str()},
                    "I3": {"value": period_headers[7].as_str()},
                    "A5": {"value": "Net Income"},
                    "E5": {"formula": "='Income Statement'!E15", "format": "currency"},
                    "F5": {"formula": "='Income Statement'!F15", "format": "currency"},
                    "G5": {"formula": "='Income Statement'!G15", "format": "currency"},
                    "H5": {"formula": "='Income Statement'!H15", "format": "currency"},
                    "I5": {"formula": "='Income Statement'!I15", "format": "currency"},
                    "A6": {"value": "D&A"},
                    "E6": {"formula": "='Income Statement'!E5*E21", "format": "currency"},
                    "F6": {"formula": "='Income Statement'!F5*F21", "format": "currency"},
                    "G6": {"formula": "='Income Statement'!G5*G21", "format": "currency"},
                    "H6": {"formula": "='Income Statement'!H5*H21", "format": "currency"},
                    "I6": {"formula": "='Income Statement'!I5*I21", "format": "currency"},
                    "A7": {"value": "Change in AR"},
                    "E7": {"formula": "='Balance Sheet'!D6-'Balance Sheet'!E6", "format": "currency"},
                    "F7": {"formula": "='Balance Sheet'!E6-'Balance Sheet'!F6", "format": "currency"},
                    "G7": {"formula": "='Balance Sheet'!F6-'Balance Sheet'!G6", "format": "currency"},
                    "H7": {"formula": "='Balance Sheet'!G6-'Balance Sheet'!H6", "format": "currency"},
                    "I7": {"formula": "='Balance Sheet'!H6-'Balance Sheet'!I6", "format": "currency"},
                    "A8": {"value": "Change in Inventory"},
                    "E8": {"formula": "='Balance Sheet'!D7-'Balance Sheet'!E7", "format": "currency"},
                    "F8": {"formula": "='Balance Sheet'!E7-'Balance Sheet'!F7", "format": "currency"},
                    "G8": {"formula": "='Balance Sheet'!F7-'Balance Sheet'!G7", "format": "currency"},
                    "H8": {"formula": "='Balance Sheet'!G7-'Balance Sheet'!H7", "format": "currency"},
                    "I8": {"formula": "='Balance Sheet'!H7-'Balance Sheet'!I7", "format": "currency"},
                    "A9": {"value": "Change in AP"},
                    "E9": {"formula": "='Balance Sheet'!E15-'Balance Sheet'!D15", "format": "currency"},
                    "F9": {"formula": "='Balance Sheet'!F15-'Balance Sheet'!E15", "format": "currency"},
                    "G9": {"formula": "='Balance Sheet'!G15-'Balance Sheet'!F15", "format": "currency"},
                    "H9": {"formula": "='Balance Sheet'!H15-'Balance Sheet'!G15", "format": "currency"},
                    "I9": {"formula": "='Balance Sheet'!I15-'Balance Sheet'!H15", "format": "currency"},
                    "A10": {"value": "Cash Flow from Operations", "binding": {"kind": "sec_edgar", "field": "operating_cash_flow"}},
                    "B10": {"value": 240, "format": "currency"},
                    "C10": {"value": 260, "format": "currency"},
                    "D10": {"value": 285, "format": "currency"},
                    "E10": {"formula": "=SUM(E5:E9)", "format": "currency"},
                    "F10": {"formula": "=SUM(F5:F9)", "format": "currency"},
                    "G10": {"formula": "=SUM(G5:G9)", "format": "currency"},
                    "H10": {"formula": "=SUM(H5:H9)", "format": "currency"},
                    "I10": {"formula": "=SUM(I5:I9)", "format": "currency"},
                    "A11": {"value": "Capex", "binding": {"kind": "sec_edgar", "field": "capital_expenditure"}},
                    "B11": {"value": 80, "format": "currency"},
                    "C11": {"value": 85, "format": "currency"},
                    "D11": {"value": 90, "format": "currency"},
                    "E11": {"formula": "='Income Statement'!E5*E20", "format": "currency"},
                    "F11": {"formula": "='Income Statement'!F5*F20", "format": "currency"},
                    "G11": {"formula": "='Income Statement'!G5*G20", "format": "currency"},
                    "H11": {"formula": "='Income Statement'!H5*H20", "format": "currency"},
                    "I11": {"formula": "='Income Statement'!I5*I20", "format": "currency"},
                    "A12": {"value": "Cash Flow from Investing", "binding": {"kind": "sec_edgar", "field": "investing_cash_flow"}},
                    "E12": {"formula": "=-E11", "format": "currency"},
                    "F12": {"formula": "=-F11", "format": "currency"},
                    "G12": {"formula": "=-G11", "format": "currency"},
                    "H12": {"formula": "=-H11", "format": "currency"},
                    "I12": {"formula": "=-I11", "format": "currency"},
                    "A13": {"value": "Debt Issuance / Repayment"},
                    "E13": {"value": 0, "format": "currency"},
                    "F13": {"value": 0, "format": "currency"},
                    "G13": {"value": 0, "format": "currency"},
                    "H13": {"value": 0, "format": "currency"},
                    "I13": {"value": 0, "format": "currency"},
                    "A14": {"value": "Dividends / Buybacks"},
                    "E14": {"value": 0, "format": "currency", "cellId": "three-statement-dividends-buybacks-fy1"},
                    "F14": {"value": 0, "format": "currency", "cellId": "three-statement-dividends-buybacks-fy2"},
                    "G14": {"value": 0, "format": "currency", "cellId": "three-statement-dividends-buybacks-fy3"},
                    "H14": {"value": 0, "format": "currency", "cellId": "three-statement-dividends-buybacks-fy4"},
                    "I14": {"value": 0, "format": "currency", "cellId": "three-statement-dividends-buybacks-fy5"},
                    "A15": {"value": "Cash Flow from Financing", "binding": {"kind": "sec_edgar", "field": "financing_cash_flow"}},
                    "E15": {"formula": "=E13-E14", "format": "currency"},
                    "F15": {"formula": "=F13-F14", "format": "currency"},
                    "G15": {"formula": "=G13-G14", "format": "currency"},
                    "H15": {"formula": "=H13-H14", "format": "currency"},
                    "I15": {"formula": "=I13-I14", "format": "currency"},
                    "A16": {"value": "Net Change in Cash"},
                    "E16": {"formula": "=E10+E12+E15", "format": "currency"},
                    "F16": {"formula": "=F10+F12+F15", "format": "currency"},
                    "G16": {"formula": "=G10+G12+G15", "format": "currency"},
                    "H16": {"formula": "=H10+H12+H15", "format": "currency"},
                    "I16": {"formula": "=I10+I12+I15", "format": "currency"},
                    "A17": {"value": "Beginning Cash"},
                    "E17": {"formula": "='Balance Sheet'!D5", "format": "currency"},
                    "F17": {"formula": "=E18", "format": "currency"},
                    "G17": {"formula": "=F18", "format": "currency"},
                    "H17": {"formula": "=G18", "format": "currency"},
                    "I17": {"formula": "=H18", "format": "currency"},
                    "A18": {"value": "Ending Cash"},
                    "E18": {"formula": "=E17+E16", "format": "currency"},
                    "F18": {"formula": "=F17+F16", "format": "currency"},
                    "G18": {"formula": "=G17+G16", "format": "currency"},
                    "H18": {"formula": "=H17+H16", "format": "currency"},
                    "I18": {"formula": "=I17+I16", "format": "currency"},
                    "A19": {"value": "Free Cash Flow", "binding": {"kind": "sec_edgar", "field": "free_cash_flow"}},
                    "B19": {"value": 160, "format": "currency"},
                    "C19": {"value": 175, "format": "currency"},
                    "D19": {"value": 195, "format": "currency"},
                    "E19": {"formula": "=E10-E11", "format": "currency"},
                    "F19": {"formula": "=F10-F11", "format": "currency"},
                    "G19": {"formula": "=G10-G11", "format": "currency"},
                    "H19": {"formula": "=H10-H11", "format": "currency"},
                    "I19": {"formula": "=I10-I11", "format": "currency"},
                    "A20": {"value": "Capex % Revenue"},
                    "E20": {"value": 0.07, "format": "percent", "cellId": "three-statement-capex-percent-fy1"},
                    "F20": {"value": 0.07, "format": "percent", "cellId": "three-statement-capex-percent-fy2"},
                    "G20": {"value": 0.06, "format": "percent", "cellId": "three-statement-capex-percent-fy3"},
                    "H20": {"value": 0.06, "format": "percent", "cellId": "three-statement-capex-percent-fy4"},
                    "I20": {"value": 0.06, "format": "percent", "cellId": "three-statement-capex-percent-fy5"},
                    "A21": {"value": "D&A % Revenue"},
                    "E21": {"value": 0.04, "format": "percent", "cellId": "three-statement-da-percent-fy1"},
                    "F21": {"value": 0.04, "format": "percent", "cellId": "three-statement-da-percent-fy2"},
                    "G21": {"value": 0.04, "format": "percent", "cellId": "three-statement-da-percent-fy3"},
                    "H21": {"value": 0.04, "format": "percent", "cellId": "three-statement-da-percent-fy4"},
                    "I21": {"value": 0.04, "format": "percent", "cellId": "three-statement-da-percent-fy5"}
                }
            }
        ]
    })
}

fn build_model_template_annotations(
    template_type: ModelTemplateType,
    now: &str,
) -> Vec<ModelCellAnnotation> {
    if template_type == ModelTemplateType::ThreeStatement {
        return build_three_statement_template_annotations(now);
    }

    if template_type != ModelTemplateType::Dcf {
        return Vec::new();
    }

    [
        ("dcf-b4-revenue-growth", "B4"),
        ("dcf-b5-ebit-margin", "B5"),
        ("dcf-b6-tax-rate", "B6"),
        ("dcf-b7-wacc", "B7"),
        ("dcf-b8-terminal-growth", "B8"),
    ]
    .into_iter()
    .map(|(cell_id, address)| ModelCellAnnotation {
        cell_id: cell_id.to_string(),
        sheet_id: "dcf".to_string(),
        address: address.to_string(),
        linked_note_ids: Vec::new(),
        evidence_status: ModelEvidenceStatus::Unsourced,
        confidence_score: 0.0,
        last_evidence_update: None,
        updated_at: now.to_string(),
    })
    .collect()
}

fn build_three_statement_template_annotations(now: &str) -> Vec<ModelCellAnnotation> {
    [
        ("revenue_build", "E13", "three-statement-product-growth-fy1"),
        ("revenue_build", "E14", "three-statement-service-growth-fy1"),
        ("revenue_build", "E15", "three-statement-other-growth-fy1"),
        ("revenue_build", "E10", "three-statement-gross-margin-fy1"),
        ("revenue_build", "E17", "three-statement-sga-percent-fy1"),
        ("revenue_build", "E18", "three-statement-rd-percent-fy1"),
        ("income_statement", "E19", "three-statement-tax-rate-fy1"),
        (
            "income_statement",
            "E18",
            "three-statement-interest-rate-fy1",
        ),
        (
            "income_statement",
            "E20",
            "three-statement-share-growth-fy1",
        ),
        ("balance_sheet", "E23", "three-statement-ar-days-fy1"),
        ("balance_sheet", "E24", "three-statement-inventory-days-fy1"),
        ("balance_sheet", "E25", "three-statement-ap-days-fy1"),
        (
            "cashflow_statement",
            "E20",
            "three-statement-capex-percent-fy1",
        ),
        (
            "cashflow_statement",
            "E21",
            "three-statement-da-percent-fy1",
        ),
        (
            "cashflow_statement",
            "E14",
            "three-statement-dividends-buybacks-fy1",
        ),
    ]
    .into_iter()
    .map(|(sheet_id, address, cell_id)| ModelCellAnnotation {
        cell_id: cell_id.to_string(),
        sheet_id: sheet_id.to_string(),
        address: address.to_string(),
        linked_note_ids: Vec::new(),
        evidence_status: ModelEvidenceStatus::Unsourced,
        confidence_score: 0.0,
        last_evidence_update: None,
        updated_at: now.to_string(),
    })
    .collect()
}

fn upsert_annotation_link(
    annotations: &mut Vec<ModelCellAnnotation>,
    sheet_id: &str,
    address: &str,
    note_id: &str,
    should_link: bool,
    now: &str,
) -> ModelCellAnnotation {
    let normalized_address = address.trim().to_ascii_uppercase();
    let index = annotations.iter().position(|annotation| {
        annotation.sheet_id == sheet_id && annotation.address.eq_ignore_ascii_case(address)
    });
    let mut annotation = index
        .and_then(|index| annotations.get(index).cloned())
        .unwrap_or_else(|| ModelCellAnnotation {
            cell_id: format!("{}-{}", sheet_id, normalized_address.to_ascii_lowercase()),
            sheet_id: sheet_id.to_string(),
            address: normalized_address.clone(),
            linked_note_ids: Vec::new(),
            evidence_status: ModelEvidenceStatus::Unsourced,
            confidence_score: 0.0,
            last_evidence_update: None,
            updated_at: now.to_string(),
        });

    if should_link && !annotation.linked_note_ids.iter().any(|id| id == note_id) {
        annotation.linked_note_ids.push(note_id.to_string());
    } else if !should_link {
        annotation.linked_note_ids.retain(|id| id != note_id);
    }

    annotation.evidence_status = match annotation.linked_note_ids.len() {
        0 => ModelEvidenceStatus::Unsourced,
        1 => ModelEvidenceStatus::SingleSource,
        _ => ModelEvidenceStatus::Corroborated,
    };
    annotation.confidence_score = match annotation.evidence_status {
        ModelEvidenceStatus::Unsourced => 0.0,
        ModelEvidenceStatus::SingleSource => 0.55,
        ModelEvidenceStatus::Corroborated => 0.8,
        ModelEvidenceStatus::Inferred => 0.35,
        ModelEvidenceStatus::Contradicted => 0.25,
        ModelEvidenceStatus::Stale => 0.4,
    };
    annotation.last_evidence_update = Some(now.to_string());
    annotation.updated_at = now.to_string();

    if let Some(index) = index {
        annotations[index] = annotation.clone();
    } else {
        annotations.push(annotation.clone());
    }
    annotation
}

fn write_model_xlsx(model: &FinancialModel, file_path: &Path) -> Result<()> {
    let mut workbook = rust_xlsxwriter::Workbook::new();
    if let Some(sheets) = model.workbook_json.get("sheets").and_then(Value::as_array) {
        for sheet in sheets {
            let worksheet = workbook.add_worksheet();
            if let Some(name) = sheet.get("name").and_then(Value::as_str) {
                worksheet
                    .set_name(name)
                    .map_err(|error| ResearchError::Validation(error.to_string()))?;
            }
            if let Some(cells) = sheet.get("cells").and_then(Value::as_object) {
                for (address, cell) in cells {
                    let Some((row, col)) = xlsx_address(address) else {
                        continue;
                    };
                    if let Some(formula) = cell.get("formula").and_then(Value::as_str) {
                        worksheet
                            .write_formula(row, col, formula)
                            .map_err(|error| ResearchError::Validation(error.to_string()))?;
                    } else if let Some(value) = cell.get("value") {
                        match value {
                            Value::Number(number) => {
                                if let Some(number) = number.as_f64() {
                                    worksheet.write_number(row, col, number).map_err(|error| {
                                        ResearchError::Validation(error.to_string())
                                    })?;
                                }
                            }
                            Value::Bool(value) => {
                                worksheet.write_boolean(row, col, *value).map_err(|error| {
                                    ResearchError::Validation(error.to_string())
                                })?;
                            }
                            Value::String(value) => {
                                worksheet.write_string(row, col, value).map_err(|error| {
                                    ResearchError::Validation(error.to_string())
                                })?;
                            }
                            _ => {}
                        }
                    }
                }
            }
        }
    }

    let context = workbook.add_worksheet();
    context
        .set_name("Research Context")
        .map_err(|error| ResearchError::Validation(error.to_string()))?;
    context
        .write_string(0, 0, "Model")
        .map_err(|error| ResearchError::Validation(error.to_string()))?;
    context
        .write_string(0, 1, &model.name)
        .map_err(|error| ResearchError::Validation(error.to_string()))?;
    context
        .write_string(1, 0, "Linked evidence cells")
        .map_err(|error| ResearchError::Validation(error.to_string()))?;
    for (row, annotation) in model.annotations.iter().enumerate() {
        let row = (row + 2) as u32;
        context
            .write_string(row, 0, &annotation.sheet_id)
            .map_err(|error| ResearchError::Validation(error.to_string()))?;
        context
            .write_string(row, 1, &annotation.address)
            .map_err(|error| ResearchError::Validation(error.to_string()))?;
        context
            .write_string(row, 2, format!("{:?}", annotation.evidence_status))
            .map_err(|error| ResearchError::Validation(error.to_string()))?;
        context
            .write_string(row, 3, annotation.linked_note_ids.join(", "))
            .map_err(|error| ResearchError::Validation(error.to_string()))?;
    }

    workbook
        .save(file_path)
        .map_err(|error| ResearchError::Validation(error.to_string()))
}

fn xlsx_address(address: &str) -> Option<(u32, u16)> {
    let mut letters = String::new();
    let mut digits = String::new();
    for char in address.replace('$', "").chars() {
        if char.is_ascii_alphabetic() && digits.is_empty() {
            letters.push(char.to_ascii_uppercase());
        } else if char.is_ascii_digit() {
            digits.push(char);
        } else {
            return None;
        }
    }
    let col = letters
        .chars()
        .fold(0u16, |acc, char| acc * 26 + (char as u16 - 'A' as u16 + 1))
        .checked_sub(1)?;
    let row = digits.parse::<u32>().ok()?.checked_sub(1)?;
    Some((row, col))
}

fn source_ref_from_embed(input: &NoteEmbedInput) -> Result<SourceReferenceInput> {
    let url = input
        .canonical_url
        .as_deref()
        .unwrap_or(input.url.as_str())
        .trim();
    if !(url.starts_with("https://") || url.starts_with("http://")) {
        return Err(ResearchError::Validation(
            "embedded links require an http or https URL".to_string(),
        ));
    }

    Ok(SourceReferenceInput {
        kind: input.kind,
        title: normalize_optional_title(&input.title),
        url: Some(url.to_string()),
        published_at: None,
        filing_accession: None,
        form_type: None,
        excerpt_text: input
            .excerpt
            .clone()
            .filter(|value| !value.trim().is_empty()),
        location_label: None,
    })
}

fn normalize_optional_title(input: &str) -> Option<String> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

fn note_source_ids(source_id: Option<&String>, embeds: &[NoteEmbed]) -> Vec<String> {
    dedupe_ids(
        source_id
            .into_iter()
            .cloned()
            .chain(embeds.iter().filter_map(|embed| embed.source_id.clone())),
    )
}

#[must_use]
fn capture_method_from_source_kind(source_kind: SourceKind) -> CaptureMethod {
    match source_kind {
        SourceKind::Transcript => CaptureMethod::TranscriptClip,
        SourceKind::Filing => CaptureMethod::FilingExtract,
        SourceKind::NewsFeed | SourceKind::Article => CaptureMethod::NewsImport,
        SourceKind::Model => CaptureMethod::ManualLink,
        SourceKind::Manual => CaptureMethod::QuickEntry,
    }
}

#[must_use]
fn placement_lane(priority: NotePriority) -> String {
    match priority {
        NotePriority::Critical => "critical",
        NotePriority::High => "focus",
        NotePriority::Normal => "active",
        NotePriority::Low => "backlog",
    }
    .to_string()
}

fn merge_unique(mut left: Vec<String>, right: Vec<String>) -> Vec<String> {
    let mut seen: HashSet<String> = left.iter().cloned().collect();
    for value in right {
        if seen.insert(value.clone()) {
            left.push(value);
        }
    }
    left
}

#[cfg(test)]
mod tests {
    use std::env;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    use chrono::{Datelike, Utc};
    use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime};

    use super::{
        build_model_template_annotations, build_model_template_workbook, detect_file_preview_kind,
        ResearchService, MAX_RESEARCH_FILE_BYTES,
    };
    use crate::research::model_formula::evaluate_workbook;
    use crate::research::types::{
        CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceBasis,
        GetResearchFileTextPreviewRequest, GetWorkspaceProjectionRequest, GhostFreshnessRisk,
        GhostLifecycleState, GhostNote, GhostNoteClass, GhostRecommendedAction,
        GhostResolutionStatus, GhostSupportStrength, GhostTargetClaimKind, GhostTone,
        GhostVisibilityState, LinkOrigin, LinkStrength, LinkType, ModelTemplateType,
        NoteEmbedInput, NoteLink, NoteType, PreviewLinkRequest, ResearchFilePreviewKind,
        ResearchStage, SourceKind, UploadResearchFileInput, UploadResearchFilesRequest,
        WorkspaceViewKind,
    };

    fn build_app() -> tauri::App<MockRuntime> {
        mock_builder()
            .plugin(tauri_plugin_store::Builder::new().build())
            .build(mock_context(noop_assets()))
            .unwrap()
    }

    fn unique_identifier(prefix: &str) -> String {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        format!("com.mosaiciq.research.tests.{prefix}.{nanos}")
    }

    fn with_test_home<T>(prefix: &str, test: impl FnOnce() -> T) -> T {
        let _lock = crate::test_support::env_lock().lock().unwrap();
        let home = env::temp_dir().join(unique_identifier(prefix));
        fs::create_dir_all(&home).unwrap();
        let original_home = env::var_os("HOME");
        env::set_var("HOME", &home);

        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test));

        if let Some(original_home) = original_home {
            env::set_var("HOME", original_home);
        } else {
            env::remove_var("HOME");
        }

        result.unwrap()
    }

    fn test_link(workspace_id: &str, from_note_id: &str, to_note_id: &str) -> NoteLink {
        let now = crate::research::util::now_rfc3339();
        NoteLink {
            id: "link-1".to_string(),
            workspace_id: workspace_id.to_string(),
            from_note_id: from_note_id.to_string(),
            to_note_id: to_note_id.to_string(),
            link_type: LinkType::Supports,
            directional: true,
            confidence: 0.8,
            strength: LinkStrength::Medium,
            evidence_basis: EvidenceBasis::Lexical,
            created_by: LinkOrigin::Heuristic,
            created_at: now.clone(),
            updated_at: now,
            source_revision_pair: (1, 1),
            stale: false,
            stale_reason: None,
        }
    }

    fn test_ghost(workspace_id: &str, supporting_note_id: &str) -> GhostNote {
        let now = crate::research::util::now_rfc3339();
        GhostNote {
            id: "ghost-1".to_string(),
            workspace_id: workspace_id.to_string(),
            ghost_class: GhostNoteClass::CandidateThesis,
            headline: "Possible thesis".to_string(),
            body: "Evidence suggests a thesis.".to_string(),
            tone: GhostTone::Tentative,
            confidence: 0.75,
            visibility_state: GhostVisibilityState::Visible,
            state: GhostLifecycleState::Generated,
            supporting_note_ids: vec![supporting_note_id.to_string()],
            contradicting_note_ids: Vec::new(),
            source_ids: Vec::new(),
            evidence_threshold_met: true,
            created_at: now.clone(),
            updated_at: now,
            superseded_by_ghost_id: None,
            promoted_note_id: None,
            memo_section_hint: None,
            target_note_id: Some(supporting_note_id.to_string()),
            parent_thesis_note_id: None,
            target_claim_text: "Evidence suggests a thesis.".to_string(),
            target_claim_kind: GhostTargetClaimKind::CoreThesis,
            why_it_matters: "This thesis candidate affects conviction.".to_string(),
            current_support_summary: "One supporting note.".to_string(),
            counter_case: "The evidence may not be decisive.".to_string(),
            recommended_action: GhostRecommendedAction::RewriteClaim,
            recommended_action_rationale: "Tighten the thesis claim.".to_string(),
            resolution_status: GhostResolutionStatus::Open,
            leverage_score: 0.75,
            support_strength: GhostSupportStrength::Weak,
            freshness_risk: GhostFreshnessRisk::Low,
            agent_reasoning_version: 1,
        }
    }

    #[test]
    fn three_statement_template_has_expected_sheets_and_links() {
        let workbook = build_model_template_workbook(ModelTemplateType::ThreeStatement);
        let sheets = workbook
            .get("sheets")
            .and_then(serde_json::Value::as_array)
            .unwrap();
        let sheet_ids = sheets
            .iter()
            .map(|sheet| sheet.get("id").and_then(serde_json::Value::as_str).unwrap())
            .collect::<Vec<_>>();

        assert_eq!(
            sheet_ids,
            vec![
                "revenue_build",
                "income_statement",
                "balance_sheet",
                "cashflow_statement"
            ]
        );
        assert_eq!(
            workbook
                .get("activeSheetId")
                .and_then(serde_json::Value::as_str),
            Some("revenue_build")
        );
        assert_eq!(
            workbook
                .pointer("/metadata/mosaiciq/historicalYears")
                .and_then(serde_json::Value::as_i64),
            Some(3)
        );
        let latest_historical_year = Utc::now().year() - 1;
        let first_historical_label = format!("FY {}", latest_historical_year - 2);
        let latest_historical_label = format!("FY {}", latest_historical_year);
        let first_forecast_label = format!("FY {}", latest_historical_year + 1);
        assert_eq!(
            workbook
                .pointer("/sheets/0/cells/B3/value")
                .and_then(serde_json::Value::as_str),
            Some(first_historical_label.as_str())
        );
        assert_eq!(
            workbook
                .pointer("/sheets/0/cells/D3/value")
                .and_then(serde_json::Value::as_str),
            Some(latest_historical_label.as_str())
        );
        assert_eq!(
            workbook
                .pointer("/sheets/0/cells/E3/value")
                .and_then(serde_json::Value::as_str),
            Some(first_forecast_label.as_str())
        );
        assert_eq!(
            workbook
                .pointer("/sheets/1/cells/E5/formula")
                .and_then(serde_json::Value::as_str),
            Some("='Revenue Build'!E8")
        );
        assert_eq!(
            workbook
                .pointer("/sheets/2/cells/E5/formula")
                .and_then(serde_json::Value::as_str),
            Some("='Cashflow Statement'!E18")
        );
        assert_eq!(
            workbook
                .pointer("/sheets/2/cells/A22/value")
                .and_then(serde_json::Value::as_str),
            Some("Balance Check")
        );

        let result = evaluate_workbook(&workbook);
        assert!(
            result.formula_errors.is_empty(),
            "{:?}",
            result.formula_errors
        );
    }

    #[test]
    fn three_statement_template_creates_assumption_annotations() {
        let annotations = build_model_template_annotations(
            ModelTemplateType::ThreeStatement,
            "2026-01-01T00:00:00Z",
        );
        assert!(annotations.iter().any(|annotation| {
            annotation.cell_id == "three-statement-gross-margin-fy1"
                && annotation.sheet_id == "revenue_build"
                && annotation.address == "E10"
        }));
        assert!(annotations.iter().any(|annotation| {
            annotation.cell_id == "three-statement-capex-percent-fy1"
                && annotation.sheet_id == "cashflow_statement"
                && annotation.address == "E20"
        }));
    }

    #[test]
    fn capture_pipeline_should_return_note_and_jobs_immediately() {
        with_test_home("capture", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();

            let result = runtime
                .block_on(service.capture_note(CaptureResearchNoteRequest {
                    workspace_id: workspace.id,
                    raw_text: "Mgmt says enterprise demand improved sequentially and expects gross margin to exit above 70% next half.".to_string(),
                    body_html: None,
                    embeds: Vec::new(),
                    file_ids: Vec::new(),
                    company_id: None,
                    ticker: Some("AAPL".to_string()),
                    source_ref: None,
                    position_hint: None,
                    user_note_type_override: None,
                }))
                .unwrap();

            assert_eq!(result.note.note_type, NoteType::ManagementSignal);
            assert!(result.queued_jobs.len() >= 4);
        });
    }

    #[test]
    fn edited_note_should_cause_old_enrichment_job_to_fail_revision_check_and_preserve_new_note() {
        with_test_home("revision", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();
            let captured = runtime
                .block_on(service.capture_note(CaptureResearchNoteRequest {
                    workspace_id: workspace.id.clone(),
                    raw_text: "Initial claim about margin expansion.".to_string(),
                    body_html: None,
                    embeds: Vec::new(),
                    file_ids: Vec::new(),
                    company_id: None,
                    ticker: Some("AAPL".to_string()),
                    source_ref: None,
                    position_hint: None,
                    user_note_type_override: None,
                }))
                .unwrap();

            let outdated_jobs = runtime
                .block_on(service.retry_research_jobs(
                    crate::research::types::RetryResearchJobsRequest {
                        workspace_id: workspace.id.clone(),
                        job_kind: Some(crate::research::types::JobKind::EnrichNote),
                    },
                ))
                .unwrap();
            assert!(outdated_jobs.is_empty());

            let updated = runtime
                .block_on(
                    service.update_note(crate::research::types::UpdateResearchNoteRequest {
                        note_id: captured.note.id.clone(),
                        raw_text: Some(
                            "Updated claim about margin expansion with a new framing.".to_string(),
                        ),
                        body_html: None,
                        embeds: None,
                        file_ids: None,
                        title: None,
                        note_type: None,
                        subtype: None,
                        analyst_status: None,
                        pinned: None,
                        priority: None,
                        thesis_status: None,
                    }),
                )
                .unwrap();

            assert_eq!(updated.revision, 2);
        });
    }

    #[test]
    fn workspace_touch_is_best_effort_when_metadata_update_fails() {
        with_test_home("touch-best-effort", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();

            runtime.block_on(service.try_touch_workspace("missing-workspace"));
        });
    }

    #[test]
    fn preview_note_link_rejects_non_http_urls() {
        with_test_home("preview-invalid", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();

            let error = runtime
                .block_on(service.preview_note_link(PreviewLinkRequest {
                    url: "file:///tmp/source.html".to_string(),
                    kind: Some(SourceKind::Article),
                }))
                .unwrap_err();

            assert!(error.to_string().contains("http or https"));
        });
    }

    #[test]
    fn upload_files_persists_bytes_and_text_preview() {
        with_test_home("upload-file", || {
            let app = build_app();
            let db_dir = env::temp_dir().join(unique_identifier("upload-db"));
            fs::create_dir_all(&db_dir).unwrap();
            let service =
                ResearchService::new(app.handle(), db_dir.join("research.sqlite")).unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();

            let files = runtime
                .block_on(service.upload_files(UploadResearchFilesRequest {
                    workspace_id: workspace.id.clone(),
                    files: vec![UploadResearchFileInput {
                        name: "note.txt".to_string(),
                        mime_type: Some("text/plain".to_string()),
                        bytes: b"hello research".to_vec(),
                        extracted_text: None,
                        extracted_text_source: None,
                        extracted_text_truncated: None,
                    }],
                }))
                .unwrap();

            assert_eq!(files.len(), 1);
            assert!(std::path::Path::new(&files[0].stored_path).exists());
            assert_eq!(files[0].preview_kind, ResearchFilePreviewKind::Text);

            let preview = runtime
                .block_on(
                    service.get_file_text_preview(GetResearchFileTextPreviewRequest {
                        file_id: files[0].id.clone(),
                        max_bytes: Some(5),
                    }),
                )
                .unwrap();
            assert_eq!(preview.text, "hello");
            assert!(preview.truncated);
        });
    }

    #[test]
    fn upload_files_persists_extracted_text_and_uses_it_for_preview() {
        with_test_home("upload-extracted-text", || {
            let app = build_app();
            let db_dir = env::temp_dir().join(unique_identifier("upload-extracted-db"));
            fs::create_dir_all(&db_dir).unwrap();
            let service =
                ResearchService::new(app.handle(), db_dir.join("research.sqlite")).unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();

            let files = runtime
                .block_on(service.upload_files(UploadResearchFilesRequest {
                    workspace_id: workspace.id,
                    files: vec![UploadResearchFileInput {
                        name: "deck.pdf".to_string(),
                        mime_type: Some("application/pdf".to_string()),
                        bytes: b"%PDF fake".to_vec(),
                        extracted_text: Some("extracted liteparse context".to_string()),
                        extracted_text_source: Some("liteparse".to_string()),
                        extracted_text_truncated: Some(false),
                    }],
                }))
                .unwrap();

            assert_eq!(files[0].preview_kind, ResearchFilePreviewKind::Pdf);
            assert_eq!(
                files[0].extracted_text.as_deref(),
                Some("extracted liteparse context")
            );
            assert_eq!(files[0].extracted_text_source.as_deref(), Some("liteparse"));

            let preview = runtime
                .block_on(
                    service.get_file_text_preview(GetResearchFileTextPreviewRequest {
                        file_id: files[0].id.clone(),
                        max_bytes: Some(9),
                    }),
                )
                .unwrap();
            assert_eq!(preview.text, "extracted");
            assert!(preview.truncated);
        });
    }

    #[test]
    fn upload_files_dedupe_upgrades_missing_extracted_text() {
        with_test_home("upload-dedupe-extracted", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();

            let first = runtime
                .block_on(service.upload_files(UploadResearchFilesRequest {
                    workspace_id: workspace.id.clone(),
                    files: vec![UploadResearchFileInput {
                        name: "deck.pdf".to_string(),
                        mime_type: Some("application/pdf".to_string()),
                        bytes: b"%PDF same".to_vec(),
                        extracted_text: None,
                        extracted_text_source: None,
                        extracted_text_truncated: None,
                    }],
                }))
                .unwrap();
            let second = runtime
                .block_on(service.upload_files(UploadResearchFilesRequest {
                    workspace_id: workspace.id,
                    files: vec![UploadResearchFileInput {
                        name: "deck.pdf".to_string(),
                        mime_type: Some("application/pdf".to_string()),
                        bytes: b"%PDF same".to_vec(),
                        extracted_text: Some("late extracted text".to_string()),
                        extracted_text_source: Some("liteparse".to_string()),
                        extracted_text_truncated: Some(false),
                    }],
                }))
                .unwrap();

            assert_eq!(first[0].id, second[0].id);
            assert_eq!(
                second[0].extracted_text.as_deref(),
                Some("late extracted text")
            );
        });
    }

    #[test]
    fn upload_files_rejects_oversized_files() {
        with_test_home("upload-oversized", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();

            let error = runtime
                .block_on(service.upload_files(UploadResearchFilesRequest {
                    workspace_id: workspace.id,
                    files: vec![UploadResearchFileInput {
                        name: "large.bin".to_string(),
                        mime_type: None,
                        bytes: vec![0; MAX_RESEARCH_FILE_BYTES + 1],
                        extracted_text: None,
                        extracted_text_source: None,
                        extracted_text_truncated: None,
                    }],
                }))
                .unwrap_err();

            assert!(error.to_string().contains("25 MB max"));
        });
    }

    #[test]
    fn upload_files_dedupes_matching_checksum_and_name() {
        with_test_home("upload-dedupe", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();
            let request = || UploadResearchFilesRequest {
                workspace_id: workspace.id.clone(),
                files: vec![UploadResearchFileInput {
                    name: "same.txt".to_string(),
                    mime_type: Some("text/plain".to_string()),
                    bytes: b"same".to_vec(),
                    extracted_text: None,
                    extracted_text_source: None,
                    extracted_text_truncated: None,
                }],
            };

            let first = runtime.block_on(service.upload_files(request())).unwrap();
            let second = runtime.block_on(service.upload_files(request())).unwrap();

            assert_eq!(first[0].id, second[0].id);
        });
    }

    #[test]
    fn note_file_ids_must_belong_to_note_workspace() {
        with_test_home("cross-workspace-file", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace_a = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();
            let workspace_b = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Nvidia Research".to_string(),
                    primary_ticker: "NVDA".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();
            let file = runtime
                .block_on(service.upload_files(UploadResearchFilesRequest {
                    workspace_id: workspace_a.id,
                    files: vec![UploadResearchFileInput {
                        name: "source.txt".to_string(),
                        mime_type: Some("text/plain".to_string()),
                        bytes: b"source".to_vec(),
                        extracted_text: None,
                        extracted_text_source: None,
                        extracted_text_truncated: None,
                    }],
                }))
                .unwrap()
                .remove(0);

            let file_id = file.id.clone();
            let error = runtime
                .block_on(service.capture_note(CaptureResearchNoteRequest {
                    workspace_id: workspace_b.id.clone(),
                    raw_text: "Cross workspace attachment should fail.".to_string(),
                    body_html: None,
                    embeds: Vec::new(),
                    file_ids: vec![file_id.clone()],
                    company_id: None,
                    ticker: Some("NVDA".to_string()),
                    source_ref: None,
                    position_hint: None,
                    user_note_type_override: None,
                }))
                .unwrap_err();

            assert!(error.to_string().contains("does not belong"));

            let captured = runtime
                .block_on(service.capture_note(CaptureResearchNoteRequest {
                    workspace_id: workspace_b.id,
                    raw_text: "Valid note.".to_string(),
                    body_html: None,
                    embeds: Vec::new(),
                    file_ids: Vec::new(),
                    company_id: None,
                    ticker: Some("NVDA".to_string()),
                    source_ref: None,
                    position_hint: None,
                    user_note_type_override: None,
                }))
                .unwrap();
            let update_error = runtime
                .block_on(
                    service.update_note(crate::research::types::UpdateResearchNoteRequest {
                        note_id: captured.note.id,
                        raw_text: None,
                        body_html: None,
                        embeds: None,
                        file_ids: Some(vec![file_id]),
                        title: None,
                        note_type: None,
                        subtype: None,
                        analyst_status: None,
                        pinned: None,
                        priority: None,
                        thesis_status: None,
                    }),
                )
                .unwrap_err();
            assert!(update_error.to_string().contains("does not belong"));
        });
    }

    #[test]
    fn preview_kind_detection_matches_supported_formats() {
        assert_eq!(
            detect_file_preview_kind(Some("image/png"), "chart.bin"),
            ResearchFilePreviewKind::Image,
        );
        assert_eq!(
            detect_file_preview_kind(Some("application/pdf"), "memo.bin"),
            ResearchFilePreviewKind::Pdf,
        );
        assert_eq!(
            detect_file_preview_kind(None, "model.csv"),
            ResearchFilePreviewKind::Text,
        );
        assert_eq!(
            detect_file_preview_kind(None, "archive.zip"),
            ResearchFilePreviewKind::None,
        );
    }

    #[test]
    fn capture_note_resolves_embeds_into_sources_and_audit_trail() {
        with_test_home("embedded-source", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();

            let result = runtime
                .block_on(service.capture_note(CaptureResearchNoteRequest {
                    workspace_id: workspace.id.clone(),
                    raw_text: "Example article supports the demand setup.".to_string(),
                    body_html: Some(
                        "<p>Example article supports the demand setup.</p>".to_string(),
                    ),
                    embeds: vec![NoteEmbedInput {
                        url: "https://example.com/article".to_string(),
                        canonical_url: None,
                        title: "Example article".to_string(),
                        publisher: Some("example.com".to_string()),
                        excerpt: Some("Example excerpt".to_string()),
                        kind: SourceKind::Article,
                        image_url: None,
                    }],
                    file_ids: Vec::new(),
                    company_id: None,
                    ticker: Some("AAPL".to_string()),
                    source_ref: None,
                    position_hint: None,
                    user_note_type_override: None,
                }))
                .unwrap();

            assert_eq!(result.note.embeds.len(), 1);
            assert!(result.note.embeds[0].source_id.is_some());

            let audit = runtime
                .block_on(service.get_note_audit_trail(
                    crate::research::types::GetNoteAuditTrailRequest {
                        note_id: result.note.id,
                    },
                ))
                .unwrap();

            assert_eq!(audit.sources.len(), 1);
            assert_eq!(audit.sources[0].title, "Example article");
        });
    }

    #[test]
    fn get_workspace_projection_should_return_persisted_notes_links_and_ghosts() {
        with_test_home("projection", || {
            let app = build_app();
            let service = ResearchService::new(
                app.handle(),
                env::temp_dir().join(unique_identifier("research.sqlite")),
            )
            .unwrap();
            let runtime = tokio::runtime::Runtime::new().unwrap();
            let workspace = runtime
                .block_on(service.create_workspace(CreateResearchWorkspaceRequest {
                    name: "Apple Research".to_string(),
                    primary_ticker: "AAPL".to_string(),
                    stage: ResearchStage::Capture,
                    default_view: WorkspaceViewKind::Canvas,
                }))
                .unwrap();
            let first = runtime
                .block_on(service.capture_note(CaptureResearchNoteRequest {
                    workspace_id: workspace.id.clone(),
                    raw_text: "Apple services growth supports margins.".to_string(),
                    body_html: None,
                    embeds: Vec::new(),
                    file_ids: Vec::new(),
                    company_id: None,
                    ticker: Some("AAPL".to_string()),
                    source_ref: None,
                    position_hint: None,
                    user_note_type_override: None,
                }))
                .unwrap();
            let second = runtime
                .block_on(service.capture_note(CaptureResearchNoteRequest {
                    workspace_id: workspace.id.clone(),
                    raw_text: "Higher mix of services can offset hardware pressure.".to_string(),
                    body_html: None,
                    embeds: Vec::new(),
                    file_ids: Vec::new(),
                    company_id: None,
                    ticker: Some("AAPL".to_string()),
                    source_ref: None,
                    position_hint: None,
                    user_note_type_override: None,
                }))
                .unwrap();

            runtime
                .block_on(service.repository.replace_links_for_workspace(
                    &workspace.id,
                    vec![test_link(&workspace.id, &first.note.id, &second.note.id)],
                ))
                .unwrap();
            runtime
                .block_on(service.repository.replace_ghosts_for_workspace(
                    &workspace.id,
                    vec![test_ghost(&workspace.id, &first.note.id)],
                ))
                .unwrap();

            let projection = runtime
                .block_on(
                    service.get_workspace_projection(GetWorkspaceProjectionRequest {
                        workspace_id: workspace.id,
                        view: Some(WorkspaceViewKind::Graph),
                    }),
                )
                .unwrap();

            assert_eq!(projection.active_view, WorkspaceViewKind::Graph);
            assert_eq!(projection.notes.len(), 2);
            assert_eq!(projection.links.len(), 1);
            assert_eq!(projection.ghosts.len(), 1);
        });
    }
}
