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/// Current on-disk match-cache schema version.
33///
34/// Bumped whenever the prompt structure or cache layout changes in a
35/// way that invalidates earlier entries. Historical values: `"1.0"`
36/// (pre-XML prompt rewrite); `"2.0"` (current — XML-tagged prompt with
37/// optional `language` / `target_filename_suffix`).
38pub(crate) const CURRENT_CACHE_VERSION: &str = "2.0";
39
40/// Returns whether a cache file with the given `cache_version` string is
41/// considered compatible with the current code. Used by
42/// [`MatchEngine::check_file_list_cache`] to reject entries written by
43/// earlier prompt schemas.
44pub(crate) fn is_cache_version_current(version: &str) -> bool {
45    version == CURRENT_CACHE_VERSION
46}
47
48/// Sanitize an AI-supplied filename suffix.
49///
50/// Accepts only ASCII alphanumerics, underscore, and hyphen. The check is
51/// applied to the **whole string**: any disallowed character (including
52/// path separators or `.`) causes outright rejection rather than silent
53/// stripping, so payloads such as `"../etc"` cannot smuggle a usable
54/// `"etc"` token through.
55///
56/// # Arguments
57///
58/// * `raw` - Raw suffix supplied by the AI (e.g. `"tc"`, `"english"`,
59///   `"../etc"`).
60///
61/// # Returns
62///
63/// `Some(raw)` when the input is non-empty, no longer than 16 bytes, and
64/// composed entirely of `[A-Za-z0-9_-]`; `None` otherwise.
65pub(crate) fn sanitize_suffix(raw: &str) -> Option<String> {
66    if raw.is_empty() || raw.len() > 16 {
67        return None;
68    }
69    if raw
70        .chars()
71        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
72    {
73        Some(raw.to_string())
74    } else {
75        None
76    }
77}
78
79/// Normalize an AI-supplied language label without going through the
80/// strict ASCII gate of [`sanitize_suffix`].
81///
82/// This helper preserves Unicode (so labels such as `繁中` or `简中` reach
83/// [`LanguageDetector::normalize`]), only lower-casing ASCII letters and
84/// trimming whitespace before lookup. Sentinel values (`""` and `und`,
85/// case-insensitive) return `None`. After detector lookup, the returned
86/// canonical code is finally validated through [`sanitize_suffix`] so the
87/// downstream filename insertion remains safe.
88///
89/// # Arguments
90///
91/// * `detector` - Configured [`LanguageDetector`] used for synonym lookup.
92/// * `raw` - Raw label as supplied by the AI.
93///
94/// # Returns
95///
96/// `Some(code)` with a sanitized canonical short code (e.g. `"tc"`,
97/// `"en"`); `None` for empty input, the sentinel `und`, or labels that
98/// resolve to something unsafe for filenames.
99pub(crate) fn normalize_ai_language(detector: &LanguageDetector, raw: &str) -> Option<String> {
100    let trimmed = raw.trim();
101    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("und") {
102        return None;
103    }
104    let lowered: String = trimmed
105        .chars()
106        .map(|c| {
107            if c.is_ascii_uppercase() {
108                c.to_ascii_lowercase()
109            } else {
110                c
111            }
112        })
113        .collect();
114    let resolved = detector.normalize(&lowered)?;
115    sanitize_suffix(&resolved)
116}
117
118/// Globally enforce unique final target paths across a batch of
119/// [`MatchOperation`] values.
120///
121/// Run **after** any post-engine relocation rewrites (e.g.
122/// `match_command.rs` archive-origin forced relocation) so the
123/// uniqueness guarantee holds at the actual destination paths. The
124/// allocator iterates operations sorted by `(target_directory,
125/// subtitle_file.relative_path)` for stability across reruns and
126/// probes `<base>.<n>.<ext>` (preserving any language segment) starting
127/// at `n = 2` until a free path is found, mutating both
128/// `new_subtitle_name` and `relocation_target_path` so downstream
129/// consumers see consistent values.
130///
131/// # Arguments
132///
133/// * `operations` - Mutable slice of operations to deduplicate in place.
134pub fn apply_unique_target_paths(operations: &mut [MatchOperation]) {
135    use std::collections::HashSet;
136
137    fn final_target(op: &MatchOperation) -> PathBuf {
138        if let Some(p) = &op.relocation_target_path {
139            p.clone()
140        } else {
141            let parent = op
142                .subtitle_file
143                .path
144                .parent()
145                .unwrap_or_else(|| std::path::Path::new("."));
146            parent.join(&op.new_subtitle_name)
147        }
148    }
149
150    fn split_filename(name: &str) -> (String, String) {
151        // Split filename into (stem, ".ext"); preserves multi-dot stems.
152        if let Some(idx) = name.rfind('.') {
153            if idx > 0 {
154                return (name[..idx].to_string(), name[idx..].to_string());
155            }
156        }
157        (name.to_string(), String::new())
158    }
159
160    /// Strip a trailing `.<digits>` segment from `stem`, returning
161    /// `(base_stem, existing_counter)`. Used so that when the AI (or a
162    /// prior pass) already produced `movie.2.srt`, the next collision
163    /// becomes `movie.3.srt` rather than `movie.2.2.srt`.
164    fn split_numeric_tail(stem: &str) -> (String, Option<u32>) {
165        if let Some(idx) = stem.rfind('.') {
166            let (head, tail) = (&stem[..idx], &stem[idx + 1..]);
167            if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
168                if let Ok(n) = tail.parse::<u32>() {
169                    return (head.to_string(), Some(n));
170                }
171            }
172        }
173        (stem.to_string(), None)
174    }
175
176    // Sort indices so we can mutate operations in-place but iterate in a
177    // deterministic order independent of the AI's response ordering.
178    let mut indices: Vec<usize> = (0..operations.len()).collect();
179    indices.sort_by(|&a, &b| {
180        let pa = final_target(&operations[a]);
181        let pb = final_target(&operations[b]);
182        let dir_a = pa.parent().map(|p| p.to_path_buf()).unwrap_or_default();
183        let dir_b = pb.parent().map(|p| p.to_path_buf()).unwrap_or_default();
184        dir_a.cmp(&dir_b).then_with(|| {
185            operations[a]
186                .subtitle_file
187                .relative_path
188                .cmp(&operations[b].subtitle_file.relative_path)
189        })
190    });
191
192    // Reserve every operation's *original* candidate so that two
193    // operations cannot both claim a numerically-suffixed slot that a
194    // third operation already legitimately needed. See OpenSpec scenario
195    // "Three-way duplicates and a pre-existing numeric suffix" — without
196    // this guard `[movie.srt, movie.srt, movie.2.srt]` would steal
197    // `movie.2.srt` from the third op while resolving the second.
198    let reserved: HashSet<PathBuf> = (0..operations.len())
199        .map(|i| final_target(&operations[i]))
200        .collect();
201
202    let mut claimed: HashSet<PathBuf> = HashSet::new();
203
204    for idx in indices {
205        let candidate = final_target(&operations[idx]);
206        let parent = candidate
207            .parent()
208            .map(|p| p.to_path_buf())
209            .unwrap_or_default();
210        let filename = candidate
211            .file_name()
212            .map(|f| f.to_string_lossy().to_string())
213            .unwrap_or_default();
214        let (stem, ext) = split_filename(&filename);
215        let (base_stem, existing_counter) = split_numeric_tail(&stem);
216        let source_path = operations[idx].subtitle_file.path.clone();
217
218        // A probe path is taken if another op has already committed it
219        // (via `claimed`), if some *other* op still holds it as its
220        // original candidate (via `reserved`, minus the current op's own
221        // candidate so the current op can keep its preferred slot when
222        // free), or if a file already exists on disk at that path
223        // (excluding the operation's own source file so a no-op rename
224        // can keep its current name).
225        let is_taken = |p: &PathBuf| -> bool {
226            claimed.contains(p)
227                || (p != &candidate && reserved.contains(p))
228                || (p != &source_path && p.exists())
229        };
230
231        let mut resolved = candidate.clone();
232        let mut resolved_name = filename.clone();
233        if is_taken(&resolved) {
234            let mut counter = existing_counter.map(|n| n + 1).unwrap_or(2).max(2);
235            loop {
236                let new_name = format!("{}.{}{}", base_stem, counter, ext);
237                let probe = parent.join(&new_name);
238                if !is_taken(&probe) {
239                    resolved = probe;
240                    resolved_name = new_name;
241                    break;
242                }
243                counter = counter.saturating_add(1);
244                if counter > 9999 {
245                    break;
246                }
247            }
248        }
249
250        let op = &mut operations[idx];
251        op.new_subtitle_name = resolved_name;
252        if op.relocation_target_path.is_some() {
253            op.relocation_target_path = Some(resolved.clone());
254        }
255        claimed.insert(resolved);
256    }
257}
258
259/// File relocation mode for matched subtitle files
260#[derive(Debug, Clone, PartialEq)]
261pub enum FileRelocationMode {
262    /// No file relocation
263    None,
264    /// Copy subtitle files to video folders
265    Copy,
266    /// Move subtitle files to video folders
267    Move,
268}
269
270/// Strategy for handling filename conflicts during relocation
271#[derive(Debug, Clone)]
272pub enum ConflictResolution {
273    /// Skip relocation if conflict exists
274    Skip,
275    /// Automatically rename with numeric suffix
276    AutoRename,
277    /// Prompt user for decision (interactive mode only)
278    Prompt,
279}
280
281/// Configuration settings for the file matching engine.
282///
283/// Controls various aspects of the subtitle-to-video matching process,
284/// including confidence thresholds and analysis options.
285#[derive(Debug, Clone)]
286pub struct MatchConfig {
287    /// Minimum confidence score required for a successful match (0.0 to 1.0)
288    pub confidence_threshold: f32,
289    /// Maximum number of characters to sample from subtitle content
290    pub max_sample_length: usize,
291    /// Whether to enable advanced content analysis for matching
292    pub enable_content_analysis: bool,
293    /// Whether to create backup files before operations
294    pub backup_enabled: bool,
295    /// File relocation mode
296    pub relocation_mode: FileRelocationMode,
297    /// Strategy for handling filename conflicts during relocation
298    pub conflict_resolution: ConflictResolution,
299    /// AI model name used for analysis
300    pub ai_model: String,
301    /// Maximum subtitle file size in bytes accepted for content sampling.
302    /// Populated from `GeneralConfig.max_subtitle_bytes`.
303    pub max_subtitle_bytes: u64,
304}
305
306#[cfg(test)]
307mod language_name_tests {
308    use super::*;
309    use crate::core::matcher::discovery::{MediaFile, MediaFileType};
310    use crate::services::ai::{
311        AIProvider, AnalysisRequest, ConfidenceScore, FileMatch, MatchResult, VerificationRequest,
312    };
313    use async_trait::async_trait;
314    use std::path::PathBuf;
315
316    fn legacy_match() -> FileMatch {
317        FileMatch {
318            video_file_id: "v".into(),
319            subtitle_file_id: "s".into(),
320            confidence: 1.0,
321            match_factors: vec![],
322            language: None,
323            target_filename_suffix: None,
324        }
325    }
326
327    fn match_with_language(language: Option<&str>, suffix: Option<&str>) -> FileMatch {
328        FileMatch {
329            video_file_id: "v".into(),
330            subtitle_file_id: "s".into(),
331            confidence: 1.0,
332            match_factors: vec![],
333            language: language.map(|s| s.to_string()),
334            target_filename_suffix: suffix.map(|s| s.to_string()),
335        }
336    }
337
338    struct DummyAI;
339    #[async_trait]
340    impl AIProvider for DummyAI {
341        async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
342            unimplemented!()
343        }
344        async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
345            unimplemented!()
346        }
347    }
348
349    #[test]
350    fn test_generate_subtitle_name_with_directory_language() {
351        let engine = MatchEngine::new(
352            Box::new(DummyAI),
353            MatchConfig {
354                confidence_threshold: 0.0,
355                max_sample_length: 0,
356                enable_content_analysis: false,
357                backup_enabled: false,
358                relocation_mode: FileRelocationMode::None,
359                conflict_resolution: ConflictResolution::Skip,
360                ai_model: "test-model".to_string(),
361                max_subtitle_bytes: 52_428_800,
362            },
363        );
364        let video = MediaFile {
365            id: "".to_string(),
366            relative_path: "".to_string(),
367            path: PathBuf::from("movie01.mp4"),
368            file_type: MediaFileType::Video,
369            size: 0,
370            name: "movie01".to_string(),
371            extension: "mp4".to_string(),
372        };
373        let subtitle = MediaFile {
374            id: "".to_string(),
375            relative_path: "".to_string(),
376            path: PathBuf::from("tc/subtitle01.ass"),
377            file_type: MediaFileType::Subtitle,
378            size: 0,
379            name: "subtitle01".to_string(),
380            extension: "ass".to_string(),
381        };
382        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
383        assert_eq!(new_name, "movie01.tc.ass");
384    }
385
386    #[test]
387    fn test_generate_subtitle_name_with_filename_language() {
388        let engine = MatchEngine::new(
389            Box::new(DummyAI),
390            MatchConfig {
391                confidence_threshold: 0.0,
392                max_sample_length: 0,
393                enable_content_analysis: false,
394                backup_enabled: false,
395                relocation_mode: FileRelocationMode::None,
396                conflict_resolution: ConflictResolution::Skip,
397                ai_model: "test-model".to_string(),
398                max_subtitle_bytes: 52_428_800,
399            },
400        );
401        let video = MediaFile {
402            id: "".to_string(),
403            relative_path: "".to_string(),
404            path: PathBuf::from("movie02.mp4"),
405            file_type: MediaFileType::Video,
406            size: 0,
407            name: "movie02".to_string(),
408            extension: "mp4".to_string(),
409        };
410        let subtitle = MediaFile {
411            id: "".to_string(),
412            relative_path: "".to_string(),
413            path: PathBuf::from("subtitle02.sc.ass"),
414            file_type: MediaFileType::Subtitle,
415            size: 0,
416            name: "subtitle02".to_string(),
417            extension: "ass".to_string(),
418        };
419        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
420        assert_eq!(new_name, "movie02.sc.ass");
421    }
422
423    #[test]
424    fn test_generate_subtitle_name_without_language() {
425        let engine = MatchEngine::new(
426            Box::new(DummyAI),
427            MatchConfig {
428                confidence_threshold: 0.0,
429                max_sample_length: 0,
430                enable_content_analysis: false,
431                backup_enabled: false,
432                relocation_mode: FileRelocationMode::None,
433                conflict_resolution: ConflictResolution::Skip,
434                ai_model: "test-model".to_string(),
435                max_subtitle_bytes: 52_428_800,
436            },
437        );
438        let video = MediaFile {
439            id: "".to_string(),
440            relative_path: "".to_string(),
441            path: PathBuf::from("movie03.mp4"),
442            file_type: MediaFileType::Video,
443            size: 0,
444            name: "movie03".to_string(),
445            extension: "mp4".to_string(),
446        };
447        let subtitle = MediaFile {
448            id: "".to_string(),
449            relative_path: "".to_string(),
450            path: PathBuf::from("subtitle03.ass"),
451            file_type: MediaFileType::Subtitle,
452            size: 0,
453            name: "subtitle03".to_string(),
454            extension: "ass".to_string(),
455        };
456        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
457        assert_eq!(new_name, "movie03.ass");
458    }
459    #[test]
460    fn test_generate_subtitle_name_removes_video_extension() {
461        let engine = MatchEngine::new(
462            Box::new(DummyAI),
463            MatchConfig {
464                confidence_threshold: 0.0,
465                max_sample_length: 0,
466                enable_content_analysis: false,
467                backup_enabled: false,
468                relocation_mode: FileRelocationMode::None,
469                conflict_resolution: ConflictResolution::Skip,
470                ai_model: "test-model".to_string(),
471                max_subtitle_bytes: 52_428_800,
472            },
473        );
474        let video = MediaFile {
475            id: "".to_string(),
476            relative_path: "".to_string(),
477            path: PathBuf::from("movie.mkv"),
478            file_type: MediaFileType::Video,
479            size: 0,
480            name: "movie.mkv".to_string(),
481            extension: "mkv".to_string(),
482        };
483        let subtitle = MediaFile {
484            id: "".to_string(),
485            relative_path: "".to_string(),
486            path: PathBuf::from("subtitle.srt"),
487            file_type: MediaFileType::Subtitle,
488            size: 0,
489            name: "subtitle".to_string(),
490            extension: "srt".to_string(),
491        };
492        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
493        assert_eq!(new_name, "movie.srt");
494    }
495
496    #[test]
497    fn test_generate_subtitle_name_with_language_removes_video_extension() {
498        let engine = MatchEngine::new(
499            Box::new(DummyAI),
500            MatchConfig {
501                confidence_threshold: 0.0,
502                max_sample_length: 0,
503                enable_content_analysis: false,
504                backup_enabled: false,
505                relocation_mode: FileRelocationMode::None,
506                conflict_resolution: ConflictResolution::Skip,
507                ai_model: "test-model".to_string(),
508                max_subtitle_bytes: 52_428_800,
509            },
510        );
511        let video = MediaFile {
512            id: "".to_string(),
513            relative_path: "".to_string(),
514            path: PathBuf::from("movie.mkv"),
515            file_type: MediaFileType::Video,
516            size: 0,
517            name: "movie.mkv".to_string(),
518            extension: "mkv".to_string(),
519        };
520        let subtitle = MediaFile {
521            id: "".to_string(),
522            relative_path: "".to_string(),
523            path: PathBuf::from("tc/subtitle.srt"),
524            file_type: MediaFileType::Subtitle,
525            size: 0,
526            name: "subtitle".to_string(),
527            extension: "srt".to_string(),
528        };
529        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
530        assert_eq!(new_name, "movie.tc.srt");
531    }
532
533    #[test]
534    fn test_generate_subtitle_name_edge_cases() {
535        let engine = MatchEngine::new(
536            Box::new(DummyAI),
537            MatchConfig {
538                confidence_threshold: 0.0,
539                max_sample_length: 0,
540                enable_content_analysis: false,
541                backup_enabled: false,
542                relocation_mode: FileRelocationMode::None,
543                conflict_resolution: ConflictResolution::Skip,
544                ai_model: "test-model".to_string(),
545                max_subtitle_bytes: 52_428_800,
546            },
547        );
548        // File name contains multiple dots and no extension case
549        let video = MediaFile {
550            id: "".to_string(),
551            relative_path: "".to_string(),
552            path: PathBuf::from("a.b.c"),
553            file_type: MediaFileType::Video,
554            size: 0,
555            name: "a.b.c".to_string(),
556            extension: "".to_string(),
557        };
558        let subtitle = MediaFile {
559            id: "".to_string(),
560            relative_path: "".to_string(),
561            path: PathBuf::from("sub.srt"),
562            file_type: MediaFileType::Subtitle,
563            size: 0,
564            name: "sub".to_string(),
565            extension: "srt".to_string(),
566        };
567        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
568        assert_eq!(new_name, "a.b.c.srt");
569    }
570
571    fn make_engine() -> MatchEngine {
572        MatchEngine::new(
573            Box::new(DummyAI),
574            MatchConfig {
575                confidence_threshold: 0.0,
576                max_sample_length: 0,
577                enable_content_analysis: false,
578                backup_enabled: false,
579                relocation_mode: FileRelocationMode::None,
580                conflict_resolution: ConflictResolution::Skip,
581                ai_model: "test-model".to_string(),
582                max_subtitle_bytes: 52_428_800,
583            },
584        )
585    }
586
587    fn media(path: &str, name: &str, ext: &str, ty: MediaFileType) -> MediaFile {
588        MediaFile {
589            id: "".into(),
590            relative_path: path.into(),
591            path: PathBuf::from(path),
592            file_type: ty,
593            size: 0,
594            name: name.into(),
595            extension: ext.into(),
596        }
597    }
598
599    #[test]
600    fn test_generate_subtitle_name_ai_suffix_wins() {
601        let engine = make_engine();
602        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
603        let subtitle = media("tc/subs.srt", "subs", "srt", MediaFileType::Subtitle);
604        let m = match_with_language(Some("en"), Some("tc"));
605        assert_eq!(
606            engine.generate_subtitle_name(&video, &subtitle, &m),
607            "movie.tc.srt"
608        );
609    }
610
611    #[test]
612    fn test_generate_subtitle_name_ai_language_used() {
613        let engine = make_engine();
614        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
615        let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
616        let m = match_with_language(Some("ja"), None);
617        assert_eq!(
618            engine.generate_subtitle_name(&video, &subtitle, &m),
619            "movie.ja.srt"
620        );
621    }
622
623    #[test]
624    fn test_generate_subtitle_name_language_synonym_normalized() {
625        let engine = make_engine();
626        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
627        let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
628        for variant in &["english", "eng", "EN"] {
629            let m = match_with_language(Some(variant), None);
630            assert_eq!(
631                engine.generate_subtitle_name(&video, &subtitle, &m),
632                "movie.en.srt",
633                "variant {variant} should normalize to en"
634            );
635        }
636    }
637
638    #[test]
639    fn test_generate_subtitle_name_und_collapses_to_no_tag() {
640        let engine = make_engine();
641        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
642        let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
643        let m = match_with_language(Some("und"), None);
644        assert_eq!(
645            engine.generate_subtitle_name(&video, &subtitle, &m),
646            "movie.srt"
647        );
648    }
649
650    #[test]
651    fn test_generate_subtitle_name_sanitization_drops_path_traversal() {
652        let engine = make_engine();
653        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
654        let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
655        // Whole-string rejection: any disallowed character (including
656        // `.` and `/`) causes `sanitize_suffix` to return None, so a
657        // payload like "../etc" cannot smuggle "etc" through.
658        let m = match_with_language(None, Some("../etc"));
659        assert_eq!(
660            engine.generate_subtitle_name(&video, &subtitle, &m),
661            "movie.srt"
662        );
663    }
664
665    #[test]
666    fn test_sanitize_suffix_helper() {
667        assert_eq!(super::sanitize_suffix(""), None);
668        assert_eq!(super::sanitize_suffix("../"), None);
669        // Whole-string rejection: presence of `.` or `/` aborts.
670        assert_eq!(super::sanitize_suffix("../etc"), None);
671        assert_eq!(super::sanitize_suffix("a-b_c"), Some("a-b_c".into()));
672        assert_eq!(super::sanitize_suffix("繁中"), None);
673        // Length cap: anything > 16 bytes is rejected outright (no
674        // truncation, since silent truncation could mask injection).
675        assert_eq!(super::sanitize_suffix("0123456789abcdefGHIJKL"), None);
676        assert_eq!(
677            super::sanitize_suffix("0123456789abcdef"),
678            Some("0123456789abcdef".into())
679        );
680    }
681
682    #[test]
683    fn test_normalize_ai_language_helper() {
684        let det = LanguageDetector::new();
685        assert_eq!(
686            super::normalize_ai_language(&det, "english"),
687            Some("en".into())
688        );
689        assert_eq!(super::normalize_ai_language(&det, "ENG"), Some("en".into()));
690        assert_eq!(super::normalize_ai_language(&det, "EN"), Some("en".into()));
691        assert_eq!(super::normalize_ai_language(&det, "und"), None);
692        assert_eq!(super::normalize_ai_language(&det, "UND"), None);
693        assert_eq!(super::normalize_ai_language(&det, ""), None);
694        assert_eq!(super::normalize_ai_language(&det, "cht"), Some("tc".into()));
695        assert_eq!(super::normalize_ai_language(&det, "chs"), Some("sc".into()));
696        // Unicode label preserved long enough to reach the language map.
697        assert_eq!(
698            super::normalize_ai_language(&det, "繁中"),
699            Some("tc".into())
700        );
701        assert_eq!(
702            super::normalize_ai_language(&det, "简中"),
703            Some("sc".into())
704        );
705        // Hyphenated and underscored regional aliases.
706        assert_eq!(
707            super::normalize_ai_language(&det, "traditional-chinese"),
708            Some("tc".into())
709        );
710        assert_eq!(
711            super::normalize_ai_language(&det, "Traditional_Chinese"),
712            Some("tc".into())
713        );
714        assert_eq!(
715            super::normalize_ai_language(&det, "zh-Hant"),
716            Some("tc".into())
717        );
718        assert_eq!(
719            super::normalize_ai_language(&det, "zh_hans"),
720            Some("sc".into())
721        );
722        // Pass-through for unknown but otherwise valid short codes.
723        assert_eq!(super::normalize_ai_language(&det, "vi"), Some("vi".into()));
724        assert_eq!(super::normalize_ai_language(&det, "ID"), Some("id".into()));
725    }
726
727    fn op(parent: &str, name: &str, sub_relpath: &str, relocate: bool) -> MatchOperation {
728        let video_path = PathBuf::from(parent).join("movie.mkv");
729        let subtitle_path = PathBuf::from(sub_relpath);
730        let relocation_target_path = if relocate {
731            Some(PathBuf::from(parent).join(name))
732        } else {
733            None
734        };
735        MatchOperation {
736            video_file: MediaFile {
737                id: "v".into(),
738                relative_path: video_path.to_string_lossy().to_string(),
739                path: video_path,
740                file_type: MediaFileType::Video,
741                size: 0,
742                name: "movie.mkv".into(),
743                extension: "mkv".into(),
744            },
745            subtitle_file: MediaFile {
746                id: "s".into(),
747                relative_path: sub_relpath.into(),
748                path: subtitle_path,
749                file_type: MediaFileType::Subtitle,
750                size: 0,
751                name: name.into(),
752                extension: "srt".into(),
753            },
754            new_subtitle_name: name.into(),
755            confidence: 1.0,
756            reasoning: vec![],
757            relocation_mode: if relocate {
758                FileRelocationMode::Copy
759            } else {
760                FileRelocationMode::None
761            },
762            relocation_target_path,
763            requires_relocation: relocate,
764        }
765    }
766
767    #[test]
768    fn test_unique_target_paths_two_duplicates_same_dir() {
769        let mut ops = vec![
770            op("/d", "movie.srt", "/d/a.srt", false),
771            op("/d", "movie.srt", "/d/b.srt", false),
772        ];
773        super::apply_unique_target_paths(&mut ops);
774        assert_eq!(ops[0].new_subtitle_name, "movie.srt");
775        assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
776    }
777
778    #[test]
779    fn test_unique_target_paths_three_way_with_existing_two() {
780        // Sorted order: /d/a.srt → movie.srt, /d/b.srt → movie.srt
781        // (clones), /d/c.srt → movie.2.srt (already-unique candidate).
782        // Spec requires the *third* op to keep its preferred slot
783        // movie.2.srt, so the second op must skip past it (yielding
784        // movie.3.srt) instead of stealing it. See OpenSpec scenario
785        // "Three-way duplicates and a pre-existing numeric suffix".
786        let mut ops = vec![
787            op("/d", "movie.srt", "/d/a.srt", false),
788            op("/d", "movie.srt", "/d/b.srt", false),
789            op("/d", "movie.2.srt", "/d/c.srt", false),
790        ];
791        super::apply_unique_target_paths(&mut ops);
792        assert_eq!(ops[0].new_subtitle_name, "movie.srt");
793        assert_eq!(ops[1].new_subtitle_name, "movie.3.srt");
794        assert_eq!(ops[2].new_subtitle_name, "movie.2.srt");
795    }
796
797    #[test]
798    fn test_unique_target_paths_idempotent() {
799        // Running the allocator twice on the same batch must not change
800        // the result; the engine pre-pass plus the CLI post-pass rely on
801        // this.
802        let mut ops = vec![
803            op("/d", "movie.srt", "/d/a.srt", false),
804            op("/d", "movie.srt", "/d/b.srt", false),
805            op("/d", "movie.2.srt", "/d/c.srt", false),
806        ];
807        super::apply_unique_target_paths(&mut ops);
808        let snapshot: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
809        super::apply_unique_target_paths(&mut ops);
810        let after: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
811        assert_eq!(snapshot, after);
812    }
813
814    #[test]
815    fn test_unique_target_paths_two_languages_preserved() {
816        let mut ops = vec![
817            op("/d", "movie.tc.srt", "/d/a.srt", false),
818            op("/d", "movie.sc.srt", "/d/b.srt", false),
819        ];
820        super::apply_unique_target_paths(&mut ops);
821        assert_eq!(ops[0].new_subtitle_name, "movie.tc.srt");
822        assert_eq!(ops[1].new_subtitle_name, "movie.sc.srt");
823    }
824
825    #[test]
826    fn test_unique_target_paths_cross_video_collision_under_copy() {
827        // Two different videos in different source dirs both relocate
828        // into /shared with the same candidate filename "subs.srt".
829        let mut ops = vec![
830            op("/shared", "subs.srt", "/src1/subs.srt", true),
831            op("/shared", "subs.srt", "/src2/subs.srt", true),
832        ];
833        super::apply_unique_target_paths(&mut ops);
834        assert_eq!(ops[0].new_subtitle_name, "subs.srt");
835        assert_eq!(ops[1].new_subtitle_name, "subs.2.srt");
836        // relocation_target_path must be kept consistent.
837        assert_eq!(
838            ops[0].relocation_target_path.as_ref().unwrap(),
839            &PathBuf::from("/shared/subs.srt")
840        );
841        assert_eq!(
842            ops[1].relocation_target_path.as_ref().unwrap(),
843            &PathBuf::from("/shared/subs.2.srt")
844        );
845    }
846
847    #[test]
848    fn test_unique_target_paths_archive_origin_relocation_unique() {
849        // Mirrors the archive-origin scenario: relocation_target_path
850        // is set, and two ops collide on the rewritten path.
851        let mut ops = vec![
852            op("/videos", "movie.srt", "/tmp/a.srt", true),
853            op("/videos", "movie.srt", "/tmp/b.srt", true),
854        ];
855        super::apply_unique_target_paths(&mut ops);
856        assert_eq!(ops[0].new_subtitle_name, "movie.srt");
857        assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
858    }
859
860    #[test]
861    fn test_unique_target_paths_skip_existing_on_disk() {
862        // Two ops want the same target. The output directory already
863        // contains `movie.srt` and `movie.1.srt`. The allocator must
864        // skip both pre-existing slots, otherwise downstream
865        // `resolve_filename_conflict` re-probing can race and steal
866        // a numerical slot that was already allocated to a peer op
867        // (depending on the operations[] iteration order).
868        let dir = tempfile::tempdir().unwrap();
869        let dir_path = dir.path().to_path_buf();
870        std::fs::write(dir_path.join("movie.srt"), b"existing").unwrap();
871        std::fs::write(dir_path.join("movie.1.srt"), b"existing").unwrap();
872        let dir_str = dir_path.to_string_lossy().to_string();
873        let src1 = dir_path.parent().unwrap().join("sub1.srt");
874        let src2 = dir_path.parent().unwrap().join("sub2.srt");
875        let mut ops = vec![
876            op(&dir_str, "movie.srt", src1.to_str().unwrap(), true),
877            op(&dir_str, "movie.srt", src2.to_str().unwrap(), true),
878        ];
879        super::apply_unique_target_paths(&mut ops);
880        let mut got: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
881        got.sort();
882        assert_eq!(
883            got,
884            vec!["movie.2.srt".to_string(), "movie.3.srt".to_string()]
885        );
886    }
887
888    #[test]
889    fn test_legacy_v1_cache_rejected() {
890        // After the v1.0 → v2.0 bump, `check_file_list_cache` calls
891        // `is_cache_version_current` on the loaded payload and bails out
892        // with `Ok(None)` before considering operations, so stale
893        // duplicate-name results from the legacy prompt cannot leak
894        // through. Round-trip a real on-disk v1 cache through
895        // `CacheData::load` (the same loader `check_file_list_cache`
896        // uses) to prove the rejection path actually fires — going
897        // through the engine method itself would require manipulating
898        // `XDG_CONFIG_HOME`, which AGENTS.md forbids.
899        use crate::core::matcher::cache::CacheData;
900        use std::time::{SystemTime, UNIX_EPOCH};
901        use tempfile::TempDir;
902
903        assert_eq!(CURRENT_CACHE_VERSION, "2.0");
904        assert!(super::is_cache_version_current("2.0"));
905        assert!(!super::is_cache_version_current("1.0"));
906        assert!(!super::is_cache_version_current(""));
907
908        let temp = TempDir::new().unwrap();
909        let cache_path = temp.path().join("legacy_v1_cache.json");
910        let now = SystemTime::now()
911            .duration_since(UNIX_EPOCH)
912            .unwrap()
913            .as_secs();
914        let legacy = serde_json::json!({
915            "cache_version": "1.0",
916            "directory": "filelist_deadbeef",
917            "file_snapshot": [],
918            "match_operations": [],
919            "created_at": now,
920            "ai_model_used": "test-model",
921            "config_hash": "0000000000000000",
922            "original_relocation_mode": "None",
923            "original_backup_enabled": false,
924        });
925        std::fs::write(&cache_path, serde_json::to_string(&legacy).unwrap()).unwrap();
926
927        let loaded = CacheData::load(&cache_path).expect("legacy cache should parse");
928        assert_eq!(loaded.cache_version, "1.0");
929        assert!(
930            !super::is_cache_version_current(&loaded.cache_version),
931            "loaded v1 cache must be rejected by the version gate"
932        );
933    }
934
935    #[tokio::test]
936    async fn test_rename_file_displays_success_check_mark() {
937        use std::fs;
938        use tempfile::TempDir;
939
940        let temp_dir = TempDir::new().unwrap();
941        let temp_path = temp_dir.path();
942
943        // Create a test file
944        let original_file = temp_path.join("original.srt");
945        fs::write(
946            &original_file,
947            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
948        )
949        .unwrap();
950
951        // Create a test MatchEngine
952        let engine = MatchEngine::new(
953            Box::new(DummyAI),
954            MatchConfig {
955                confidence_threshold: 0.0,
956                max_sample_length: 0,
957                enable_content_analysis: false,
958                backup_enabled: false,
959                relocation_mode: FileRelocationMode::None,
960                conflict_resolution: ConflictResolution::Skip,
961                ai_model: "test-model".to_string(),
962                max_subtitle_bytes: 52_428_800,
963            },
964        );
965
966        // Create a MatchOperation
967        let subtitle_file = MediaFile {
968            id: "test_id".to_string(),
969            relative_path: "original.srt".to_string(),
970            path: original_file.clone(),
971            file_type: MediaFileType::Subtitle,
972            size: 40,
973            name: "original".to_string(),
974            extension: "srt".to_string(),
975        };
976
977        let match_op = MatchOperation {
978            video_file: MediaFile {
979                id: "video_id".to_string(),
980                relative_path: "test.mp4".to_string(),
981                path: temp_path.join("test.mp4"),
982                file_type: MediaFileType::Video,
983                size: 1000,
984                name: "test".to_string(),
985                extension: "mp4".to_string(),
986            },
987            subtitle_file,
988            new_subtitle_name: "renamed.srt".to_string(),
989            confidence: 95.0,
990            reasoning: vec!["Test match".to_string()],
991            requires_relocation: false,
992            relocation_target_path: None,
993            relocation_mode: FileRelocationMode::None,
994        };
995
996        // Execute the rename operation
997        let result = engine.rename_file(&match_op).await;
998
999        // Verify the operation was successful
1000        assert!(result.is_ok());
1001
1002        // Verify the file has been renamed
1003        let renamed_file = temp_path.join("renamed.srt");
1004        assert!(renamed_file.exists(), "The renamed file should exist");
1005        assert!(
1006            !original_file.exists(),
1007            "The original file should have been renamed"
1008        );
1009
1010        // Verify the file content is correct
1011        let content = fs::read_to_string(&renamed_file).unwrap();
1012        assert!(content.contains("Test subtitle"));
1013    }
1014
1015    #[tokio::test]
1016    async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
1017        use std::fs;
1018        use tempfile::TempDir;
1019
1020        let temp_dir = TempDir::new().unwrap();
1021        let temp_path = temp_dir.path();
1022
1023        // Create test file
1024        let original_file = temp_path.join("original.srt");
1025        fs::write(
1026            &original_file,
1027            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1028        )
1029        .unwrap();
1030
1031        // Create a test MatchEngine
1032        let engine = MatchEngine::new(
1033            Box::new(DummyAI),
1034            MatchConfig {
1035                confidence_threshold: 0.0,
1036                max_sample_length: 0,
1037                enable_content_analysis: false,
1038                backup_enabled: false,
1039                relocation_mode: FileRelocationMode::None,
1040                conflict_resolution: ConflictResolution::Skip,
1041                ai_model: "test-model".to_string(),
1042                max_subtitle_bytes: 52_428_800,
1043            },
1044        );
1045
1046        // Create a MatchOperation
1047        let subtitle_file = MediaFile {
1048            id: "test_id".to_string(),
1049            relative_path: "original.srt".to_string(),
1050            path: original_file.clone(),
1051            file_type: MediaFileType::Subtitle,
1052            size: 40,
1053            name: "original".to_string(),
1054            extension: "srt".to_string(),
1055        };
1056
1057        let match_op = MatchOperation {
1058            video_file: MediaFile {
1059                id: "video_id".to_string(),
1060                relative_path: "test.mp4".to_string(),
1061                path: temp_path.join("test.mp4"),
1062                file_type: MediaFileType::Video,
1063                size: 1000,
1064                name: "test".to_string(),
1065                extension: "mp4".to_string(),
1066            },
1067            subtitle_file,
1068            new_subtitle_name: "renamed.srt".to_string(),
1069            confidence: 95.0,
1070            reasoning: vec!["Test match".to_string()],
1071            requires_relocation: false,
1072            relocation_target_path: None,
1073            relocation_mode: FileRelocationMode::None,
1074        };
1075
1076        // Simulate file not existing after operation
1077        // First, execute the rename operation normally
1078        let result = engine.rename_file(&match_op).await;
1079        assert!(result.is_ok());
1080
1081        // Manually delete the renamed file to simulate failure
1082        let renamed_file = temp_path.join("renamed.srt");
1083        if renamed_file.exists() {
1084            fs::remove_file(&renamed_file).unwrap();
1085        }
1086
1087        // Recreate the original file for the second test
1088        fs::write(
1089            &original_file,
1090            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1091        )
1092        .unwrap();
1093
1094        // Create a rename operation that will fail, by overwriting the rename implementation
1095        // Since we cannot directly simulate std::fs::rename failure with file not existing,
1096        // we test the scenario where the file is manually removed after the operation completes
1097        let result = engine.rename_file(&match_op).await;
1098        assert!(result.is_ok());
1099
1100        // Manually delete the file again
1101        let renamed_file = temp_path.join("renamed.srt");
1102        if renamed_file.exists() {
1103            fs::remove_file(&renamed_file).unwrap();
1104        }
1105
1106        // This test mainly verifies the code structure is correct, the actual error message display needs to be validated through integration tests
1107        // Because we cannot easily simulate the scenario where the file system operation succeeds but the file does not exist
1108    }
1109
1110    #[test]
1111    fn test_file_operation_message_format() {
1112        // Test error message format
1113        let source_name = "test.srt";
1114        let target_name = "renamed.srt";
1115
1116        // Simulate success message format
1117        let success_msg = format!("  ✓ Renamed: {} -> {}", source_name, target_name);
1118        assert!(success_msg.contains("✓"));
1119        assert!(success_msg.contains("Renamed:"));
1120        assert!(success_msg.contains(source_name));
1121        assert!(success_msg.contains(target_name));
1122
1123        // Simulate failure message format
1124        let error_msg = format!(
1125            "  ✗ Rename failed: {} -> {} (target file does not exist after operation)",
1126            source_name, target_name
1127        );
1128        assert!(error_msg.contains("✗"));
1129        assert!(error_msg.contains("Rename failed:"));
1130        assert!(error_msg.contains("target file does not exist"));
1131        assert!(error_msg.contains(source_name));
1132        assert!(error_msg.contains(target_name));
1133    }
1134
1135    #[test]
1136    fn test_copy_operation_message_format() {
1137        // Test copy operation message format
1138        let source_name = "subtitle.srt";
1139        let target_name = "video.srt";
1140
1141        // Simulate success message format
1142        let success_msg = format!("  ✓ Copied: {} -> {}", source_name, target_name);
1143        assert!(success_msg.contains("✓"));
1144        assert!(success_msg.contains("Copied:"));
1145
1146        // Simulate failure message format
1147        let error_msg = format!(
1148            "  ✗ Copy failed: {} -> {} (target file does not exist after operation)",
1149            source_name, target_name
1150        );
1151        assert!(error_msg.contains("✗"));
1152        assert!(error_msg.contains("Copy failed:"));
1153        assert!(error_msg.contains("target file does not exist"));
1154    }
1155
1156    #[test]
1157    fn test_move_operation_message_format() {
1158        // Test move operation message format
1159        let source_name = "subtitle.srt";
1160        let target_name = "video.srt";
1161
1162        // Simulate success message format
1163        let success_msg = format!("  ✓ Moved: {} -> {}", source_name, target_name);
1164        assert!(success_msg.contains("✓"));
1165        assert!(success_msg.contains("Moved:"));
1166
1167        // Simulate failure message format
1168        let error_msg = format!(
1169            "  ✗ Move failed: {} -> {} (target file does not exist after operation)",
1170            source_name, target_name
1171        );
1172        assert!(error_msg.contains("✗"));
1173        assert!(error_msg.contains("Move failed:"));
1174        assert!(error_msg.contains("target file does not exist"));
1175    }
1176}
1177
1178/// Match operation result representing a single video-subtitle match.
1179///
1180/// Contains all information about a successful match between a video file
1181/// and a subtitle file, including confidence metrics and reasoning.
1182#[derive(Debug)]
1183pub struct MatchOperation {
1184    /// The matched video file
1185    pub video_file: MediaFile,
1186    /// The matched subtitle file
1187    pub subtitle_file: MediaFile,
1188    /// The new filename for the subtitle file
1189    pub new_subtitle_name: String,
1190    /// Confidence score of the match (0.0 to 1.0)
1191    pub confidence: f32,
1192    /// List of reasons supporting this match
1193    pub reasoning: Vec<String>,
1194    /// File relocation mode for this operation
1195    pub relocation_mode: FileRelocationMode,
1196    /// Target relocation path if operation is needed
1197    pub relocation_target_path: Option<std::path::PathBuf>,
1198    /// Whether relocation operation is needed (different folders)
1199    pub requires_relocation: bool,
1200}
1201
1202/// AI-suggested candidate that did not become a match operation.
1203///
1204/// Emitted by [`MatchEngine::match_file_list_with_audit`] so machine-readable
1205/// callers can surface AI suggestions that were rejected (sub-threshold or
1206/// referencing unknown file IDs).
1207#[derive(Debug, Clone)]
1208pub struct RejectedCandidate {
1209    /// Path of the candidate video file (empty string if unresolved).
1210    pub video_path: String,
1211    /// Path of the candidate subtitle file (empty string if unresolved).
1212    pub subtitle_path: String,
1213    /// AI-reported confidence score (0.0 to 1.0).
1214    pub confidence: f32,
1215    /// Stable reason code (`"below_threshold"` or `"id_not_found"`).
1216    pub reason: &'static str,
1217}
1218
1219/// Result of the auditable match planning pass: accepted operations plus
1220/// rejected candidates.
1221#[derive(Debug)]
1222pub struct MatchAudit {
1223    /// Operations that satisfied the confidence threshold and resolved to real files.
1224    pub operations: Vec<MatchOperation>,
1225    /// Candidates rejected by the planner.
1226    pub rejected: Vec<RejectedCandidate>,
1227}
1228
1229/// Per-operation outcome captured by [`MatchEngine::execute_operations_audit`].
1230///
1231/// Unlike [`MatchEngine::execute_operations`] which aborts on first failure,
1232/// the audit variant records each operation's outcome so callers can report
1233/// partial success/failure (used by JSON output mode).
1234#[derive(Debug)]
1235pub struct OperationOutcome {
1236    /// Whether the operation was applied to the filesystem.
1237    pub applied: bool,
1238    /// Set when the operation failed (mutually exclusive with `applied == true`).
1239    pub error: Option<OperationError>,
1240}
1241
1242/// Self-contained per-operation error metadata for machine-readable output.
1243#[derive(Debug, Clone)]
1244pub struct OperationError {
1245    /// Error category from [`SubXError::category`].
1246    pub category: &'static str,
1247    /// Stable machine code from [`SubXError::machine_code`].
1248    pub code: &'static str,
1249    /// User-friendly message from [`SubXError::user_friendly_message`].
1250    pub message: String,
1251}
1252
1253/// Convert a [`SubXError`] into a self-contained [`OperationError`] for
1254/// audit reporting.
1255fn operation_error_from(err: &SubXError) -> OperationError {
1256    OperationError {
1257        category: err.category(),
1258        code: err.machine_code(),
1259        message: err.user_friendly_message(),
1260    }
1261}
1262
1263/// Engine for matching video and subtitle files using AI analysis.
1264pub struct MatchEngine {
1265    ai_client: Box<dyn AIProvider>,
1266    discovery: FileDiscovery,
1267    config: MatchConfig,
1268}
1269
1270impl MatchEngine {
1271    /// Creates a new `MatchEngine` with the given AI provider and configuration.
1272    pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
1273        Self {
1274            ai_client,
1275            discovery: FileDiscovery::new(),
1276            config,
1277        }
1278    }
1279
1280    /// Matches video and subtitle files from a specified list of files.
1281    ///
1282    /// This method processes a user-provided list of files, filtering them into
1283    /// video and subtitle files, then performing AI-powered matching analysis.
1284    /// This is useful when users specify exact files via -i parameters.
1285    ///
1286    /// # Arguments
1287    ///
1288    /// * `file_paths` - A slice of file paths to process for matching
1289    ///
1290    /// # Returns
1291    ///
1292    /// A list of `MatchOperation` entries that meet the confidence threshold.
1293    pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
1294        Ok(self
1295            .match_file_list_with_audit(file_paths)
1296            .await?
1297            .operations)
1298    }
1299
1300    /// Auditable variant of [`MatchEngine::match_file_list`] that also returns
1301    /// rejected candidates (sub-threshold or unresolvable AI suggestions).
1302    ///
1303    /// When operations are served from cache, `rejected` is returned empty
1304    /// because cache entries do not preserve rejection metadata.
1305    pub async fn match_file_list_with_audit(&self, file_paths: &[PathBuf]) -> Result<MatchAudit> {
1306        let json_mode = crate::cli::output::active_mode().is_json();
1307        // 1. Process the file list to create MediaFile objects
1308        let files = self.discovery.scan_file_list(file_paths)?;
1309
1310        let videos: Vec<_> = files
1311            .iter()
1312            .filter(|f| matches!(f.file_type, MediaFileType::Video))
1313            .collect();
1314        let subtitles: Vec<_> = files
1315            .iter()
1316            .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
1317            .collect();
1318
1319        if videos.is_empty() || subtitles.is_empty() {
1320            return Ok(MatchAudit {
1321                operations: Vec::new(),
1322                rejected: Vec::new(),
1323            });
1324        }
1325
1326        // 2. Check if we can use cache for file list operations
1327        let cache_key = self.calculate_file_list_cache_key(file_paths)?;
1328        if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
1329            return Ok(MatchAudit {
1330                operations: ops,
1331                rejected: Vec::new(),
1332            });
1333        }
1334
1335        // 3. Content sampling
1336        let content_samples = if self.config.enable_content_analysis {
1337            self.extract_content_samples(&subtitles).await?
1338        } else {
1339            Vec::new()
1340        };
1341
1342        // 4. AI analysis request
1343        let video_files: Vec<String> = videos
1344            .iter()
1345            .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
1346            .collect();
1347        let subtitle_files: Vec<String> = subtitles
1348            .iter()
1349            .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
1350            .collect();
1351
1352        let analysis_request = AnalysisRequest {
1353            video_files,
1354            subtitle_files,
1355            content_samples,
1356        };
1357
1358        // 5. Query AI service
1359        let match_result = self.ai_client.analyze_content(analysis_request).await?;
1360
1361        // Debug: Log AI analysis results (suppressed in JSON mode to keep
1362        // stderr free of free-form chatter).
1363        if !json_mode {
1364            eprintln!("🔍 AI Analysis Results:");
1365            eprintln!("   - Total matches: {}", match_result.matches.len());
1366            eprintln!(
1367                "   - Confidence threshold: {:.2}",
1368                self.config.confidence_threshold
1369            );
1370            for ai_match in &match_result.matches {
1371                eprintln!(
1372                    "   - {} -> {} (confidence: {:.2})",
1373                    ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
1374                );
1375            }
1376        }
1377
1378        // 6. Assemble match operation list
1379        let mut operations = Vec::new();
1380        let mut rejected = Vec::new();
1381
1382        for ai_match in match_result.matches {
1383            let video_match =
1384                Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
1385            let subtitle_match =
1386                Self::find_media_file_by_id_or_path(&subtitles, &ai_match.subtitle_file_id, None);
1387
1388            if ai_match.confidence < self.config.confidence_threshold {
1389                rejected.push(RejectedCandidate {
1390                    video_path: video_match
1391                        .map(|v| v.path.display().to_string())
1392                        .unwrap_or_default(),
1393                    subtitle_path: subtitle_match
1394                        .map(|s| s.path.display().to_string())
1395                        .unwrap_or_default(),
1396                    confidence: ai_match.confidence,
1397                    reason: "below_threshold",
1398                });
1399                continue;
1400            }
1401
1402            match (video_match, subtitle_match) {
1403                (Some(video), Some(subtitle)) => {
1404                    let new_name = self.generate_subtitle_name(video, subtitle, &ai_match);
1405
1406                    let requires_relocation = self.config.relocation_mode
1407                        != FileRelocationMode::None
1408                        && subtitle.path.parent() != video.path.parent();
1409
1410                    let relocation_target_path = if requires_relocation {
1411                        let video_dir = video.path.parent().unwrap();
1412                        Some(video_dir.join(&new_name))
1413                    } else {
1414                        None
1415                    };
1416
1417                    operations.push(MatchOperation {
1418                        video_file: (*video).clone(),
1419                        subtitle_file: (*subtitle).clone(),
1420                        new_subtitle_name: new_name,
1421                        confidence: ai_match.confidence,
1422                        reasoning: ai_match.match_factors,
1423                        relocation_mode: self.config.relocation_mode.clone(),
1424                        relocation_target_path,
1425                        requires_relocation,
1426                    });
1427                }
1428                _ => {
1429                    if !json_mode {
1430                        eprintln!(
1431                            "⚠️  Cannot find AI-suggested file pair:\n     Video ID: '{}'\n     Subtitle ID: '{}'",
1432                            ai_match.video_file_id, ai_match.subtitle_file_id
1433                        );
1434                    }
1435                    rejected.push(RejectedCandidate {
1436                        video_path: video_match
1437                            .map(|v| v.path.display().to_string())
1438                            .unwrap_or_default(),
1439                        subtitle_path: subtitle_match
1440                            .map(|s| s.path.display().to_string())
1441                            .unwrap_or_default(),
1442                        confidence: ai_match.confidence,
1443                        reason: "id_not_found",
1444                    });
1445                }
1446            }
1447        }
1448
1449        // 7. Globally enforce unique target paths before caching so the
1450        //    cached operations are already conflict-free. The CLI-level
1451        //    `match_command.rs` runs a second pass after archive-origin
1452        //    relocation rewrites — `apply_unique_target_paths` is
1453        //    idempotent, so the two passes compose cleanly.
1454        apply_unique_target_paths(&mut operations);
1455
1456        // 8. Save to cache for future use
1457        self.save_file_list_cache(&cache_key, &operations).await?;
1458
1459        Ok(MatchAudit {
1460            operations,
1461            rejected,
1462        })
1463    }
1464
1465    async fn extract_content_samples(
1466        &self,
1467        subtitles: &[&MediaFile],
1468    ) -> Result<Vec<ContentSample>> {
1469        let mut samples = Vec::new();
1470
1471        for subtitle in subtitles {
1472            let path = subtitle.path.clone();
1473            crate::core::fs_util::check_file_size(
1474                &path,
1475                self.config.max_subtitle_bytes,
1476                "Subtitle",
1477            )
1478            .map_err(SubXError::Io)?;
1479            let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
1480                .await
1481                .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
1482            let preview = self.create_content_preview(&content);
1483
1484            samples.push(ContentSample {
1485                filename: subtitle.name.clone(),
1486                subtitle_file_id: subtitle.id.clone(),
1487                content_preview: preview,
1488                file_size: subtitle.size,
1489            });
1490        }
1491
1492        Ok(samples)
1493    }
1494
1495    fn create_content_preview(&self, content: &str) -> String {
1496        let lines: Vec<&str> = content.lines().take(20).collect();
1497        let preview = lines.join("\n");
1498
1499        if preview.len() > self.config.max_sample_length {
1500            format!("{}...", &preview[..self.config.max_sample_length])
1501        } else {
1502            preview
1503        }
1504    }
1505
1506    fn generate_subtitle_name(
1507        &self,
1508        video: &MediaFile,
1509        subtitle: &MediaFile,
1510        ai_match: &crate::services::ai::FileMatch,
1511    ) -> String {
1512        let detector = LanguageDetector::new();
1513
1514        // Remove the extension from the video file name (if any)
1515        let video_base_name = if !video.extension.is_empty() {
1516            video
1517                .name
1518                .strip_suffix(&format!(".{}", video.extension))
1519                .unwrap_or(&video.name)
1520        } else {
1521            &video.name
1522        };
1523
1524        // Four-step precedence per the AI-driven naming spec:
1525        // 1. Sanitized + normalized AI `target_filename_suffix` wins.
1526        // 2. Sanitized + normalized AI `language` (with `und` → no tag).
1527        // 3. Filename-derived language detection.
1528        // 4. No language tag.
1529        let ai_tag = ai_match
1530            .target_filename_suffix
1531            .as_deref()
1532            .and_then(sanitize_suffix)
1533            .or_else(|| {
1534                ai_match
1535                    .language
1536                    .as_deref()
1537                    .and_then(|s| normalize_ai_language(&detector, s))
1538            });
1539
1540        let code = ai_tag.or_else(|| detector.get_primary_language(&subtitle.path));
1541
1542        if let Some(code) = code {
1543            format!("{}.{}.{}", video_base_name, code, subtitle.extension)
1544        } else {
1545            format!("{}.{}", video_base_name, subtitle.extension)
1546        }
1547    }
1548
1549    /// Execute match operations with dry-run mode support.
1550    ///
1551    /// When `dry_run` is false, a transactional journal is written to
1552    /// [`journal_path`] recording every successfully completed operation.
1553    /// The journal is saved atomically after each operation so that a
1554    /// crash mid-batch still leaves a consistent, resumable on-disk
1555    /// record. No journal is written in dry-run mode or when the batch
1556    /// performs zero operations.
1557    pub async fn execute_operations(
1558        &self,
1559        operations: &[MatchOperation],
1560        dry_run: bool,
1561    ) -> Result<()> {
1562        if dry_run {
1563            // Text-mode dry-run preview. JSON mode never reaches this path
1564            // (the match command takes the JSON branch via
1565            // `execute_operations_audit` before calling this method), but
1566            // gate defensively so a future caller cannot corrupt the
1567            // single-envelope contract.
1568            let json_mode = crate::cli::output::active_mode().is_json();
1569            for op in operations {
1570                if !json_mode {
1571                    println!(
1572                        "Preview: {} -> {}",
1573                        op.subtitle_file.name, op.new_subtitle_name
1574                    );
1575                }
1576                if op.requires_relocation {
1577                    if let Some(target_path) = &op.relocation_target_path {
1578                        let operation_verb = match op.relocation_mode {
1579                            FileRelocationMode::Copy => "Copy",
1580                            FileRelocationMode::Move => "Move",
1581                            _ => "",
1582                        };
1583                        if !json_mode {
1584                            println!(
1585                                "Preview: {} {} to {}",
1586                                operation_verb,
1587                                op.subtitle_file.path.display(),
1588                                target_path.display()
1589                            );
1590                        }
1591                    }
1592                }
1593            }
1594            return Ok(());
1595        }
1596
1597        // Prepare a fresh journal for this batch. The journal is saved
1598        // atomically after each successful operation so that the on-disk
1599        // record never diverges from the in-memory state by more than a
1600        // single completed entry.
1601        let created_at = std::time::SystemTime::now()
1602            .duration_since(std::time::UNIX_EPOCH)
1603            .map(|d| d.as_secs())
1604            .unwrap_or(0);
1605        let batch_id = {
1606            use std::collections::hash_map::DefaultHasher;
1607            use std::hash::{Hash, Hasher};
1608            let mut hasher = DefaultHasher::new();
1609            created_at.hash(&mut hasher);
1610            operations.len().hash(&mut hasher);
1611            for op in operations {
1612                op.subtitle_file.path.hash(&mut hasher);
1613                op.new_subtitle_name.hash(&mut hasher);
1614            }
1615            format!("{:016x}", hasher.finish())
1616        };
1617        let mut journal = JournalData {
1618            batch_id,
1619            created_at,
1620            entries: Vec::new(),
1621        };
1622        let journal_file = journal_path().ok();
1623
1624        let mut first_error: Option<SubXError> = None;
1625
1626        for op in operations {
1627            // Build the task list the same way the previous implementation
1628            // did so relocation, backup and rename semantics are preserved.
1629            let mut backup_path: Option<PathBuf> = None;
1630
1631            if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
1632                let backup_task =
1633                    self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
1634                if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
1635                    backup_path = Some(backup.clone());
1636                }
1637                if let TaskResult::Failed(err) = backup_task.execute().await {
1638                    first_error = Some(SubXError::FileOperationFailed(err));
1639                    break;
1640                }
1641            }
1642
1643            // Either a copy-with-rename task (Copy mode) or a rename task
1644            // (Move / None modes) produces the primary journal entry for
1645            // this operation.
1646            let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1647                self.create_copy_task(op)
1648            } else {
1649                self.create_rename_task(op)
1650            };
1651
1652            let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1653            {
1654                ProcessingOperation::CopyWithRename { source, target }
1655                | ProcessingOperation::CopyToVideoFolder { source, target } => {
1656                    (source.clone(), target.clone(), JournalOperationType::Copied)
1657                }
1658                ProcessingOperation::MoveToVideoFolder { source, target } => {
1659                    (source.clone(), target.clone(), JournalOperationType::Moved)
1660                }
1661                ProcessingOperation::RenameFile { source, target } => {
1662                    let kind = match op.relocation_mode {
1663                        FileRelocationMode::Move => JournalOperationType::Moved,
1664                        _ => JournalOperationType::Renamed,
1665                    };
1666                    (source.clone(), target.clone(), kind)
1667                }
1668                _ => (
1669                    op.subtitle_file.path.clone(),
1670                    op.relocation_target_path.clone().unwrap_or_else(|| {
1671                        op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1672                    }),
1673                    JournalOperationType::Renamed,
1674                ),
1675            };
1676
1677            // Capture source metadata before execution because move/rename
1678            // will invalidate the source path afterwards.
1679            let (pre_file_size, pre_file_mtime) = journal_source
1680                .metadata()
1681                .ok()
1682                .map(|m| {
1683                    let mtime = m
1684                        .modified()
1685                        .ok()
1686                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1687                        .map(|d| d.as_secs())
1688                        .unwrap_or(0);
1689                    (m.len(), mtime)
1690                })
1691                .unwrap_or((0, 0));
1692
1693            if let TaskResult::Failed(err) = primary_task.execute().await {
1694                first_error = Some(SubXError::FileOperationFailed(err));
1695                break;
1696            }
1697
1698            // Record destination metadata after execution so that rollback
1699            // integrity checks compare against the actual destination state.
1700            let (file_size, file_mtime) = journal_destination
1701                .metadata()
1702                .ok()
1703                .map(|m| {
1704                    let mtime = m
1705                        .modified()
1706                        .ok()
1707                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1708                        .map(|d| d.as_secs())
1709                        .unwrap_or(0);
1710                    (m.len(), mtime)
1711                })
1712                .unwrap_or((pre_file_size, pre_file_mtime));
1713
1714            journal.entries.push(JournalEntry {
1715                operation_type: journal_kind,
1716                source: journal_source,
1717                destination: journal_destination,
1718                backup_path: backup_path.clone(),
1719                status: JournalEntryStatus::Completed,
1720                file_size,
1721                file_mtime,
1722            });
1723
1724            if let Some(path) = journal_file.as_ref() {
1725                // Persist after every successful operation so interruption
1726                // leaves the on-disk journal consistent with the file system.
1727                journal.save(path).await?;
1728            }
1729        }
1730
1731        if let Some(err) = first_error {
1732            return Err(err);
1733        }
1734        Ok(())
1735    }
1736
1737    /// Auditable variant of [`MatchEngine::execute_operations`] that does NOT
1738    /// abort on first failure. Each operation produces an [`OperationOutcome`]
1739    /// describing whether it was applied or which error blocked it.
1740    ///
1741    /// This is the engine entry point used by JSON output mode to surface
1742    /// per-item statuses in the success envelope.
1743    pub async fn execute_operations_audit(
1744        &self,
1745        operations: &[MatchOperation],
1746        dry_run: bool,
1747    ) -> Result<Vec<OperationOutcome>> {
1748        if dry_run {
1749            return Ok(operations
1750                .iter()
1751                .map(|_| OperationOutcome {
1752                    applied: false,
1753                    error: None,
1754                })
1755                .collect());
1756        }
1757
1758        let created_at = std::time::SystemTime::now()
1759            .duration_since(std::time::UNIX_EPOCH)
1760            .map(|d| d.as_secs())
1761            .unwrap_or(0);
1762        let batch_id = {
1763            use std::collections::hash_map::DefaultHasher;
1764            use std::hash::{Hash, Hasher};
1765            let mut hasher = DefaultHasher::new();
1766            created_at.hash(&mut hasher);
1767            operations.len().hash(&mut hasher);
1768            for op in operations {
1769                op.subtitle_file.path.hash(&mut hasher);
1770                op.new_subtitle_name.hash(&mut hasher);
1771            }
1772            format!("{:016x}", hasher.finish())
1773        };
1774        let mut journal = JournalData {
1775            batch_id,
1776            created_at,
1777            entries: Vec::new(),
1778        };
1779        let journal_file = journal_path().ok();
1780
1781        let mut outcomes = Vec::with_capacity(operations.len());
1782
1783        for op in operations {
1784            let mut backup_path: Option<PathBuf> = None;
1785
1786            if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
1787                let backup_task =
1788                    self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
1789                if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
1790                    backup_path = Some(backup.clone());
1791                }
1792                if let TaskResult::Failed(err) = backup_task.execute().await {
1793                    let err = SubXError::FileOperationFailed(err);
1794                    outcomes.push(OperationOutcome {
1795                        applied: false,
1796                        error: Some(operation_error_from(&err)),
1797                    });
1798                    continue;
1799                }
1800            }
1801
1802            let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1803                self.create_copy_task(op)
1804            } else {
1805                self.create_rename_task(op)
1806            };
1807
1808            let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1809            {
1810                ProcessingOperation::CopyWithRename { source, target }
1811                | ProcessingOperation::CopyToVideoFolder { source, target } => {
1812                    (source.clone(), target.clone(), JournalOperationType::Copied)
1813                }
1814                ProcessingOperation::MoveToVideoFolder { source, target } => {
1815                    (source.clone(), target.clone(), JournalOperationType::Moved)
1816                }
1817                ProcessingOperation::RenameFile { source, target } => {
1818                    let kind = match op.relocation_mode {
1819                        FileRelocationMode::Move => JournalOperationType::Moved,
1820                        _ => JournalOperationType::Renamed,
1821                    };
1822                    (source.clone(), target.clone(), kind)
1823                }
1824                _ => (
1825                    op.subtitle_file.path.clone(),
1826                    op.relocation_target_path.clone().unwrap_or_else(|| {
1827                        op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1828                    }),
1829                    JournalOperationType::Renamed,
1830                ),
1831            };
1832
1833            let (pre_file_size, pre_file_mtime) = journal_source
1834                .metadata()
1835                .ok()
1836                .map(|m| {
1837                    let mtime = m
1838                        .modified()
1839                        .ok()
1840                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1841                        .map(|d| d.as_secs())
1842                        .unwrap_or(0);
1843                    (m.len(), mtime)
1844                })
1845                .unwrap_or((0, 0));
1846
1847            if let TaskResult::Failed(err) = primary_task.execute().await {
1848                let err = SubXError::FileOperationFailed(err);
1849                outcomes.push(OperationOutcome {
1850                    applied: false,
1851                    error: Some(operation_error_from(&err)),
1852                });
1853                continue;
1854            }
1855
1856            let (file_size, file_mtime) = journal_destination
1857                .metadata()
1858                .ok()
1859                .map(|m| {
1860                    let mtime = m
1861                        .modified()
1862                        .ok()
1863                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1864                        .map(|d| d.as_secs())
1865                        .unwrap_or(0);
1866                    (m.len(), mtime)
1867                })
1868                .unwrap_or((pre_file_size, pre_file_mtime));
1869
1870            journal.entries.push(JournalEntry {
1871                operation_type: journal_kind,
1872                source: journal_source,
1873                destination: journal_destination,
1874                backup_path: backup_path.clone(),
1875                status: JournalEntryStatus::Completed,
1876                file_size,
1877                file_mtime,
1878            });
1879
1880            if let Some(path) = journal_file.as_ref() {
1881                journal.save(path).await?;
1882            }
1883
1884            outcomes.push(OperationOutcome {
1885                applied: true,
1886                error: None,
1887            });
1888        }
1889
1890        Ok(outcomes)
1891    }
1892
1893    /// Rename subtitle file by delegating to FileProcessingTask
1894    async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
1895        let task = self.create_rename_task(op);
1896        match task.execute().await {
1897            TaskResult::Success(_) => Ok(()),
1898            TaskResult::Failed(err) => Err(SubXError::FileOperationFailed(err)),
1899            other => Err(SubXError::FileOperationFailed(format!(
1900                "Unexpected rename result: {:?}",
1901                other
1902            ))),
1903        }
1904    }
1905
1906    /// Resolve filename conflicts by adding numeric suffix
1907    fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
1908        if !target.exists() {
1909            return Ok(target);
1910        }
1911        let json_mode = crate::cli::output::active_mode().is_json();
1912        match self.config.conflict_resolution {
1913            ConflictResolution::Skip => {
1914                if !json_mode {
1915                    eprintln!(
1916                        "Warning: Skipping relocation due to existing file: {}",
1917                        target.display()
1918                    );
1919                }
1920                Ok(target)
1921            }
1922            ConflictResolution::AutoRename => {
1923                let file_stem = target
1924                    .file_stem()
1925                    .and_then(|s| s.to_str())
1926                    .unwrap_or("file");
1927                let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
1928                let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
1929                // Try the base name first via atomic create; if it succeeds we drop
1930                // the handle immediately since the downstream FileProcessingTask
1931                // performs its own I/O against this path.
1932                match crate::core::fs_util::atomic_create_file(&target) {
1933                    Ok(_f) => {
1934                        // Remove the placeholder so the downstream task can create it.
1935                        let _ = std::fs::remove_file(&target);
1936                        return Ok(target);
1937                    }
1938                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
1939                    Err(e) => return Err(SubXError::from(e)),
1940                }
1941                for i in 1..1000 {
1942                    let new_name = if extension.is_empty() {
1943                        format!("{}.{}", file_stem, i)
1944                    } else {
1945                        format!("{}.{}.{}", file_stem, i, extension)
1946                    };
1947                    let new_path = parent.join(new_name);
1948                    match crate::core::fs_util::atomic_create_file(&new_path) {
1949                        Ok(_f) => {
1950                            let _ = std::fs::remove_file(&new_path);
1951                            return Ok(new_path);
1952                        }
1953                        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
1954                        Err(e) => return Err(SubXError::from(e)),
1955                    }
1956                }
1957                Err(SubXError::FileOperationFailed(
1958                    "Could not resolve filename conflict".to_string(),
1959                ))
1960            }
1961            ConflictResolution::Prompt => {
1962                if !json_mode {
1963                    eprintln!(
1964                        "Warning: Conflict resolution prompt not implemented, using auto-rename"
1965                    );
1966                }
1967                self.resolve_filename_conflict(target)
1968            }
1969        }
1970    }
1971
1972    /// Create a task to copy (or rename) a file with new name
1973    fn create_copy_task(&self, op: &MatchOperation) -> FileProcessingTask {
1974        // In copy mode, always use the original subtitle file as source
1975        let source = op.subtitle_file.path.clone();
1976        let target_base = op.relocation_target_path.clone().unwrap();
1977        let final_target = self.resolve_filename_conflict(target_base).unwrap();
1978        FileProcessingTask::new(
1979            source.clone(),
1980            Some(final_target.clone()),
1981            ProcessingOperation::CopyWithRename {
1982                source,
1983                target: final_target,
1984            },
1985        )
1986    }
1987
1988    /// Create a task to backup a file
1989    fn create_backup_task(&self, source: &std::path::Path, ext: &str) -> FileProcessingTask {
1990        let backup_path = source.with_extension(format!("{}.backup", ext));
1991        FileProcessingTask::new(
1992            source.to_path_buf(),
1993            Some(backup_path.clone()),
1994            ProcessingOperation::CreateBackup {
1995                source: source.to_path_buf(),
1996                backup: backup_path,
1997            },
1998        )
1999    }
2000
2001    /// Create a task to rename (move) a file
2002    fn create_rename_task(&self, op: &MatchOperation) -> FileProcessingTask {
2003        let old = op.subtitle_file.path.clone();
2004        // If relocation is required, use the relocation target path
2005        let new_path = if op.requires_relocation && op.relocation_target_path.is_some() {
2006            let target_base = op.relocation_target_path.clone().unwrap();
2007            self.resolve_filename_conflict(target_base).unwrap()
2008        } else {
2009            old.with_file_name(&op.new_subtitle_name)
2010        };
2011
2012        FileProcessingTask::new(
2013            old.clone(),
2014            Some(new_path.clone()),
2015            ProcessingOperation::RenameFile {
2016                source: old,
2017                target: new_path,
2018            },
2019        )
2020    }
2021
2022    /// Calculate cache key for file list operations
2023    fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
2024        use std::collections::BTreeMap;
2025        use std::collections::hash_map::DefaultHasher;
2026        use std::hash::{Hash, Hasher};
2027
2028        // Sort paths to ensure consistent key generation
2029        let mut path_metadata = BTreeMap::new();
2030        for path in file_paths {
2031            if let Ok(metadata) = path.metadata() {
2032                let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
2033                path_metadata.insert(
2034                    canonical.to_string_lossy().to_string(),
2035                    (metadata.len(), metadata.modified().ok()),
2036                );
2037            }
2038        }
2039
2040        // Include config hash to invalidate cache when configuration changes
2041        let config_hash = self.calculate_config_hash()?;
2042
2043        let mut hasher = DefaultHasher::new();
2044        path_metadata.hash(&mut hasher);
2045        config_hash.hash(&mut hasher);
2046
2047        Ok(format!("filelist_{:016x}", hasher.finish()))
2048    }
2049
2050    /// Check cache for file list operations
2051    async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
2052        let cache_file_path = self.get_cache_file_path()?;
2053        let cache_data = CacheData::load(&cache_file_path).ok();
2054
2055        if let Some(cache_data) = cache_data {
2056            if !is_cache_version_current(&cache_data.cache_version) {
2057                return Ok(None);
2058            }
2059            if cache_data.directory == cache_key {
2060                // Rebuild match operation list for file list cache
2061                let mut ops = Vec::new();
2062                let mut id_gen = Uuidv7Generator::new();
2063                for item in cache_data.match_operations {
2064                    // For file list operations, we reconstruct operations from cached data
2065                    let video_path = PathBuf::from(&item.video_file);
2066                    let subtitle_path = PathBuf::from(&item.subtitle_file);
2067
2068                    if video_path.exists() && subtitle_path.exists() {
2069                        // Create minimal MediaFile objects for the operation
2070                        let video_meta = video_path.metadata()?;
2071                        let subtitle_meta = subtitle_path.metadata()?;
2072
2073                        let video_file = MediaFile {
2074                            id: generate_file_id(&mut id_gen),
2075                            path: video_path.clone(),
2076                            file_type: MediaFileType::Video,
2077                            size: video_meta.len(),
2078                            name: video_path
2079                                .file_name()
2080                                .unwrap()
2081                                .to_string_lossy()
2082                                .to_string(),
2083                            extension: video_path
2084                                .extension()
2085                                .unwrap_or_default()
2086                                .to_string_lossy()
2087                                .to_lowercase(),
2088                            relative_path: video_path
2089                                .file_name()
2090                                .unwrap()
2091                                .to_string_lossy()
2092                                .to_string(),
2093                        };
2094
2095                        let subtitle_file = MediaFile {
2096                            id: generate_file_id(&mut id_gen),
2097                            path: subtitle_path.clone(),
2098                            file_type: MediaFileType::Subtitle,
2099                            size: subtitle_meta.len(),
2100                            name: subtitle_path
2101                                .file_name()
2102                                .unwrap()
2103                                .to_string_lossy()
2104                                .to_string(),
2105                            extension: subtitle_path
2106                                .extension()
2107                                .unwrap_or_default()
2108                                .to_string_lossy()
2109                                .to_lowercase(),
2110                            relative_path: subtitle_path
2111                                .file_name()
2112                                .unwrap()
2113                                .to_string_lossy()
2114                                .to_string(),
2115                        };
2116
2117                        // Recalculate relocation information based on current configuration
2118                        let requires_relocation = self.config.relocation_mode
2119                            != FileRelocationMode::None
2120                            && subtitle_file.path.parent() != video_file.path.parent();
2121
2122                        let relocation_target_path = if requires_relocation {
2123                            let video_dir = video_file.path.parent().unwrap();
2124                            Some(video_dir.join(&item.new_subtitle_name))
2125                        } else {
2126                            None
2127                        };
2128
2129                        ops.push(MatchOperation {
2130                            video_file,
2131                            subtitle_file,
2132                            new_subtitle_name: item.new_subtitle_name,
2133                            confidence: item.confidence,
2134                            reasoning: item.reasoning,
2135                            relocation_mode: self.config.relocation_mode.clone(),
2136                            relocation_target_path,
2137                            requires_relocation,
2138                        });
2139                    }
2140                }
2141                return Ok(Some(ops));
2142            }
2143        }
2144        Ok(None)
2145    }
2146
2147    /// Save cache for file list operations
2148    async fn save_file_list_cache(
2149        &self,
2150        cache_key: &str,
2151        operations: &[MatchOperation],
2152    ) -> Result<()> {
2153        let cache_file_path = self.get_cache_file_path()?;
2154        let config_hash = self.calculate_config_hash()?;
2155
2156        let mut cache_items = Vec::new();
2157        for op in operations {
2158            cache_items.push(OpItem {
2159                video_file: op.video_file.path.to_string_lossy().to_string(),
2160                subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
2161                new_subtitle_name: op.new_subtitle_name.clone(),
2162                confidence: op.confidence,
2163                reasoning: op.reasoning.clone(),
2164            });
2165        }
2166
2167        // Build file snapshot with canonical paths, sizes, and mtimes
2168        let mut snapshot_items = Vec::new();
2169        let mut seen_paths = std::collections::HashSet::new();
2170        for op in operations {
2171            for path in [&op.video_file.path, &op.subtitle_file.path] {
2172                let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
2173                let key = canonical.to_string_lossy().to_string();
2174                if seen_paths.insert(key.clone()) {
2175                    if let Ok(meta) = std::fs::metadata(&canonical) {
2176                        let mtime = meta
2177                            .modified()
2178                            .ok()
2179                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2180                            .map(|d| d.as_secs())
2181                            .unwrap_or(0);
2182                        snapshot_items.push(SnapshotItem {
2183                            path: key,
2184                            name: canonical
2185                                .file_name()
2186                                .unwrap_or_default()
2187                                .to_string_lossy()
2188                                .to_string(),
2189                            size: meta.len(),
2190                            mtime,
2191                            file_type: if canonical.extension().is_some_and(|e| {
2192                                ["srt", "ass", "ssa", "vtt", "sub"]
2193                                    .contains(&e.to_string_lossy().to_lowercase().as_str())
2194                            }) {
2195                                "subtitle".to_string()
2196                            } else {
2197                                "video".to_string()
2198                            },
2199                        });
2200                    }
2201                }
2202            }
2203        }
2204
2205        let cache_data = CacheData {
2206            cache_version: CURRENT_CACHE_VERSION.to_string(),
2207            directory: cache_key.to_string(),
2208            file_snapshot: snapshot_items,
2209            match_operations: cache_items,
2210            created_at: std::time::SystemTime::now()
2211                .duration_since(std::time::UNIX_EPOCH)
2212                .unwrap()
2213                .as_secs(),
2214            ai_model_used: self.config.ai_model.clone(),
2215            config_hash,
2216            original_relocation_mode: format!("{:?}", self.config.relocation_mode),
2217            original_backup_enabled: self.config.backup_enabled,
2218        };
2219
2220        // Save cache data to file
2221        let cache_dir = cache_file_path.parent().unwrap().to_path_buf();
2222        let cache_json = serde_json::to_string_pretty(&cache_data)?;
2223        let cache_file_path_clone = cache_file_path.clone();
2224        tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2225            std::fs::create_dir_all(&cache_dir)?;
2226            std::fs::write(&cache_file_path_clone, cache_json)?;
2227            Ok(())
2228        })
2229        .await
2230        .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
2231
2232        Ok(())
2233    }
2234
2235    /// Get cache file path
2236    fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
2237        // First check XDG_CONFIG_HOME environment variable (used for testing)
2238        let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
2239            std::path::PathBuf::from(xdg_config)
2240        } else {
2241            dirs::config_dir()
2242                .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
2243        };
2244        Ok(dir.join("subx").join("match_cache.json"))
2245    }
2246
2247    /// Calculate current configuration hash for cache validation
2248    fn calculate_config_hash(&self) -> Result<String> {
2249        use std::collections::hash_map::DefaultHasher;
2250        use std::hash::{Hash, Hasher};
2251
2252        let mut hasher = DefaultHasher::new();
2253        // Add configuration items that affect cache validity to the hash
2254        format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
2255        self.config.backup_enabled.hash(&mut hasher);
2256        // A short prompt-schema tag so future prompt rewrites invalidate
2257        // the on-disk match cache automatically.
2258        "prompt_v2".hash(&mut hasher);
2259        // Add other relevant configuration items
2260
2261        Ok(format!("{:016x}", hasher.finish()))
2262    }
2263
2264    /// Find a media file by ID, with an optional fallback to relative path or name.
2265    fn find_media_file_by_id_or_path<'a>(
2266        files: &'a [&MediaFile],
2267        file_id: &str,
2268        fallback_path: Option<&str>,
2269    ) -> Option<&'a MediaFile> {
2270        if let Some(file) = files.iter().find(|f| f.id == file_id) {
2271            return Some(*file);
2272        }
2273        if let Some(path) = fallback_path {
2274            if let Some(file) = files.iter().find(|f| f.relative_path == path) {
2275                return Some(*file);
2276            }
2277            files.iter().find(|f| f.name == path).copied()
2278        } else {
2279            None
2280        }
2281    }
2282
2283    /// Log available files to assist debugging when a match is not found.
2284    fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
2285        if crate::cli::output::active_mode().is_json() {
2286            return;
2287        }
2288        eprintln!("   Available {} files:", file_type);
2289        for f in files {
2290            eprintln!(
2291                "     - ID: {} | Name: {} | Path: {}",
2292                f.id, f.name, f.relative_path
2293            );
2294        }
2295    }
2296
2297    /// Provide detailed information when no matches are found.
2298    fn log_no_matches_found(
2299        &self,
2300        match_result: &MatchResult,
2301        videos: &[MediaFile],
2302        subtitles: &[MediaFile],
2303    ) {
2304        if crate::cli::output::active_mode().is_json() {
2305            return;
2306        }
2307        eprintln!("\n❌ No matching files found that meet the criteria");
2308        eprintln!("🔍 AI analysis results:");
2309        eprintln!("   - Total matches: {}", match_result.matches.len());
2310        eprintln!(
2311            "   - Confidence threshold: {:.2}",
2312            self.config.confidence_threshold
2313        );
2314        eprintln!(
2315            "   - Matches meeting threshold: {}",
2316            match_result
2317                .matches
2318                .iter()
2319                .filter(|m| m.confidence >= self.config.confidence_threshold)
2320                .count()
2321        );
2322        eprintln!("\n📂 Scanned files:");
2323        eprintln!("   Video files ({} files):", videos.len());
2324        for v in videos {
2325            eprintln!("     - ID: {} | {}", v.id, v.relative_path);
2326        }
2327        eprintln!("   Subtitle files ({} files):", subtitles.len());
2328        for s in subtitles {
2329            eprintln!("     - ID: {} | {}", s.id, s.relative_path);
2330        }
2331    }
2332}
2333
2334/// Replay a frozen set of cached match operations.
2335///
2336/// This helper powers the `cache apply` command. It reconstructs
2337/// [`MatchOperation`] values from the paths recorded in `cache`, without
2338/// performing any additional AI analysis or validation, and then feeds
2339/// them through the standard [`MatchEngine::execute_operations`] pipeline
2340/// so the same journal and file-system guarantees apply.
2341///
2342/// The provided `config` determines runtime behaviour such as relocation
2343/// mode, backup handling and conflict resolution. Callers are expected to
2344/// synchronise the config with the original cache's recorded values when
2345/// strict replay fidelity is required.
2346///
2347/// # Errors
2348///
2349/// Returns an error if any cached source path cannot be read or if the
2350/// underlying execution pipeline fails.
2351pub async fn apply_cached_operations(cache: &CacheData, config: &MatchConfig) -> Result<()> {
2352    let operations = reconstruct_operations_from_cache(cache, config)?;
2353    let engine = MatchEngine::new(Box::new(NoOpAIProvider), config.clone());
2354    engine.execute_operations(&operations, false).await
2355}
2356
2357/// Rebuild [`MatchOperation`] values from a [`CacheData`] payload.
2358///
2359/// Silently skips entries whose source files no longer exist so the replay
2360/// is resilient to partial state (e.g., an earlier apply completed some
2361/// operations but was interrupted).
2362fn reconstruct_operations_from_cache(
2363    cache: &CacheData,
2364    config: &MatchConfig,
2365) -> Result<Vec<MatchOperation>> {
2366    let mut ops = Vec::new();
2367    let mut id_gen = Uuidv7Generator::new();
2368    for item in &cache.match_operations {
2369        let video_path = PathBuf::from(&item.video_file);
2370        let subtitle_path = PathBuf::from(&item.subtitle_file);
2371
2372        if !video_path.exists() || !subtitle_path.exists() {
2373            continue;
2374        }
2375
2376        let video_meta = video_path.metadata()?;
2377        let subtitle_meta = subtitle_path.metadata()?;
2378
2379        let video_file = MediaFile {
2380            id: generate_file_id(&mut id_gen),
2381            path: video_path.clone(),
2382            file_type: MediaFileType::Video,
2383            size: video_meta.len(),
2384            name: video_path
2385                .file_name()
2386                .unwrap_or_default()
2387                .to_string_lossy()
2388                .to_string(),
2389            extension: video_path
2390                .extension()
2391                .unwrap_or_default()
2392                .to_string_lossy()
2393                .to_lowercase(),
2394            relative_path: video_path
2395                .file_name()
2396                .unwrap_or_default()
2397                .to_string_lossy()
2398                .to_string(),
2399        };
2400
2401        let subtitle_file = MediaFile {
2402            id: generate_file_id(&mut id_gen),
2403            path: subtitle_path.clone(),
2404            file_type: MediaFileType::Subtitle,
2405            size: subtitle_meta.len(),
2406            name: subtitle_path
2407                .file_name()
2408                .unwrap_or_default()
2409                .to_string_lossy()
2410                .to_string(),
2411            extension: subtitle_path
2412                .extension()
2413                .unwrap_or_default()
2414                .to_string_lossy()
2415                .to_lowercase(),
2416            relative_path: subtitle_path
2417                .file_name()
2418                .unwrap_or_default()
2419                .to_string_lossy()
2420                .to_string(),
2421        };
2422
2423        let requires_relocation = config.relocation_mode != FileRelocationMode::None
2424            && subtitle_file.path.parent() != video_file.path.parent();
2425        let relocation_target_path = if requires_relocation {
2426            video_file
2427                .path
2428                .parent()
2429                .map(|p| p.join(&item.new_subtitle_name))
2430        } else {
2431            None
2432        };
2433
2434        ops.push(MatchOperation {
2435            video_file,
2436            subtitle_file,
2437            new_subtitle_name: item.new_subtitle_name.clone(),
2438            confidence: item.confidence,
2439            reasoning: item.reasoning.clone(),
2440            relocation_mode: config.relocation_mode.clone(),
2441            relocation_target_path,
2442            requires_relocation,
2443        });
2444    }
2445    Ok(ops)
2446}
2447
2448/// AI provider stub used by [`apply_cached_operations`].
2449///
2450/// `execute_operations` never calls the AI service, so replaying a cached
2451/// plan does not require a real provider; this stub panics defensively if
2452/// accidentally invoked.
2453struct NoOpAIProvider;
2454
2455#[async_trait::async_trait]
2456impl AIProvider for NoOpAIProvider {
2457    async fn analyze_content(&self, _request: AnalysisRequest) -> crate::Result<MatchResult> {
2458        Err(SubXError::config(
2459            "AI analysis is not available while replaying cached operations",
2460        ))
2461    }
2462
2463    async fn verify_match(
2464        &self,
2465        _verification: crate::services::ai::VerificationRequest,
2466    ) -> crate::Result<crate::services::ai::ConfidenceScore> {
2467        Err(SubXError::config(
2468            "AI verification is not available while replaying cached operations",
2469        ))
2470    }
2471}