//! Public orchestration service for the research subsystem.

use std::collections::HashSet;
use std::sync::Arc;

use serde_json::Value;
use tauri::{AppHandle, Runtime};

use crate::agent::{default_task_defaults, AgentSettingsService, TaskProfile};
use crate::research::ai::{build_model_info, DeterministicResearchAiGateway, 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;
use crate::research::grounding::{
    build_source_record, build_source_reference_note, 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::pipeline::ResearchPipeline;
use crate::research::projections::{build_memo_blocks, build_workspace_projection};
use crate::research::repository::ResearchRepository;
use crate::research::types::{
    ArchiveResearchNoteRequest, AuditActor, AuditEntityKind, AuditEvent, CaptureMethod,
    CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceStatus,
    ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest,
    GetWorkspaceProjectionRequest, GhostLifecycleState, GhostReviewAction, GhostStatus, LinkOrigin,
    ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, NoteAuditTrail,
    NoteCaptureResult, NotePriority, NoteType, PromoteNoteToThesisRequest, ProvenanceActor,
    ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest,
    ReviewGhostNoteRequest, SourceKind, ThesisStatus, UpdateResearchNoteRequest,
    WorkspaceProjection, WorkspaceScope,
};
use crate::research::util::{generate_id, normalize_text, now_rfc3339, sha256_hex};

pub struct ResearchService<R: Runtime> {
    repository: Arc<ResearchRepository>,
    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(),
            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> {
        Self::new_with_ai_gateway(
            app_handle,
            db_path,
            Arc::new(DeterministicResearchAiGateway),
        )
    }

    pub fn new_with_ai_gateway(
        app_handle: &AppHandle<R>,
        db_path: std::path::PathBuf,
        ai_gateway: Arc<dyn ResearchAiGateway>,
    ) -> Result<Self> {
        let repository = Arc::new(ResearchRepository::new(db_path)?);
        Ok(Self {
            pipeline: Arc::new(ResearchPipeline::new(repository.clone())),
            repository,
            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 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 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(),
            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: source_id.iter().cloned().collect(),
            },
        )
        .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.emitter.note_updated(&saved);
        self.kick_job_processor();

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

    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(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: saved.source_id.iter().cloned().collect(),
            },
        )
        .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.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 get_workspace_projection(
        &self,
        request: GetWorkspaceProjectionRequest,
    ) -> Result<WorkspaceProjection> {
        let (workspace, notes, links, ghosts) = tokio::try_join!(
            self.repository.get_workspace(&request.workspace_id),
            self.repository
                .list_notes(&request.workspace_id, false, None),
            self.repository.list_links(&request.workspace_id, None),
            self.repository.list_ghosts(&request.workspace_id, false),
        )?;
        let active_view = request.view.unwrap_or(workspace.default_view);
        Ok(build_workspace_projection(
            workspace,
            active_view,
            notes,
            links,
            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;
            }
            GhostReviewAction::Ignore => {
                ghost.state = GhostLifecycleState::Ignored;
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Collapsed;
            }
            GhostReviewAction::Dismiss => {
                ghost.state = GhostLifecycleState::Dismissed;
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Hidden;
            }
            GhostReviewAction::Pin => {
                ghost.visibility_state = crate::research::types::GhostVisibilityState::Pinned;
            }
        }
        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.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.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 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(
                ghosts
                    .iter()
                    .flat_map(|ghost| ghost.source_ids.iter().cloned()),
            ),
        );
        let sources = self.repository.list_sources_by_ids(&source_ids).await?;
        let memo_blocks = build_memo_blocks(std::slice::from_ref(&note), &ghosts);

        Ok(NoteAuditTrail {
            note,
            links,
            related_ghosts: ghosts,
            sources,
            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,
        ))
    }

    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 {
            let _ = service.process_due_jobs().await;
        });
    }

    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 => Ok(()),
            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 => Ok(()),
        }
    }

    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, model_info.clone());
        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?;
        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 ghosts = generate_ghost_notes(&workspace, &notes, &links);
        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.emitter.workspace_updated(&workspace);
        Ok(())
    }

    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(())
    }

    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(())
    }
}

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}")))
}

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 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,
    }
}

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> {
    for value in right {
        if !left.contains(&value) {
            left.push(value);
        }
    }
    left
}

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

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

    use super::ResearchService;
    use crate::research::types::{
        CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, NoteType, ResearchStage,
        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()
    }

    #[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(),
                    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(),
                    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(),
                        ),
                        title: None,
                        note_type: None,
                        subtype: None,
                        analyst_status: None,
                        pinned: None,
                        priority: None,
                        thesis_status: None,
                    }),
                )
                .unwrap();

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