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::Path;
17
18use crate::Result;
19use crate::core::language::LanguageDetector;
20use crate::core::matcher::cache::{CacheData, OpItem, SnapshotItem};
21use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
22
23use crate::error::SubXError;
24use dirs;
25use serde_json;
26
27/// File relocation mode for matched subtitle files
28#[derive(Debug, Clone, PartialEq)]
29pub enum FileRelocationMode {
30    /// No file relocation
31    None,
32    /// Copy subtitle files to video folders
33    Copy,
34    /// Move subtitle files to video folders
35    Move,
36}
37
38/// Strategy for handling filename conflicts during relocation
39#[derive(Debug, Clone)]
40pub enum ConflictResolution {
41    /// Skip relocation if conflict exists
42    Skip,
43    /// Automatically rename with numeric suffix
44    AutoRename,
45    /// Prompt user for decision (interactive mode only)
46    Prompt,
47}
48
49/// Configuration settings for the file matching engine.
50///
51/// Controls various aspects of the subtitle-to-video matching process,
52/// including confidence thresholds and analysis options.
53#[derive(Debug, Clone)]
54pub struct MatchConfig {
55    /// Minimum confidence score required for a successful match (0.0 to 1.0)
56    pub confidence_threshold: f32,
57    /// Maximum number of characters to sample from subtitle content
58    pub max_sample_length: usize,
59    /// Whether to enable advanced content analysis for matching
60    pub enable_content_analysis: bool,
61    /// Whether to create backup files before operations
62    pub backup_enabled: bool,
63    /// File relocation mode
64    pub relocation_mode: FileRelocationMode,
65    /// Strategy for handling filename conflicts during relocation
66    pub conflict_resolution: ConflictResolution,
67}
68
69#[cfg(test)]
70mod language_name_tests {
71    use super::*;
72    use crate::core::matcher::discovery::{MediaFile, MediaFileType};
73    use crate::services::ai::{
74        AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
75    };
76    use async_trait::async_trait;
77    use std::path::PathBuf;
78
79    struct DummyAI;
80    #[async_trait]
81    impl AIProvider for DummyAI {
82        async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
83            unimplemented!()
84        }
85        async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
86            unimplemented!()
87        }
88    }
89
90    #[test]
91    fn test_generate_subtitle_name_with_directory_language() {
92        let engine = MatchEngine::new(
93            Box::new(DummyAI),
94            MatchConfig {
95                confidence_threshold: 0.0,
96                max_sample_length: 0,
97                enable_content_analysis: false,
98                backup_enabled: false,
99                relocation_mode: FileRelocationMode::None,
100                conflict_resolution: ConflictResolution::Skip,
101            },
102        );
103        let video = MediaFile {
104            id: "".to_string(),
105            relative_path: "".to_string(),
106            path: PathBuf::from("movie01.mp4"),
107            file_type: MediaFileType::Video,
108            size: 0,
109            name: "movie01".to_string(),
110            extension: "mp4".to_string(),
111        };
112        let subtitle = MediaFile {
113            id: "".to_string(),
114            relative_path: "".to_string(),
115            path: PathBuf::from("tc/subtitle01.ass"),
116            file_type: MediaFileType::Subtitle,
117            size: 0,
118            name: "subtitle01".to_string(),
119            extension: "ass".to_string(),
120        };
121        let new_name = engine.generate_subtitle_name(&video, &subtitle);
122        assert_eq!(new_name, "movie01.tc.ass");
123    }
124
125    #[test]
126    fn test_generate_subtitle_name_with_filename_language() {
127        let engine = MatchEngine::new(
128            Box::new(DummyAI),
129            MatchConfig {
130                confidence_threshold: 0.0,
131                max_sample_length: 0,
132                enable_content_analysis: false,
133                backup_enabled: false,
134                relocation_mode: FileRelocationMode::None,
135                conflict_resolution: ConflictResolution::Skip,
136            },
137        );
138        let video = MediaFile {
139            id: "".to_string(),
140            relative_path: "".to_string(),
141            path: PathBuf::from("movie02.mp4"),
142            file_type: MediaFileType::Video,
143            size: 0,
144            name: "movie02".to_string(),
145            extension: "mp4".to_string(),
146        };
147        let subtitle = MediaFile {
148            id: "".to_string(),
149            relative_path: "".to_string(),
150            path: PathBuf::from("subtitle02.sc.ass"),
151            file_type: MediaFileType::Subtitle,
152            size: 0,
153            name: "subtitle02".to_string(),
154            extension: "ass".to_string(),
155        };
156        let new_name = engine.generate_subtitle_name(&video, &subtitle);
157        assert_eq!(new_name, "movie02.sc.ass");
158    }
159
160    #[test]
161    fn test_generate_subtitle_name_without_language() {
162        let engine = MatchEngine::new(
163            Box::new(DummyAI),
164            MatchConfig {
165                confidence_threshold: 0.0,
166                max_sample_length: 0,
167                enable_content_analysis: false,
168                backup_enabled: false,
169                relocation_mode: FileRelocationMode::None,
170                conflict_resolution: ConflictResolution::Skip,
171            },
172        );
173        let video = MediaFile {
174            id: "".to_string(),
175            relative_path: "".to_string(),
176            path: PathBuf::from("movie03.mp4"),
177            file_type: MediaFileType::Video,
178            size: 0,
179            name: "movie03".to_string(),
180            extension: "mp4".to_string(),
181        };
182        let subtitle = MediaFile {
183            id: "".to_string(),
184            relative_path: "".to_string(),
185            path: PathBuf::from("subtitle03.ass"),
186            file_type: MediaFileType::Subtitle,
187            size: 0,
188            name: "subtitle03".to_string(),
189            extension: "ass".to_string(),
190        };
191        let new_name = engine.generate_subtitle_name(&video, &subtitle);
192        assert_eq!(new_name, "movie03.ass");
193    }
194    #[test]
195    fn test_generate_subtitle_name_removes_video_extension() {
196        let engine = MatchEngine::new(
197            Box::new(DummyAI),
198            MatchConfig {
199                confidence_threshold: 0.0,
200                max_sample_length: 0,
201                enable_content_analysis: false,
202                backup_enabled: false,
203                relocation_mode: FileRelocationMode::None,
204                conflict_resolution: ConflictResolution::Skip,
205            },
206        );
207        let video = MediaFile {
208            id: "".to_string(),
209            relative_path: "".to_string(),
210            path: PathBuf::from("movie.mkv"),
211            file_type: MediaFileType::Video,
212            size: 0,
213            name: "movie.mkv".to_string(),
214            extension: "mkv".to_string(),
215        };
216        let subtitle = MediaFile {
217            id: "".to_string(),
218            relative_path: "".to_string(),
219            path: PathBuf::from("subtitle.srt"),
220            file_type: MediaFileType::Subtitle,
221            size: 0,
222            name: "subtitle".to_string(),
223            extension: "srt".to_string(),
224        };
225        let new_name = engine.generate_subtitle_name(&video, &subtitle);
226        assert_eq!(new_name, "movie.srt");
227    }
228
229    #[test]
230    fn test_generate_subtitle_name_with_language_removes_video_extension() {
231        let engine = MatchEngine::new(
232            Box::new(DummyAI),
233            MatchConfig {
234                confidence_threshold: 0.0,
235                max_sample_length: 0,
236                enable_content_analysis: false,
237                backup_enabled: false,
238                relocation_mode: FileRelocationMode::None,
239                conflict_resolution: ConflictResolution::Skip,
240            },
241        );
242        let video = MediaFile {
243            id: "".to_string(),
244            relative_path: "".to_string(),
245            path: PathBuf::from("movie.mkv"),
246            file_type: MediaFileType::Video,
247            size: 0,
248            name: "movie.mkv".to_string(),
249            extension: "mkv".to_string(),
250        };
251        let subtitle = MediaFile {
252            id: "".to_string(),
253            relative_path: "".to_string(),
254            path: PathBuf::from("tc/subtitle.srt"),
255            file_type: MediaFileType::Subtitle,
256            size: 0,
257            name: "subtitle".to_string(),
258            extension: "srt".to_string(),
259        };
260        let new_name = engine.generate_subtitle_name(&video, &subtitle);
261        assert_eq!(new_name, "movie.tc.srt");
262    }
263
264    #[test]
265    fn test_generate_subtitle_name_edge_cases() {
266        let engine = MatchEngine::new(
267            Box::new(DummyAI),
268            MatchConfig {
269                confidence_threshold: 0.0,
270                max_sample_length: 0,
271                enable_content_analysis: false,
272                backup_enabled: false,
273                relocation_mode: FileRelocationMode::None,
274                conflict_resolution: ConflictResolution::Skip,
275            },
276        );
277        // File name contains multiple dots and no extension case
278        let video = MediaFile {
279            id: "".to_string(),
280            relative_path: "".to_string(),
281            path: PathBuf::from("a.b.c"),
282            file_type: MediaFileType::Video,
283            size: 0,
284            name: "a.b.c".to_string(),
285            extension: "".to_string(),
286        };
287        let subtitle = MediaFile {
288            id: "".to_string(),
289            relative_path: "".to_string(),
290            path: PathBuf::from("sub.srt"),
291            file_type: MediaFileType::Subtitle,
292            size: 0,
293            name: "sub".to_string(),
294            extension: "srt".to_string(),
295        };
296        let new_name = engine.generate_subtitle_name(&video, &subtitle);
297        assert_eq!(new_name, "a.b.c.srt");
298    }
299
300    #[tokio::test]
301    async fn test_rename_file_displays_success_check_mark() {
302        use std::fs;
303        use tempfile::TempDir;
304
305        let temp_dir = TempDir::new().unwrap();
306        let temp_path = temp_dir.path();
307
308        // Create a test file
309        let original_file = temp_path.join("original.srt");
310        fs::write(
311            &original_file,
312            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
313        )
314        .unwrap();
315
316        // Create a test MatchEngine
317        let engine = MatchEngine::new(
318            Box::new(DummyAI),
319            MatchConfig {
320                confidence_threshold: 0.0,
321                max_sample_length: 0,
322                enable_content_analysis: false,
323                backup_enabled: false,
324                relocation_mode: FileRelocationMode::None,
325                conflict_resolution: ConflictResolution::Skip,
326            },
327        );
328
329        // Create a MatchOperation
330        let subtitle_file = MediaFile {
331            id: "test_id".to_string(),
332            relative_path: "original.srt".to_string(),
333            path: original_file.clone(),
334            file_type: MediaFileType::Subtitle,
335            size: 40,
336            name: "original".to_string(),
337            extension: "srt".to_string(),
338        };
339
340        let match_op = MatchOperation {
341            video_file: MediaFile {
342                id: "video_id".to_string(),
343                relative_path: "test.mp4".to_string(),
344                path: temp_path.join("test.mp4"),
345                file_type: MediaFileType::Video,
346                size: 1000,
347                name: "test".to_string(),
348                extension: "mp4".to_string(),
349            },
350            subtitle_file,
351            new_subtitle_name: "renamed.srt".to_string(),
352            confidence: 95.0,
353            reasoning: vec!["Test match".to_string()],
354            requires_relocation: false,
355            relocation_target_path: None,
356            relocation_mode: FileRelocationMode::None,
357        };
358
359        // Execute the rename operation
360        let result = engine.rename_file(&match_op).await;
361
362        // Verify the operation was successful
363        assert!(result.is_ok());
364
365        // Verify the file has been renamed
366        let renamed_file = temp_path.join("renamed.srt");
367        assert!(renamed_file.exists(), "The renamed file should exist");
368        assert!(
369            !original_file.exists(),
370            "The original file should have been renamed"
371        );
372
373        // Verify the file content is correct
374        let content = fs::read_to_string(&renamed_file).unwrap();
375        assert!(content.contains("Test subtitle"));
376    }
377
378    #[tokio::test]
379    async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
380        use std::fs;
381        use tempfile::TempDir;
382
383        let temp_dir = TempDir::new().unwrap();
384        let temp_path = temp_dir.path();
385
386        // Create test file
387        let original_file = temp_path.join("original.srt");
388        fs::write(
389            &original_file,
390            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
391        )
392        .unwrap();
393
394        // Create a test MatchEngine
395        let engine = MatchEngine::new(
396            Box::new(DummyAI),
397            MatchConfig {
398                confidence_threshold: 0.0,
399                max_sample_length: 0,
400                enable_content_analysis: false,
401                backup_enabled: false,
402                relocation_mode: FileRelocationMode::None,
403                conflict_resolution: ConflictResolution::Skip,
404            },
405        );
406
407        // Create a MatchOperation
408        let subtitle_file = MediaFile {
409            id: "test_id".to_string(),
410            relative_path: "original.srt".to_string(),
411            path: original_file.clone(),
412            file_type: MediaFileType::Subtitle,
413            size: 40,
414            name: "original".to_string(),
415            extension: "srt".to_string(),
416        };
417
418        let match_op = MatchOperation {
419            video_file: MediaFile {
420                id: "video_id".to_string(),
421                relative_path: "test.mp4".to_string(),
422                path: temp_path.join("test.mp4"),
423                file_type: MediaFileType::Video,
424                size: 1000,
425                name: "test".to_string(),
426                extension: "mp4".to_string(),
427            },
428            subtitle_file,
429            new_subtitle_name: "renamed.srt".to_string(),
430            confidence: 95.0,
431            reasoning: vec!["Test match".to_string()],
432            requires_relocation: false,
433            relocation_target_path: None,
434            relocation_mode: FileRelocationMode::None,
435        };
436
437        // Simulate file not existing after operation
438        // First, execute the rename operation normally
439        let result = engine.rename_file(&match_op).await;
440        assert!(result.is_ok());
441
442        // Manually delete the renamed file to simulate failure
443        let renamed_file = temp_path.join("renamed.srt");
444        if renamed_file.exists() {
445            fs::remove_file(&renamed_file).unwrap();
446        }
447
448        // Recreate the original file for the second test
449        fs::write(
450            &original_file,
451            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
452        )
453        .unwrap();
454
455        // Create a rename operation that will fail, by overwriting the rename implementation
456        // Since we cannot directly simulate std::fs::rename failure with file not existing,
457        // we test the scenario where the file is manually removed after the operation completes
458        let result = engine.rename_file(&match_op).await;
459        assert!(result.is_ok());
460
461        // Manually delete the file again
462        let renamed_file = temp_path.join("renamed.srt");
463        if renamed_file.exists() {
464            fs::remove_file(&renamed_file).unwrap();
465        }
466
467        // This test mainly verifies the code structure is correct, the actual error message display needs to be validated through integration tests
468        // Because we cannot easily simulate the scenario where the file system operation succeeds but the file does not exist
469    }
470
471    #[test]
472    fn test_file_operation_message_format() {
473        // Test error message format
474        let source_name = "test.srt";
475        let target_name = "renamed.srt";
476
477        // Simulate success message format
478        let success_msg = format!("  ✓ Renamed: {} -> {}", source_name, target_name);
479        assert!(success_msg.contains("✓"));
480        assert!(success_msg.contains("Renamed:"));
481        assert!(success_msg.contains(source_name));
482        assert!(success_msg.contains(target_name));
483
484        // Simulate failure message format
485        let error_msg = format!(
486            "  ✗ Rename failed: {} -> {} (target file does not exist after operation)",
487            source_name, target_name
488        );
489        assert!(error_msg.contains("✗"));
490        assert!(error_msg.contains("Rename failed:"));
491        assert!(error_msg.contains("target file does not exist"));
492        assert!(error_msg.contains(source_name));
493        assert!(error_msg.contains(target_name));
494    }
495
496    #[test]
497    fn test_copy_operation_message_format() {
498        // Test copy operation message format
499        let source_name = "subtitle.srt";
500        let target_name = "video.srt";
501
502        // Simulate success message format
503        let success_msg = format!("  ✓ Copied: {} -> {}", source_name, target_name);
504        assert!(success_msg.contains("✓"));
505        assert!(success_msg.contains("Copied:"));
506
507        // Simulate failure message format
508        let error_msg = format!(
509            "  ✗ Copy failed: {} -> {} (target file does not exist after operation)",
510            source_name, target_name
511        );
512        assert!(error_msg.contains("✗"));
513        assert!(error_msg.contains("Copy failed:"));
514        assert!(error_msg.contains("target file does not exist"));
515    }
516
517    #[test]
518    fn test_move_operation_message_format() {
519        // Test move operation message format
520        let source_name = "subtitle.srt";
521        let target_name = "video.srt";
522
523        // Simulate success message format
524        let success_msg = format!("  ✓ Moved: {} -> {}", source_name, target_name);
525        assert!(success_msg.contains("✓"));
526        assert!(success_msg.contains("Moved:"));
527
528        // Simulate failure message format
529        let error_msg = format!(
530            "  ✗ Move failed: {} -> {} (target file does not exist after operation)",
531            source_name, target_name
532        );
533        assert!(error_msg.contains("✗"));
534        assert!(error_msg.contains("Move failed:"));
535        assert!(error_msg.contains("target file does not exist"));
536    }
537}
538
539/// Match operation result representing a single video-subtitle match.
540///
541/// Contains all information about a successful match between a video file
542/// and a subtitle file, including confidence metrics and reasoning.
543#[derive(Debug)]
544pub struct MatchOperation {
545    /// The matched video file
546    pub video_file: MediaFile,
547    /// The matched subtitle file
548    pub subtitle_file: MediaFile,
549    /// The new filename for the subtitle file
550    pub new_subtitle_name: String,
551    /// Confidence score of the match (0.0 to 1.0)
552    pub confidence: f32,
553    /// List of reasons supporting this match
554    pub reasoning: Vec<String>,
555    /// File relocation mode for this operation
556    pub relocation_mode: FileRelocationMode,
557    /// Target relocation path if operation is needed
558    pub relocation_target_path: Option<std::path::PathBuf>,
559    /// Whether relocation operation is needed (different folders)
560    pub requires_relocation: bool,
561}
562
563/// Engine for matching video and subtitle files using AI analysis.
564pub struct MatchEngine {
565    ai_client: Box<dyn AIProvider>,
566    discovery: FileDiscovery,
567    config: MatchConfig,
568}
569
570impl MatchEngine {
571    /// Creates a new `MatchEngine` with the given AI provider and configuration.
572    pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
573        Self {
574            ai_client,
575            discovery: FileDiscovery::new(),
576            config,
577        }
578    }
579
580    /// Matches video and subtitle files under the given directory.
581    ///
582    /// # Arguments
583    ///
584    /// * `path` - Directory to scan for media files.
585    /// * `recursive` - Whether to include subdirectories.
586    ///
587    /// # Returns
588    ///
589    /// A list of `MatchOperation` entries that meet the confidence threshold.
590    pub async fn match_files(&self, path: &Path, recursive: bool) -> Result<Vec<MatchOperation>> {
591        // 1. Explore files
592        let files = self.discovery.scan_directory(path, recursive)?;
593
594        let videos: Vec<_> = files
595            .iter()
596            .filter(|f| matches!(f.file_type, MediaFileType::Video))
597            .collect();
598        let subtitles: Vec<_> = files
599            .iter()
600            .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
601            .collect();
602
603        if videos.is_empty() || subtitles.is_empty() {
604            return Ok(Vec::new());
605        }
606
607        // 2. Try to reuse results from Dry-run cache
608        if let Some(ops) = self.check_cache(path, recursive).await? {
609            return Ok(ops);
610        }
611        // 3. Content sampling
612        let content_samples = if self.config.enable_content_analysis {
613            self.extract_content_samples(&subtitles).await?
614        } else {
615            Vec::new()
616        };
617
618        // 4. AI analysis request
619        // Generate AI analysis request: include file IDs for precise matching
620        let video_files: Vec<String> = videos
621            .iter()
622            .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
623            .collect();
624        let subtitle_files: Vec<String> = subtitles
625            .iter()
626            .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
627            .collect();
628        let analysis_request = AnalysisRequest {
629            video_files,
630            subtitle_files,
631            content_samples,
632        };
633
634        let match_result = self.ai_client.analyze_content(analysis_request).await?;
635
636        // Debug: Log AI analysis results
637        eprintln!("🔍 AI Analysis Results:");
638        eprintln!("   - Total matches: {}", match_result.matches.len());
639        eprintln!(
640            "   - Confidence threshold: {:.2}",
641            self.config.confidence_threshold
642        );
643        for ai_match in &match_result.matches {
644            eprintln!(
645                "   - {} -> {} (confidence: {:.2})",
646                ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
647            );
648        }
649
650        // 4. Assemble match operation list
651        let mut operations = Vec::new();
652
653        for ai_match in match_result.matches {
654            if ai_match.confidence >= self.config.confidence_threshold {
655                let video_match =
656                    Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
657                let subtitle_match = Self::find_media_file_by_id_or_path(
658                    &subtitles,
659                    &ai_match.subtitle_file_id,
660                    None,
661                );
662                match (video_match, subtitle_match) {
663                    (Some(video), Some(subtitle)) => {
664                        let new_name = self.generate_subtitle_name(video, subtitle);
665
666                        // Determine if relocation is needed
667                        let requires_relocation = self.config.relocation_mode
668                            != FileRelocationMode::None
669                            && subtitle.path.parent() != video.path.parent();
670
671                        let relocation_target_path = if requires_relocation {
672                            let video_dir = video.path.parent().unwrap();
673                            Some(video_dir.join(&new_name))
674                        } else {
675                            None
676                        };
677
678                        operations.push(MatchOperation {
679                            video_file: (*video).clone(),
680                            subtitle_file: (*subtitle).clone(),
681                            new_subtitle_name: new_name,
682                            confidence: ai_match.confidence,
683                            reasoning: ai_match.match_factors,
684                            relocation_mode: self.config.relocation_mode.clone(),
685                            relocation_target_path,
686                            requires_relocation,
687                        });
688                    }
689                    (None, Some(_)) => {
690                        eprintln!(
691                            "⚠️  Cannot find AI-suggested video file ID: '{}'",
692                            ai_match.video_file_id
693                        );
694                        self.log_available_files(&videos, "video");
695                    }
696                    (Some(_), None) => {
697                        eprintln!(
698                            "⚠️  Cannot find AI-suggested subtitle file ID: '{}'",
699                            ai_match.subtitle_file_id
700                        );
701                        self.log_available_files(&subtitles, "subtitle");
702                    }
703                    (None, None) => {
704                        eprintln!("⚠️  Cannot find AI-suggested file pair:");
705                        eprintln!("     Video ID: '{}'", ai_match.video_file_id);
706                        eprintln!("     Subtitle ID: '{}'", ai_match.subtitle_file_id);
707                    }
708                }
709            } else {
710                eprintln!(
711                    "ℹ️  AI match confidence too low ({:.2}): {} <-> {}",
712                    ai_match.confidence, ai_match.video_file_id, ai_match.subtitle_file_id
713                );
714            }
715        }
716
717        // Check if no operations were generated and provide debugging info
718        if operations.is_empty() {
719            eprintln!("\n❌ No matching files found that meet the criteria");
720            eprintln!("🔍 Available file statistics:");
721            eprintln!("   Video files ({} files):", videos.len());
722            for v in &videos {
723                eprintln!("     - ID: {} | {}", v.id, v.relative_path);
724            }
725            eprintln!("   Subtitle files ({} files):", subtitles.len());
726            for s in &subtitles {
727                eprintln!("     - ID: {} | {}", s.id, s.relative_path);
728            }
729        }
730
731        Ok(operations)
732    }
733
734    async fn extract_content_samples(
735        &self,
736        subtitles: &[&MediaFile],
737    ) -> Result<Vec<ContentSample>> {
738        let mut samples = Vec::new();
739
740        for subtitle in subtitles {
741            let content = std::fs::read_to_string(&subtitle.path)?;
742            let preview = self.create_content_preview(&content);
743
744            samples.push(ContentSample {
745                filename: subtitle.name.clone(),
746                content_preview: preview,
747                file_size: subtitle.size,
748            });
749        }
750
751        Ok(samples)
752    }
753
754    fn create_content_preview(&self, content: &str) -> String {
755        let lines: Vec<&str> = content.lines().take(20).collect();
756        let preview = lines.join("\n");
757
758        if preview.len() > self.config.max_sample_length {
759            format!("{}...", &preview[..self.config.max_sample_length])
760        } else {
761            preview
762        }
763    }
764
765    fn generate_subtitle_name(&self, video: &MediaFile, subtitle: &MediaFile) -> String {
766        let detector = LanguageDetector::new();
767
768        // Remove the extension from the video file name (if any)
769        let video_base_name = if !video.extension.is_empty() {
770            video
771                .name
772                .strip_suffix(&format!(".{}", video.extension))
773                .unwrap_or(&video.name)
774        } else {
775            &video.name
776        };
777
778        if let Some(code) = detector.get_primary_language(&subtitle.path) {
779            format!("{}.{}.{}", video_base_name, code, subtitle.extension)
780        } else {
781            format!("{}.{}", video_base_name, subtitle.extension)
782        }
783    }
784
785    /// Execute match operations with dry-run mode support
786    pub async fn execute_operations(
787        &self,
788        operations: &[MatchOperation],
789        dry_run: bool,
790    ) -> Result<()> {
791        for op in operations {
792            if dry_run {
793                println!(
794                    "Preview: {} -> {}",
795                    op.subtitle_file.name, op.new_subtitle_name
796                );
797                if op.requires_relocation {
798                    if let Some(target_path) = &op.relocation_target_path {
799                        let operation_verb = match op.relocation_mode {
800                            FileRelocationMode::Copy => "Copy",
801                            FileRelocationMode::Move => "Move",
802                            _ => "",
803                        };
804                        println!(
805                            "Preview: {} {} to {}",
806                            operation_verb,
807                            op.subtitle_file.path.display(),
808                            target_path.display()
809                        );
810                    }
811                }
812            } else {
813                match op.relocation_mode {
814                    FileRelocationMode::Copy => {
815                        if op.requires_relocation {
816                            self.execute_copy_operation(op).await?;
817                        } else {
818                            // In copy mode, create a local copy with new name
819                            self.execute_local_copy(op).await?;
820                        }
821                    }
822                    FileRelocationMode::Move => {
823                        self.rename_file(op).await?;
824                        if op.requires_relocation {
825                            self.execute_relocation_operation(op).await?;
826                        }
827                    }
828                    FileRelocationMode::None => {
829                        self.rename_file(op).await?;
830                    }
831                }
832            }
833        }
834        Ok(())
835    }
836
837    /// Execute file relocation operation (copy or move)
838    async fn execute_relocation_operation(&self, op: &MatchOperation) -> Result<()> {
839        if !op.requires_relocation {
840            return Ok(());
841        }
842
843        let source_path = if op.new_subtitle_name == op.subtitle_file.name {
844            // File was not renamed, use original path
845            op.subtitle_file.path.clone()
846        } else {
847            // File was renamed, use the new path in the same directory
848            op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
849        };
850
851        if let Some(target_path) = &op.relocation_target_path {
852            // Create target directory if it doesn't exist
853            if let Some(parent) = target_path.parent() {
854                std::fs::create_dir_all(parent)?;
855            }
856
857            // Handle filename conflicts
858            let final_target = self.resolve_filename_conflict(target_path.clone())?;
859
860            match op.relocation_mode {
861                FileRelocationMode::Copy => {
862                    // Create backup of target if enabled
863                    if self.config.backup_enabled && final_target.exists() {
864                        let backup_path = final_target.with_extension(format!(
865                            "{}.backup",
866                            final_target
867                                .extension()
868                                .and_then(|s| s.to_str())
869                                .unwrap_or("")
870                        ));
871                        std::fs::copy(&final_target, backup_path)?;
872                    }
873
874                    // Execute copy operation
875                    std::fs::copy(&source_path, &final_target)?;
876
877                    // Verify the file exists after copy and display appropriate indicator
878                    if final_target.exists() {
879                        println!(
880                            "  ✓ Copied: {} -> {}",
881                            source_path
882                                .file_name()
883                                .unwrap_or_default()
884                                .to_string_lossy(),
885                            final_target
886                                .file_name()
887                                .unwrap_or_default()
888                                .to_string_lossy()
889                        );
890                    } else {
891                        eprintln!(
892                            "  ✗ Copy failed: {} -> {} (target file does not exist after operation)",
893                            source_path
894                                .file_name()
895                                .unwrap_or_default()
896                                .to_string_lossy(),
897                            final_target
898                                .file_name()
899                                .unwrap_or_default()
900                                .to_string_lossy()
901                        );
902                    }
903                }
904                FileRelocationMode::Move => {
905                    // Create backup of original if enabled
906                    if self.config.backup_enabled {
907                        let backup_path = source_path.with_extension(format!(
908                            "{}.backup",
909                            source_path
910                                .extension()
911                                .and_then(|s| s.to_str())
912                                .unwrap_or("")
913                        ));
914                        std::fs::copy(&source_path, backup_path)?;
915                    }
916
917                    // Create backup of target if exists and enabled
918                    if self.config.backup_enabled && final_target.exists() {
919                        let backup_path = final_target.with_extension(format!(
920                            "{}.backup",
921                            final_target
922                                .extension()
923                                .and_then(|s| s.to_str())
924                                .unwrap_or("")
925                        ));
926                        std::fs::copy(&final_target, backup_path)?;
927                    }
928
929                    // Execute move operation
930                    std::fs::rename(&source_path, &final_target)?;
931
932                    // Verify the file exists after move and display appropriate indicator
933                    if final_target.exists() {
934                        println!(
935                            "  ✓ Moved: {} -> {}",
936                            source_path
937                                .file_name()
938                                .unwrap_or_default()
939                                .to_string_lossy(),
940                            final_target
941                                .file_name()
942                                .unwrap_or_default()
943                                .to_string_lossy()
944                        );
945                    } else {
946                        eprintln!(
947                            "  ✗ Move failed: {} -> {} (target file does not exist after operation)",
948                            source_path
949                                .file_name()
950                                .unwrap_or_default()
951                                .to_string_lossy(),
952                            final_target
953                                .file_name()
954                                .unwrap_or_default()
955                                .to_string_lossy()
956                        );
957                    }
958                }
959                FileRelocationMode::None => {
960                    // No operation needed
961                }
962            }
963        }
964
965        Ok(())
966    }
967
968    /// Execute copy operation followed by rename of the copied file
969    /// Execute copy operation - copies original file to target location without modifying original
970    async fn execute_copy_operation(&self, op: &MatchOperation) -> Result<()> {
971        if let Some(target_path) = &op.relocation_target_path {
972            // Resolve filename conflicts
973            let final_target = self.resolve_filename_conflict(target_path.clone())?;
974            if let Some(parent) = final_target.parent() {
975                std::fs::create_dir_all(parent)?;
976            }
977            // Backup target file if it exists and backup is enabled
978            if self.config.backup_enabled && final_target.exists() {
979                let backup_path = final_target.with_extension(format!(
980                    "{}.backup",
981                    final_target
982                        .extension()
983                        .and_then(|s| s.to_str())
984                        .unwrap_or("")
985                ));
986                std::fs::copy(&final_target, backup_path)?;
987            }
988            // Copy original subtitle to target location
989            // In copy mode, the original file remains unchanged
990            std::fs::copy(&op.subtitle_file.path, &final_target)?;
991
992            // Display copy operation result
993            if final_target.exists() {
994                println!(
995                    "  ✓ Copied: {} -> {}",
996                    op.subtitle_file.name,
997                    final_target.file_name().unwrap().to_string_lossy()
998                );
999            }
1000        }
1001        Ok(())
1002    }
1003
1004    /// Execute local copy operation - creates a copy with new name in the same directory
1005    async fn execute_local_copy(&self, op: &MatchOperation) -> Result<()> {
1006        if op.new_subtitle_name != op.subtitle_file.name {
1007            let target_path = op.subtitle_file.path.with_file_name(&op.new_subtitle_name);
1008
1009            // Handle filename conflicts
1010            let final_target = self.resolve_filename_conflict(target_path)?;
1011
1012            // Backup target file if it exists and backup is enabled
1013            if self.config.backup_enabled && final_target.exists() {
1014                let backup_path = final_target.with_extension(format!(
1015                    "{}.backup",
1016                    final_target
1017                        .extension()
1018                        .and_then(|s| s.to_str())
1019                        .unwrap_or("")
1020                ));
1021                std::fs::copy(&final_target, backup_path)?;
1022            }
1023
1024            // Copy original file to new name in same directory
1025            std::fs::copy(&op.subtitle_file.path, &final_target)?;
1026
1027            // Display copy operation result
1028            if final_target.exists() {
1029                println!(
1030                    "  ✓ Copied: {} -> {}",
1031                    op.subtitle_file.name,
1032                    final_target.file_name().unwrap().to_string_lossy()
1033                );
1034            }
1035        }
1036        Ok(())
1037    }
1038
1039    /// Resolve filename conflicts by adding numeric suffix
1040    fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
1041        if !target.exists() {
1042            return Ok(target);
1043        }
1044
1045        // Use AutoRename strategy
1046        match self.config.conflict_resolution {
1047            ConflictResolution::Skip => {
1048                eprintln!(
1049                    "Warning: Skipping relocation due to existing file: {}",
1050                    target.display()
1051                );
1052                Ok(target) // Return original path but operation will be skipped
1053            }
1054            ConflictResolution::AutoRename => {
1055                // Extract filename components
1056                let file_stem = target
1057                    .file_stem()
1058                    .and_then(|s| s.to_str())
1059                    .unwrap_or("file");
1060                let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
1061
1062                let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
1063
1064                // Try adding numeric suffixes
1065                for i in 1..1000 {
1066                    let new_name = if extension.is_empty() {
1067                        format!("{}.{}", file_stem, i)
1068                    } else {
1069                        format!("{}.{}.{}", file_stem, i, extension)
1070                    };
1071                    let new_path = parent.join(new_name);
1072                    if !new_path.exists() {
1073                        return Ok(new_path);
1074                    }
1075                }
1076
1077                Err(SubXError::FileOperationFailed(
1078                    "Could not resolve filename conflict".to_string(),
1079                ))
1080            }
1081            ConflictResolution::Prompt => {
1082                // For now, fall back to AutoRename
1083                // In a future version, this could prompt the user
1084                eprintln!("Warning: Conflict resolution prompt not implemented, using auto-rename");
1085                self.resolve_filename_conflict(target)
1086            }
1087        }
1088    }
1089
1090    async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
1091        let old_path = &op.subtitle_file.path;
1092        let new_path = old_path.with_file_name(&op.new_subtitle_name);
1093
1094        // Backup file
1095        if self.config.backup_enabled {
1096            let backup_path =
1097                old_path.with_extension(format!("{}.backup", op.subtitle_file.extension));
1098            std::fs::copy(old_path, backup_path)?;
1099        }
1100
1101        std::fs::rename(old_path, &new_path)?;
1102
1103        // Verify the file exists after rename and display appropriate indicator
1104        if new_path.exists() {
1105            println!(
1106                "  ✓ Renamed: {} -> {}",
1107                old_path.file_name().unwrap_or_default().to_string_lossy(),
1108                op.new_subtitle_name
1109            );
1110        } else {
1111            eprintln!(
1112                "  ✗ Rename failed: {} -> {} (target file does not exist after operation)",
1113                old_path.file_name().unwrap_or_default().to_string_lossy(),
1114                op.new_subtitle_name
1115            );
1116        }
1117
1118        Ok(())
1119    }
1120    /// Calculate file snapshot for specified directory for cache comparison
1121    fn calculate_file_snapshot(
1122        &self,
1123        directory: &Path,
1124        recursive: bool,
1125    ) -> Result<Vec<SnapshotItem>> {
1126        let files = self.discovery.scan_directory(directory, recursive)?;
1127        let mut snapshot = Vec::new();
1128        for f in files {
1129            let metadata = std::fs::metadata(&f.path)?;
1130            let mtime = metadata
1131                .modified()
1132                .ok()
1133                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1134                .map(|d| d.as_secs())
1135                .unwrap_or(0);
1136            snapshot.push(SnapshotItem {
1137                name: f.name.clone(),
1138                size: f.size,
1139                mtime,
1140                file_type: match f.file_type {
1141                    MediaFileType::Video => "video".to_string(),
1142                    MediaFileType::Subtitle => "subtitle".to_string(),
1143                },
1144            });
1145        }
1146        Ok(snapshot)
1147    }
1148
1149    /// Check dry-run cache, return previous calculated match operations if hit
1150    pub async fn check_cache(
1151        &self,
1152        directory: &Path,
1153        recursive: bool,
1154    ) -> Result<Option<Vec<MatchOperation>>> {
1155        let current_snapshot = self.calculate_file_snapshot(directory, recursive)?;
1156        let cache_file_path = self.get_cache_file_path()?;
1157
1158        let cache_data = CacheData::load(&cache_file_path).ok();
1159        if let Some(cache_data) = cache_data {
1160            let current_config_hash = self.calculate_config_hash()?;
1161
1162            if cache_data.directory == directory.to_string_lossy()
1163                && cache_data.file_snapshot == current_snapshot
1164                && cache_data.config_hash == current_config_hash
1165            {
1166                // Rebuild match operation list
1167                let files = self.discovery.scan_directory(directory, recursive)?;
1168                let mut ops = Vec::new();
1169                for item in cache_data.match_operations {
1170                    if let (Some(video), Some(subtitle)) = (
1171                        files.iter().find(|f| {
1172                            f.name == item.video_file && matches!(f.file_type, MediaFileType::Video)
1173                        }),
1174                        files.iter().find(|f| {
1175                            f.name == item.subtitle_file
1176                                && matches!(f.file_type, MediaFileType::Subtitle)
1177                        }),
1178                    ) {
1179                        // 重新計算重定位需求(基於當前配置)
1180                        let requires_relocation = self.config.relocation_mode
1181                            != FileRelocationMode::None
1182                            && subtitle.path.parent() != video.path.parent();
1183                        let relocation_target_path = if requires_relocation {
1184                            let video_dir = video.path.parent().unwrap();
1185                            Some(video_dir.join(&item.new_subtitle_name))
1186                        } else {
1187                            None
1188                        };
1189
1190                        ops.push(MatchOperation {
1191                            video_file: (*video).clone(),
1192                            subtitle_file: (*subtitle).clone(),
1193                            new_subtitle_name: item.new_subtitle_name.clone(),
1194                            confidence: item.confidence,
1195                            reasoning: item.reasoning.clone(),
1196                            relocation_mode: self.config.relocation_mode.clone(),
1197                            relocation_target_path,
1198                            requires_relocation,
1199                        });
1200                    }
1201                }
1202                return Ok(Some(ops));
1203            }
1204        }
1205        Ok(None)
1206    }
1207
1208    /// Save dry-run cache results
1209    pub async fn save_cache(
1210        &self,
1211        directory: &Path,
1212        recursive: bool,
1213        operations: &[MatchOperation],
1214    ) -> Result<()> {
1215        let cache_data = CacheData {
1216            cache_version: "1.0".to_string(),
1217            directory: directory.to_string_lossy().to_string(),
1218            file_snapshot: self.calculate_file_snapshot(directory, recursive)?,
1219            match_operations: operations
1220                .iter()
1221                .map(|op| OpItem {
1222                    video_file: op.video_file.name.clone(),
1223                    subtitle_file: op.subtitle_file.name.clone(),
1224                    new_subtitle_name: op.new_subtitle_name.clone(),
1225                    confidence: op.confidence,
1226                    reasoning: op.reasoning.clone(),
1227                })
1228                .collect(),
1229            created_at: std::time::SystemTime::now()
1230                .duration_since(std::time::UNIX_EPOCH)
1231                .map(|d| d.as_secs())
1232                .unwrap_or(0),
1233            ai_model_used: "gpt-4.1-mini".to_string(), // TODO: 從配置服務獲取實際模型
1234            // 記錄產生 cache 時的重定位模式與備份設定
1235            original_relocation_mode: format!("{:?}", self.config.relocation_mode),
1236            original_backup_enabled: self.config.backup_enabled,
1237            config_hash: self.calculate_config_hash()?,
1238        };
1239        let path = self.get_cache_file_path()?;
1240        if let Some(parent) = path.parent() {
1241            std::fs::create_dir_all(parent)?;
1242        }
1243        let content =
1244            serde_json::to_string_pretty(&cache_data).map_err(|e| SubXError::Other(e.into()))?;
1245        std::fs::write(path, content)?;
1246        Ok(())
1247    }
1248
1249    /// Get cache file path
1250    fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
1251        // 首先檢查 XDG_CONFIG_HOME 環境變數(用於測試)
1252        let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
1253            std::path::PathBuf::from(xdg_config)
1254        } else {
1255            dirs::config_dir()
1256                .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
1257        };
1258        Ok(dir.join("subx").join("match_cache.json"))
1259    }
1260
1261    /// Calculate current configuration hash for cache validation
1262    fn calculate_config_hash(&self) -> Result<String> {
1263        use std::collections::hash_map::DefaultHasher;
1264        use std::hash::{Hash, Hasher};
1265
1266        let mut hasher = DefaultHasher::new();
1267        // 將影響快取有效性的配置項目加入雜湊
1268        format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
1269        self.config.backup_enabled.hash(&mut hasher);
1270        // 添加其他相關的配置項目
1271
1272        Ok(format!("{:016x}", hasher.finish()))
1273    }
1274
1275    /// Find a media file by ID, with an optional fallback to relative path or name.
1276    fn find_media_file_by_id_or_path<'a>(
1277        files: &'a [&MediaFile],
1278        file_id: &str,
1279        fallback_path: Option<&str>,
1280    ) -> Option<&'a MediaFile> {
1281        if let Some(file) = files.iter().find(|f| f.id == file_id) {
1282            return Some(*file);
1283        }
1284        if let Some(path) = fallback_path {
1285            if let Some(file) = files.iter().find(|f| f.relative_path == path) {
1286                return Some(*file);
1287            }
1288            files.iter().find(|f| f.name == path).copied()
1289        } else {
1290            None
1291        }
1292    }
1293
1294    /// Log available files to assist debugging when a match is not found.
1295    fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
1296        eprintln!("   Available {} files:", file_type);
1297        for f in files {
1298            eprintln!(
1299                "     - ID: {} | Name: {} | Path: {}",
1300                f.id, f.name, f.relative_path
1301            );
1302        }
1303    }
1304
1305    /// Provide detailed information when no matches are found.
1306    fn log_no_matches_found(
1307        &self,
1308        match_result: &MatchResult,
1309        videos: &[MediaFile],
1310        subtitles: &[MediaFile],
1311    ) {
1312        eprintln!("\n❌ No matching files found that meet the criteria");
1313        eprintln!("🔍 AI analysis results:");
1314        eprintln!("   - Total matches: {}", match_result.matches.len());
1315        eprintln!(
1316            "   - Confidence threshold: {:.2}",
1317            self.config.confidence_threshold
1318        );
1319        eprintln!(
1320            "   - Matches meeting threshold: {}",
1321            match_result
1322                .matches
1323                .iter()
1324                .filter(|m| m.confidence >= self.config.confidence_threshold)
1325                .count()
1326        );
1327        eprintln!("\n📂 Scanned files:");
1328        eprintln!("   Video files ({} files):", videos.len());
1329        for v in videos {
1330            eprintln!("     - ID: {} | {}", v.id, v.relative_path);
1331        }
1332        eprintln!("   Subtitle files ({} files):", subtitles.len());
1333        for s in subtitles {
1334            eprintln!("     - ID: {} | {}", s.id, s.relative_path);
1335        }
1336    }
1337}