Skip to main content

subx_cli/core/matcher/
engine.rs

1//! File matching engine that uses AI content analysis to align video and subtitle files.
2//!
3//! This module provides the `MatchEngine`, which orchestrates discovery,
4//! content sampling, AI analysis, and caching to generate subtitle matching operations.
5//!
6//! # Examples
7//!
8//! ```rust,ignore
9//! use subx_cli::core::matcher::engine::{MatchEngine, MatchConfig};
10//! // Create a match engine with default configuration
11//! let config = MatchConfig { confidence_threshold: 0.8, max_sample_length: 1024, enable_content_analysis: true, backup_enabled: false };
12//! let engine = MatchEngine::new(Box::new(DummyAI), config);
13//! ```
14
15use crate::services::ai::{AIProvider, AnalysisRequest, ContentSample, MatchResult};
16use std::path::PathBuf;
17
18use crate::Result;
19use crate::core::language::LanguageDetector;
20use crate::core::matcher::cache::{CacheData, OpItem, SnapshotItem};
21use crate::core::matcher::discovery::generate_file_id;
22use crate::core::matcher::journal::{
23    JournalData, JournalEntry, JournalEntryStatus, JournalOperationType, journal_path,
24};
25use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
26use crate::core::parallel::{FileProcessingTask, ProcessingOperation, Task, TaskResult};
27use crate::core::uuidv7::Uuidv7Generator;
28use crate::error::SubXError;
29use dirs;
30use serde_json;
31
32/// File relocation mode for matched subtitle files
33#[derive(Debug, Clone, PartialEq)]
34pub enum FileRelocationMode {
35    /// No file relocation
36    None,
37    /// Copy subtitle files to video folders
38    Copy,
39    /// Move subtitle files to video folders
40    Move,
41}
42
43/// Strategy for handling filename conflicts during relocation
44#[derive(Debug, Clone)]
45pub enum ConflictResolution {
46    /// Skip relocation if conflict exists
47    Skip,
48    /// Automatically rename with numeric suffix
49    AutoRename,
50    /// Prompt user for decision (interactive mode only)
51    Prompt,
52}
53
54/// Configuration settings for the file matching engine.
55///
56/// Controls various aspects of the subtitle-to-video matching process,
57/// including confidence thresholds and analysis options.
58#[derive(Debug, Clone)]
59pub struct MatchConfig {
60    /// Minimum confidence score required for a successful match (0.0 to 1.0)
61    pub confidence_threshold: f32,
62    /// Maximum number of characters to sample from subtitle content
63    pub max_sample_length: usize,
64    /// Whether to enable advanced content analysis for matching
65    pub enable_content_analysis: bool,
66    /// Whether to create backup files before operations
67    pub backup_enabled: bool,
68    /// File relocation mode
69    pub relocation_mode: FileRelocationMode,
70    /// Strategy for handling filename conflicts during relocation
71    pub conflict_resolution: ConflictResolution,
72    /// AI model name used for analysis
73    pub ai_model: String,
74    /// Maximum subtitle file size in bytes accepted for content sampling.
75    /// Populated from `GeneralConfig.max_subtitle_bytes`.
76    pub max_subtitle_bytes: u64,
77}
78
79#[cfg(test)]
80mod language_name_tests {
81    use super::*;
82    use crate::core::matcher::discovery::{MediaFile, MediaFileType};
83    use crate::services::ai::{
84        AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
85    };
86    use async_trait::async_trait;
87    use std::path::PathBuf;
88
89    struct DummyAI;
90    #[async_trait]
91    impl AIProvider for DummyAI {
92        async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
93            unimplemented!()
94        }
95        async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
96            unimplemented!()
97        }
98    }
99
100    #[test]
101    fn test_generate_subtitle_name_with_directory_language() {
102        let engine = MatchEngine::new(
103            Box::new(DummyAI),
104            MatchConfig {
105                confidence_threshold: 0.0,
106                max_sample_length: 0,
107                enable_content_analysis: false,
108                backup_enabled: false,
109                relocation_mode: FileRelocationMode::None,
110                conflict_resolution: ConflictResolution::Skip,
111                ai_model: "test-model".to_string(),
112                max_subtitle_bytes: 52_428_800,
113            },
114        );
115        let video = MediaFile {
116            id: "".to_string(),
117            relative_path: "".to_string(),
118            path: PathBuf::from("movie01.mp4"),
119            file_type: MediaFileType::Video,
120            size: 0,
121            name: "movie01".to_string(),
122            extension: "mp4".to_string(),
123        };
124        let subtitle = MediaFile {
125            id: "".to_string(),
126            relative_path: "".to_string(),
127            path: PathBuf::from("tc/subtitle01.ass"),
128            file_type: MediaFileType::Subtitle,
129            size: 0,
130            name: "subtitle01".to_string(),
131            extension: "ass".to_string(),
132        };
133        let new_name = engine.generate_subtitle_name(&video, &subtitle);
134        assert_eq!(new_name, "movie01.tc.ass");
135    }
136
137    #[test]
138    fn test_generate_subtitle_name_with_filename_language() {
139        let engine = MatchEngine::new(
140            Box::new(DummyAI),
141            MatchConfig {
142                confidence_threshold: 0.0,
143                max_sample_length: 0,
144                enable_content_analysis: false,
145                backup_enabled: false,
146                relocation_mode: FileRelocationMode::None,
147                conflict_resolution: ConflictResolution::Skip,
148                ai_model: "test-model".to_string(),
149                max_subtitle_bytes: 52_428_800,
150            },
151        );
152        let video = MediaFile {
153            id: "".to_string(),
154            relative_path: "".to_string(),
155            path: PathBuf::from("movie02.mp4"),
156            file_type: MediaFileType::Video,
157            size: 0,
158            name: "movie02".to_string(),
159            extension: "mp4".to_string(),
160        };
161        let subtitle = MediaFile {
162            id: "".to_string(),
163            relative_path: "".to_string(),
164            path: PathBuf::from("subtitle02.sc.ass"),
165            file_type: MediaFileType::Subtitle,
166            size: 0,
167            name: "subtitle02".to_string(),
168            extension: "ass".to_string(),
169        };
170        let new_name = engine.generate_subtitle_name(&video, &subtitle);
171        assert_eq!(new_name, "movie02.sc.ass");
172    }
173
174    #[test]
175    fn test_generate_subtitle_name_without_language() {
176        let engine = MatchEngine::new(
177            Box::new(DummyAI),
178            MatchConfig {
179                confidence_threshold: 0.0,
180                max_sample_length: 0,
181                enable_content_analysis: false,
182                backup_enabled: false,
183                relocation_mode: FileRelocationMode::None,
184                conflict_resolution: ConflictResolution::Skip,
185                ai_model: "test-model".to_string(),
186                max_subtitle_bytes: 52_428_800,
187            },
188        );
189        let video = MediaFile {
190            id: "".to_string(),
191            relative_path: "".to_string(),
192            path: PathBuf::from("movie03.mp4"),
193            file_type: MediaFileType::Video,
194            size: 0,
195            name: "movie03".to_string(),
196            extension: "mp4".to_string(),
197        };
198        let subtitle = MediaFile {
199            id: "".to_string(),
200            relative_path: "".to_string(),
201            path: PathBuf::from("subtitle03.ass"),
202            file_type: MediaFileType::Subtitle,
203            size: 0,
204            name: "subtitle03".to_string(),
205            extension: "ass".to_string(),
206        };
207        let new_name = engine.generate_subtitle_name(&video, &subtitle);
208        assert_eq!(new_name, "movie03.ass");
209    }
210    #[test]
211    fn test_generate_subtitle_name_removes_video_extension() {
212        let engine = MatchEngine::new(
213            Box::new(DummyAI),
214            MatchConfig {
215                confidence_threshold: 0.0,
216                max_sample_length: 0,
217                enable_content_analysis: false,
218                backup_enabled: false,
219                relocation_mode: FileRelocationMode::None,
220                conflict_resolution: ConflictResolution::Skip,
221                ai_model: "test-model".to_string(),
222                max_subtitle_bytes: 52_428_800,
223            },
224        );
225        let video = MediaFile {
226            id: "".to_string(),
227            relative_path: "".to_string(),
228            path: PathBuf::from("movie.mkv"),
229            file_type: MediaFileType::Video,
230            size: 0,
231            name: "movie.mkv".to_string(),
232            extension: "mkv".to_string(),
233        };
234        let subtitle = MediaFile {
235            id: "".to_string(),
236            relative_path: "".to_string(),
237            path: PathBuf::from("subtitle.srt"),
238            file_type: MediaFileType::Subtitle,
239            size: 0,
240            name: "subtitle".to_string(),
241            extension: "srt".to_string(),
242        };
243        let new_name = engine.generate_subtitle_name(&video, &subtitle);
244        assert_eq!(new_name, "movie.srt");
245    }
246
247    #[test]
248    fn test_generate_subtitle_name_with_language_removes_video_extension() {
249        let engine = MatchEngine::new(
250            Box::new(DummyAI),
251            MatchConfig {
252                confidence_threshold: 0.0,
253                max_sample_length: 0,
254                enable_content_analysis: false,
255                backup_enabled: false,
256                relocation_mode: FileRelocationMode::None,
257                conflict_resolution: ConflictResolution::Skip,
258                ai_model: "test-model".to_string(),
259                max_subtitle_bytes: 52_428_800,
260            },
261        );
262        let video = MediaFile {
263            id: "".to_string(),
264            relative_path: "".to_string(),
265            path: PathBuf::from("movie.mkv"),
266            file_type: MediaFileType::Video,
267            size: 0,
268            name: "movie.mkv".to_string(),
269            extension: "mkv".to_string(),
270        };
271        let subtitle = MediaFile {
272            id: "".to_string(),
273            relative_path: "".to_string(),
274            path: PathBuf::from("tc/subtitle.srt"),
275            file_type: MediaFileType::Subtitle,
276            size: 0,
277            name: "subtitle".to_string(),
278            extension: "srt".to_string(),
279        };
280        let new_name = engine.generate_subtitle_name(&video, &subtitle);
281        assert_eq!(new_name, "movie.tc.srt");
282    }
283
284    #[test]
285    fn test_generate_subtitle_name_edge_cases() {
286        let engine = MatchEngine::new(
287            Box::new(DummyAI),
288            MatchConfig {
289                confidence_threshold: 0.0,
290                max_sample_length: 0,
291                enable_content_analysis: false,
292                backup_enabled: false,
293                relocation_mode: FileRelocationMode::None,
294                conflict_resolution: ConflictResolution::Skip,
295                ai_model: "test-model".to_string(),
296                max_subtitle_bytes: 52_428_800,
297            },
298        );
299        // File name contains multiple dots and no extension case
300        let video = MediaFile {
301            id: "".to_string(),
302            relative_path: "".to_string(),
303            path: PathBuf::from("a.b.c"),
304            file_type: MediaFileType::Video,
305            size: 0,
306            name: "a.b.c".to_string(),
307            extension: "".to_string(),
308        };
309        let subtitle = MediaFile {
310            id: "".to_string(),
311            relative_path: "".to_string(),
312            path: PathBuf::from("sub.srt"),
313            file_type: MediaFileType::Subtitle,
314            size: 0,
315            name: "sub".to_string(),
316            extension: "srt".to_string(),
317        };
318        let new_name = engine.generate_subtitle_name(&video, &subtitle);
319        assert_eq!(new_name, "a.b.c.srt");
320    }
321
322    #[tokio::test]
323    async fn test_rename_file_displays_success_check_mark() {
324        use std::fs;
325        use tempfile::TempDir;
326
327        let temp_dir = TempDir::new().unwrap();
328        let temp_path = temp_dir.path();
329
330        // Create a test file
331        let original_file = temp_path.join("original.srt");
332        fs::write(
333            &original_file,
334            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
335        )
336        .unwrap();
337
338        // Create a test MatchEngine
339        let engine = MatchEngine::new(
340            Box::new(DummyAI),
341            MatchConfig {
342                confidence_threshold: 0.0,
343                max_sample_length: 0,
344                enable_content_analysis: false,
345                backup_enabled: false,
346                relocation_mode: FileRelocationMode::None,
347                conflict_resolution: ConflictResolution::Skip,
348                ai_model: "test-model".to_string(),
349                max_subtitle_bytes: 52_428_800,
350            },
351        );
352
353        // Create a MatchOperation
354        let subtitle_file = MediaFile {
355            id: "test_id".to_string(),
356            relative_path: "original.srt".to_string(),
357            path: original_file.clone(),
358            file_type: MediaFileType::Subtitle,
359            size: 40,
360            name: "original".to_string(),
361            extension: "srt".to_string(),
362        };
363
364        let match_op = MatchOperation {
365            video_file: MediaFile {
366                id: "video_id".to_string(),
367                relative_path: "test.mp4".to_string(),
368                path: temp_path.join("test.mp4"),
369                file_type: MediaFileType::Video,
370                size: 1000,
371                name: "test".to_string(),
372                extension: "mp4".to_string(),
373            },
374            subtitle_file,
375            new_subtitle_name: "renamed.srt".to_string(),
376            confidence: 95.0,
377            reasoning: vec!["Test match".to_string()],
378            requires_relocation: false,
379            relocation_target_path: None,
380            relocation_mode: FileRelocationMode::None,
381        };
382
383        // Execute the rename operation
384        let result = engine.rename_file(&match_op).await;
385
386        // Verify the operation was successful
387        assert!(result.is_ok());
388
389        // Verify the file has been renamed
390        let renamed_file = temp_path.join("renamed.srt");
391        assert!(renamed_file.exists(), "The renamed file should exist");
392        assert!(
393            !original_file.exists(),
394            "The original file should have been renamed"
395        );
396
397        // Verify the file content is correct
398        let content = fs::read_to_string(&renamed_file).unwrap();
399        assert!(content.contains("Test subtitle"));
400    }
401
402    #[tokio::test]
403    async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
404        use std::fs;
405        use tempfile::TempDir;
406
407        let temp_dir = TempDir::new().unwrap();
408        let temp_path = temp_dir.path();
409
410        // Create test file
411        let original_file = temp_path.join("original.srt");
412        fs::write(
413            &original_file,
414            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
415        )
416        .unwrap();
417
418        // Create a test MatchEngine
419        let engine = MatchEngine::new(
420            Box::new(DummyAI),
421            MatchConfig {
422                confidence_threshold: 0.0,
423                max_sample_length: 0,
424                enable_content_analysis: false,
425                backup_enabled: false,
426                relocation_mode: FileRelocationMode::None,
427                conflict_resolution: ConflictResolution::Skip,
428                ai_model: "test-model".to_string(),
429                max_subtitle_bytes: 52_428_800,
430            },
431        );
432
433        // Create a MatchOperation
434        let subtitle_file = MediaFile {
435            id: "test_id".to_string(),
436            relative_path: "original.srt".to_string(),
437            path: original_file.clone(),
438            file_type: MediaFileType::Subtitle,
439            size: 40,
440            name: "original".to_string(),
441            extension: "srt".to_string(),
442        };
443
444        let match_op = MatchOperation {
445            video_file: MediaFile {
446                id: "video_id".to_string(),
447                relative_path: "test.mp4".to_string(),
448                path: temp_path.join("test.mp4"),
449                file_type: MediaFileType::Video,
450                size: 1000,
451                name: "test".to_string(),
452                extension: "mp4".to_string(),
453            },
454            subtitle_file,
455            new_subtitle_name: "renamed.srt".to_string(),
456            confidence: 95.0,
457            reasoning: vec!["Test match".to_string()],
458            requires_relocation: false,
459            relocation_target_path: None,
460            relocation_mode: FileRelocationMode::None,
461        };
462
463        // Simulate file not existing after operation
464        // First, execute the rename operation normally
465        let result = engine.rename_file(&match_op).await;
466        assert!(result.is_ok());
467
468        // Manually delete the renamed file to simulate failure
469        let renamed_file = temp_path.join("renamed.srt");
470        if renamed_file.exists() {
471            fs::remove_file(&renamed_file).unwrap();
472        }
473
474        // Recreate the original file for the second test
475        fs::write(
476            &original_file,
477            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
478        )
479        .unwrap();
480
481        // Create a rename operation that will fail, by overwriting the rename implementation
482        // Since we cannot directly simulate std::fs::rename failure with file not existing,
483        // we test the scenario where the file is manually removed after the operation completes
484        let result = engine.rename_file(&match_op).await;
485        assert!(result.is_ok());
486
487        // Manually delete the file again
488        let renamed_file = temp_path.join("renamed.srt");
489        if renamed_file.exists() {
490            fs::remove_file(&renamed_file).unwrap();
491        }
492
493        // This test mainly verifies the code structure is correct, the actual error message display needs to be validated through integration tests
494        // Because we cannot easily simulate the scenario where the file system operation succeeds but the file does not exist
495    }
496
497    #[test]
498    fn test_file_operation_message_format() {
499        // Test error message format
500        let source_name = "test.srt";
501        let target_name = "renamed.srt";
502
503        // Simulate success message format
504        let success_msg = format!("  āœ“ Renamed: {} -> {}", source_name, target_name);
505        assert!(success_msg.contains("āœ“"));
506        assert!(success_msg.contains("Renamed:"));
507        assert!(success_msg.contains(source_name));
508        assert!(success_msg.contains(target_name));
509
510        // Simulate failure message format
511        let error_msg = format!(
512            "  āœ— Rename failed: {} -> {} (target file does not exist after operation)",
513            source_name, target_name
514        );
515        assert!(error_msg.contains("āœ—"));
516        assert!(error_msg.contains("Rename failed:"));
517        assert!(error_msg.contains("target file does not exist"));
518        assert!(error_msg.contains(source_name));
519        assert!(error_msg.contains(target_name));
520    }
521
522    #[test]
523    fn test_copy_operation_message_format() {
524        // Test copy operation message format
525        let source_name = "subtitle.srt";
526        let target_name = "video.srt";
527
528        // Simulate success message format
529        let success_msg = format!("  āœ“ Copied: {} -> {}", source_name, target_name);
530        assert!(success_msg.contains("āœ“"));
531        assert!(success_msg.contains("Copied:"));
532
533        // Simulate failure message format
534        let error_msg = format!(
535            "  āœ— Copy failed: {} -> {} (target file does not exist after operation)",
536            source_name, target_name
537        );
538        assert!(error_msg.contains("āœ—"));
539        assert!(error_msg.contains("Copy failed:"));
540        assert!(error_msg.contains("target file does not exist"));
541    }
542
543    #[test]
544    fn test_move_operation_message_format() {
545        // Test move operation message format
546        let source_name = "subtitle.srt";
547        let target_name = "video.srt";
548
549        // Simulate success message format
550        let success_msg = format!("  āœ“ Moved: {} -> {}", source_name, target_name);
551        assert!(success_msg.contains("āœ“"));
552        assert!(success_msg.contains("Moved:"));
553
554        // Simulate failure message format
555        let error_msg = format!(
556            "  āœ— Move failed: {} -> {} (target file does not exist after operation)",
557            source_name, target_name
558        );
559        assert!(error_msg.contains("āœ—"));
560        assert!(error_msg.contains("Move failed:"));
561        assert!(error_msg.contains("target file does not exist"));
562    }
563}
564
565/// Match operation result representing a single video-subtitle match.
566///
567/// Contains all information about a successful match between a video file
568/// and a subtitle file, including confidence metrics and reasoning.
569#[derive(Debug)]
570pub struct MatchOperation {
571    /// The matched video file
572    pub video_file: MediaFile,
573    /// The matched subtitle file
574    pub subtitle_file: MediaFile,
575    /// The new filename for the subtitle file
576    pub new_subtitle_name: String,
577    /// Confidence score of the match (0.0 to 1.0)
578    pub confidence: f32,
579    /// List of reasons supporting this match
580    pub reasoning: Vec<String>,
581    /// File relocation mode for this operation
582    pub relocation_mode: FileRelocationMode,
583    /// Target relocation path if operation is needed
584    pub relocation_target_path: Option<std::path::PathBuf>,
585    /// Whether relocation operation is needed (different folders)
586    pub requires_relocation: bool,
587}
588
589/// AI-suggested candidate that did not become a match operation.
590///
591/// Emitted by [`MatchEngine::match_file_list_with_audit`] so machine-readable
592/// callers can surface AI suggestions that were rejected (sub-threshold or
593/// referencing unknown file IDs).
594#[derive(Debug, Clone)]
595pub struct RejectedCandidate {
596    /// Path of the candidate video file (empty string if unresolved).
597    pub video_path: String,
598    /// Path of the candidate subtitle file (empty string if unresolved).
599    pub subtitle_path: String,
600    /// AI-reported confidence score (0.0 to 1.0).
601    pub confidence: f32,
602    /// Stable reason code (`"below_threshold"` or `"id_not_found"`).
603    pub reason: &'static str,
604}
605
606/// Result of the auditable match planning pass: accepted operations plus
607/// rejected candidates.
608#[derive(Debug)]
609pub struct MatchAudit {
610    /// Operations that satisfied the confidence threshold and resolved to real files.
611    pub operations: Vec<MatchOperation>,
612    /// Candidates rejected by the planner.
613    pub rejected: Vec<RejectedCandidate>,
614}
615
616/// Per-operation outcome captured by [`MatchEngine::execute_operations_audit`].
617///
618/// Unlike [`MatchEngine::execute_operations`] which aborts on first failure,
619/// the audit variant records each operation's outcome so callers can report
620/// partial success/failure (used by JSON output mode).
621#[derive(Debug)]
622pub struct OperationOutcome {
623    /// Whether the operation was applied to the filesystem.
624    pub applied: bool,
625    /// Set when the operation failed (mutually exclusive with `applied == true`).
626    pub error: Option<OperationError>,
627}
628
629/// Self-contained per-operation error metadata for machine-readable output.
630#[derive(Debug, Clone)]
631pub struct OperationError {
632    /// Error category from [`SubXError::category`].
633    pub category: &'static str,
634    /// Stable machine code from [`SubXError::machine_code`].
635    pub code: &'static str,
636    /// User-friendly message from [`SubXError::user_friendly_message`].
637    pub message: String,
638}
639
640/// Convert a [`SubXError`] into a self-contained [`OperationError`] for
641/// audit reporting.
642fn operation_error_from(err: &SubXError) -> OperationError {
643    OperationError {
644        category: err.category(),
645        code: err.machine_code(),
646        message: err.user_friendly_message(),
647    }
648}
649
650/// Engine for matching video and subtitle files using AI analysis.
651pub struct MatchEngine {
652    ai_client: Box<dyn AIProvider>,
653    discovery: FileDiscovery,
654    config: MatchConfig,
655}
656
657impl MatchEngine {
658    /// Creates a new `MatchEngine` with the given AI provider and configuration.
659    pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
660        Self {
661            ai_client,
662            discovery: FileDiscovery::new(),
663            config,
664        }
665    }
666
667    /// Matches video and subtitle files from a specified list of files.
668    ///
669    /// This method processes a user-provided list of files, filtering them into
670    /// video and subtitle files, then performing AI-powered matching analysis.
671    /// This is useful when users specify exact files via -i parameters.
672    ///
673    /// # Arguments
674    ///
675    /// * `file_paths` - A slice of file paths to process for matching
676    ///
677    /// # Returns
678    ///
679    /// A list of `MatchOperation` entries that meet the confidence threshold.
680    pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
681        Ok(self
682            .match_file_list_with_audit(file_paths)
683            .await?
684            .operations)
685    }
686
687    /// Auditable variant of [`MatchEngine::match_file_list`] that also returns
688    /// rejected candidates (sub-threshold or unresolvable AI suggestions).
689    ///
690    /// When operations are served from cache, `rejected` is returned empty
691    /// because cache entries do not preserve rejection metadata.
692    pub async fn match_file_list_with_audit(&self, file_paths: &[PathBuf]) -> Result<MatchAudit> {
693        let json_mode = crate::cli::output::active_mode().is_json();
694        // 1. Process the file list to create MediaFile objects
695        let files = self.discovery.scan_file_list(file_paths)?;
696
697        let videos: Vec<_> = files
698            .iter()
699            .filter(|f| matches!(f.file_type, MediaFileType::Video))
700            .collect();
701        let subtitles: Vec<_> = files
702            .iter()
703            .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
704            .collect();
705
706        if videos.is_empty() || subtitles.is_empty() {
707            return Ok(MatchAudit {
708                operations: Vec::new(),
709                rejected: Vec::new(),
710            });
711        }
712
713        // 2. Check if we can use cache for file list operations
714        let cache_key = self.calculate_file_list_cache_key(file_paths)?;
715        if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
716            return Ok(MatchAudit {
717                operations: ops,
718                rejected: Vec::new(),
719            });
720        }
721
722        // 3. Content sampling
723        let content_samples = if self.config.enable_content_analysis {
724            self.extract_content_samples(&subtitles).await?
725        } else {
726            Vec::new()
727        };
728
729        // 4. AI analysis request
730        let video_files: Vec<String> = videos
731            .iter()
732            .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
733            .collect();
734        let subtitle_files: Vec<String> = subtitles
735            .iter()
736            .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
737            .collect();
738
739        let analysis_request = AnalysisRequest {
740            video_files,
741            subtitle_files,
742            content_samples,
743        };
744
745        // 5. Query AI service
746        let match_result = self.ai_client.analyze_content(analysis_request).await?;
747
748        // Debug: Log AI analysis results (suppressed in JSON mode to keep
749        // stderr free of free-form chatter).
750        if !json_mode {
751            eprintln!("šŸ” AI Analysis Results:");
752            eprintln!("   - Total matches: {}", match_result.matches.len());
753            eprintln!(
754                "   - Confidence threshold: {:.2}",
755                self.config.confidence_threshold
756            );
757            for ai_match in &match_result.matches {
758                eprintln!(
759                    "   - {} -> {} (confidence: {:.2})",
760                    ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
761                );
762            }
763        }
764
765        // 6. Assemble match operation list
766        let mut operations = Vec::new();
767        let mut rejected = Vec::new();
768
769        for ai_match in match_result.matches {
770            let video_match =
771                Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
772            let subtitle_match =
773                Self::find_media_file_by_id_or_path(&subtitles, &ai_match.subtitle_file_id, None);
774
775            if ai_match.confidence < self.config.confidence_threshold {
776                rejected.push(RejectedCandidate {
777                    video_path: video_match
778                        .map(|v| v.path.display().to_string())
779                        .unwrap_or_default(),
780                    subtitle_path: subtitle_match
781                        .map(|s| s.path.display().to_string())
782                        .unwrap_or_default(),
783                    confidence: ai_match.confidence,
784                    reason: "below_threshold",
785                });
786                continue;
787            }
788
789            match (video_match, subtitle_match) {
790                (Some(video), Some(subtitle)) => {
791                    let new_name = self.generate_subtitle_name(video, subtitle);
792
793                    let requires_relocation = self.config.relocation_mode
794                        != FileRelocationMode::None
795                        && subtitle.path.parent() != video.path.parent();
796
797                    let relocation_target_path = if requires_relocation {
798                        let video_dir = video.path.parent().unwrap();
799                        Some(video_dir.join(&new_name))
800                    } else {
801                        None
802                    };
803
804                    operations.push(MatchOperation {
805                        video_file: (*video).clone(),
806                        subtitle_file: (*subtitle).clone(),
807                        new_subtitle_name: new_name,
808                        confidence: ai_match.confidence,
809                        reasoning: ai_match.match_factors,
810                        relocation_mode: self.config.relocation_mode.clone(),
811                        relocation_target_path,
812                        requires_relocation,
813                    });
814                }
815                _ => {
816                    if !json_mode {
817                        eprintln!(
818                            "āš ļø  Cannot find AI-suggested file pair:\n     Video ID: '{}'\n     Subtitle ID: '{}'",
819                            ai_match.video_file_id, ai_match.subtitle_file_id
820                        );
821                    }
822                    rejected.push(RejectedCandidate {
823                        video_path: video_match
824                            .map(|v| v.path.display().to_string())
825                            .unwrap_or_default(),
826                        subtitle_path: subtitle_match
827                            .map(|s| s.path.display().to_string())
828                            .unwrap_or_default(),
829                        confidence: ai_match.confidence,
830                        reason: "id_not_found",
831                    });
832                }
833            }
834        }
835
836        // 7. Save to cache for future use
837        self.save_file_list_cache(&cache_key, &operations).await?;
838
839        Ok(MatchAudit {
840            operations,
841            rejected,
842        })
843    }
844
845    async fn extract_content_samples(
846        &self,
847        subtitles: &[&MediaFile],
848    ) -> Result<Vec<ContentSample>> {
849        let mut samples = Vec::new();
850
851        for subtitle in subtitles {
852            let path = subtitle.path.clone();
853            crate::core::fs_util::check_file_size(
854                &path,
855                self.config.max_subtitle_bytes,
856                "Subtitle",
857            )
858            .map_err(SubXError::Io)?;
859            let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
860                .await
861                .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
862            let preview = self.create_content_preview(&content);
863
864            samples.push(ContentSample {
865                filename: subtitle.name.clone(),
866                content_preview: preview,
867                file_size: subtitle.size,
868            });
869        }
870
871        Ok(samples)
872    }
873
874    fn create_content_preview(&self, content: &str) -> String {
875        let lines: Vec<&str> = content.lines().take(20).collect();
876        let preview = lines.join("\n");
877
878        if preview.len() > self.config.max_sample_length {
879            format!("{}...", &preview[..self.config.max_sample_length])
880        } else {
881            preview
882        }
883    }
884
885    fn generate_subtitle_name(&self, video: &MediaFile, subtitle: &MediaFile) -> String {
886        let detector = LanguageDetector::new();
887
888        // Remove the extension from the video file name (if any)
889        let video_base_name = if !video.extension.is_empty() {
890            video
891                .name
892                .strip_suffix(&format!(".{}", video.extension))
893                .unwrap_or(&video.name)
894        } else {
895            &video.name
896        };
897
898        if let Some(code) = detector.get_primary_language(&subtitle.path) {
899            format!("{}.{}.{}", video_base_name, code, subtitle.extension)
900        } else {
901            format!("{}.{}", video_base_name, subtitle.extension)
902        }
903    }
904
905    /// Execute match operations with dry-run mode support.
906    ///
907    /// When `dry_run` is false, a transactional journal is written to
908    /// [`journal_path`] recording every successfully completed operation.
909    /// The journal is saved atomically after each operation so that a
910    /// crash mid-batch still leaves a consistent, resumable on-disk
911    /// record. No journal is written in dry-run mode or when the batch
912    /// performs zero operations.
913    pub async fn execute_operations(
914        &self,
915        operations: &[MatchOperation],
916        dry_run: bool,
917    ) -> Result<()> {
918        if dry_run {
919            // Text-mode dry-run preview. JSON mode never reaches this path
920            // (the match command takes the JSON branch via
921            // `execute_operations_audit` before calling this method), but
922            // gate defensively so a future caller cannot corrupt the
923            // single-envelope contract.
924            let json_mode = crate::cli::output::active_mode().is_json();
925            for op in operations {
926                if !json_mode {
927                    println!(
928                        "Preview: {} -> {}",
929                        op.subtitle_file.name, op.new_subtitle_name
930                    );
931                }
932                if op.requires_relocation {
933                    if let Some(target_path) = &op.relocation_target_path {
934                        let operation_verb = match op.relocation_mode {
935                            FileRelocationMode::Copy => "Copy",
936                            FileRelocationMode::Move => "Move",
937                            _ => "",
938                        };
939                        if !json_mode {
940                            println!(
941                                "Preview: {} {} to {}",
942                                operation_verb,
943                                op.subtitle_file.path.display(),
944                                target_path.display()
945                            );
946                        }
947                    }
948                }
949            }
950            return Ok(());
951        }
952
953        // Prepare a fresh journal for this batch. The journal is saved
954        // atomically after each successful operation so that the on-disk
955        // record never diverges from the in-memory state by more than a
956        // single completed entry.
957        let created_at = std::time::SystemTime::now()
958            .duration_since(std::time::UNIX_EPOCH)
959            .map(|d| d.as_secs())
960            .unwrap_or(0);
961        let batch_id = {
962            use std::collections::hash_map::DefaultHasher;
963            use std::hash::{Hash, Hasher};
964            let mut hasher = DefaultHasher::new();
965            created_at.hash(&mut hasher);
966            operations.len().hash(&mut hasher);
967            for op in operations {
968                op.subtitle_file.path.hash(&mut hasher);
969                op.new_subtitle_name.hash(&mut hasher);
970            }
971            format!("{:016x}", hasher.finish())
972        };
973        let mut journal = JournalData {
974            batch_id,
975            created_at,
976            entries: Vec::new(),
977        };
978        let journal_file = journal_path().ok();
979
980        let mut first_error: Option<SubXError> = None;
981
982        for op in operations {
983            // Build the task list the same way the previous implementation
984            // did so relocation, backup and rename semantics are preserved.
985            let mut backup_path: Option<PathBuf> = None;
986
987            if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
988                let backup_task =
989                    self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
990                if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
991                    backup_path = Some(backup.clone());
992                }
993                if let TaskResult::Failed(err) = backup_task.execute().await {
994                    first_error = Some(SubXError::FileOperationFailed(err));
995                    break;
996                }
997            }
998
999            // Either a copy-with-rename task (Copy mode) or a rename task
1000            // (Move / None modes) produces the primary journal entry for
1001            // this operation.
1002            let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1003                self.create_copy_task(op)
1004            } else {
1005                self.create_rename_task(op)
1006            };
1007
1008            let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1009            {
1010                ProcessingOperation::CopyWithRename { source, target }
1011                | ProcessingOperation::CopyToVideoFolder { source, target } => {
1012                    (source.clone(), target.clone(), JournalOperationType::Copied)
1013                }
1014                ProcessingOperation::MoveToVideoFolder { source, target } => {
1015                    (source.clone(), target.clone(), JournalOperationType::Moved)
1016                }
1017                ProcessingOperation::RenameFile { source, target } => {
1018                    let kind = match op.relocation_mode {
1019                        FileRelocationMode::Move => JournalOperationType::Moved,
1020                        _ => JournalOperationType::Renamed,
1021                    };
1022                    (source.clone(), target.clone(), kind)
1023                }
1024                _ => (
1025                    op.subtitle_file.path.clone(),
1026                    op.relocation_target_path.clone().unwrap_or_else(|| {
1027                        op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1028                    }),
1029                    JournalOperationType::Renamed,
1030                ),
1031            };
1032
1033            // Capture source metadata before execution because move/rename
1034            // will invalidate the source path afterwards.
1035            let (pre_file_size, pre_file_mtime) = journal_source
1036                .metadata()
1037                .ok()
1038                .map(|m| {
1039                    let mtime = m
1040                        .modified()
1041                        .ok()
1042                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1043                        .map(|d| d.as_secs())
1044                        .unwrap_or(0);
1045                    (m.len(), mtime)
1046                })
1047                .unwrap_or((0, 0));
1048
1049            if let TaskResult::Failed(err) = primary_task.execute().await {
1050                first_error = Some(SubXError::FileOperationFailed(err));
1051                break;
1052            }
1053
1054            // Record destination metadata after execution so that rollback
1055            // integrity checks compare against the actual destination state.
1056            let (file_size, file_mtime) = journal_destination
1057                .metadata()
1058                .ok()
1059                .map(|m| {
1060                    let mtime = m
1061                        .modified()
1062                        .ok()
1063                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1064                        .map(|d| d.as_secs())
1065                        .unwrap_or(0);
1066                    (m.len(), mtime)
1067                })
1068                .unwrap_or((pre_file_size, pre_file_mtime));
1069
1070            journal.entries.push(JournalEntry {
1071                operation_type: journal_kind,
1072                source: journal_source,
1073                destination: journal_destination,
1074                backup_path: backup_path.clone(),
1075                status: JournalEntryStatus::Completed,
1076                file_size,
1077                file_mtime,
1078            });
1079
1080            if let Some(path) = journal_file.as_ref() {
1081                // Persist after every successful operation so interruption
1082                // leaves the on-disk journal consistent with the file system.
1083                journal.save(path).await?;
1084            }
1085        }
1086
1087        if let Some(err) = first_error {
1088            return Err(err);
1089        }
1090        Ok(())
1091    }
1092
1093    /// Auditable variant of [`MatchEngine::execute_operations`] that does NOT
1094    /// abort on first failure. Each operation produces an [`OperationOutcome`]
1095    /// describing whether it was applied or which error blocked it.
1096    ///
1097    /// This is the engine entry point used by JSON output mode to surface
1098    /// per-item statuses in the success envelope.
1099    pub async fn execute_operations_audit(
1100        &self,
1101        operations: &[MatchOperation],
1102        dry_run: bool,
1103    ) -> Result<Vec<OperationOutcome>> {
1104        if dry_run {
1105            return Ok(operations
1106                .iter()
1107                .map(|_| OperationOutcome {
1108                    applied: false,
1109                    error: None,
1110                })
1111                .collect());
1112        }
1113
1114        let created_at = std::time::SystemTime::now()
1115            .duration_since(std::time::UNIX_EPOCH)
1116            .map(|d| d.as_secs())
1117            .unwrap_or(0);
1118        let batch_id = {
1119            use std::collections::hash_map::DefaultHasher;
1120            use std::hash::{Hash, Hasher};
1121            let mut hasher = DefaultHasher::new();
1122            created_at.hash(&mut hasher);
1123            operations.len().hash(&mut hasher);
1124            for op in operations {
1125                op.subtitle_file.path.hash(&mut hasher);
1126                op.new_subtitle_name.hash(&mut hasher);
1127            }
1128            format!("{:016x}", hasher.finish())
1129        };
1130        let mut journal = JournalData {
1131            batch_id,
1132            created_at,
1133            entries: Vec::new(),
1134        };
1135        let journal_file = journal_path().ok();
1136
1137        let mut outcomes = Vec::with_capacity(operations.len());
1138
1139        for op in operations {
1140            let mut backup_path: Option<PathBuf> = None;
1141
1142            if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
1143                let backup_task =
1144                    self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
1145                if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
1146                    backup_path = Some(backup.clone());
1147                }
1148                if let TaskResult::Failed(err) = backup_task.execute().await {
1149                    let err = SubXError::FileOperationFailed(err);
1150                    outcomes.push(OperationOutcome {
1151                        applied: false,
1152                        error: Some(operation_error_from(&err)),
1153                    });
1154                    continue;
1155                }
1156            }
1157
1158            let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1159                self.create_copy_task(op)
1160            } else {
1161                self.create_rename_task(op)
1162            };
1163
1164            let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1165            {
1166                ProcessingOperation::CopyWithRename { source, target }
1167                | ProcessingOperation::CopyToVideoFolder { source, target } => {
1168                    (source.clone(), target.clone(), JournalOperationType::Copied)
1169                }
1170                ProcessingOperation::MoveToVideoFolder { source, target } => {
1171                    (source.clone(), target.clone(), JournalOperationType::Moved)
1172                }
1173                ProcessingOperation::RenameFile { source, target } => {
1174                    let kind = match op.relocation_mode {
1175                        FileRelocationMode::Move => JournalOperationType::Moved,
1176                        _ => JournalOperationType::Renamed,
1177                    };
1178                    (source.clone(), target.clone(), kind)
1179                }
1180                _ => (
1181                    op.subtitle_file.path.clone(),
1182                    op.relocation_target_path.clone().unwrap_or_else(|| {
1183                        op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1184                    }),
1185                    JournalOperationType::Renamed,
1186                ),
1187            };
1188
1189            let (pre_file_size, pre_file_mtime) = journal_source
1190                .metadata()
1191                .ok()
1192                .map(|m| {
1193                    let mtime = m
1194                        .modified()
1195                        .ok()
1196                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1197                        .map(|d| d.as_secs())
1198                        .unwrap_or(0);
1199                    (m.len(), mtime)
1200                })
1201                .unwrap_or((0, 0));
1202
1203            if let TaskResult::Failed(err) = primary_task.execute().await {
1204                let err = SubXError::FileOperationFailed(err);
1205                outcomes.push(OperationOutcome {
1206                    applied: false,
1207                    error: Some(operation_error_from(&err)),
1208                });
1209                continue;
1210            }
1211
1212            let (file_size, file_mtime) = journal_destination
1213                .metadata()
1214                .ok()
1215                .map(|m| {
1216                    let mtime = m
1217                        .modified()
1218                        .ok()
1219                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1220                        .map(|d| d.as_secs())
1221                        .unwrap_or(0);
1222                    (m.len(), mtime)
1223                })
1224                .unwrap_or((pre_file_size, pre_file_mtime));
1225
1226            journal.entries.push(JournalEntry {
1227                operation_type: journal_kind,
1228                source: journal_source,
1229                destination: journal_destination,
1230                backup_path: backup_path.clone(),
1231                status: JournalEntryStatus::Completed,
1232                file_size,
1233                file_mtime,
1234            });
1235
1236            if let Some(path) = journal_file.as_ref() {
1237                journal.save(path).await?;
1238            }
1239
1240            outcomes.push(OperationOutcome {
1241                applied: true,
1242                error: None,
1243            });
1244        }
1245
1246        Ok(outcomes)
1247    }
1248
1249    /// Rename subtitle file by delegating to FileProcessingTask
1250    async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
1251        let task = self.create_rename_task(op);
1252        match task.execute().await {
1253            TaskResult::Success(_) => Ok(()),
1254            TaskResult::Failed(err) => Err(SubXError::FileOperationFailed(err)),
1255            other => Err(SubXError::FileOperationFailed(format!(
1256                "Unexpected rename result: {:?}",
1257                other
1258            ))),
1259        }
1260    }
1261
1262    /// Resolve filename conflicts by adding numeric suffix
1263    fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
1264        if !target.exists() {
1265            return Ok(target);
1266        }
1267        let json_mode = crate::cli::output::active_mode().is_json();
1268        match self.config.conflict_resolution {
1269            ConflictResolution::Skip => {
1270                if !json_mode {
1271                    eprintln!(
1272                        "Warning: Skipping relocation due to existing file: {}",
1273                        target.display()
1274                    );
1275                }
1276                Ok(target)
1277            }
1278            ConflictResolution::AutoRename => {
1279                let file_stem = target
1280                    .file_stem()
1281                    .and_then(|s| s.to_str())
1282                    .unwrap_or("file");
1283                let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
1284                let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
1285                // Try the base name first via atomic create; if it succeeds we drop
1286                // the handle immediately since the downstream FileProcessingTask
1287                // performs its own I/O against this path.
1288                match crate::core::fs_util::atomic_create_file(&target) {
1289                    Ok(_f) => {
1290                        // Remove the placeholder so the downstream task can create it.
1291                        let _ = std::fs::remove_file(&target);
1292                        return Ok(target);
1293                    }
1294                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
1295                    Err(e) => return Err(SubXError::from(e)),
1296                }
1297                for i in 1..1000 {
1298                    let new_name = if extension.is_empty() {
1299                        format!("{}.{}", file_stem, i)
1300                    } else {
1301                        format!("{}.{}.{}", file_stem, i, extension)
1302                    };
1303                    let new_path = parent.join(new_name);
1304                    match crate::core::fs_util::atomic_create_file(&new_path) {
1305                        Ok(_f) => {
1306                            let _ = std::fs::remove_file(&new_path);
1307                            return Ok(new_path);
1308                        }
1309                        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
1310                        Err(e) => return Err(SubXError::from(e)),
1311                    }
1312                }
1313                Err(SubXError::FileOperationFailed(
1314                    "Could not resolve filename conflict".to_string(),
1315                ))
1316            }
1317            ConflictResolution::Prompt => {
1318                if !json_mode {
1319                    eprintln!(
1320                        "Warning: Conflict resolution prompt not implemented, using auto-rename"
1321                    );
1322                }
1323                self.resolve_filename_conflict(target)
1324            }
1325        }
1326    }
1327
1328    /// Create a task to copy (or rename) a file with new name
1329    fn create_copy_task(&self, op: &MatchOperation) -> FileProcessingTask {
1330        // In copy mode, always use the original subtitle file as source
1331        let source = op.subtitle_file.path.clone();
1332        let target_base = op.relocation_target_path.clone().unwrap();
1333        let final_target = self.resolve_filename_conflict(target_base).unwrap();
1334        FileProcessingTask::new(
1335            source.clone(),
1336            Some(final_target.clone()),
1337            ProcessingOperation::CopyWithRename {
1338                source,
1339                target: final_target,
1340            },
1341        )
1342    }
1343
1344    /// Create a task to backup a file
1345    fn create_backup_task(&self, source: &std::path::Path, ext: &str) -> FileProcessingTask {
1346        let backup_path = source.with_extension(format!("{}.backup", ext));
1347        FileProcessingTask::new(
1348            source.to_path_buf(),
1349            Some(backup_path.clone()),
1350            ProcessingOperation::CreateBackup {
1351                source: source.to_path_buf(),
1352                backup: backup_path,
1353            },
1354        )
1355    }
1356
1357    /// Create a task to rename (move) a file
1358    fn create_rename_task(&self, op: &MatchOperation) -> FileProcessingTask {
1359        let old = op.subtitle_file.path.clone();
1360        // If relocation is required, use the relocation target path
1361        let new_path = if op.requires_relocation && op.relocation_target_path.is_some() {
1362            let target_base = op.relocation_target_path.clone().unwrap();
1363            self.resolve_filename_conflict(target_base).unwrap()
1364        } else {
1365            old.with_file_name(&op.new_subtitle_name)
1366        };
1367
1368        FileProcessingTask::new(
1369            old.clone(),
1370            Some(new_path.clone()),
1371            ProcessingOperation::RenameFile {
1372                source: old,
1373                target: new_path,
1374            },
1375        )
1376    }
1377
1378    /// Calculate cache key for file list operations
1379    fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
1380        use std::collections::BTreeMap;
1381        use std::collections::hash_map::DefaultHasher;
1382        use std::hash::{Hash, Hasher};
1383
1384        // Sort paths to ensure consistent key generation
1385        let mut path_metadata = BTreeMap::new();
1386        for path in file_paths {
1387            if let Ok(metadata) = path.metadata() {
1388                let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1389                path_metadata.insert(
1390                    canonical.to_string_lossy().to_string(),
1391                    (metadata.len(), metadata.modified().ok()),
1392                );
1393            }
1394        }
1395
1396        // Include config hash to invalidate cache when configuration changes
1397        let config_hash = self.calculate_config_hash()?;
1398
1399        let mut hasher = DefaultHasher::new();
1400        path_metadata.hash(&mut hasher);
1401        config_hash.hash(&mut hasher);
1402
1403        Ok(format!("filelist_{:016x}", hasher.finish()))
1404    }
1405
1406    /// Check cache for file list operations
1407    async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
1408        let cache_file_path = self.get_cache_file_path()?;
1409        let cache_data = CacheData::load(&cache_file_path).ok();
1410
1411        if let Some(cache_data) = cache_data {
1412            if cache_data.directory == cache_key {
1413                // Rebuild match operation list for file list cache
1414                let mut ops = Vec::new();
1415                let mut id_gen = Uuidv7Generator::new();
1416                for item in cache_data.match_operations {
1417                    // For file list operations, we reconstruct operations from cached data
1418                    let video_path = PathBuf::from(&item.video_file);
1419                    let subtitle_path = PathBuf::from(&item.subtitle_file);
1420
1421                    if video_path.exists() && subtitle_path.exists() {
1422                        // Create minimal MediaFile objects for the operation
1423                        let video_meta = video_path.metadata()?;
1424                        let subtitle_meta = subtitle_path.metadata()?;
1425
1426                        let video_file = MediaFile {
1427                            id: generate_file_id(&mut id_gen),
1428                            path: video_path.clone(),
1429                            file_type: MediaFileType::Video,
1430                            size: video_meta.len(),
1431                            name: video_path
1432                                .file_name()
1433                                .unwrap()
1434                                .to_string_lossy()
1435                                .to_string(),
1436                            extension: video_path
1437                                .extension()
1438                                .unwrap_or_default()
1439                                .to_string_lossy()
1440                                .to_lowercase(),
1441                            relative_path: video_path
1442                                .file_name()
1443                                .unwrap()
1444                                .to_string_lossy()
1445                                .to_string(),
1446                        };
1447
1448                        let subtitle_file = MediaFile {
1449                            id: generate_file_id(&mut id_gen),
1450                            path: subtitle_path.clone(),
1451                            file_type: MediaFileType::Subtitle,
1452                            size: subtitle_meta.len(),
1453                            name: subtitle_path
1454                                .file_name()
1455                                .unwrap()
1456                                .to_string_lossy()
1457                                .to_string(),
1458                            extension: subtitle_path
1459                                .extension()
1460                                .unwrap_or_default()
1461                                .to_string_lossy()
1462                                .to_lowercase(),
1463                            relative_path: subtitle_path
1464                                .file_name()
1465                                .unwrap()
1466                                .to_string_lossy()
1467                                .to_string(),
1468                        };
1469
1470                        // Recalculate relocation information based on current configuration
1471                        let requires_relocation = self.config.relocation_mode
1472                            != FileRelocationMode::None
1473                            && subtitle_file.path.parent() != video_file.path.parent();
1474
1475                        let relocation_target_path = if requires_relocation {
1476                            let video_dir = video_file.path.parent().unwrap();
1477                            Some(video_dir.join(&item.new_subtitle_name))
1478                        } else {
1479                            None
1480                        };
1481
1482                        ops.push(MatchOperation {
1483                            video_file,
1484                            subtitle_file,
1485                            new_subtitle_name: item.new_subtitle_name,
1486                            confidence: item.confidence,
1487                            reasoning: item.reasoning,
1488                            relocation_mode: self.config.relocation_mode.clone(),
1489                            relocation_target_path,
1490                            requires_relocation,
1491                        });
1492                    }
1493                }
1494                return Ok(Some(ops));
1495            }
1496        }
1497        Ok(None)
1498    }
1499
1500    /// Save cache for file list operations
1501    async fn save_file_list_cache(
1502        &self,
1503        cache_key: &str,
1504        operations: &[MatchOperation],
1505    ) -> Result<()> {
1506        let cache_file_path = self.get_cache_file_path()?;
1507        let config_hash = self.calculate_config_hash()?;
1508
1509        let mut cache_items = Vec::new();
1510        for op in operations {
1511            cache_items.push(OpItem {
1512                video_file: op.video_file.path.to_string_lossy().to_string(),
1513                subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
1514                new_subtitle_name: op.new_subtitle_name.clone(),
1515                confidence: op.confidence,
1516                reasoning: op.reasoning.clone(),
1517            });
1518        }
1519
1520        // Build file snapshot with canonical paths, sizes, and mtimes
1521        let mut snapshot_items = Vec::new();
1522        let mut seen_paths = std::collections::HashSet::new();
1523        for op in operations {
1524            for path in [&op.video_file.path, &op.subtitle_file.path] {
1525                let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
1526                let key = canonical.to_string_lossy().to_string();
1527                if seen_paths.insert(key.clone()) {
1528                    if let Ok(meta) = std::fs::metadata(&canonical) {
1529                        let mtime = meta
1530                            .modified()
1531                            .ok()
1532                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1533                            .map(|d| d.as_secs())
1534                            .unwrap_or(0);
1535                        snapshot_items.push(SnapshotItem {
1536                            path: key,
1537                            name: canonical
1538                                .file_name()
1539                                .unwrap_or_default()
1540                                .to_string_lossy()
1541                                .to_string(),
1542                            size: meta.len(),
1543                            mtime,
1544                            file_type: if canonical.extension().is_some_and(|e| {
1545                                ["srt", "ass", "ssa", "vtt", "sub"]
1546                                    .contains(&e.to_string_lossy().to_lowercase().as_str())
1547                            }) {
1548                                "subtitle".to_string()
1549                            } else {
1550                                "video".to_string()
1551                            },
1552                        });
1553                    }
1554                }
1555            }
1556        }
1557
1558        let cache_data = CacheData {
1559            cache_version: "1.0".to_string(),
1560            directory: cache_key.to_string(),
1561            file_snapshot: snapshot_items,
1562            match_operations: cache_items,
1563            created_at: std::time::SystemTime::now()
1564                .duration_since(std::time::UNIX_EPOCH)
1565                .unwrap()
1566                .as_secs(),
1567            ai_model_used: self.config.ai_model.clone(),
1568            config_hash,
1569            original_relocation_mode: format!("{:?}", self.config.relocation_mode),
1570            original_backup_enabled: self.config.backup_enabled,
1571        };
1572
1573        // Save cache data to file
1574        let cache_dir = cache_file_path.parent().unwrap().to_path_buf();
1575        let cache_json = serde_json::to_string_pretty(&cache_data)?;
1576        let cache_file_path_clone = cache_file_path.clone();
1577        tokio::task::spawn_blocking(move || -> std::io::Result<()> {
1578            std::fs::create_dir_all(&cache_dir)?;
1579            std::fs::write(&cache_file_path_clone, cache_json)?;
1580            Ok(())
1581        })
1582        .await
1583        .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
1584
1585        Ok(())
1586    }
1587
1588    /// Get cache file path
1589    fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
1590        // First check XDG_CONFIG_HOME environment variable (used for testing)
1591        let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
1592            std::path::PathBuf::from(xdg_config)
1593        } else {
1594            dirs::config_dir()
1595                .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
1596        };
1597        Ok(dir.join("subx").join("match_cache.json"))
1598    }
1599
1600    /// Calculate current configuration hash for cache validation
1601    fn calculate_config_hash(&self) -> Result<String> {
1602        use std::collections::hash_map::DefaultHasher;
1603        use std::hash::{Hash, Hasher};
1604
1605        let mut hasher = DefaultHasher::new();
1606        // Add configuration items that affect cache validity to the hash
1607        format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
1608        self.config.backup_enabled.hash(&mut hasher);
1609        // Add other relevant configuration items
1610
1611        Ok(format!("{:016x}", hasher.finish()))
1612    }
1613
1614    /// Find a media file by ID, with an optional fallback to relative path or name.
1615    fn find_media_file_by_id_or_path<'a>(
1616        files: &'a [&MediaFile],
1617        file_id: &str,
1618        fallback_path: Option<&str>,
1619    ) -> Option<&'a MediaFile> {
1620        if let Some(file) = files.iter().find(|f| f.id == file_id) {
1621            return Some(*file);
1622        }
1623        if let Some(path) = fallback_path {
1624            if let Some(file) = files.iter().find(|f| f.relative_path == path) {
1625                return Some(*file);
1626            }
1627            files.iter().find(|f| f.name == path).copied()
1628        } else {
1629            None
1630        }
1631    }
1632
1633    /// Log available files to assist debugging when a match is not found.
1634    fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
1635        if crate::cli::output::active_mode().is_json() {
1636            return;
1637        }
1638        eprintln!("   Available {} files:", file_type);
1639        for f in files {
1640            eprintln!(
1641                "     - ID: {} | Name: {} | Path: {}",
1642                f.id, f.name, f.relative_path
1643            );
1644        }
1645    }
1646
1647    /// Provide detailed information when no matches are found.
1648    fn log_no_matches_found(
1649        &self,
1650        match_result: &MatchResult,
1651        videos: &[MediaFile],
1652        subtitles: &[MediaFile],
1653    ) {
1654        if crate::cli::output::active_mode().is_json() {
1655            return;
1656        }
1657        eprintln!("\nāŒ No matching files found that meet the criteria");
1658        eprintln!("šŸ” AI analysis results:");
1659        eprintln!("   - Total matches: {}", match_result.matches.len());
1660        eprintln!(
1661            "   - Confidence threshold: {:.2}",
1662            self.config.confidence_threshold
1663        );
1664        eprintln!(
1665            "   - Matches meeting threshold: {}",
1666            match_result
1667                .matches
1668                .iter()
1669                .filter(|m| m.confidence >= self.config.confidence_threshold)
1670                .count()
1671        );
1672        eprintln!("\nšŸ“‚ Scanned files:");
1673        eprintln!("   Video files ({} files):", videos.len());
1674        for v in videos {
1675            eprintln!("     - ID: {} | {}", v.id, v.relative_path);
1676        }
1677        eprintln!("   Subtitle files ({} files):", subtitles.len());
1678        for s in subtitles {
1679            eprintln!("     - ID: {} | {}", s.id, s.relative_path);
1680        }
1681    }
1682}
1683
1684/// Replay a frozen set of cached match operations.
1685///
1686/// This helper powers the `cache apply` command. It reconstructs
1687/// [`MatchOperation`] values from the paths recorded in `cache`, without
1688/// performing any additional AI analysis or validation, and then feeds
1689/// them through the standard [`MatchEngine::execute_operations`] pipeline
1690/// so the same journal and file-system guarantees apply.
1691///
1692/// The provided `config` determines runtime behaviour such as relocation
1693/// mode, backup handling and conflict resolution. Callers are expected to
1694/// synchronise the config with the original cache's recorded values when
1695/// strict replay fidelity is required.
1696///
1697/// # Errors
1698///
1699/// Returns an error if any cached source path cannot be read or if the
1700/// underlying execution pipeline fails.
1701pub async fn apply_cached_operations(cache: &CacheData, config: &MatchConfig) -> Result<()> {
1702    let operations = reconstruct_operations_from_cache(cache, config)?;
1703    let engine = MatchEngine::new(Box::new(NoOpAIProvider), config.clone());
1704    engine.execute_operations(&operations, false).await
1705}
1706
1707/// Rebuild [`MatchOperation`] values from a [`CacheData`] payload.
1708///
1709/// Silently skips entries whose source files no longer exist so the replay
1710/// is resilient to partial state (e.g., an earlier apply completed some
1711/// operations but was interrupted).
1712fn reconstruct_operations_from_cache(
1713    cache: &CacheData,
1714    config: &MatchConfig,
1715) -> Result<Vec<MatchOperation>> {
1716    let mut ops = Vec::new();
1717    let mut id_gen = Uuidv7Generator::new();
1718    for item in &cache.match_operations {
1719        let video_path = PathBuf::from(&item.video_file);
1720        let subtitle_path = PathBuf::from(&item.subtitle_file);
1721
1722        if !video_path.exists() || !subtitle_path.exists() {
1723            continue;
1724        }
1725
1726        let video_meta = video_path.metadata()?;
1727        let subtitle_meta = subtitle_path.metadata()?;
1728
1729        let video_file = MediaFile {
1730            id: generate_file_id(&mut id_gen),
1731            path: video_path.clone(),
1732            file_type: MediaFileType::Video,
1733            size: video_meta.len(),
1734            name: video_path
1735                .file_name()
1736                .unwrap_or_default()
1737                .to_string_lossy()
1738                .to_string(),
1739            extension: video_path
1740                .extension()
1741                .unwrap_or_default()
1742                .to_string_lossy()
1743                .to_lowercase(),
1744            relative_path: video_path
1745                .file_name()
1746                .unwrap_or_default()
1747                .to_string_lossy()
1748                .to_string(),
1749        };
1750
1751        let subtitle_file = MediaFile {
1752            id: generate_file_id(&mut id_gen),
1753            path: subtitle_path.clone(),
1754            file_type: MediaFileType::Subtitle,
1755            size: subtitle_meta.len(),
1756            name: subtitle_path
1757                .file_name()
1758                .unwrap_or_default()
1759                .to_string_lossy()
1760                .to_string(),
1761            extension: subtitle_path
1762                .extension()
1763                .unwrap_or_default()
1764                .to_string_lossy()
1765                .to_lowercase(),
1766            relative_path: subtitle_path
1767                .file_name()
1768                .unwrap_or_default()
1769                .to_string_lossy()
1770                .to_string(),
1771        };
1772
1773        let requires_relocation = config.relocation_mode != FileRelocationMode::None
1774            && subtitle_file.path.parent() != video_file.path.parent();
1775        let relocation_target_path = if requires_relocation {
1776            video_file
1777                .path
1778                .parent()
1779                .map(|p| p.join(&item.new_subtitle_name))
1780        } else {
1781            None
1782        };
1783
1784        ops.push(MatchOperation {
1785            video_file,
1786            subtitle_file,
1787            new_subtitle_name: item.new_subtitle_name.clone(),
1788            confidence: item.confidence,
1789            reasoning: item.reasoning.clone(),
1790            relocation_mode: config.relocation_mode.clone(),
1791            relocation_target_path,
1792            requires_relocation,
1793        });
1794    }
1795    Ok(ops)
1796}
1797
1798/// AI provider stub used by [`apply_cached_operations`].
1799///
1800/// `execute_operations` never calls the AI service, so replaying a cached
1801/// plan does not require a real provider; this stub panics defensively if
1802/// accidentally invoked.
1803struct NoOpAIProvider;
1804
1805#[async_trait::async_trait]
1806impl AIProvider for NoOpAIProvider {
1807    async fn analyze_content(&self, _request: AnalysisRequest) -> crate::Result<MatchResult> {
1808        Err(SubXError::config(
1809            "AI analysis is not available while replaying cached operations",
1810        ))
1811    }
1812
1813    async fn verify_match(
1814        &self,
1815        _verification: crate::services::ai::VerificationRequest,
1816    ) -> crate::Result<crate::services::ai::ConfidenceScore> {
1817        Err(SubXError::config(
1818            "AI verification is not available while replaying cached operations",
1819        ))
1820    }
1821}