Skip to main content

avatarr_parser/quality/
parser.rs

1// Ported from Sonarr v4.0.17.2952 (97e85a90):
2//   src/NzbDrone.Core/Parser/QualityParser.cs
3
4use once_cell::sync::Lazy;
5use regex::Regex;
6
7/// Source-detection regex constants ported from `QualityParser.cs`.
8///
9/// These mirror the C# `private static readonly Regex` declarations at lines
10/// 17 to 66 of `QualityParser.cs`. The Rust `regex` crate is RE2-based and
11/// does not support backreferences or lookarounds; where a C# pattern uses
12/// either, the constant here is a relaxed superset and a sibling helper
13/// function applies the missing constraint in code post-match. Each such
14/// case is documented inline above the affected `Lazy<Regex>` declaration.
15pub mod regexes {
16    use super::{Lazy, Regex};
17
18    /// Source-class detector. Ported from `QualityParser.cs:17-30`.
19    ///
20    /// The C# pattern uses three negative lookarounds inside the `bluray` and
21    /// `webdl` branches:
22    ///
23    /// 1. `BD(?!$)` (bluray branch). Bare `BD` only counts when not at end of
24    ///    string. Rust workaround: relaxed to `BD`; `match_source` rejects a
25    ///    `bluray` match whose entire captured text is `BD` and ends at the
26    ///    input boundary.
27    /// 2. `(?-i:WEB)$` (webdl branch). Case-sensitive uppercase `WEB` at end
28    ///    of input. Rust regex DOES support `(?-i:...)` flag-disable groups,
29    ///    so this is ported verbatim.
30    /// 3. `(?:AMZN|NF|DP)[. -]WEB[. -](?!Rip)` (webdl branch). Provider tag
31    ///    followed by `WEB` and a separator that is not `Rip`. Rust workaround:
32    ///    drops the `(?!Rip)` and wraps the provider alternative in a nested
33    ///    named sub-capture `(?P<provider_web>...)`. `match_source` then gates
34    ///    the post-match `(?!Rip)` filter on `provider_web.is_some()`,
35    ///    structurally identifying which alternation branch fired rather than
36    ///    relying on alternation order or prefix heuristics.
37    ///
38    /// Callers reach a fully-correct match through [`super::match_source`];
39    /// the raw `SOURCE_REGEX` is exported for tests that want to assert which
40    /// branch the alternation hit (named groups: `bluray`, `webdl`, `webrip`,
41    /// `hdtv`, `bdrip`, `brrip`, `dvd`, `dsr`, `pdtv`, `sdtv`, `tvrip`).
42    pub static SOURCE_REGEX: Lazy<Regex> = Lazy::new(|| {
43        Regex::new(
44            r"(?ix)
45            \b(?:
46                (?P<bluray>BluRay|Blu-Ray|HD-?DVD|BDMux|BD)|
47                (?P<webdl>
48                    WEB[-_.\x20]DL(?:mux)?|WEBDL|AmazonHD|AmazonSD|iTunesHD|MaxdomeHD|NetflixU?HD|WebHD|HBOMaxHD|DisneyHD|
49                    [.\x20]WEB[.\x20](?:[xh][ .]?26[45]|AVC|HEVC|DDP?5[. ]1)|
50                    [.\x20](?-i:WEB)$|
51                    (?:720|1080|2160)p[-.\x20]WEB[-.\x20]|
52                    [-.\x20]WEB[-.\x20](?:720|1080|2160)p|
53                    \b\s/\sWEB\s/\s\b|
54                    (?P<provider_web>(?:AMZN|NF|DP)[.\x20-]WEB[.\x20-])
55                )|
56                (?P<webrip>WebRip|Web-Rip|WEBMux)|
57                (?P<hdtv>HDTV)|
58                (?P<bdrip>BDRip|BDLight)|
59                (?P<brrip>BRRip)|
60                (?P<dvd>DVD|DVDRip|NTSC|PAL|xvidvd)|
61                (?P<dsr>WS[-_.\x20]DSR|DSR)|
62                (?P<pdtv>PDTV)|
63                (?P<sdtv>SDTV)|
64                (?P<tvrip>TVRip)
65            )(?:\b|$|[\x20.])",
66        )
67        .expect("SOURCE_REGEX must compile")
68    });
69
70    /// `RawHD` / `Raw-HD` / `Raw_HD` / `Raw.HD` / `Raw HD` detector. Ported
71    /// from `QualityParser.cs:32-33`.
72    pub static RAW_HD_REGEX: Lazy<Regex> = Lazy::new(|| {
73        Regex::new(r"(?i)\b(?P<rawhd>RawHD|Raw[-_. ]HD)\b").expect("RAW_HD_REGEX must compile")
74    });
75
76    /// `MPEG2` / `MPEG-2` / `MPEG_2` / `MPEG.2` / `MPEG 2` detector. Ported
77    /// from `QualityParser.cs:35`. Note: the C# pattern is **case-sensitive**
78    /// (no `RegexOptions.IgnoreCase`), so we omit the `(?i)` flag here.
79    pub static MPEG2_REGEX: Lazy<Regex> =
80        Lazy::new(|| Regex::new(r"\b(?P<mpeg2>MPEG[-_. ]?2)\b").expect("MPEG2_REGEX must compile"));
81
82    /// Remux detector. Ported from `QualityParser.cs:66`.
83    ///
84    /// The C# pattern declares the same group name `(?<remux>...)` twice in an
85    /// alternation. .NET allows that; Rust's `regex` crate rejects duplicate
86    /// names within a single pattern. To preserve identical match semantics
87    /// while staying compilable, the second branch's group is renamed
88    /// `remux_post`. Callers should treat a hit on EITHER `remux` or
89    /// `remux_post` as a positive Remux signal; `super::match_remux` exposes
90    /// that contract directly.
91    pub static REMUX_REGEX: Lazy<Regex> = Lazy::new(|| {
92        Regex::new(
93            r"(?i)(?:[_. ]|\d{4}p-|\bHybrid-)(?P<remux>(?:(BD|UHD)[-_. ]?)?Remux)\b|(?P<remux_post>(?:(BD|UHD)[-_. ]?)?Remux[_. ]\d{4}p)",
94        )
95        .expect("REMUX_REGEX must compile")
96    });
97
98    /// Anime-Bluray detector. Ported from `QualityParser.cs:61`.
99    ///
100    /// The C# pattern uses paired lookarounds for the bare `bd` branch:
101    /// `(?<=[-_. (\[])bd(?=[-_. )\]])`. Rust's `regex` crate does not support
102    /// lookarounds, so the constant here is the relaxed superset
103    /// `bd(?:720|1080|2160)|bd`. Callers must reach a fully-correct match
104    /// through [`super::matches_anime_bluray`], which applies the surround
105    /// check in code on bare `bd` candidates.
106    pub static ANIME_BLURAY_REGEX: Lazy<Regex> = Lazy::new(|| {
107        Regex::new(r"(?i)bd(?:720|1080|2160)|bd").expect("ANIME_BLURAY_REGEX must compile")
108    });
109
110    /// Anime-WEB-DL detector. Ported from `QualityParser.cs:62`.
111    pub static ANIME_WEBDL_REGEX: Lazy<Regex> = Lazy::new(|| {
112        Regex::new(r"(?i)\[WEB\]|[\[(]WEB[ .]").expect("ANIME_WEBDL_REGEX must compile")
113    });
114
115    /// Resolution detector. Ported from `QualityParser.cs:49-50`.
116    ///
117    /// Named groups: `R360p`, `R480p`, `R540p`, `R576p`, `R720p`, `R1080p`,
118    /// `R2160p`. Case-insensitive (`IgnoreCase` in C# → `(?i)` flag here).
119    ///
120    /// Cross-checked against C#: every alternative branch, including the
121    /// `4kto1080p` downscaled-UHD token on the `R1080p` branch, is reproduced
122    /// verbatim. The one place this port deliberately differs from the m50
123    /// plan-spec draft is the `R2160p` branch's 4K alternatives: the plan
124    /// drafted `4kto2160p`, but C# actually carries
125    /// `4k[-_. ](?:UHD|HEVC|BD|H265)|(?:UHD|HEVC|BD|H265)[-_. ]4k`. C# is
126    /// authoritative per the m50 standing rules, so the port mirrors C#.
127    /// The Rust `regex` crate accepts the pattern as-is, with every named
128    /// group unique and no lookarounds.
129    pub static RESOLUTION_REGEX: Lazy<Regex> = Lazy::new(|| {
130        Regex::new(
131            r"(?i)\b(?:(?P<R360p>360p)|(?P<R480p>480p|480i|640x480|848x480)|(?P<R540p>540p)|(?P<R576p>576p)|(?P<R720p>720p|1280x720|960p)|(?P<R1080p>1080p|1920x1080|1440p|FHD|1080i|4kto1080p)|(?P<R2160p>2160p|3840x2160|4k[-_. ](?:UHD|HEVC|BD|H265)|(?:UHD|HEVC|BD|H265)[-_. ]4k))\b",
132        )
133        .expect("RESOLUTION_REGEX must compile")
134    });
135
136    /// Alternative resolution detector for releases that omit a numeric
137    /// resolution token. Ported from `QualityParser.cs:53-54`.
138    ///
139    /// The C# pattern declares `(?<R2160p>...)` twice in an alternation
140    /// (`(?<R2160p>UHD)\b|(?<R2160p>\[4K\])`). .NET allows duplicate group
141    /// names and merges their captures; Rust's `regex` crate rejects this. To
142    /// preserve identical match semantics while staying compilable, the second
143    /// branch is renamed `R2160p_alt`. Callers should treat a hit on EITHER
144    /// `R2160p` or `R2160p_alt` as a positive 2160p signal;
145    /// [`super::matches_alternative_resolution`] coalesces both branches into
146    /// a single boolean.
147    pub static ALTERNATIVE_RESOLUTION_REGEX: Lazy<Regex> = Lazy::new(|| {
148        Regex::new(r"(?i)\b(?P<R2160p>UHD)\b|(?P<R2160p_alt>\[4K\])")
149            .expect("ALTERNATIVE_RESOLUTION_REGEX must compile")
150    });
151
152    /// Codec detector. Ported from `QualityParser.cs:56-57`.
153    ///
154    /// Named groups: `x264`, `h264`, `xvidhd`, `xvid`, `divx`. Case-insensitive
155    /// (`IgnoreCase` in C# → `(?i)` flag here). Note that the C# pattern uses
156    /// the literal `Xvid` (no hyphen variant), so this port matches `Xvid` and
157    /// `xvid` (case-insensitive) but NOT `X-vid`. The plan-spec drafted
158    /// `X-?vid`; C# is authoritative per the m50 standing rules, so we keep
159    /// the C# form.
160    pub static CODEC_REGEX: Lazy<Regex> = Lazy::new(|| {
161        Regex::new(
162            r"(?i)\b(?:(?P<x264>x264)|(?P<h264>h264)|(?P<xvidhd>XvidHD)|(?P<xvid>Xvid)|(?P<divx>divx))\b",
163        )
164        .expect("CODEC_REGEX must compile")
165    });
166
167    /// `HD-TV` / `SD-TV` alternative-form detector. Ported from
168    /// `QualityParser.cs:59`. Named groups: `hdtv`, `sdtv`. Case-insensitive.
169    pub static OTHER_SOURCE_REGEX: Lazy<Regex> = Lazy::new(|| {
170        Regex::new(r"(?i)(?P<hdtv>HD[-_. ]TV)|(?P<sdtv>SD[-_. ]TV)")
171            .expect("OTHER_SOURCE_REGEX must compile")
172    });
173
174    /// `hr-ws` (high-def PDTV) detector. Ported from `QualityParser.cs:64`.
175    /// Case-insensitive.
176    pub static HIGH_DEF_PDTV_REGEX: Lazy<Regex> =
177        Lazy::new(|| Regex::new(r"(?i)hr[-_. ]ws").expect("HIGH_DEF_PDTV_REGEX must compile"));
178
179    /// PROPER detector. Ported from `QualityParser.cs:37-38`.
180    /// Named group: `proper`. Case-insensitive.
181    pub static PROPER_REGEX: Lazy<Regex> =
182        Lazy::new(|| Regex::new(r"(?i)\b(?P<proper>proper)\b").expect("PROPER_REGEX must compile"));
183
184    /// REPACK / RERIP detector. Ported from `QualityParser.cs:40-41`. Matches
185    /// `repack`, `repack1`, `repack2`, ..., `rerip`, `rerip1`, `rerip2`, etc.
186    /// Named group: `repack`. Case-insensitive.
187    pub static REPACK_REGEX: Lazy<Regex> = Lazy::new(|| {
188        Regex::new(r"(?i)\b(?P<repack>repack\d?|rerip\d?)\b").expect("REPACK_REGEX must compile")
189    });
190
191    /// Explicit version-marker detector. Ported from `QualityParser.cs:43-44`.
192    ///
193    /// The C# pattern declares the same `<version>` named group on FIVE
194    /// alternation branches:
195    ///
196    /// 1. `\d[-._ ]?v(?<version>\d)[-._ ]` (e.g. `1v2.`, `01-v2_`)
197    /// 2. `\[v(?<version>\d)\]` (e.g. `[v2]`)
198    /// 3. `repack(?<version>\d)` (e.g. `repack2`)
199    /// 4. `rerip(?<version>\d)` (e.g. `rerip3`)
200    /// 5. `(?:480|576|720|1080|2160)p[._ ]v(?<version>\d)` (e.g. `1080p.v2`)
201    ///
202    /// .NET allows duplicate names and merges captures; Rust's `regex` crate
203    /// rejects them. We rename branches 2-5 to `version2`, `version3`,
204    /// `version4`, `version5`. Callers should walk all five names and pick
205    /// the first hit:
206    ///
207    /// ```ignore
208    /// caps.name("version")
209    ///     .or_else(|| caps.name("version2"))
210    ///     .or_else(|| caps.name("version3"))
211    ///     .or_else(|| caps.name("version4"))
212    ///     .or_else(|| caps.name("version5"))
213    /// ```
214    ///
215    /// T6's modifier cascade owns that walk; the helper is intentionally not
216    /// added in T5 because the cascade has additional decision logic that
217    /// would be split awkwardly. Case-insensitive (`IgnoreCase` in C# → `(?i)`
218    /// flag here).
219    pub static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| {
220        Regex::new(
221            r"(?i)\d[-._ ]?v(?P<version>\d)[-._ ]|\[v(?P<version2>\d)\]|repack(?P<version3>\d)|rerip(?P<version4>\d)|(?:480|576|720|1080|2160)p[._ ]v(?P<version5>\d)",
222        )
223        .expect("VERSION_REGEX must compile")
224    });
225
226    /// REAL detector. Ported from `QualityParser.cs:46-47`.
227    ///
228    /// **Case-sensitive intentionally.** The C# pattern carries
229    /// `RegexOptions.Compiled` only (no `IgnoreCase`), so only uppercase
230    /// `REAL` matches. We omit the `(?i)` flag here to mirror C# faithfully.
231    pub static REAL_REGEX: Lazy<Regex> =
232        Lazy::new(|| Regex::new(r"\b(?P<real>REAL)\b").expect("REAL_REGEX must compile"));
233}
234
235use regexes::*;
236
237/// Source-class signal extracted from a single `SOURCE_REGEX` candidate that
238/// has passed all post-match lookaround filters. Carries which named group
239/// hit (so callers can route to the correct `Quality` variant) plus the
240/// matched substring (for debug logging and round-trip tests).
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub(crate) struct SourceMatch<'a> {
243    pub group: SourceGroup,
244    pub matched: &'a str,
245}
246
247/// Which named alternative inside `SOURCE_REGEX` matched.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
249pub(crate) enum SourceGroup {
250    Bluray,
251    Webdl,
252    Webrip,
253    Hdtv,
254    Bdrip,
255    Brrip,
256    Dvd,
257    Dsr,
258    Pdtv,
259    Sdtv,
260    Tvrip,
261}
262
263/// Classify a single `SOURCE_REGEX` `Captures` into a `SourceMatch`, or
264/// `None` if the capture fails the C# lookaround filters.
265///
266/// Filters applied (mirroring `QualityParser.cs:17-30`):
267///
268/// 1. `bluray` branch: a bare `BD` that ends at the input boundary is
269///    rejected (`BD(?!$)` in C#).
270/// 2. `webdl` branch: an `(?:AMZN|NF|DP)[. -]WEB[. -]` capture that is
271///    followed in the input by `Rip` (case-insensitive) is rejected
272///    (`(?!Rip)` in C#). Identified structurally via the nested
273///    `provider_web` sub-capture rather than a prefix check on the
274///    outer match. Robust against future alternation reordering.
275fn classify_source_capture<'a>(s: &'a str, caps: &regex::Captures<'a>) -> Option<SourceMatch<'a>> {
276    let (group, mat) = if let Some(m) = caps.name("bluray") {
277        (SourceGroup::Bluray, m)
278    } else if let Some(m) = caps.name("webdl") {
279        (SourceGroup::Webdl, m)
280    } else if let Some(m) = caps.name("webrip") {
281        (SourceGroup::Webrip, m)
282    } else if let Some(m) = caps.name("hdtv") {
283        (SourceGroup::Hdtv, m)
284    } else if let Some(m) = caps.name("bdrip") {
285        (SourceGroup::Bdrip, m)
286    } else if let Some(m) = caps.name("brrip") {
287        (SourceGroup::Brrip, m)
288    } else if let Some(m) = caps.name("dvd") {
289        (SourceGroup::Dvd, m)
290    } else if let Some(m) = caps.name("dsr") {
291        (SourceGroup::Dsr, m)
292    } else if let Some(m) = caps.name("pdtv") {
293        (SourceGroup::Pdtv, m)
294    } else if let Some(m) = caps.name("sdtv") {
295        (SourceGroup::Sdtv, m)
296    } else if let Some(m) = caps.name("tvrip") {
297        (SourceGroup::Tvrip, m)
298    } else {
299        return None;
300    };
301
302    // Filter 1: bluray bare-BD-at-end-of-input rejection (C# `BD(?!$)`).
303    if group == SourceGroup::Bluray
304        && mat.as_str().eq_ignore_ascii_case("BD")
305        && mat.end() == s.len()
306    {
307        return None;
308    }
309
310    // Filter 2: webdl provider-tag-followed-by-Rip rejection
311    // (C# `(?:AMZN|NF|DP)[. -]WEB[. -](?!Rip)`). Gated on the nested
312    // `provider_web` sub-capture so we apply the (?!Rip) filter only when
313    // that exact alternation branch fired, independent of alternation
314    // order or text-prefix heuristics on the outer match.
315    if group == SourceGroup::Webdl && caps.name("provider_web").is_some() {
316        let tail = &s[mat.end()..];
317        if tail.len() >= 3 && tail.as_bytes()[..3].eq_ignore_ascii_case(b"Rip") {
318            // Consumed-iterator pattern: the webdl branch consumed the WEB token,
319            // so the webrip branch can never fire. Synthesize the Webrip match
320            // to mirror C#'s backtrack semantics.
321            return Some(SourceMatch {
322                group: SourceGroup::Webrip,
323                matched: mat.as_str(),
324            });
325        }
326    }
327
328    Some(SourceMatch {
329        group,
330        matched: mat.as_str(),
331    })
332}
333
334/// Find the first source-class match in `s`, applying the lookahead filters
335/// that Rust regex cannot express directly. Returns `None` if no candidate
336/// satisfies the C# semantics.
337///
338/// See [`classify_source_capture`] for the filter list.
339///
340/// Test-only: the production cascade uses [`match_source_last`] (last-wins
341/// semantics, see C# `QualityParser.cs:115`). This first-match form exists
342/// to pin the C# regex's per-branch alternation behaviour from the test
343/// suite without leaking a never-used helper into the production build.
344#[cfg(test)]
345pub(crate) fn match_source(s: &str) -> Option<SourceMatch<'_>> {
346    for caps in SOURCE_REGEX.captures_iter(s) {
347        if let Some(m) = classify_source_capture(s, &caps) {
348            return Some(m);
349        }
350    }
351    None
352}
353
354/// Find the LAST source-class match in `s`, mirroring C#'s
355/// `sourceMatches.OfType<Match>().LastOrDefault()` at `QualityParser.cs:115`.
356///
357/// The cascade uses last-match-wins semantics: a release like
358/// `Movie.HDTV.Repack.BluRay.x264` classifies as bluray, not hdtv, because
359/// `BluRay` appears AFTER `HDTV` in the string. C# achieves this with
360/// `Regex.Matches(...).OfType<Match>().LastOrDefault()`; we mirror it by
361/// walking every candidate from `captures_iter` and keeping the last that
362/// passes [`classify_source_capture`]'s filters.
363///
364/// Returns `None` only when no candidate in the entire string satisfies the
365/// post-match lookaround filters. A candidate that is rejected by a filter
366/// is skipped; an earlier accepted candidate may still be returned if no
367/// later candidate passes.
368pub(crate) fn match_source_last(s: &str) -> Option<SourceMatch<'_>> {
369    let mut last: Option<SourceMatch<'_>> = None;
370    for caps in SOURCE_REGEX.captures_iter(s) {
371        if let Some(m) = classify_source_capture(s, &caps) {
372            last = Some(m);
373        }
374    }
375    last
376}
377
378/// Anime-Bluray detection respecting C#'s lookaround semantics.
379///
380/// The relaxed `ANIME_BLURAY_REGEX` matches `bd720`/`bd1080`/`bd2160`
381/// directly, and also matches a bare `bd` anywhere in the input. C# only
382/// counts a bare `bd` when it is surrounded by one of `[-_. (\[]` on the left
383/// and one of `[-_. )\]]` on the right. This function applies that surround
384/// check in code, returning `true` on the first candidate that passes.
385///
386/// Bare `bd` requires a separator on both sides. Bare `bd` at start- or
387/// end-of-input does NOT match, matching C#'s paired
388/// `(?<=[-_. (\[])bd(?=[-_. )\]])` lookaround semantics where the
389/// lookbehind/lookahead fails at the input boundary.
390pub(crate) fn matches_anime_bluray(s: &str) -> bool {
391    for m in ANIME_BLURAY_REGEX.find_iter(s) {
392        let matched = m.as_str();
393        if matched.len() == 2 {
394            // Bare `bd`: check the C# surround constraint.
395            let bytes = s.as_bytes();
396            let start = m.start();
397            let end = m.end();
398            // Note: `as_bytes().get(start - 1)` returns a single byte at a UTF-8 boundary.
399            // For multi-byte chars (e.g. 'é'), the read returns a continuation byte
400            // (0x80..=0xBF) which is outside the ASCII separator set, so the surround
401            // check correctly rejects non-ASCII surrounds.
402            let before_ok = start > 0
403                && matches!(
404                    bytes.get(start - 1),
405                    Some(b'-' | b'_' | b'.' | b' ' | b'(' | b'[')
406                );
407            let after_ok = end < s.len()
408                && matches!(
409                    bytes.get(end),
410                    Some(b'-' | b'_' | b'.' | b' ' | b')' | b']')
411                );
412            if before_ok && after_ok {
413                return true;
414            }
415        } else {
416            // `bd720` / `bd1080` / `bd2160`: always counts.
417            return true;
418        }
419    }
420    false
421}
422
423/// Remux detector that merges the two same-named C# capture groups
424/// (`remux` and `remux_post` in the Rust port) into a single signal.
425///
426/// Returns `Some(matched_text)` on the first hit, `None` otherwise. Callers
427/// that need positional information can fall back to `REMUX_REGEX` directly;
428/// this helper is the canonical "is this a remux release?" gate.
429pub(crate) fn match_remux(s: &str) -> Option<&str> {
430    for caps in REMUX_REGEX.captures_iter(s) {
431        if let Some(m) = caps.name("remux").or_else(|| caps.name("remux_post")) {
432            return Some(m.as_str());
433        }
434    }
435    None
436}
437
438/// Alternative-resolution detector that merges the two same-named C# capture
439/// groups (`R2160p` and `R2160p_alt` in the Rust port) into a single boolean
440/// signal.
441///
442/// Returns `true` if either branch fires, mirroring C#'s
443/// `(?<R2160p>UHD)\b|(?<R2160p>\[4K\])` semantics where both alternatives are
444/// recorded under the same name.
445pub(crate) fn matches_alternative_resolution(s: &str) -> bool {
446    ALTERNATIVE_RESOLUTION_REGEX
447        .captures_iter(s)
448        .any(|caps| caps.name("R2160p").is_some() || caps.name("R2160p_alt").is_some())
449}
450
451/// Resolution detector mirroring C#'s `ParseResolution`
452/// (`QualityParser.cs:600-651`).
453///
454/// Walks `RESOLUTION_REGEX`'s named groups in C# evaluation order
455/// (R360p, R480p, R540p, R576p, R720p, R1080p, R2160p) and returns the first
456/// hit. If no main-regex group fires, falls back to
457/// [`matches_alternative_resolution`] to detect the `UHD` / `[4K]` tokens that
458/// C#'s `AlternativeResolutionRegex` covers; either alternative-regex branch
459/// counts as `R2160p`. Returns `Resolution::Unknown` if neither regex matches.
460///
461/// **Group-order note.** The C# code checks groups starting from the smallest
462/// resolution upward (R360p first, R2160p last). The named groups in
463/// `RESOLUTION_REGEX` are mutually exclusive within a single capture (an
464/// alternation only fires one branch), so the order does not affect behaviour
465/// for any input the regex can match. We mirror the C# walk order verbatim
466/// to keep the port reviewable against the source.
467fn detect_resolution(name: &str) -> crate::quality::Resolution {
468    use crate::quality::Resolution;
469
470    if let Some(caps) = RESOLUTION_REGEX.captures(name) {
471        if caps.name("R360p").is_some() {
472            return Resolution::R360p;
473        }
474        if caps.name("R480p").is_some() {
475            return Resolution::R480p;
476        }
477        if caps.name("R540p").is_some() {
478            return Resolution::R540p;
479        }
480        if caps.name("R576p").is_some() {
481            return Resolution::R576p;
482        }
483        if caps.name("R720p").is_some() {
484            return Resolution::R720p;
485        }
486        if caps.name("R1080p").is_some() {
487            return Resolution::R1080p;
488        }
489        if caps.name("R2160p").is_some() {
490            return Resolution::R2160p;
491        }
492    }
493
494    if matches_alternative_resolution(name) {
495        return Resolution::R2160p;
496    }
497
498    Resolution::Unknown
499}
500
501/// `Quality.Source` reverse lookup. Mirrors the C# constructor pairs in
502/// `Quality.cs:77-124` where each `Quality` static is paired with a
503/// `QualitySource` argument.
504///
505/// Used by [`parse_quality_name`]'s resolution-only fallback to derive a
506/// `QualitySource` from the extension-derived `Quality`, mirroring C#
507/// `QualityParser.cs:444` (`source = quality.Source`).
508fn quality_source(q: crate::quality::Quality) -> crate::quality::QualitySource {
509    use crate::quality::{Quality, QualitySource};
510    match q {
511        Quality::Unknown => QualitySource::Unknown,
512        Quality::Sdtv => QualitySource::Television,
513        Quality::Hdtv720p => QualitySource::Television,
514        Quality::Hdtv1080p => QualitySource::Television,
515        Quality::Hdtv2160p => QualitySource::Television,
516        Quality::RawHd => QualitySource::TelevisionRaw,
517        Quality::Webdl480p => QualitySource::Web,
518        Quality::Webdl720p => QualitySource::Web,
519        Quality::Webdl1080p => QualitySource::Web,
520        Quality::Webdl2160p => QualitySource::Web,
521        Quality::Webrip480p => QualitySource::WebRip,
522        Quality::Webrip720p => QualitySource::WebRip,
523        Quality::Webrip1080p => QualitySource::WebRip,
524        Quality::Webrip2160p => QualitySource::WebRip,
525        Quality::Dvd => QualitySource::Dvd,
526        Quality::Bluray480p => QualitySource::Bluray,
527        Quality::Bluray576p => QualitySource::Bluray,
528        Quality::Bluray720p => QualitySource::Bluray,
529        Quality::Bluray1080p => QualitySource::Bluray,
530        Quality::Bluray2160p => QualitySource::Bluray,
531        Quality::Bluray1080pRemux => QualitySource::BlurayRaw,
532        Quality::Bluray2160pRemux => QualitySource::BlurayRaw,
533    }
534}
535
536/// File-extension to `Quality` lookup. Ports
537/// `MediaFileExtensions.cs:9-71` + `GetQualityForExtension` (lines 76-84).
538///
539/// Returns `Quality::Unknown` for any extension not in the table OR an empty
540/// extension. The C# wrapper at `QualityParser.cs:82-95` and the inline
541/// extension lookup at `:437-451` both use this; the inline path uses Sonarr's
542/// `string.GetPathExtension()` (`PathExtensions.cs:74-83`), which is a bare
543/// `LastIndexOf('.')` slice (no path-validity checks).
544///
545/// Comparison is ASCII-case-insensitive to match
546/// `StringComparer.OrdinalIgnoreCase` at `MediaFileExtensions.cs:13`.
547fn quality_for_extension(extension: &str) -> crate::quality::Quality {
548    use crate::quality::Quality;
549    // C# MediaFileExtensions.cs:9-71. Pairs are (extension-with-dot, Quality).
550    const TABLE: &[(&str, Quality)] = &[
551        // Unknown
552        (".webm", Quality::Unknown),
553        // SDTV
554        (".m4v", Quality::Sdtv),
555        (".3gp", Quality::Sdtv),
556        (".nsv", Quality::Sdtv),
557        (".ty", Quality::Sdtv),
558        (".strm", Quality::Sdtv),
559        (".rm", Quality::Sdtv),
560        (".rmvb", Quality::Sdtv),
561        (".m3u", Quality::Sdtv),
562        (".ifo", Quality::Sdtv),
563        (".mov", Quality::Sdtv),
564        (".qt", Quality::Sdtv),
565        (".divx", Quality::Sdtv),
566        (".xvid", Quality::Sdtv),
567        (".bivx", Quality::Sdtv),
568        (".nrg", Quality::Sdtv),
569        (".pva", Quality::Sdtv),
570        (".wmv", Quality::Sdtv),
571        (".asf", Quality::Sdtv),
572        (".asx", Quality::Sdtv),
573        (".ogm", Quality::Sdtv),
574        (".ogv", Quality::Sdtv),
575        (".m2v", Quality::Sdtv),
576        (".avi", Quality::Sdtv),
577        (".bin", Quality::Sdtv),
578        (".dat", Quality::Sdtv),
579        (".dvr-ms", Quality::Sdtv),
580        (".mpg", Quality::Sdtv),
581        (".mpeg", Quality::Sdtv),
582        (".mp4", Quality::Sdtv),
583        (".avc", Quality::Sdtv),
584        (".vp3", Quality::Sdtv),
585        (".svq3", Quality::Sdtv),
586        (".nuv", Quality::Sdtv),
587        (".viv", Quality::Sdtv),
588        (".dv", Quality::Sdtv),
589        (".fli", Quality::Sdtv),
590        (".flv", Quality::Sdtv),
591        (".wpl", Quality::Sdtv),
592        // DVD
593        (".img", Quality::Dvd),
594        (".iso", Quality::Dvd),
595        (".vob", Quality::Dvd),
596        // HD
597        (".mkv", Quality::Hdtv720p),
598        (".ts", Quality::Hdtv720p),
599        (".wtv", Quality::Hdtv720p),
600        // Bluray
601        (".m2ts", Quality::Bluray720p),
602    ];
603
604    if extension.is_empty() {
605        return Quality::Unknown;
606    }
607    for (ext, q) in TABLE {
608        if extension.eq_ignore_ascii_case(ext) {
609            return *q;
610        }
611    }
612    Quality::Unknown
613}
614
615/// Sonarr's `string.GetPathExtension()` from
616/// `NzbDrone.Common/Extensions/PathExtensions.cs:74-83`.
617///
618/// Returns the substring from the LAST `.` to end-of-string, INCLUDING the
619/// dot. Returns an empty string if there is no `.` or if the dot is the final
620/// character. This is NOT `Path.GetExtension` semantics; it does no path
621/// validation, so it picks the trailing token from any string regardless of
622/// platform path syntax. The resolution-only fallback at C#
623/// `QualityParser.cs:439` calls this helper, which is why a release name like
624/// `Movie.2020.1080p.x264.mkv` will yield `.mkv` even though the filename
625/// portion has no directory structure.
626fn get_path_extension(path: &str) -> &str {
627    if let Some(idx) = path.rfind('.')
628        && idx + 1 < path.len()
629    {
630        return &path[idx..];
631    }
632    ""
633}
634
635/// ASCII case-insensitive substring search, mirroring .NET's
636/// `string.ContainsIgnoreCase`.
637///
638/// C#'s `ContainsIgnoreCase` is a culture-aware match by default, but the
639/// Sonarr extension method (`Extensions/StringExtensions.cs`) implements it as
640/// `IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0`. Ordinal-IgnoreCase
641/// folds only the ASCII A-Z / a-z range; non-ASCII code points round-trip
642/// unchanged. The Rust port mirrors that contract via
643/// [`str::eq_ignore_ascii_case`] on byte-aligned windows.
644fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
645    let n = needle.len();
646    if n == 0 {
647        return true;
648    }
649    let h = haystack.as_bytes();
650    if h.len() < n {
651        return false;
652    }
653    h.windows(n)
654        .any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
655}
656
657/// Quality-cascade entry-point. Ported from `QualityParser.cs:100-598`
658/// (`ParseQualityName`).
659///
660/// **Duties.**
661///
662/// 1. Normalise: `name.Replace('_', ' ').Trim()` and `name.Trim()` for the raw
663///    side, mirroring C#'s twin `Trim()` calls at `QualityParser.cs:77` and
664///    `QualityParser.cs:102`.
665/// 2. Run `parse_quality_modifiers` against both raw and normalised name to
666///    populate `Revision`. Modifiers that fire also set
667///    `revision_detection_source = Name`.
668/// 3. RawHD short-circuit (C# line 105): return `Quality::RawHd` with
669///    `source_detection_source = resolution_detection_source = Name`.
670/// 4. Source-to-quality cascade (C# lines 114-318): take the LAST source
671///    match, pair with the parsed resolution + codec + remux signals, and
672///    route to a concrete `Quality` variant. Branches:
673///
674///    - `bluray`: codec(xvid|divx) -> 480p, else by-resolution
675///      (2160 / 1080 / 576 / {360, 480, 540}); remux+resolution!=720 -> 1080pRemux;
676///      else 720p.
677///    - `webdl`: by-resolution (2160 / 1080 / 720); raw-name `[WEBDL]` -> 720p;
678///      else 480p.
679///    - `webrip`: by-resolution (2160 / 1080 / 720); else 480p.
680///    - `hdtv`: MPEG2 -> RawHD; else by-resolution; raw-name `[HDTV]` -> 720p;
681///      else SDTV.
682///    - `bdrip` / `brrip`: by-resolution (720 / 1080 / 2160); else 480p.
683///    - `dvd`: DVD always.
684///    - `pdtv` / `sdtv` / `dsr` / `tvrip`: 1080p match -> Hdtv1080p;
685///      720p match -> Hdtv720p; HighDefPdtv (`hr-ws`) -> Hdtv720p; else SDTV.
686///
687///    Provenance flags (`source_detection_source`, `resolution_detection_source`)
688///    are populated according to the C# rules: `source_detection_source` is set
689///    to `Name` whenever a sourceMatch fires; `resolution_detection_source` is
690///    set to `Name` when the resolution-regex hit (line 122) or, in the
691///    pdtv-cluster branch, when the `ContainsIgnoreCase` substring fallback
692///    fires (line 308's `HighDefPdtvRegex` path).
693///
694/// **Fallthrough cascade (T8).** When no source match fires, the following
695/// fallbacks run in C# order, each returning on a hit:
696///
697/// - **Sourceless remux** (C# 320-347): `remuxMatch && resolution != Unknown`,
698///   dispatched per resolution. SourceDetectionSource is explicitly `Unknown`
699///   here per C# line 322.
700/// - **Anime-bluray** (C# 349-390): `AnimeBlurayRegex` hits, resolution-routed
701///   (480/1080/2160/720 defaults). 480p in this branch maps to `Quality::Dvd`
702///   per C# line 359.
703/// - **Anime-webdl** (C# 392-424): `AnimeWebDlRegex` hits, resolution-routed.
704/// - **Resolution-only** (C# 426-497): `resolution != Unknown`. Derives a
705///   `QualitySource` from `remuxMatch` (`BlurayRaw`) OR from the file
706///   extension via [`quality_for_extension`] + [`quality_source`], then
707///   dispatches via `find_by_source_and_resolution`. Unknown source falls
708///   back to Television-class defaults (Hdtv2160p / Hdtv1080p / Hdtv720p / Sdtv).
709/// - **x264-SDTV** (C# 499-504): bare `x264` with no other signal -> `Sdtv`.
710/// - **Pixel-tag fallbacks** (C# 506-560): literal `848x480`, `1280x720`,
711///   `1920x1080` substrings, optionally combined with `dvd` / `bluray` markers.
712/// - **Bare-bluray-resolution** (C# 562-587): literal `bluray720p` /
713///   `bluray1080p` / `bluray2160p` -> `Bluray720p` / `Bluray1080p` / `Bluray2160p`.
714/// - **`OtherSourceMatch`** (C# 589-595, 653-672): `OTHER_SOURCE_REGEX` hit
715///   (`HD-TV` / `SD-TV` alternative form) -> `Hdtv720p` / `Sdtv`.
716///
717/// **Most callers want [`parse_quality`] instead.** That wrapper adds the
718/// extension-fallback behaviour from `QualityParser.cs:81-95` on top of
719/// `parse_quality_name`'s output. Use `parse_quality_name` directly only
720/// when you specifically want to bypass the extension lookup (e.g.,
721/// text-only parsing, no file path implied). This split mirrors C#'s
722/// `ParseQuality` (public) vs `ParseQualityName` (test-seam) shape.
723pub fn parse_quality_name(name: &str) -> crate::quality::QualityModel {
724    use crate::quality::{
725        Quality, QualityDetectionSource, QualityModel, QualitySource, Resolution,
726    };
727
728    // C# `QualityParser.cs:77` (`name.Trim()` in ParseQuality, the outer
729    // wrapper). The plan delegates this trim to ParseQualityName because the
730    // Rust port has only one entry point. Apply to the raw input so the
731    // `[WEBDL]` / `[HDTV]` substring checks against `name` see the trimmed
732    // string just like C#.
733    let raw = name.trim();
734
735    // C# `QualityParser.cs:102`: `name.Replace('_', ' ').Trim()`.
736    let normalized = raw.replace('_', " ");
737    let normalized = normalized.trim();
738
739    let (revision, revision_detection_source) = parse_quality_modifiers(raw, normalized);
740
741    if RAW_HD_REGEX.is_match(normalized) {
742        return QualityModel {
743            quality: Quality::RawHd,
744            revision,
745            source_detection_source: QualityDetectionSource::Name,
746            resolution_detection_source: QualityDetectionSource::Name,
747            revision_detection_source,
748        };
749    }
750
751    // C# QualityParser.cs:114-118: gather the per-name signals in one pass.
752    // - sourceMatch: LAST sourceMatch wins (line 115's LastOrDefault).
753    // - resolution: ParseResolution, our `detect_resolution` helper.
754    // - codec_*: x264 / xvid / divx / xvidhd / h264 named groups.
755    // - remux_match: REMUX_REGEX hit, gated through `match_remux`.
756    let source_match = match_source_last(normalized);
757    let resolution = detect_resolution(normalized);
758    let codec_caps = CODEC_REGEX.captures(normalized);
759    let codec_xvid = codec_caps.as_ref().and_then(|c| c.name("xvid")).is_some();
760    let codec_divx = codec_caps.as_ref().and_then(|c| c.name("divx")).is_some();
761    let remux_match = match_remux(normalized).is_some();
762
763    // C# QualityParser.cs:120-123: a non-Unknown resolution upgrades the
764    // resolution-detection source to Name, regardless of whether any source
765    // branch ultimately consumes the resolution value.
766    let resolution_detection_source = if resolution != Resolution::Unknown {
767        QualityDetectionSource::Name
768    } else {
769        QualityDetectionSource::Unknown
770    };
771
772    // Helper to materialise a final QualityModel with the per-cascade-arm
773    // detection-source flips applied. Source detection source is set to Name
774    // whenever this helper fires (every caller is inside a sourceMatch arm).
775    let mk = |quality: Quality, resolution_source: QualityDetectionSource| QualityModel {
776        quality,
777        revision,
778        source_detection_source: QualityDetectionSource::Name,
779        resolution_detection_source: resolution_source,
780        revision_detection_source,
781    };
782
783    if let Some(sm) = source_match {
784        match sm.group {
785            // C# QualityParser.cs:129-173. Bluray.
786            SourceGroup::Bluray => {
787                if codec_xvid || codec_divx {
788                    // Line 131-135: codec downgrade.
789                    return mk(Quality::Bluray480p, resolution_detection_source);
790                }
791                match resolution {
792                    // Line 137-142: 2160p (with remux variant).
793                    Resolution::R2160p => {
794                        let q = if remux_match {
795                            Quality::Bluray2160pRemux
796                        } else {
797                            Quality::Bluray2160p
798                        };
799                        return mk(q, resolution_detection_source);
800                    }
801                    // Line 144-148: 1080p (with remux variant).
802                    Resolution::R1080p => {
803                        let q = if remux_match {
804                            Quality::Bluray1080pRemux
805                        } else {
806                            Quality::Bluray1080p
807                        };
808                        return mk(q, resolution_detection_source);
809                    }
810                    // Line 150-154: 576p.
811                    Resolution::R576p => {
812                        return mk(Quality::Bluray576p, resolution_detection_source);
813                    }
814                    // Line 156-161: 360p / 480p / 540p collapse to 480p.
815                    Resolution::R360p | Resolution::R480p | Resolution::R540p => {
816                        return mk(Quality::Bluray480p, resolution_detection_source);
817                    }
818                    // Line 165 explicit comment: "Treat a remux without a
819                    // source as 1080p, not 720p. 720p remux should fallback
820                    // as 720p BluRay." So the R720p arm ignores `remux_match`
821                    // and routes to Bluray720p. The R720p arm in C# is the
822                    // implicit fall-through after the resolution checks fail
823                    // to match anything except R720p; we make that explicit.
824                    Resolution::R720p => {
825                        return mk(Quality::Bluray720p, resolution_detection_source);
826                    }
827                    // Line 165-169 + 171-172: Unknown resolution falls
828                    // through to the remux-fallback (1080pRemux) when remux
829                    // fired, otherwise the C# implicit default of Bluray720p.
830                    Resolution::Unknown => {
831                        if remux_match {
832                            return mk(Quality::Bluray1080pRemux, resolution_detection_source);
833                        }
834                        return mk(Quality::Bluray720p, resolution_detection_source);
835                    }
836                }
837            }
838
839            // C# QualityParser.cs:175-203. Webdl.
840            SourceGroup::Webdl => match resolution {
841                Resolution::R2160p => return mk(Quality::Webdl2160p, resolution_detection_source),
842                Resolution::R1080p => return mk(Quality::Webdl1080p, resolution_detection_source),
843                Resolution::R720p => return mk(Quality::Webdl720p, resolution_detection_source),
844                _ => {
845                    // Line 195-199: raw-name `[WEBDL]` substring -> 720p.
846                    if raw.contains("[WEBDL]") {
847                        return mk(Quality::Webdl720p, resolution_detection_source);
848                    }
849                    return mk(Quality::Webdl480p, resolution_detection_source);
850                }
851            },
852
853            // C# QualityParser.cs:205-227. Webrip.
854            SourceGroup::Webrip => match resolution {
855                Resolution::R2160p => return mk(Quality::Webrip2160p, resolution_detection_source),
856                Resolution::R1080p => return mk(Quality::Webrip1080p, resolution_detection_source),
857                Resolution::R720p => return mk(Quality::Webrip720p, resolution_detection_source),
858                _ => return mk(Quality::Webrip480p, resolution_detection_source),
859            },
860
861            // C# QualityParser.cs:229-263. Hdtv.
862            SourceGroup::Hdtv => {
863                // Line 231-234: MPEG2 short-circuit. C#'s MPEG2_REGEX is
864                // case-sensitive (verified at T4) and runs against
865                // normalizedName.
866                if MPEG2_REGEX.is_match(normalized) {
867                    return mk(Quality::RawHd, resolution_detection_source);
868                }
869                match resolution {
870                    Resolution::R2160p => {
871                        return mk(Quality::Hdtv2160p, resolution_detection_source);
872                    }
873                    Resolution::R1080p => {
874                        return mk(Quality::Hdtv1080p, resolution_detection_source);
875                    }
876                    Resolution::R720p => return mk(Quality::Hdtv720p, resolution_detection_source),
877                    _ => {
878                        // Line 255-259: raw-name `[HDTV]` substring -> 720p.
879                        if raw.contains("[HDTV]") {
880                            return mk(Quality::Hdtv720p, resolution_detection_source);
881                        }
882                        return mk(Quality::Sdtv, resolution_detection_source);
883                    }
884                }
885            }
886
887            // C# QualityParser.cs:265-283. BDRip / BRRip.
888            SourceGroup::Bdrip | SourceGroup::Brrip => match resolution {
889                Resolution::R720p => return mk(Quality::Bluray720p, resolution_detection_source),
890                Resolution::R1080p => return mk(Quality::Bluray1080p, resolution_detection_source),
891                Resolution::R2160p => return mk(Quality::Bluray2160p, resolution_detection_source),
892                _ => return mk(Quality::Bluray480p, resolution_detection_source),
893            },
894
895            // C# QualityParser.cs:285-289. DVD source.
896            SourceGroup::Dvd => return mk(Quality::Dvd, resolution_detection_source),
897
898            // C# QualityParser.cs:291-317. PDTV / SDTV / DSR / TVRip cluster.
899            SourceGroup::Pdtv | SourceGroup::Sdtv | SourceGroup::Dsr | SourceGroup::Tvrip => {
900                // Line 296-300: 1080p (regex hit OR substring fallback).
901                if resolution == Resolution::R1080p
902                    || contains_ignore_ascii_case(normalized, "1080p")
903                {
904                    return mk(Quality::Hdtv1080p, resolution_detection_source);
905                }
906                // Line 302-306: 720p (regex hit OR substring fallback).
907                if resolution == Resolution::R720p || contains_ignore_ascii_case(normalized, "720p")
908                {
909                    return mk(Quality::Hdtv720p, resolution_detection_source);
910                }
911                // Line 308-313: HighDefPdtv (hr-ws). C# explicitly sets
912                // ResolutionDetectionSource = Name on this branch even though
913                // the resolution regex did NOT fire.
914                if HIGH_DEF_PDTV_REGEX.is_match(normalized) {
915                    return mk(Quality::Hdtv720p, QualityDetectionSource::Name);
916                }
917                return mk(Quality::Sdtv, resolution_detection_source);
918            }
919        }
920    }
921
922    // ---------------------------------------------------------------------
923    // T8 fallthrough cascade (C# QualityParser.cs:320-595).
924    // ---------------------------------------------------------------------
925    //
926    // From here down, source_match is None (every source-arm above returned).
927    // The C# control flow runs the following blocks in order; each returns
928    // on a hit. We reuse the modifier+resolution_detection_source values
929    // gathered above.
930
931    // Builder for branches that flip source_detection_source to Name (anime,
932    // pixel-tag, bare-bluray, OtherSourceMatch). Identical to the T7 `mk`
933    // closure; defined here to avoid borrowing the T7 closure across the
934    // T8 boundary.
935    let mk_named = |quality: Quality, resolution_source: QualityDetectionSource| QualityModel {
936        quality,
937        revision,
938        source_detection_source: QualityDetectionSource::Name,
939        resolution_detection_source: resolution_source,
940        revision_detection_source,
941    };
942
943    // C# QualityParser.cs:320-347: sourceless remux. Explicit C# behaviour
944    // (line 322): `result.SourceDetectionSource = QualityDetectionSource.Unknown;`
945    // even though we matched a remux signal. Resolutions outside
946    // {480p, 720p, 1080p, 2160p} (e.g. 360p, 540p, 576p) fall through to the
947    // anime / resolution-only branches.
948    if source_match.is_none() && remux_match && resolution != Resolution::Unknown {
949        let q = match resolution {
950            Resolution::R480p => Some(Quality::Bluray480p),
951            Resolution::R720p => Some(Quality::Bluray720p),
952            Resolution::R2160p => Some(Quality::Bluray2160pRemux),
953            Resolution::R1080p => Some(Quality::Bluray1080pRemux),
954            _ => None,
955        };
956        if let Some(quality) = q {
957            return QualityModel {
958                quality,
959                revision,
960                // C# line 322 hard-codes Unknown here.
961                source_detection_source: QualityDetectionSource::Unknown,
962                resolution_detection_source,
963                revision_detection_source,
964            };
965        }
966    }
967
968    // C# QualityParser.cs:349-390: anime-bluray. Matches before the
969    // resolution-only fallback because C#'s control flow checks anime
970    // detection first (at the same depth as sourceless-remux). NB: the
971    // 480p substring path collapses to `Quality::Dvd`, NOT a Bluray480p
972    // variant, intentionally per C# line 359.
973    if matches_anime_bluray(normalized) {
974        // Anime-bluray treats the substring "480p" as equivalent to a 480p
975        // resolution token even when the resolution regex didn't fire.
976        // The substring path explicitly flips ResolutionDetectionSource to
977        // Name (C# lines 358 / 366 / 374).
978        let resolution_source_anime = QualityDetectionSource::Name;
979
980        if matches!(
981            resolution,
982            Resolution::R360p | Resolution::R480p | Resolution::R540p | Resolution::R576p
983        ) || contains_ignore_ascii_case(normalized, "480p")
984        {
985            return mk_named(Quality::Dvd, resolution_source_anime);
986        }
987
988        if resolution == Resolution::R1080p || contains_ignore_ascii_case(normalized, "1080p") {
989            let q = if remux_match {
990                Quality::Bluray1080pRemux
991            } else {
992                Quality::Bluray1080p
993            };
994            return mk_named(q, resolution_source_anime);
995        }
996
997        if resolution == Resolution::R2160p || contains_ignore_ascii_case(normalized, "2160p") {
998            let q = if remux_match {
999                Quality::Bluray2160pRemux
1000            } else {
1001                Quality::Bluray2160p
1002            };
1003            return mk_named(q, resolution_source_anime);
1004        }
1005
1006        // C# line 382-386: remux without a 720p resolution (incl. Unknown)
1007        // collapses to 1080pRemux. R720p with remux falls through to the
1008        // 720p default below.
1009        if remux_match && resolution != Resolution::R720p {
1010            return mk_named(Quality::Bluray1080pRemux, resolution_detection_source);
1011        }
1012
1013        // C# line 388: anime-bluray default is 720p. Resolution detection
1014        // source carries through whatever the regex hit: if R720p fired the
1015        // regex, it's Name; else Unknown.
1016        return mk_named(Quality::Bluray720p, resolution_detection_source);
1017    }
1018
1019    // C# QualityParser.cs:392-424: anime-webdl. Substring "480p" / "1080p" /
1020    // "2160p" promotes the resolution-detection source to Name even when the
1021    // regex itself didn't fire (C# lines 400 / 408 / 416).
1022    if ANIME_WEBDL_REGEX.is_match(normalized) {
1023        let resolution_source_anime = QualityDetectionSource::Name;
1024
1025        if matches!(
1026            resolution,
1027            Resolution::R360p | Resolution::R480p | Resolution::R540p | Resolution::R576p
1028        ) || contains_ignore_ascii_case(normalized, "480p")
1029        {
1030            return mk_named(Quality::Webdl480p, resolution_source_anime);
1031        }
1032
1033        if resolution == Resolution::R1080p || contains_ignore_ascii_case(normalized, "1080p") {
1034            return mk_named(Quality::Webdl1080p, resolution_source_anime);
1035        }
1036
1037        if resolution == Resolution::R2160p || contains_ignore_ascii_case(normalized, "2160p") {
1038            return mk_named(Quality::Webdl2160p, resolution_source_anime);
1039        }
1040
1041        // C# line 422: anime-webdl default is 720p.
1042        return mk_named(Quality::Webdl720p, resolution_detection_source);
1043    }
1044
1045    // C# QualityParser.cs:426-497: resolution-only fallback. Derives a
1046    // QualitySource from EITHER remuxMatch (BlurayRaw) OR the file extension
1047    // via MediaFileExtensions, then uses find_by_source_and_resolution. When
1048    // the derived source is Unknown, falls back to the Television-class
1049    // default for each resolution.
1050    if resolution != Resolution::Unknown {
1051        // C# line 428 + 432-451. `source` defaults to Unknown; `remuxMatch`
1052        // sets it to BlurayRaw (and flips SourceDetectionSource to Name);
1053        // otherwise the extension lookup may set it via the path's last
1054        // dotted token.
1055        let mut derived_source = QualitySource::Unknown;
1056        let mut source_detection = QualityDetectionSource::Unknown;
1057
1058        if remux_match {
1059            derived_source = QualitySource::BlurayRaw;
1060            source_detection = QualityDetectionSource::Name;
1061        } else {
1062            // C# line 437-450 calls Sonarr's GetPathExtension
1063            // (LastIndexOf('.')), distinct from Path.GetExtension. The C#
1064            // version wraps the call in try/catch over ArgumentException,
1065            // but our Rust port's get_path_extension is total (no exceptions);
1066            // we read the extension verbatim and rely on
1067            // quality_for_extension to return Unknown for empty / unrecognised
1068            // inputs.
1069            //
1070            // C# uses `name` (raw) here, NOT `normalizedName`. For path-like
1071            // inputs that contain underscores (e.g. extension `.dvr_ms`, but
1072            // the dictionary actually carries `.dvr-ms`), the `_ -> ` `
1073            // normalisation could turn extensions into multi-token strings.
1074            // We mirror C# faithfully and read from `raw`.
1075            let ext = get_path_extension(raw);
1076            let from_ext = quality_for_extension(ext);
1077            if from_ext != Quality::Unknown {
1078                derived_source = quality_source(from_ext);
1079                source_detection = QualityDetectionSource::Extension;
1080            }
1081        }
1082
1083        // Build the result with the resolution-arm-specific source/resolution
1084        // detection-source flips.
1085        let mk_resfb = |quality: Quality| QualityModel {
1086            quality,
1087            revision,
1088            source_detection_source: source_detection,
1089            // C# lines 455 / 466 / 477 / 489: ResolutionDetectionSource
1090            // explicitly set to Name in every resolution-only return.
1091            resolution_detection_source: QualityDetectionSource::Name,
1092            revision_detection_source,
1093        };
1094
1095        let q = match resolution {
1096            Resolution::R2160p => {
1097                if derived_source == QualitySource::Unknown {
1098                    Quality::Hdtv2160p
1099                } else {
1100                    crate::quality::finder::find_by_source_and_resolution(derived_source, 2160)
1101                }
1102            }
1103            Resolution::R1080p => {
1104                if derived_source == QualitySource::Unknown {
1105                    Quality::Hdtv1080p
1106                } else {
1107                    crate::quality::finder::find_by_source_and_resolution(derived_source, 1080)
1108                }
1109            }
1110            Resolution::R720p => {
1111                if derived_source == QualitySource::Unknown {
1112                    Quality::Hdtv720p
1113                } else {
1114                    crate::quality::finder::find_by_source_and_resolution(derived_source, 720)
1115                }
1116            }
1117            Resolution::R360p | Resolution::R480p | Resolution::R540p | Resolution::R576p => {
1118                if derived_source == QualitySource::Unknown {
1119                    Quality::Sdtv
1120                } else {
1121                    crate::quality::finder::find_by_source_and_resolution(derived_source, 480)
1122                }
1123            }
1124            // Resolution::Unknown is excluded by the outer `if`.
1125            Resolution::Unknown => unreachable!("resolution != Unknown gated by outer if"),
1126        };
1127
1128        return mk_resfb(q);
1129    }
1130
1131    // C# QualityParser.cs:499-504: x264 codec with no other signal -> SDTV.
1132    // The codec_caps were already gathered above. This branch does NOT flip
1133    // any detection source to Name; per C# line 501 only Quality is set.
1134    if codec_caps.as_ref().and_then(|c| c.name("x264")).is_some() {
1135        return QualityModel {
1136            quality: Quality::Sdtv,
1137            revision,
1138            source_detection_source: QualityDetectionSource::Unknown,
1139            resolution_detection_source,
1140            revision_detection_source,
1141        };
1142    }
1143
1144    // C# QualityParser.cs:506-526: 848x480 pixel tag. NB: C# uses
1145    // case-sensitive `Contains("dvd")` here (line 510) and case-insensitive
1146    // `ContainsIgnoreCase("bluray")` (line 515). We mirror that asymmetry.
1147    if normalized.contains("848x480") {
1148        if normalized.contains("dvd") {
1149            return mk_named(Quality::Dvd, QualityDetectionSource::Name);
1150        }
1151        if contains_ignore_ascii_case(normalized, "bluray") {
1152            return mk_named(Quality::Bluray480p, QualityDetectionSource::Name);
1153        }
1154        // Bare 848x480 with no source marker: only the resolution detection
1155        // source flips to Name; the quality is SDTV. C# line 522 doesn't
1156        // set SourceDetectionSource on this default-arm.
1157        return QualityModel {
1158            quality: Quality::Sdtv,
1159            revision,
1160            source_detection_source: QualityDetectionSource::Unknown,
1161            resolution_detection_source: QualityDetectionSource::Name,
1162            revision_detection_source,
1163        };
1164    }
1165
1166    // C# QualityParser.cs:528-543: 1280x720 pixel tag. Both C# checks are
1167    // case-insensitive (line 528 + 532).
1168    if contains_ignore_ascii_case(normalized, "1280x720") {
1169        if contains_ignore_ascii_case(normalized, "bluray") {
1170            return mk_named(Quality::Bluray720p, QualityDetectionSource::Name);
1171        }
1172        return QualityModel {
1173            quality: Quality::Hdtv720p,
1174            revision,
1175            source_detection_source: QualityDetectionSource::Unknown,
1176            resolution_detection_source: QualityDetectionSource::Name,
1177            revision_detection_source,
1178        };
1179    }
1180
1181    // C# QualityParser.cs:545-560: 1920x1080 pixel tag. Same shape as 1280x720.
1182    if contains_ignore_ascii_case(normalized, "1920x1080") {
1183        if contains_ignore_ascii_case(normalized, "bluray") {
1184            return mk_named(Quality::Bluray1080p, QualityDetectionSource::Name);
1185        }
1186        return QualityModel {
1187            quality: Quality::Hdtv1080p,
1188            revision,
1189            source_detection_source: QualityDetectionSource::Unknown,
1190            resolution_detection_source: QualityDetectionSource::Name,
1191            revision_detection_source,
1192        };
1193    }
1194
1195    // C# QualityParser.cs:562-587: bare bluray-resolution tokens. The literal
1196    // "bluray720p" / "bluray1080p" / "bluray2160p" without separators bypasses
1197    // SOURCE_REGEX (which expects word boundaries around `BluRay`).
1198    if contains_ignore_ascii_case(normalized, "bluray720p") {
1199        return mk_named(Quality::Bluray720p, QualityDetectionSource::Name);
1200    }
1201    if contains_ignore_ascii_case(normalized, "bluray1080p") {
1202        return mk_named(Quality::Bluray1080p, QualityDetectionSource::Name);
1203    }
1204    if contains_ignore_ascii_case(normalized, "bluray2160p") {
1205        return mk_named(Quality::Bluray2160p, QualityDetectionSource::Name);
1206    }
1207
1208    // C# QualityParser.cs:589-595 + 653-672: OtherSourceMatch. HD-TV / SD-TV
1209    // alternative-form via OTHER_SOURCE_REGEX. Only Quality + source-detection
1210    // are set; resolution-detection-source carries through from above.
1211    if let Some(caps) = OTHER_SOURCE_REGEX.captures(normalized) {
1212        let q = if caps.name("sdtv").is_some() {
1213            Some(Quality::Sdtv)
1214        } else if caps.name("hdtv").is_some() {
1215            Some(Quality::Hdtv720p)
1216        } else {
1217            None
1218        };
1219        if let Some(quality) = q {
1220            return QualityModel {
1221                quality,
1222                revision,
1223                source_detection_source: QualityDetectionSource::Name,
1224                resolution_detection_source,
1225                revision_detection_source,
1226            };
1227        }
1228    }
1229
1230    // Carry-out: nothing matched. Return the default with the parsed revision
1231    // and any resolution-detection-source flag we accumulated.
1232    QualityModel {
1233        revision,
1234        revision_detection_source,
1235        resolution_detection_source,
1236        ..Default::default()
1237    }
1238}
1239
1240/// **Canonical entry-point** for parsing a release name into a
1241/// [`QualityModel`](crate::quality::QualityModel). Ported from
1242/// `QualityParser.cs:68-98` (`ParseQuality`). Use [`parse_quality_name`]
1243/// only if you need to bypass the extension lookup.
1244///
1245/// Wraps [`parse_quality_name`] with the C# extension fallback at lines
1246/// 81-95: when the inner cascade returns `Quality::Unknown`, look up the file
1247/// extension via [`quality_for_extension`] and overlay the result. The C#
1248/// branch sets `SourceDetectionSource` and `ResolutionDetectionSource` to
1249/// `Extension` BEFORE inspecting the returned Quality (lines 87-88), so even
1250/// a recognised-but-`Unknown` extension (e.g. `.webm`) flips both flags.
1251///
1252/// C# guards this branch with `!name.ContainsInvalidPathChars()`. The Rust
1253/// port mirrors that guard via [`contains_invalid_path_chars`], which is the
1254/// `Path::GetInvalidPathChars()` set: NUL plus the C0 control range
1255/// `\x00-\x1F`. Cross-platform, no Windows-only chars (matches the C#
1256/// behaviour where `Path.GetInvalidPathChars` excludes the per-platform
1257/// "additional" chars like `<>:"/\?*`).
1258pub fn parse_quality(name: &str) -> crate::quality::QualityModel {
1259    use crate::quality::{Quality, QualityDetectionSource};
1260
1261    let trimmed = name.trim();
1262    let mut result = parse_quality_name(trimmed);
1263
1264    if result.quality == Quality::Unknown && !contains_invalid_path_chars(trimmed) {
1265        let ext = get_path_extension(trimmed);
1266        // C# QualityParser.cs:87-88: detection sources are set BEFORE the
1267        // dictionary lookup, so even an Unknown-mapped extension (e.g.
1268        // `.webm`) flips both flags. Mirror that ordering.
1269        result.source_detection_source = QualityDetectionSource::Extension;
1270        result.resolution_detection_source = QualityDetectionSource::Extension;
1271        result.quality = quality_for_extension(ext);
1272    }
1273
1274    result
1275}
1276
1277/// Mirrors `string.ContainsInvalidPathChars` from
1278/// `NzbDrone.Common/Extensions/PathExtensions.cs:184-192`. Returns true if
1279/// the string contains any character that .NET's
1280/// `Path.GetInvalidPathChars()` lists.
1281///
1282/// On both .NET Framework and .NET Core, `Path.GetInvalidPathChars()` returns
1283/// `{ '\0', '\x01' .. '\x1F' }` (NUL plus the C0 control range). It does NOT
1284/// include the per-platform "additional" characters (`<>:"/\?*` on Windows)
1285/// because those are reserved for `Path.GetInvalidFileNameChars()`. Mirroring
1286/// the C# behaviour means we accept release names containing `:` or `/` etc.
1287/// even though those would be invalid in a real filesystem path.
1288fn contains_invalid_path_chars(text: &str) -> bool {
1289    text.bytes().any(|b| b == 0 || (0x01..=0x1F).contains(&b))
1290}
1291
1292/// Modifier-parsing helper. Ported from `QualityParser.cs:675-711`.
1293///
1294/// Returns `(revision, revision_detection_source)`. The detection source is
1295/// `Name` if any of the four C# modifier branches fire (Version, Proper,
1296/// Repack, Real); otherwise `Unknown`.
1297///
1298/// Order of operations (mirrors C#):
1299///
1300/// 1. **Version regex** (line 679): on hit, set `version = N`.
1301/// 2. **Proper regex** (line 687): on hit, set `version = (versionMatch ? N : 1) + 1`.
1302///    Equivalently: if Version did not fire, version is 2; if it did, version
1303///    is the captured N + 1. This is **assignment**, not increment, so a
1304///    second Proper hit does not bump again.
1305/// 3. **Repack regex** (line 693): on hit, same assignment as Proper, plus
1306///    `is_repack = true`. If both Proper and Repack fire, the Repack branch
1307///    overrides the Proper version (both compute the same value, so the
1308///    practical effect is just the `is_repack` flag).
1309/// 4. **Real regex** (line 702): set `real = matches.Count` (count, not
1310///    flag). Uses the **raw** input `name`, not the normalised one. The
1311///    other three branches use `normalizedName`. This is intentional in C#
1312///    and we preserve it.
1313///
1314/// Plan-spec drift notes (resolved in favour of C#):
1315///
1316/// - The plan-spec drafted Proper as `version += 1` (increment). C# uses
1317///   assignment with a Version-aware fallback. Ported per C#.
1318/// - The plan-spec ordered the checks Proper -> Repack -> Version -> Real.
1319///   C# orders them Version -> Proper -> Repack -> Real. Ported per C#.
1320/// - The plan-spec modelled `Real` as `bool`. C# stores it as `int` with
1321///   `Matches.Count` semantics. The Rust port carries `Revision.real: u32`;
1322///   this helper writes the count.
1323fn parse_quality_modifiers(
1324    name: &str,
1325    normalized_name: &str,
1326) -> (
1327    crate::quality::Revision,
1328    crate::quality::QualityDetectionSource,
1329) {
1330    use crate::quality::{QualityDetectionSource, Revision};
1331
1332    let mut rev = Revision::default();
1333    let mut detection = QualityDetectionSource::Unknown;
1334
1335    // Step 1: Version regex (C# line 679, run against normalizedName).
1336    // Walks all 5 named groups (`version` through `version5`) per the
1337    // VERSION_REGEX doc-comment, picking the first that captured. The captured
1338    // text is a single ASCII digit, so `parse::<u32>()` cannot widen overflow.
1339    let version_caps = VERSION_REGEX.captures(normalized_name);
1340    let version_value: Option<u32> = version_caps.as_ref().and_then(|caps| {
1341        caps.name("version")
1342            .or_else(|| caps.name("version2"))
1343            .or_else(|| caps.name("version3"))
1344            .or_else(|| caps.name("version4"))
1345            .or_else(|| caps.name("version5"))
1346            .and_then(|m| m.as_str().parse().ok())
1347    });
1348    if let Some(v) = version_value {
1349        rev.version = v;
1350        detection = QualityDetectionSource::Name;
1351    }
1352
1353    // Step 2: Proper regex (C# line 687, run against normalizedName).
1354    // C#: `result.Revision.Version = versionRegexResult.Success ? Convert.ToInt32(...) + 1 : 2;`
1355    // -- assignment, not increment. The fallback when Version did NOT fire is
1356    // the literal 2 (NOT the current `rev.version + 1`).
1357    if PROPER_REGEX.is_match(normalized_name) {
1358        rev.version = match version_value {
1359            Some(v) => v + 1,
1360            None => 2,
1361        };
1362        detection = QualityDetectionSource::Name;
1363    }
1364
1365    // Step 3: Repack regex (C# line 693, run against normalizedName).
1366    // Same assignment semantics as Proper, plus IsRepack = true.
1367    if REPACK_REGEX.is_match(normalized_name) {
1368        rev.version = match version_value {
1369            Some(v) => v + 1,
1370            None => 2,
1371        };
1372        rev.is_repack = true;
1373        detection = QualityDetectionSource::Name;
1374    }
1375
1376    // Step 4: Real regex (C# line 702, run against the RAW name -- not
1377    // normalizedName -- because that is what C# does. Real is case-sensitive
1378    // (REAL_REGEX has no `(?i)` flag). The C# code uses `Matches(name).Count`,
1379    // which we mirror via `find_iter().count()`.
1380    let real_count = REAL_REGEX.find_iter(name).count();
1381    if real_count > 0 {
1382        // C# stores the raw count; we cap at `u32::MAX` defensively because
1383        // `usize` is wider on 64-bit hosts. In practice the count is bounded
1384        // by input length, which is bounded by Sonarr's release-name limit
1385        // (well under 4 billion).
1386        rev.real = u32::try_from(real_count).unwrap_or(u32::MAX);
1387        detection = QualityDetectionSource::Name;
1388    }
1389
1390    (rev, detection)
1391}
1392
1393#[cfg(test)]
1394mod tests {
1395    use super::regexes::*;
1396    use super::{
1397        SourceGroup, detect_resolution, match_remux, match_source, match_source_last,
1398        matches_alternative_resolution, matches_anime_bluray, parse_quality, parse_quality_name,
1399    };
1400    use crate::quality::{Quality, QualityDetectionSource, Resolution};
1401
1402    // Source-regex direct hits.
1403
1404    #[test]
1405    fn source_regex_detects_bluray() {
1406        let m = SOURCE_REGEX
1407            .captures("Movie.2020.1080p.BluRay.x264")
1408            .unwrap();
1409        assert!(m.name("bluray").is_some());
1410    }
1411
1412    #[test]
1413    fn source_regex_detects_webdl() {
1414        let m = SOURCE_REGEX
1415            .captures("Show.S01E01.WEB-DL.AAC.x264")
1416            .unwrap();
1417        assert!(m.name("webdl").is_some());
1418    }
1419
1420    #[test]
1421    fn source_regex_detects_webrip() {
1422        let m = SOURCE_REGEX.captures("Show.S01E01.WEBRip.x264").unwrap();
1423        assert!(m.name("webrip").is_some());
1424    }
1425
1426    #[test]
1427    fn source_regex_detects_hdtv() {
1428        let m = SOURCE_REGEX.captures("Show.S01E01.HDTV.x264").unwrap();
1429        assert!(m.name("hdtv").is_some());
1430    }
1431
1432    #[test]
1433    fn source_regex_detects_dvd() {
1434        let m = SOURCE_REGEX.captures("Movie.2010.DVDRip.XviD").unwrap();
1435        assert!(m.name("dvd").is_some());
1436    }
1437
1438    // Source helper: lookaround workarounds.
1439
1440    #[test]
1441    fn match_source_rejects_bare_bd_at_end_of_string() {
1442        // C# `BD(?!$)`. Bare `BD` at end of input must NOT match the bluray branch.
1443        // The release name "Show.BD" should fall through to other branches or yield None.
1444        // We craft an input where the only `BD`-shaped token is at the very end.
1445        let result = match_source("Show.S01E01.BD");
1446        // Either no match or a non-bluray match is acceptable; the bluray reject is the gate.
1447        if let Some(found) = result {
1448            assert_ne!(
1449                found.group,
1450                SourceGroup::Bluray,
1451                "bare BD at EOL should not match bluray; got matched={:?}",
1452                found.matched
1453            );
1454        }
1455    }
1456
1457    #[test]
1458    fn match_source_accepts_bd_when_followed_by_more() {
1459        // `BD` mid-string is fine.
1460        let m = match_source("Show.BD.x264.somegroup").expect("should match bluray");
1461        assert_eq!(m.group, SourceGroup::Bluray);
1462        // Case-exact: input is uppercase BD; assert the regex returned the literal "BD"
1463        // rather than any normalised text. Catches accidental case-folding regressions.
1464        assert_eq!(m.matched, "BD");
1465    }
1466
1467    #[test]
1468    fn match_source_accepts_bdmux_at_eol() {
1469        // `BDMux` is its own alternative and is not constrained by the BD-not-at-EOL rule.
1470        let m = match_source("Show.BDMux").expect("BDMux at EOL must match bluray");
1471        assert_eq!(m.group, SourceGroup::Bluray);
1472    }
1473
1474    #[test]
1475    fn match_source_rejects_amzn_web_rip() {
1476        // C# `(?:AMZN|NF|DP)[. -]WEB[. -](?!Rip)`. AMZN.WEB.Rip must NOT match webdl.
1477        let result = match_source("Show.S01E01.AMZN.WEB.Rip.x264");
1478        if let Some(found) = result {
1479            // Should fall through to webrip, not webdl.
1480            assert_ne!(
1481                found.group,
1482                SourceGroup::Webdl,
1483                "AMZN.WEB.Rip must not be classified as webdl; got matched={:?}",
1484                found.matched
1485            );
1486        }
1487    }
1488
1489    #[test]
1490    fn match_source_accepts_amzn_web_dl() {
1491        // AMZN.WEB-DL must classify as webdl. C# semantics: the leftmost-match
1492        // wins, so on this input the (?:AMZN|NF|DP)[. -]WEB[. -] provider branch
1493        // fires first (it starts at `AMZN`, earlier than where WEB-DL begins),
1494        // and its (?!Rip) post-filter passes because the next char after the
1495        // provider match is `D`, not `R`. The end result (webdl group) is what
1496        // callers care about; this test pins both.
1497        let m = match_source("Show.S01E01.AMZN.WEB-DL.x264").expect("AMZN.WEB-DL must match webdl");
1498        assert_eq!(m.group, SourceGroup::Webdl);
1499        // Pin which branch fires. If the alternation order or anchoring ever
1500        // shifts so that WEB[-_. ]DL claims the match instead, this catches it.
1501        assert_eq!(
1502            m.matched.to_ascii_uppercase(),
1503            "AMZN.WEB-",
1504            "expected provider_web branch (AMZN.WEB-), got `{}`",
1505            m.matched
1506        );
1507    }
1508
1509    #[test]
1510    fn match_source_accepts_amzn_web_when_not_followed_by_rip() {
1511        // AMZN.WEB.h264 must match the (?:AMZN|NF|DP)[. -]WEB[. -] branch since
1512        // the trailing chars are not Rip.
1513        let m = match_source("Show.S01E01.AMZN.WEB.h264").expect("AMZN.WEB.h264 must match webdl");
1514        assert_eq!(m.group, SourceGroup::Webdl);
1515    }
1516
1517    #[test]
1518    fn source_regex_uppercase_web_at_eol_matches_webdl() {
1519        // The C# `[. ](?-i:WEB)$` branch matches an uppercase `WEB` at end of input.
1520        // `(?-i:...)` is supported natively by Rust regex, so this is a verbatim port.
1521        let m = SOURCE_REGEX.captures("Show.S01E01.WEB").unwrap();
1522        assert!(
1523            m.name("webdl").is_some(),
1524            "uppercase WEB at EOL must match webdl"
1525        );
1526    }
1527
1528    #[test]
1529    fn source_regex_lowercase_web_at_eol_does_not_match_via_eol_branch() {
1530        // Verifies that `(?-i:WEB)$` is in fact case-sensitive in our port.
1531        // `Show.S01E01.web` has no other webdl-matching token, so the only path
1532        // would be the EOL branch, which must reject lowercase.
1533        let result = SOURCE_REGEX.captures("Show.S01E01.web");
1534        // Either no match, or a non-webdl branch (none should hit `web` though).
1535        if let Some(c) = result {
1536            assert!(
1537                c.name("webdl").is_none(),
1538                "lowercase web at EOL must not match webdl"
1539            );
1540        }
1541    }
1542
1543    // RawHD.
1544
1545    #[test]
1546    fn raw_hd_regex_detects_rawhd() {
1547        assert!(RAW_HD_REGEX.is_match("Show.S01E01.RawHD.MPEG2"));
1548    }
1549
1550    #[test]
1551    fn raw_hd_regex_detects_raw_hyphen_hd() {
1552        assert!(RAW_HD_REGEX.is_match("Show.S01E01.Raw-HD"));
1553    }
1554
1555    #[test]
1556    fn raw_hd_regex_does_not_match_unrelated() {
1557        assert!(!RAW_HD_REGEX.is_match("Show.S01E01.RAWisCool"));
1558    }
1559
1560    // MPEG2.
1561
1562    #[test]
1563    fn mpeg2_regex_detects_mpeg2() {
1564        assert!(MPEG2_REGEX.is_match("Movie.MPEG2.x264"));
1565    }
1566
1567    #[test]
1568    fn mpeg2_regex_detects_mpeg_dash_2() {
1569        assert!(MPEG2_REGEX.is_match("Movie.MPEG-2.x264"));
1570    }
1571
1572    #[test]
1573    fn mpeg2_regex_is_case_sensitive() {
1574        // C# uses no IgnoreCase flag on this pattern, so `mpeg2` (lowercase) must NOT match.
1575        assert!(!MPEG2_REGEX.is_match("Movie.mpeg2.x264"));
1576    }
1577
1578    // Remux.
1579
1580    #[test]
1581    fn remux_regex_detects_uhd_remux() {
1582        assert!(REMUX_REGEX.is_match("Movie.2020.UHD.Remux.2160p"));
1583    }
1584
1585    #[test]
1586    fn remux_regex_detects_bd_remux_prefix() {
1587        // `_Remux` form: `\d{4}p-Remux` matches via `\d{4}p-` prefix.
1588        assert!(REMUX_REGEX.is_match("Movie.2020.1080p-Remux"));
1589    }
1590
1591    #[test]
1592    fn remux_regex_detects_remux_then_resolution() {
1593        // `Remux_2160p` form: matches the second alternative.
1594        assert!(REMUX_REGEX.is_match("Movie.2020.Remux_2160p"));
1595    }
1596
1597    #[test]
1598    fn match_remux_returns_text() {
1599        let s = "Movie.2020.UHD.Remux.2160p";
1600        let got = match_remux(s).expect("must match");
1601        assert!(got.to_ascii_lowercase().contains("remux"));
1602    }
1603
1604    // Anime Bluray (lookaround workaround).
1605
1606    #[test]
1607    fn anime_bluray_regex_detects_bd_with_resolution() {
1608        assert!(matches_anime_bluray("[group] Show - 01 [BD1080p]"));
1609    }
1610
1611    #[test]
1612    fn anime_bluray_regex_detects_bd_in_brackets() {
1613        // `[BD]` -> bare `bd` surrounded by `[` and `]`.
1614        assert!(matches_anime_bluray("[group] Show - 01 [BD]"));
1615    }
1616
1617    #[test]
1618    fn anime_bluray_regex_detects_bd_with_space() {
1619        // `[BD 1080p]` -> bare `bd` surrounded by `[` and ` `.
1620        assert!(matches_anime_bluray("[group] Show - 01 [BD 1080p]"));
1621    }
1622
1623    #[test]
1624    fn anime_bluray_regex_does_not_match_substring() {
1625        // Without surround chars, bare `bd` inside a word must not count.
1626        assert!(!matches_anime_bluray("Some.abdef.movie"));
1627    }
1628
1629    #[test]
1630    fn anime_bluray_regex_does_not_match_bd_then_letter() {
1631        // `bdrip` has surround `.` left but `r` right, must not match the bare-bd branch.
1632        assert!(!matches_anime_bluray("Show.bdrip"));
1633    }
1634
1635    #[test]
1636    fn anime_bluray_regex_rejects_bare_bd_at_sol() {
1637        // C#'s (?<=[-_. (\[])bd... lookbehind cannot match at start-of-input.
1638        assert!(!matches_anime_bluray("bd"));
1639        assert!(!matches_anime_bluray("bd.x264"));
1640    }
1641
1642    #[test]
1643    fn anime_bluray_regex_rejects_bare_bd_at_eol() {
1644        // C#'s ...bd(?=[-_. )\]]) lookahead cannot match at end-of-input.
1645        assert!(!matches_anime_bluray("show.bd"));
1646    }
1647
1648    #[test]
1649    fn anime_bluray_regex_accepts_bd1080_at_boundaries() {
1650        // bd720/bd1080/bd2160 are the lookaround-free branch; boundaries are fine.
1651        assert!(matches_anime_bluray("bd1080"));
1652        assert!(matches_anime_bluray("bd1080.mkv"));
1653    }
1654
1655    // Anime WebDL.
1656
1657    #[test]
1658    fn anime_webdl_regex_detects_web_brackets() {
1659        assert!(ANIME_WEBDL_REGEX.is_match("[group] Show - 01 [WEB] [1080p]"));
1660    }
1661
1662    #[test]
1663    fn anime_webdl_regex_detects_paren_web_dot() {
1664        assert!(ANIME_WEBDL_REGEX.is_match("(WEB.1080p) Show"));
1665    }
1666
1667    #[test]
1668    fn anime_webdl_regex_does_not_match_bare_web() {
1669        assert!(!ANIME_WEBDL_REGEX.is_match("Show.WEB.1080p"));
1670    }
1671
1672    // Resolution.
1673
1674    #[test]
1675    fn resolution_regex_detects_2160p() {
1676        let m = RESOLUTION_REGEX.captures("Movie.2160p.HDR").unwrap();
1677        assert!(m.name("R2160p").is_some());
1678    }
1679
1680    #[test]
1681    fn resolution_regex_detects_1280x720_as_720p() {
1682        let m = RESOLUTION_REGEX
1683            .captures("[group] Show - 01 (1280x720)")
1684            .unwrap();
1685        assert!(m.name("R720p").is_some());
1686    }
1687
1688    #[test]
1689    fn resolution_regex_detects_uhd_4k_form() {
1690        // C# 4K branch: `(?:UHD|HEVC|BD|H265)[-_. ]4k`. `UHD-4k` must hit R2160p.
1691        let m = RESOLUTION_REGEX.captures("Movie.2020.UHD-4k.x265").unwrap();
1692        assert!(m.name("R2160p").is_some());
1693    }
1694
1695    #[test]
1696    fn resolution_regex_detects_4k_uhd_form() {
1697        // C# 4K branch: `4k[-_. ](?:UHD|HEVC|BD|H265)`. `4k-UHD` must hit R2160p.
1698        let m = RESOLUTION_REGEX.captures("Movie.2020.4k-UHD.x265").unwrap();
1699        assert!(m.name("R2160p").is_some());
1700    }
1701
1702    #[test]
1703    fn resolution_regex_detects_480p() {
1704        let m = RESOLUTION_REGEX.captures("Show.S01E01.480p.x264").unwrap();
1705        assert!(m.name("R480p").is_some());
1706    }
1707
1708    #[test]
1709    fn resolution_regex_detects_1080p_fhd() {
1710        let m = RESOLUTION_REGEX.captures("Movie.2020.FHD.x264").unwrap();
1711        assert!(m.name("R1080p").is_some());
1712    }
1713
1714    #[test]
1715    fn resolution_regex_detects_4kto1080p_form() {
1716        // C# QualityParser.cs:49 - R1080p group includes `4kto1080p` for
1717        // downscaled-UHD releases. Regression guard against the T5-original
1718        // miss that dropped this token while correcting the R2160p 4K branches.
1719        let m = RESOLUTION_REGEX.captures("Movie.4kto1080p.x264").unwrap();
1720        assert!(
1721            m.name("R1080p").is_some(),
1722            "expected R1080p branch to match `4kto1080p`"
1723        );
1724    }
1725
1726    // Alternative resolution (duplicate-group workaround).
1727
1728    #[test]
1729    fn alternative_resolution_regex_matches_uhd() {
1730        assert!(matches_alternative_resolution("Movie.UHD.Bluray"));
1731    }
1732
1733    #[test]
1734    fn alternative_resolution_regex_matches_bracket_4k() {
1735        assert!(matches_alternative_resolution("Movie [4K] Bluray"));
1736    }
1737
1738    #[test]
1739    fn alternative_resolution_regex_does_not_match_unrelated() {
1740        assert!(!matches_alternative_resolution("Movie.1080p.Bluray"));
1741    }
1742
1743    #[test]
1744    fn alternative_resolution_regex_named_groups_are_distinct() {
1745        // Direct regex assertion: C# uses the same `<R2160p>` name twice; we
1746        // renamed the second to `R2160p_alt` to satisfy Rust regex's no-dup
1747        // rule. Confirm both names exist and fire on their respective inputs.
1748        let uhd = ALTERNATIVE_RESOLUTION_REGEX
1749            .captures("Movie.UHD.x264")
1750            .unwrap();
1751        assert!(uhd.name("R2160p").is_some());
1752        assert!(uhd.name("R2160p_alt").is_none());
1753
1754        let four_k = ALTERNATIVE_RESOLUTION_REGEX
1755            .captures("Movie [4K] x264")
1756            .unwrap();
1757        assert!(four_k.name("R2160p").is_none());
1758        assert!(four_k.name("R2160p_alt").is_some());
1759    }
1760
1761    // Codec.
1762
1763    #[test]
1764    fn codec_regex_detects_xvid() {
1765        let m = CODEC_REGEX.captures("Show.S01E01.Xvid.AC3").unwrap();
1766        assert!(m.name("xvid").is_some());
1767    }
1768
1769    #[test]
1770    fn codec_regex_does_not_match_x_hyphen_vid() {
1771        // Plan-spec drafted `X-?vid`; C# QualityParser.cs:56-57 is `Xvid`
1772        // literal. A future relaxation back to the hyphenated form would
1773        // silently widen matching. Mirror of the resolution_regex_detects_4kto1080p_form
1774        // regression-guard pattern.
1775        assert!(CODEC_REGEX.captures("Movie.X-vid.AC3").is_none());
1776    }
1777
1778    #[test]
1779    fn codec_regex_detects_x264() {
1780        let m = CODEC_REGEX.captures("Movie.1080p.x264.AC3").unwrap();
1781        assert!(m.name("x264").is_some());
1782    }
1783
1784    #[test]
1785    fn codec_regex_detects_h264_case_insensitive() {
1786        let m = CODEC_REGEX.captures("Movie.1080p.H264.AC3").unwrap();
1787        assert!(m.name("h264").is_some());
1788    }
1789
1790    #[test]
1791    fn codec_regex_detects_xvidhd() {
1792        let m = CODEC_REGEX.captures("Movie.XvidHD.AC3").unwrap();
1793        // XvidHD must hit the xvidhd group, not xvid (alternation order matters).
1794        assert!(m.name("xvidhd").is_some());
1795        assert!(m.name("xvid").is_none());
1796    }
1797
1798    #[test]
1799    fn codec_regex_detects_divx() {
1800        let m = CODEC_REGEX.captures("Movie.divx.AC3").unwrap();
1801        assert!(m.name("divx").is_some());
1802    }
1803
1804    // Other source.
1805
1806    #[test]
1807    fn other_source_regex_detects_hd_tv() {
1808        let m = OTHER_SOURCE_REGEX.captures("Show.HD-TV.x264").unwrap();
1809        assert!(m.name("hdtv").is_some());
1810    }
1811
1812    #[test]
1813    fn other_source_regex_detects_sd_tv() {
1814        let m = OTHER_SOURCE_REGEX.captures("Show.SD.TV.x264").unwrap();
1815        assert!(m.name("sdtv").is_some());
1816    }
1817
1818    // High-def PDTV.
1819
1820    #[test]
1821    fn high_def_pdtv_regex_detects_hr_ws() {
1822        assert!(HIGH_DEF_PDTV_REGEX.is_match("Show.S01E01.hr-ws.x264"));
1823    }
1824
1825    #[test]
1826    fn high_def_pdtv_regex_does_not_match_unrelated() {
1827        assert!(!HIGH_DEF_PDTV_REGEX.is_match("Show.S01E01.hrws.x264"));
1828    }
1829
1830    // Proper.
1831
1832    #[test]
1833    fn proper_regex_detects_proper() {
1834        assert!(PROPER_REGEX.is_match("Show PROPER 720p"));
1835    }
1836
1837    #[test]
1838    fn proper_regex_detects_proper_lowercase() {
1839        // C# uses IgnoreCase; lowercase must match too.
1840        assert!(PROPER_REGEX.is_match("Show.proper.720p"));
1841    }
1842
1843    // Repack.
1844
1845    #[test]
1846    fn repack_regex_detects_repack() {
1847        assert!(REPACK_REGEX.is_match("Show REPACK2 1080p"));
1848    }
1849
1850    #[test]
1851    fn repack_regex_detects_rerip() {
1852        assert!(REPACK_REGEX.is_match("Show.RERIP.1080p"));
1853    }
1854
1855    #[test]
1856    fn repack_regex_detects_bare_repack() {
1857        // No trailing digit is also valid: `repack\d?` makes the digit optional.
1858        assert!(REPACK_REGEX.is_match("Show.REPACK.1080p"));
1859    }
1860
1861    // Version.
1862
1863    #[test]
1864    fn version_regex_detects_v2() {
1865        // Rust regex does not allow duplicate names, so we renamed the C#
1866        // alternation branches to version/version2/version3/version4/version5.
1867        // For the `1080p.v2` form, the fifth branch (`version5`) fires.
1868        let m = VERSION_REGEX.captures("Show 1080p v2").unwrap();
1869        let version = m
1870            .name("version")
1871            .or_else(|| m.name("version2"))
1872            .or_else(|| m.name("version3"))
1873            .or_else(|| m.name("version4"))
1874            .or_else(|| m.name("version5"))
1875            .expect("at least one named version group must capture");
1876        assert_eq!(version.as_str(), "2");
1877    }
1878
1879    #[test]
1880    fn version_regex_detects_bracket_v3() {
1881        // `[v3]` form fires the second branch (version2 in our port).
1882        let m = VERSION_REGEX.captures("Show [v3] 1080p").unwrap();
1883        assert!(m.name("version2").is_some());
1884        assert_eq!(m.name("version2").unwrap().as_str(), "3");
1885    }
1886
1887    #[test]
1888    fn version_regex_detects_repack_with_digit() {
1889        // `repack2` form fires the third branch (version3 in our port).
1890        let m = VERSION_REGEX.captures("Show.repack2.1080p").unwrap();
1891        assert!(m.name("version3").is_some());
1892        assert_eq!(m.name("version3").unwrap().as_str(), "2");
1893    }
1894
1895    #[test]
1896    fn version_regex_detects_rerip_with_digit() {
1897        // `rerip3` form fires the fourth branch (version4 in our port).
1898        let m = VERSION_REGEX.captures("Show.rerip3.1080p").unwrap();
1899        assert!(m.name("version4").is_some());
1900        assert_eq!(m.name("version4").unwrap().as_str(), "3");
1901    }
1902
1903    #[test]
1904    fn version_regex_detects_digit_v_digit_form() {
1905        // `01-v2_` form fires the first branch (version in our port).
1906        // Pattern: `\d[-._ ]?v(?<version>\d)[-._ ]`.
1907        let m = VERSION_REGEX.captures("Show 01-v2_1080p").unwrap();
1908        assert!(m.name("version").is_some());
1909        assert_eq!(m.name("version").unwrap().as_str(), "2");
1910    }
1911
1912    // Real (case-sensitive).
1913
1914    #[test]
1915    fn real_regex_detects_real() {
1916        assert!(REAL_REGEX.is_match("Show REAL PROPER 1080p"));
1917    }
1918
1919    #[test]
1920    fn real_regex_is_case_sensitive() {
1921        // C# `RealRegex` carries no IgnoreCase flag, so lowercase `real` must
1922        // NOT match. This gates the absence of the `(?i)` flag in our port.
1923        assert!(!REAL_REGEX.is_match("show real proper"));
1924        assert!(!REAL_REGEX.is_match("Show Real Proper"));
1925    }
1926
1927    // T6 cascade entry-point + modifier parsing.
1928    //
1929    // Cross-checked against `QualityParser.cs:100-112` (ParseQualityName) and
1930    // `QualityParser.cs:675-711` (ParseQualityModifiers). Plan-spec versus C#
1931    // discrepancies are noted in `parse_quality_modifiers` itself; the tests
1932    // below pin C# semantics (the source of truth).
1933
1934    #[test]
1935    fn parses_proper_increments_revision() {
1936        // C# (line 689): proper sets version = match? +1 : 2 (no version match
1937        // here, so version becomes 2).
1938        let m = parse_quality_name("Show S01E01 PROPER HDTV XviD");
1939        assert_eq!(m.revision.version, 2);
1940    }
1941
1942    #[test]
1943    fn parses_repack_sets_is_repack() {
1944        // C# (line 696): repack sets IsRepack = true. version becomes 2 via the
1945        // same fallback as proper.
1946        let m = parse_quality_name("Show.2010.REPACK.1080p.WEB");
1947        assert!(m.revision.is_repack);
1948        assert_eq!(m.revision.version, 2);
1949    }
1950
1951    #[test]
1952    fn parses_real_sets_real_flag() {
1953        // C# (line 706): Real = realRegexResult.Count, so for one REAL match
1954        // the count is 1. The PROPER on this input bumps version to 2 (the
1955        // REAL itself does not change version).
1956        let m = parse_quality_name("Show.REAL.PROPER.1080p");
1957        assert_eq!(m.revision.real, 1);
1958        assert_eq!(m.revision.version, 2);
1959    }
1960
1961    #[test]
1962    fn parses_explicit_version_2() {
1963        // C# (line 683): VersionRegex sets version = N. The 5th VERSION_REGEX
1964        // branch fires for the `1080p.v2` form.
1965        let m = parse_quality_name("Show.S01E01.1080p.v2");
1966        assert_eq!(m.revision.version, 2);
1967    }
1968
1969    #[test]
1970    fn raw_hd_short_circuits() {
1971        // C# (line 105): if RawHDRegex matches, set Quality = RAWHD and return
1972        // immediately. The cascade bails before considering source/resolution.
1973        let m = parse_quality_name("Show.S01E01.RawHD");
1974        assert_eq!(m.quality, Quality::RawHd);
1975    }
1976
1977    // Extra coverage on T6's load-bearing edges.
1978
1979    #[test]
1980    fn parse_quality_name_default_when_no_modifiers_or_rawhd() {
1981        // No modifiers, no RawHD: T6 returns the default QualityModel with
1982        // version=1 and Quality::Unknown. T7's source-to-quality cascade is
1983        // what flips Quality based on source/resolution; T6 must not.
1984        let m = parse_quality_name("Show.S01E01.MysteryFormat");
1985        assert_eq!(m.quality, Quality::Unknown);
1986        assert_eq!(m.revision.version, 1);
1987        assert_eq!(m.revision.real, 0);
1988        assert!(!m.revision.is_repack);
1989    }
1990
1991    #[test]
1992    fn parse_quality_name_empty_input_returns_default() {
1993        // C# ParseQuality:72 short-circuits on IsNullOrWhiteSpace. The Rust
1994        // port handles empty input via natural cascade fallthrough (no source
1995        // match, no resolution, no modifiers -> default QualityModel).
1996        // Pins the graceful-empty behaviour against a future "validate
1997        // input non-empty" change that might panic.
1998        let m = parse_quality_name("");
1999        assert_eq!(m.quality, Quality::Unknown);
2000        assert_eq!(m.revision.version, 1);
2001        assert_eq!(m.revision.real, 0);
2002        assert!(!m.revision.is_repack);
2003    }
2004
2005    #[test]
2006    fn parse_quality_name_normalizes_underscores() {
2007        // C# (line 102): name.Replace('_', ' '). PROPER sandwiched in
2008        // underscores must still classify as a proper. Our `\b(?<proper>proper)\b`
2009        // also matches across `_` boundaries (regex `\b` treats underscore as a
2010        // word char, so `_PROPER_` does NOT have a word boundary), which is
2011        // exactly why C# normalises first.
2012        let m = parse_quality_name("Show_S01E01_PROPER_HDTV");
2013        assert_eq!(m.revision.version, 2);
2014    }
2015
2016    #[test]
2017    fn parse_quality_name_real_uses_raw_input_not_normalized() {
2018        // C# (line 702): RealRegex.Matches(name). The raw input is used, not
2019        // normalizedName. Real is case-sensitive uppercase. Underscores around
2020        // REAL are fine because the regex uses \b boundaries with word-char
2021        // semantics (`_` is a word char, so `_REAL_` has no \b, but that is
2022        // NOT a problem here because the literal `REAL` token is bordered by
2023        // other separators in real-world inputs).
2024        let m = parse_quality_name("Show.REAL.S01E01.1080p");
2025        assert_eq!(m.revision.real, 1);
2026    }
2027
2028    #[test]
2029    fn parse_quality_name_real_underscore_bordered_does_not_match() {
2030        // C# uses RealRegex.Matches(name) on the RAW input. `_REAL_` has no \b
2031        // boundary on either side because `_` is a word-char in the regex
2032        // engine. If a future refactor accidentally switched Real to scan the
2033        // normalized name (` REAL `), the boundary would fire and this would
2034        // flip from 0 to 1. This test gates that drift; the existing
2035        // parse_quality_name_real_uses_raw_input_not_normalized test does NOT
2036        // discriminate (its input produces identical results raw and normalized).
2037        let m = parse_quality_name("Show_S01E01_REAL_1080p");
2038        assert_eq!(m.revision.real, 0);
2039    }
2040
2041    #[test]
2042    fn parse_quality_name_real_lowercase_is_ignored() {
2043        // C# REAL_REGEX is case-sensitive (no IgnoreCase flag). lowercase
2044        // `real` must not bump the counter.
2045        let m = parse_quality_name("Show.real.S01E01.1080p");
2046        assert_eq!(m.revision.real, 0);
2047    }
2048
2049    #[test]
2050    fn parse_quality_name_multi_real_counts() {
2051        // C# `Real = realRegexResult.Count`. Two REAL tokens => Real = 2.
2052        // Verifies the u32 vs bool decision: a `bool` would lose this signal.
2053        let m = parse_quality_name("Show.REAL.REAL.1080p");
2054        assert_eq!(m.revision.real, 2);
2055    }
2056
2057    #[test]
2058    fn parse_quality_name_repack_with_digit_takes_version() {
2059        // C# (line 695): when version regex AND repack both match, the version
2060        // value is `versionRegexResult.Groups["version"].Value + 1`. For
2061        // `repack2`, the version regex captures "2", and the repack branch
2062        // sets version = 2 + 1 = 3.
2063        let m = parse_quality_name("Show.repack2.1080p");
2064        assert_eq!(m.revision.version, 3);
2065        assert!(m.revision.is_repack);
2066    }
2067
2068    #[test]
2069    fn parse_quality_name_proper_with_digit_v2_takes_version() {
2070        // C# (line 689): version=2, proper bumps to 3.
2071        let m = parse_quality_name("Show.PROPER.1080p.v2");
2072        assert_eq!(m.revision.version, 3);
2073    }
2074
2075    #[test]
2076    fn raw_hd_short_circuits_populates_detection_sources() {
2077        // C# (lines 107-109): when RawHD matches, source + resolution detection
2078        // sources are set to Name. Revision detection source is set if any
2079        // modifier fired (none here, so it stays Unknown).
2080        let m = parse_quality_name("Show.S01E01.RawHD");
2081        assert_eq!(
2082            m.source_detection_source,
2083            crate::quality::QualityDetectionSource::Name
2084        );
2085        assert_eq!(
2086            m.resolution_detection_source,
2087            crate::quality::QualityDetectionSource::Name
2088        );
2089        assert_eq!(
2090            m.revision_detection_source,
2091            crate::quality::QualityDetectionSource::Unknown
2092        );
2093    }
2094
2095    #[test]
2096    fn raw_hd_short_circuits_with_proper_sets_revision_detection_source() {
2097        // C# (line 690): proper sets RevisionDetectionSource = Name. Even
2098        // through a RawHD short-circuit, the revision-detection source must
2099        // travel along because ParseQualityModifiers ran first.
2100        let m = parse_quality_name("Show.RawHD.PROPER");
2101        assert_eq!(m.quality, Quality::RawHd);
2102        assert_eq!(m.revision.version, 2);
2103        assert_eq!(
2104            m.revision_detection_source,
2105            crate::quality::QualityDetectionSource::Name
2106        );
2107    }
2108
2109    // T7 detect_resolution helper.
2110
2111    #[test]
2112    fn detect_resolution_returns_2160p_for_2160p_token() {
2113        assert_eq!(
2114            detect_resolution("Movie.2020.2160p.BluRay"),
2115            Resolution::R2160p
2116        );
2117    }
2118
2119    #[test]
2120    fn detect_resolution_returns_2160p_for_uhd_token() {
2121        // Falls back to ALTERNATIVE_RESOLUTION_REGEX when the main
2122        // RESOLUTION_REGEX would not capture (no numeric resolution token).
2123        assert_eq!(
2124            detect_resolution("Movie.2020.UHD.BluRay"),
2125            Resolution::R2160p
2126        );
2127    }
2128
2129    #[test]
2130    fn detect_resolution_returns_2160p_for_bracket_4k() {
2131        assert_eq!(
2132            detect_resolution("Movie.2020 [4K] BluRay"),
2133            Resolution::R2160p
2134        );
2135    }
2136
2137    #[test]
2138    fn detect_resolution_returns_1080p_for_1080p_token() {
2139        assert_eq!(
2140            detect_resolution("Movie.2020.1080p.WEB-DL"),
2141            Resolution::R1080p
2142        );
2143    }
2144
2145    #[test]
2146    fn detect_resolution_returns_720p_for_1280x720_token() {
2147        // C# RESOLUTION_REGEX folds 1280x720 into R720p.
2148        assert_eq!(detect_resolution("Show.S01E01.1280x720"), Resolution::R720p);
2149    }
2150
2151    #[test]
2152    fn detect_resolution_returns_480p_for_480p_token() {
2153        assert_eq!(
2154            detect_resolution("Show.S01E01.480p.HDTV"),
2155            Resolution::R480p
2156        );
2157    }
2158
2159    #[test]
2160    fn detect_resolution_returns_540p_for_540p_token() {
2161        assert_eq!(
2162            detect_resolution("Show.S01E01.540p.HDTV"),
2163            Resolution::R540p
2164        );
2165    }
2166
2167    #[test]
2168    fn detect_resolution_returns_576p_for_576p_token() {
2169        assert_eq!(
2170            detect_resolution("Show.S01E01.576p.HDTV"),
2171            Resolution::R576p
2172        );
2173    }
2174
2175    #[test]
2176    fn detect_resolution_returns_360p_for_360p_token() {
2177        assert_eq!(
2178            detect_resolution("Show.S01E01.360p.HDTV"),
2179            Resolution::R360p
2180        );
2181    }
2182
2183    #[test]
2184    fn detect_resolution_returns_unknown_when_no_token() {
2185        assert_eq!(
2186            detect_resolution("Show.S01E01.HDTV.x264"),
2187            Resolution::Unknown
2188        );
2189    }
2190
2191    // T7 match_source_last (LastOrDefault semantics).
2192
2193    #[test]
2194    fn match_source_last_returns_last_when_two_distinct_sources() {
2195        // C# `sourceMatches.OfType<Match>().LastOrDefault()`: HDTV at the
2196        // start, BluRay at the end -> last match is BluRay.
2197        let m = match_source_last("Show.HDTV.Repack.1080p.BluRay.x264").expect("BluRay must match");
2198        assert_eq!(m.group, SourceGroup::Bluray);
2199    }
2200
2201    #[test]
2202    fn match_source_last_returns_first_when_only_one_source() {
2203        // Single sourceMatch -> first == last; trivially exercised.
2204        let m = match_source_last("Movie.2020.1080p.BluRay.x264").expect("BluRay must match");
2205        assert_eq!(m.group, SourceGroup::Bluray);
2206    }
2207
2208    #[test]
2209    fn match_source_last_skips_filtered_candidates() {
2210        // C# `BD(?!$)` rejects bare BD at end. If the only candidate is
2211        // filtered, return None.
2212        assert!(match_source_last("Show.S01E01.BD").is_none());
2213    }
2214
2215    // T7 cascade: bluray arm.
2216
2217    #[test]
2218    fn bluray_2160p() {
2219        // Plan-required test #1.
2220        assert_eq!(
2221            parse_quality_name("Movie.2020.2160p.BluRay.x265").quality,
2222            Quality::Bluray2160p
2223        );
2224    }
2225
2226    #[test]
2227    fn bluray_1080p_remux() {
2228        // Plan-required test #2.
2229        assert_eq!(
2230            parse_quality_name("Movie.2020.1080p.BluRay.Remux.AVC").quality,
2231            Quality::Bluray1080pRemux
2232        );
2233    }
2234
2235    #[test]
2236    fn bluray_720p() {
2237        // Plan-required test #3.
2238        assert_eq!(
2239            parse_quality_name("Movie.720p.BluRay.x264").quality,
2240            Quality::Bluray720p
2241        );
2242    }
2243
2244    #[test]
2245    fn bluray_576p() {
2246        // C# QualityParser.cs:150-154 covers 576p as its own bucket.
2247        assert_eq!(
2248            parse_quality_name("Movie.576p.BluRay.x264").quality,
2249            Quality::Bluray576p
2250        );
2251    }
2252
2253    #[test]
2254    fn bluray_480p_via_xvid_codec() {
2255        // C# QualityParser.cs:131-135: a bluray release with xvid OR divx
2256        // codec is downgraded to 480p regardless of resolution.
2257        assert_eq!(
2258            parse_quality_name("Movie.2020.BluRay.Xvid.AC3").quality,
2259            Quality::Bluray480p
2260        );
2261    }
2262
2263    #[test]
2264    fn bluray_480p_via_divx_codec() {
2265        // C# QualityParser.cs:131-135: divx co-equal with xvid.
2266        assert_eq!(
2267            parse_quality_name("Movie.2020.BluRay.divx.AC3").quality,
2268            Quality::Bluray480p
2269        );
2270    }
2271
2272    #[test]
2273    fn bluray_480p_via_low_resolution() {
2274        // C# QualityParser.cs:156-161: 360p / 480p / 540p collapse to 480p.
2275        assert_eq!(
2276            parse_quality_name("Movie.2020.480p.BluRay.x264").quality,
2277            Quality::Bluray480p
2278        );
2279        assert_eq!(
2280            parse_quality_name("Movie.2020.540p.BluRay.x264").quality,
2281            Quality::Bluray480p
2282        );
2283    }
2284
2285    #[test]
2286    fn bluray_2160p_remux_via_2160p_token_and_remux() {
2287        // C# QualityParser.cs:139.
2288        assert_eq!(
2289            parse_quality_name("Movie.2020.2160p.BluRay.Remux.HDR.x265").quality,
2290            Quality::Bluray2160pRemux
2291        );
2292    }
2293
2294    #[test]
2295    fn bluray_remux_unknown_resolution_falls_back_to_1080p_remux() {
2296        // C# QualityParser.cs:165-169: remux without a 720p resolution falls
2297        // back to 1080pRemux. Unknown resolution + remux + bluray -> 1080pRemux.
2298        assert_eq!(
2299            parse_quality_name("Movie.BluRay.Remux.x264").quality,
2300            Quality::Bluray1080pRemux
2301        );
2302    }
2303
2304    #[test]
2305    fn bluray_720p_with_remux_stays_720p() {
2306        // C# QualityParser.cs:165 explicit comment: "720p remux should
2307        // fallback as 720p BluRay". Pin that the 720p arm ignores the remux
2308        // signal.
2309        assert_eq!(
2310            parse_quality_name("Movie.720p.BluRay.Remux.x264").quality,
2311            Quality::Bluray720p
2312        );
2313    }
2314
2315    #[test]
2316    fn bluray_unknown_resolution_no_remux_falls_back_to_720p() {
2317        // C# QualityParser.cs:171-172: implicit default at the bottom of the
2318        // bluray arm is Bluray720p when no resolution token, no remux, no
2319        // codec downgrade hits. Pins the Resolution::Unknown + remux=false
2320        // -> Bluray720p path. The 720p-with-remux carve-out test
2321        // (bluray_720p_with_remux_stays_720p) covers the symmetric case;
2322        // this completes the matrix.
2323        assert_eq!(
2324            parse_quality_name("Movie.BluRay.x264").quality,
2325            Quality::Bluray720p
2326        );
2327    }
2328
2329    // T7 cascade: webdl arm.
2330
2331    #[test]
2332    fn webdl_1080p() {
2333        // Plan-required test #4.
2334        assert_eq!(
2335            parse_quality_name("Movie.2020.1080p.WEB-DL.x264").quality,
2336            Quality::Webdl1080p
2337        );
2338    }
2339
2340    #[test]
2341    fn webdl_720p_itunes() {
2342        // Plan-required test #5. iTunesHD is a webdl-branch alternative.
2343        assert_eq!(
2344            parse_quality_name("Movie.720p.iTunesHD.AVC").quality,
2345            Quality::Webdl720p
2346        );
2347    }
2348
2349    #[test]
2350    fn webdl_2160p() {
2351        // C# QualityParser.cs:177-180.
2352        assert_eq!(
2353            parse_quality_name("Movie.2020.2160p.WEB-DL.x265").quality,
2354            Quality::Webdl2160p
2355        );
2356    }
2357
2358    #[test]
2359    fn webdl_480p_default() {
2360        // C# QualityParser.cs:201-202: any webdl with no R720p / R1080p /
2361        // R2160p resolution match falls back to 480p.
2362        assert_eq!(
2363            parse_quality_name("Movie.2020.WEB-DL.AAC.x264").quality,
2364            Quality::Webdl480p
2365        );
2366    }
2367
2368    #[test]
2369    fn webdl_720p_via_bracket_marker() {
2370        // C# QualityParser.cs:195-199: raw-name `[WEBDL]` substring forces
2371        // 720p when no resolution match is present. The substring is checked
2372        // against the RAW input, NOT the normalised name (underscores become
2373        // spaces in normalised, but the literal bracket text is identical).
2374        assert_eq!(
2375            parse_quality_name("Movie.[WEBDL].WEB-DL.AC3").quality,
2376            Quality::Webdl720p
2377        );
2378    }
2379
2380    // T7 cascade: webrip arm.
2381
2382    #[test]
2383    fn webrip_2160p() {
2384        // Plan-required test #6.
2385        assert_eq!(
2386            parse_quality_name("Movie.2160p.WebRip.x265").quality,
2387            Quality::Webrip2160p
2388        );
2389    }
2390
2391    #[test]
2392    fn webrip_1080p() {
2393        // C# QualityParser.cs:213-216.
2394        assert_eq!(
2395            parse_quality_name("Movie.2020.1080p.WEBRip.x264").quality,
2396            Quality::Webrip1080p
2397        );
2398    }
2399
2400    #[test]
2401    fn webrip_720p() {
2402        // C# QualityParser.cs:219-222.
2403        assert_eq!(
2404            parse_quality_name("Show.S01E01.720p.WEBRip.x264").quality,
2405            Quality::Webrip720p
2406        );
2407    }
2408
2409    #[test]
2410    fn webrip_480p_default() {
2411        // C# QualityParser.cs:225-226: webrip with no resolution match -> 480p.
2412        assert_eq!(
2413            parse_quality_name("Show.S01E01.WEBRip.x264").quality,
2414            Quality::Webrip480p
2415        );
2416    }
2417
2418    // T7 cascade: hdtv arm.
2419
2420    #[test]
2421    fn hdtv_720p() {
2422        // Plan-required test #7.
2423        assert_eq!(
2424            parse_quality_name("Show.S01E01.720p.HDTV.x264").quality,
2425            Quality::Hdtv720p
2426        );
2427    }
2428
2429    #[test]
2430    fn hdtv_1080p() {
2431        // Plan-required test #8.
2432        assert_eq!(
2433            parse_quality_name("Show.S01E01.1080p.HDTV.x264").quality,
2434            Quality::Hdtv1080p
2435        );
2436    }
2437
2438    #[test]
2439    fn hdtv_2160p() {
2440        // C# QualityParser.cs:237-240.
2441        assert_eq!(
2442            parse_quality_name("Show.S01E01.2160p.HDTV.x265").quality,
2443            Quality::Hdtv2160p
2444        );
2445    }
2446
2447    #[test]
2448    fn hdtv_mpeg2_short_circuits_to_rawhd() {
2449        // C# QualityParser.cs:231-234: when the source is HDTV and the
2450        // normalized name contains MPEG2 (case-sensitive), the cascade
2451        // short-circuits to RAWHD even with a 1080p token in the input.
2452        assert_eq!(
2453            parse_quality_name("Show.S01E01.1080p.HDTV.MPEG2").quality,
2454            Quality::RawHd
2455        );
2456    }
2457
2458    #[test]
2459    fn hdtv_mpeg2_lowercase_does_not_short_circuit() {
2460        // C#'s MPEG2_REGEX is case-sensitive (no IgnoreCase flag): a
2461        // lowercase `mpeg2` token must NOT trigger the short-circuit.
2462        // Matches the modifier behaviour pinned at T4 (mpeg2_regex_is_case_sensitive).
2463        assert_eq!(
2464            parse_quality_name("Show.S01E01.1080p.HDTV.mpeg2.x264").quality,
2465            Quality::Hdtv1080p
2466        );
2467    }
2468
2469    #[test]
2470    fn hdtv_720p_via_bracket_marker() {
2471        // C# QualityParser.cs:255-259: raw-name `[HDTV]` substring forces
2472        // 720p when no resolution match is present.
2473        assert_eq!(
2474            parse_quality_name("Show.S01E01.[HDTV].x264").quality,
2475            Quality::Hdtv720p
2476        );
2477    }
2478
2479    // T7 cascade: dvd arm.
2480
2481    #[test]
2482    fn dvd() {
2483        // Plan-required test #9. DVD source always maps to DVD regardless of
2484        // resolution.
2485        assert_eq!(
2486            parse_quality_name("Movie.2010.DVDRip.XviD-AAC").quality,
2487            Quality::Dvd
2488        );
2489    }
2490
2491    #[test]
2492    fn dvd_with_explicit_dvd_token() {
2493        // The bare `DVD` token also fires the dvd branch.
2494        assert_eq!(
2495            parse_quality_name("Movie.2010.DVD.XviD-AAC").quality,
2496            Quality::Dvd
2497        );
2498    }
2499
2500    #[test]
2501    fn dvd_ntsc_token() {
2502        // C# QualityParser.cs:25 includes NTSC in the dvd alternation.
2503        assert_eq!(
2504            parse_quality_name("Movie.2010.NTSC.XviD-AAC").quality,
2505            Quality::Dvd
2506        );
2507    }
2508
2509    // T7 cascade: bdrip / brrip (sub-bluray) arms.
2510
2511    #[test]
2512    fn bdrip_720p_maps_to_bluray720p() {
2513        // C# QualityParser.cs:265-272.
2514        assert_eq!(
2515            parse_quality_name("Movie.720p.BDRip.x264").quality,
2516            Quality::Bluray720p
2517        );
2518    }
2519
2520    #[test]
2521    fn bdrip_1080p_maps_to_bluray1080p() {
2522        assert_eq!(
2523            parse_quality_name("Movie.1080p.BDRip.x264").quality,
2524            Quality::Bluray1080p
2525        );
2526    }
2527
2528    #[test]
2529    fn bdrip_default_maps_to_bluray480p() {
2530        // C# QualityParser.cs:280-281: BDRip default is 480p.
2531        assert_eq!(
2532            parse_quality_name("Movie.BDRip.x264").quality,
2533            Quality::Bluray480p
2534        );
2535    }
2536
2537    #[test]
2538    fn brrip_2160p_maps_to_bluray2160p() {
2539        // C# QualityParser.cs:276-278.
2540        assert_eq!(
2541            parse_quality_name("Movie.2160p.BRRip.x265").quality,
2542            Quality::Bluray2160p
2543        );
2544    }
2545
2546    // T7 cascade: pdtv / sdtv / dsr / tvrip arm.
2547
2548    #[test]
2549    fn sdtv_default() {
2550        // Plan-required test #10. HDTV with no resolution token -> SDTV.
2551        // (The hdtv arm hits the no-resolution fallback at C# line 261.)
2552        assert_eq!(
2553            parse_quality_name("Show.S01E01.HDTV.x264").quality,
2554            Quality::Sdtv
2555        );
2556    }
2557
2558    #[test]
2559    fn pdtv_no_resolution_maps_to_sdtv() {
2560        // C# QualityParser.cs:315-316.
2561        assert_eq!(
2562            parse_quality_name("Show.S01E01.PDTV.x264").quality,
2563            Quality::Sdtv
2564        );
2565    }
2566
2567    #[test]
2568    fn pdtv_1080p_maps_to_hdtv1080p() {
2569        // C# QualityParser.cs:296-300.
2570        assert_eq!(
2571            parse_quality_name("Show.S01E01.1080p.PDTV.x264").quality,
2572            Quality::Hdtv1080p
2573        );
2574    }
2575
2576    #[test]
2577    fn pdtv_720p_maps_to_hdtv720p() {
2578        // C# QualityParser.cs:302-306.
2579        assert_eq!(
2580            parse_quality_name("Show.S01E01.720p.PDTV.x264").quality,
2581            Quality::Hdtv720p
2582        );
2583    }
2584
2585    #[test]
2586    fn pdtv_hr_ws_maps_to_hdtv720p() {
2587        // C# QualityParser.cs:308-312: HighDefPdtvRegex (`hr-ws`) on a pdtv
2588        // source promotes to Hdtv720p AND flips the resolution-detection
2589        // source to Name even though no resolution-regex group fired.
2590        let m = parse_quality_name("Show.S01E01.PDTV.hr-ws.x264");
2591        assert_eq!(m.quality, Quality::Hdtv720p);
2592        assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2593    }
2594
2595    #[test]
2596    fn sdtv_branch_with_explicit_sdtv_token() {
2597        // The bare `SDTV` token routes into the pdtv-cluster arm.
2598        assert_eq!(
2599            parse_quality_name("Show.S01E01.SDTV.x264").quality,
2600            Quality::Sdtv
2601        );
2602    }
2603
2604    #[test]
2605    fn dsr_branch_default_sdtv() {
2606        // C# QualityParser.cs:293-294: DSR routes through the pdtv-cluster
2607        // arm and falls back to SDTV without a resolution match.
2608        assert_eq!(
2609            parse_quality_name("Show.S01E01.DSR.x264").quality,
2610            Quality::Sdtv
2611        );
2612    }
2613
2614    #[test]
2615    fn tvrip_branch_default_sdtv() {
2616        // C# QualityParser.cs:294: TVRip in the pdtv-cluster arm.
2617        assert_eq!(
2618            parse_quality_name("Show.S01E01.TVRip.x264").quality,
2619            Quality::Sdtv
2620        );
2621    }
2622
2623    // T7 cascade: cross-arm semantics.
2624
2625    #[test]
2626    fn cascade_uses_last_source_match() {
2627        // C# QualityParser.cs:115 LastOrDefault: HDTV appears first, BluRay
2628        // appears later -> classification is bluray, not hdtv. This is the
2629        // single most important cross-arm guarantee in the cascade.
2630        let m = parse_quality_name("Show.HDTV.Repack.1080p.BluRay.x264");
2631        assert_eq!(m.quality, Quality::Bluray1080p);
2632    }
2633
2634    #[test]
2635    fn cascade_populates_source_detection_when_source_fires() {
2636        // Any sourceMatch sets source_detection_source = Name (C# line 127).
2637        let m = parse_quality_name("Movie.1080p.BluRay.x264");
2638        assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2639    }
2640
2641    #[test]
2642    fn cascade_populates_resolution_detection_when_resolution_fires() {
2643        // C# QualityParser.cs:120-123: a non-Unknown resolution flips
2644        // resolution_detection_source to Name regardless of source-arm.
2645        let m = parse_quality_name("Movie.1080p.BluRay.x264");
2646        assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2647    }
2648
2649    #[test]
2650    fn cascade_leaves_resolution_detection_unknown_when_no_resolution() {
2651        // No resolution token -> resolution_detection_source stays Unknown
2652        // even though the source arm fires.
2653        let m = parse_quality_name("Movie.WEB-DL.x264");
2654        assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2655        assert_eq!(
2656            m.resolution_detection_source,
2657            QualityDetectionSource::Unknown
2658        );
2659    }
2660
2661    #[test]
2662    fn cascade_falls_back_to_unknown_when_no_source() {
2663        // A name with no source, no resolution, no anime markers, no codec, no
2664        // pixel tag, no bare-bluray token, and no OtherSourceMatch hit (no
2665        // recognised file extension either) returns Quality::Unknown.
2666        let m = parse_quality_name("Show.S01E01.MysteryFormat");
2667        assert_eq!(m.quality, Quality::Unknown);
2668    }
2669
2670    #[test]
2671    fn cascade_resolution_only_1080p_maps_to_hdtv1080p() {
2672        // T8 resolution-only fallback. C# QualityParser.cs:464-473: with no
2673        // source match, `Movie.2020.1080p.x264` derives source from extension
2674        // (`.x264` is unknown -> QualitySource::Unknown), then routes via
2675        // line 468's `source == Unknown` branch to Quality::Hdtv1080p.
2676        let m = parse_quality_name("Movie.2020.1080p.x264");
2677        assert_eq!(m.quality, Quality::Hdtv1080p);
2678        // Resolution detection flips to Name because the resolution regex
2679        // fired (line 122 + line 466).
2680        assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2681    }
2682
2683    #[test]
2684    fn cascade_underscore_normalised_for_source_match() {
2685        // C# normalises `_` -> ` ` before sourceMatch. A release like
2686        // `Movie_1080p_BluRay_x264` must classify as bluray-1080p just like
2687        // its dotted twin.
2688        let m = parse_quality_name("Movie_2020_1080p_BluRay_x264");
2689        assert_eq!(m.quality, Quality::Bluray1080p);
2690    }
2691
2692    #[test]
2693    fn cascade_trims_outer_whitespace() {
2694        // C# QualityParser.cs:77 trims the raw input before the pipeline.
2695        // Leading/trailing whitespace must NOT change classification.
2696        let m = parse_quality_name("   Movie.2020.1080p.BluRay.x264   ");
2697        assert_eq!(m.quality, Quality::Bluray1080p);
2698    }
2699
2700    #[test]
2701    fn cascade_revision_carries_through_arm() {
2702        // PROPER bumps version to 2; the cascade must preserve the
2703        // revision through the bluray arm.
2704        let m = parse_quality_name("Movie.2020.1080p.BluRay.PROPER.x264");
2705        assert_eq!(m.quality, Quality::Bluray1080p);
2706        assert_eq!(m.revision.version, 2);
2707        assert_eq!(m.revision_detection_source, QualityDetectionSource::Name);
2708    }
2709
2710    // T8 cascade: sourceless-remux (C# QualityParser.cs:320-347).
2711
2712    #[test]
2713    fn sourceless_remux_2160p_maps_to_bluray2160p_remux() {
2714        // C# QualityParser.cs:336-340: REMUX + 2160p without a source token
2715        // routes through the sourceless-remux block to Bluray2160pRemux.
2716        let m = parse_quality_name("Movie.2020.2160p.Remux.HDR.x265");
2717        assert_eq!(m.quality, Quality::Bluray2160pRemux);
2718        // Line 322: SourceDetectionSource is explicitly set to Unknown for
2719        // sourceless remux even though we matched something.
2720        assert_eq!(m.source_detection_source, QualityDetectionSource::Unknown);
2721        assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2722    }
2723
2724    #[test]
2725    fn sourceless_remux_1080p_maps_to_bluray1080p_remux() {
2726        // C# QualityParser.cs:342-346.
2727        let m = parse_quality_name("Movie.2020.1080p.Remux.AVC.DTS-HD");
2728        assert_eq!(m.quality, Quality::Bluray1080pRemux);
2729        assert_eq!(m.source_detection_source, QualityDetectionSource::Unknown);
2730    }
2731
2732    #[test]
2733    fn sourceless_remux_720p_maps_to_bluray720p() {
2734        // C# QualityParser.cs:330-334. The 720p remux without source is
2735        // explicitly Bluray720p, NOT Bluray720pRemux (which doesn't exist).
2736        let m = parse_quality_name("Movie.2020.720p.Remux.AVC");
2737        assert_eq!(m.quality, Quality::Bluray720p);
2738    }
2739
2740    #[test]
2741    fn sourceless_remux_480p_maps_to_bluray480p() {
2742        // C# QualityParser.cs:324-328.
2743        let m = parse_quality_name("Movie.2020.480p.Remux.x264");
2744        assert_eq!(m.quality, Quality::Bluray480p);
2745    }
2746
2747    // T8 cascade: anime-bluray (C# QualityParser.cs:349-390).
2748
2749    #[test]
2750    fn anime_bluray_720p() {
2751        // Plan-required test #1. C# QualityParser.cs:388: anime-bluray with
2752        // unknown resolution falls through to default Bluray720p.
2753        //
2754        // NB: token form `[BD720p]` (no space) matches ANIME_BLURAY_REGEX's
2755        // `bd720` branch but NOT SOURCE_REGEX's bluray group (which requires
2756        // a word-boundary or space/period after `BD`). A name with `[BD 720p]`
2757        // (with space) would short-circuit through the T7 bluray arm instead.
2758        let m = parse_quality_name("[group] Show - 01 [BD720p]");
2759        assert_eq!(m.quality, Quality::Bluray720p);
2760        assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2761    }
2762
2763    #[test]
2764    fn anime_bluray_1080p() {
2765        // C# QualityParser.cs:364-369. Token form `[BD1080p]` matches
2766        // ANIME_BLURAY_REGEX's `bd1080` branch.
2767        let m = parse_quality_name("[group] Show - 01 [BD1080p]");
2768        assert_eq!(m.quality, Quality::Bluray1080p);
2769    }
2770
2771    #[test]
2772    fn anime_bluray_2160p() {
2773        // C# QualityParser.cs:372-377. Token form `[BD2160p]`.
2774        let m = parse_quality_name("[group] Show - 01 [BD2160p]");
2775        assert_eq!(m.quality, Quality::Bluray2160p);
2776    }
2777
2778    #[test]
2779    fn anime_bluray_480p_maps_to_dvd() {
2780        // C# QualityParser.cs:354-362: anime-bluray at 360p / 480p / 540p /
2781        // 576p (or substring "480p") is intentionally collapsed to Quality::Dvd.
2782        //
2783        // Constructing a name that triggers this branch is delicate. Both
2784        // `bd720`/`bd1080`/`bd2160` literal forms AND bare `bd` with separator
2785        // surround can match ANIME_BLURAY_REGEX, but bare-bd with separator
2786        // surround ALSO matches SOURCE_REGEX's bluray group (because
2787        // separator chars like `[` `]` `(` `)` `-` `.` ` ` give a `\b` or
2788        // `[ .]` after `bd`). The only way to fire anime-bluray without
2789        // SOURCE_REGEX hijacking is via a `bd<digits>` literal (e.g. `bd720`)
2790        // because no `\b|$|[ .]` follows `bd` in that case (digits are word
2791        // chars).
2792        //
2793        // So we use `bd720` to fire ANIME_BLURAY_REGEX, plus a separate
2794        // `480p` token to drive the 480p anime-bluray sub-branch. The 720p
2795        // in `bd720` does NOT register as a resolution because RESOLUTION_REGEX
2796        // expects `\b720p\b` (and the regex needs `bd` to be separated from
2797        // `720` by a word boundary, which it isn't).
2798        let m = parse_quality_name("Show.480p.bd720.AC3");
2799        assert_eq!(m.quality, Quality::Dvd);
2800        assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2801    }
2802
2803    #[test]
2804    fn anime_bluray_remux_no_resolution_falls_back_to_1080p_remux() {
2805        // C# QualityParser.cs:382-386: anime-bluray + remux + resolution !=
2806        // R720p (incl. Unknown) -> Bluray1080pRemux. Use the surround-bd
2807        // form to match anime-bluray without firing SOURCE_REGEX's bluray
2808        // group.
2809        let m = parse_quality_name("[group] Show.(bd).Remux");
2810        assert_eq!(m.quality, Quality::Bluray1080pRemux);
2811    }
2812
2813    #[test]
2814    fn anime_bluray_1080p_remux_combo() {
2815        // C# QualityParser.cs:367 with remux: Bluray1080pRemux. Use the
2816        // surround-bd form + a 1080p resolution token.
2817        let m = parse_quality_name("[group] Show.1080p.(bd).Remux");
2818        assert_eq!(m.quality, Quality::Bluray1080pRemux);
2819    }
2820
2821    #[test]
2822    fn anime_bluray_unknown_resolution_no_remux_defaults_720p() {
2823        // C# QualityParser.cs:388: anime-bluray with no resolution and no
2824        // remux defaults to Bluray720p. Surround-bd form.
2825        let m = parse_quality_name("[group] Show.(bd).x264");
2826        assert_eq!(m.quality, Quality::Bluray720p);
2827    }
2828
2829    // T8 cascade: anime-webdl (C# QualityParser.cs:392-424).
2830
2831    #[test]
2832    fn anime_webdl_1080p() {
2833        // Plan-required test #2. C# QualityParser.cs:406-411.
2834        let m = parse_quality_name("[group] Show - 01 [WEB][1080p]");
2835        assert_eq!(m.quality, Quality::Webdl1080p);
2836        assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2837    }
2838
2839    #[test]
2840    fn anime_webdl_2160p() {
2841        // C# QualityParser.cs:414-420.
2842        let m = parse_quality_name("[group] Show - 01 [WEB][2160p]");
2843        assert_eq!(m.quality, Quality::Webdl2160p);
2844    }
2845
2846    #[test]
2847    fn anime_webdl_480p() {
2848        // C# QualityParser.cs:396-404: 480p / 540p / 576p / substring "480p"
2849        // -> Webdl480p.
2850        let m = parse_quality_name("[group] Show - 01 [WEB][480p]");
2851        assert_eq!(m.quality, Quality::Webdl480p);
2852    }
2853
2854    #[test]
2855    fn anime_webdl_unknown_resolution_defaults_720p() {
2856        // C# QualityParser.cs:422: anime-webdl with no resolution defaults
2857        // to Webdl720p.
2858        let m = parse_quality_name("[group] Show - 01 (WEB.)");
2859        assert_eq!(m.quality, Quality::Webdl720p);
2860    }
2861
2862    // T8 cascade: resolution-only fallback (C# QualityParser.cs:426-497).
2863
2864    #[test]
2865    fn resolution_only_2160p_maps_to_hdtv2160p() {
2866        // C# QualityParser.cs:453-461. No source, 2160p resolution, no remux,
2867        // unknown extension -> Hdtv2160p.
2868        let m = parse_quality_name("Movie.2020.2160p.x265");
2869        assert_eq!(m.quality, Quality::Hdtv2160p);
2870    }
2871
2872    #[test]
2873    fn resolution_only_720p_maps_to_hdtv720p() {
2874        // C# QualityParser.cs:475-484.
2875        let m = parse_quality_name("Movie.2020.720p.x264");
2876        assert_eq!(m.quality, Quality::Hdtv720p);
2877    }
2878
2879    #[test]
2880    fn resolution_only_480p_maps_to_sdtv() {
2881        // C# QualityParser.cs:486-496. 360 / 480 / 540 / 576 -> SDTV when
2882        // source is Unknown.
2883        let m = parse_quality_name("Movie.2020.480p.x264");
2884        assert_eq!(m.quality, Quality::Sdtv);
2885    }
2886
2887    #[test]
2888    fn resolution_only_with_mkv_extension_promotes_via_television_source() {
2889        // C# QualityParser.cs:437-451: extension `.mkv` resolves to Quality
2890        // HDTV720p (Television source). resolution-only at 1080p with
2891        // Television source returns find_by_source_and_resolution(Television,
2892        // 1080) = Quality::Hdtv1080p. The detection-source flag is Extension
2893        // for the source, Name for the resolution.
2894        let m = parse_quality_name("Movie.2020.1080p.x264.mkv");
2895        assert_eq!(m.quality, Quality::Hdtv1080p);
2896        assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
2897        assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2898    }
2899
2900    #[test]
2901    fn resolution_only_with_iso_extension_promotes_to_dvd_at_480p() {
2902        // `.iso` -> Quality::Dvd (source = Dvd). At 480p, exact match in
2903        // ALL[] yields Dvd directly.
2904        let m = parse_quality_name("Movie.2020.480p.x264.iso");
2905        assert_eq!(m.quality, Quality::Dvd);
2906        assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
2907    }
2908
2909    // T8 cascade: x264-SDTV fallback (C# QualityParser.cs:499-504).
2910
2911    #[test]
2912    fn x264_codec_alone_maps_to_sdtv() {
2913        // C# QualityParser.cs:499-503: x264 codec with no source, no
2914        // resolution, no anime, no remux -> SDTV.
2915        let m = parse_quality_name("Movie.x264.AC3");
2916        assert_eq!(m.quality, Quality::Sdtv);
2917    }
2918
2919    // T8 cascade: pixel-tag fallbacks (C# QualityParser.cs:506-560).
2920    //
2921    // RESOLUTION_REGEX already matches 848x480 / 1280x720 / 1920x1080 with
2922    // word boundaries, so these pixel-tag blocks only fire when the regex
2923    // boundary check fails. To exercise them, we glue the pixel tag against
2924    // surrounding text WITHOUT word boundaries.
2925
2926    #[test]
2927    fn pixel_tag_848x480_with_dvd_token_no_word_boundary() {
2928        // C# QualityParser.cs:506-513: literal "848x480" + "dvd" -> DVD,
2929        // case-sensitive on "dvd" per C# `Contains("dvd")`.
2930        let m = parse_quality_name("Movie848x480dvd");
2931        assert_eq!(m.quality, Quality::Dvd);
2932        assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2933        assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2934    }
2935
2936    #[test]
2937    fn pixel_tag_848x480_with_bluray_token_no_word_boundary() {
2938        // C# QualityParser.cs:515-519: literal "848x480" + "bluray"
2939        // (case-insensitive) -> Bluray480p.
2940        let m = parse_quality_name("Movie848x480bluray");
2941        assert_eq!(m.quality, Quality::Bluray480p);
2942    }
2943
2944    #[test]
2945    fn pixel_tag_848x480_default_sdtv() {
2946        // C# QualityParser.cs:520-523: bare "848x480" without dvd/bluray
2947        // marker -> SDTV.
2948        let m = parse_quality_name("Movie848x480extra");
2949        assert_eq!(m.quality, Quality::Sdtv);
2950    }
2951
2952    #[test]
2953    fn pixel_tag_1280x720_default_hdtv720p() {
2954        // C# QualityParser.cs:528-543: bare "1280x720" without bluray ->
2955        // Hdtv720p.
2956        let m = parse_quality_name("Movie1280x720extra");
2957        assert_eq!(m.quality, Quality::Hdtv720p);
2958    }
2959
2960    #[test]
2961    fn pixel_tag_1280x720_with_bluray_token() {
2962        // C# QualityParser.cs:532-536.
2963        let m = parse_quality_name("Movie1280x720bluray");
2964        assert_eq!(m.quality, Quality::Bluray720p);
2965    }
2966
2967    #[test]
2968    fn pixel_tag_1920x1080_default_hdtv1080p() {
2969        // C# QualityParser.cs:545-560.
2970        let m = parse_quality_name("Movie1920x1080extra");
2971        assert_eq!(m.quality, Quality::Hdtv1080p);
2972    }
2973
2974    #[test]
2975    fn pixel_tag_1920x1080_with_bluray_token() {
2976        // C# QualityParser.cs:549-553.
2977        let m = parse_quality_name("Movie1920x1080bluray");
2978        assert_eq!(m.quality, Quality::Bluray1080p);
2979    }
2980
2981    // T8 cascade: bare bluray720p/1080p/2160p tokens (C# QualityParser.cs:562-587).
2982
2983    #[test]
2984    fn bare_bluray720p_token() {
2985        // C# QualityParser.cs:562-569. The literal "bluray720p" without
2986        // separators bypasses SOURCE_REGEX (which requires `\b|$|[ .]` after
2987        // `BluRay`) and RESOLUTION_REGEX (which requires `\b...720p\b`).
2988        // Also avoid any x264/h264 codec token: C# line 499 short-circuits
2989        // x264 to SDTV BEFORE this branch (`bluray720p` runs at line 562).
2990        let m = parse_quality_name("Movie.bluray720p.AC3");
2991        assert_eq!(m.quality, Quality::Bluray720p);
2992        assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
2993        assert_eq!(m.resolution_detection_source, QualityDetectionSource::Name);
2994    }
2995
2996    #[test]
2997    fn bare_bluray1080p_token() {
2998        // C# QualityParser.cs:571-578. Same x264-gating note as the 720p test.
2999        let m = parse_quality_name("Movie.bluray1080p.AC3");
3000        assert_eq!(m.quality, Quality::Bluray1080p);
3001    }
3002
3003    #[test]
3004    fn bare_bluray2160p_token() {
3005        // C# QualityParser.cs:580-587. Same x264-gating note.
3006        let m = parse_quality_name("Movie.bluray2160p.AC3");
3007        assert_eq!(m.quality, Quality::Bluray2160p);
3008    }
3009
3010    // T8 cascade: OtherSourceMatch (C# QualityParser.cs:589-595 + 653-672).
3011
3012    #[test]
3013    fn other_source_hd_tv_maps_to_hdtv720p() {
3014        // C# QualityParser.cs:589-594 + 666-670. OTHER_SOURCE_REGEX matches
3015        // "HD TV" / "HD-TV" / "HD_TV" / "HD.TV" -> Hdtv720p when nothing else
3016        // matched. Avoid x264 codec because C# line 499 short-circuits to
3017        // SDTV BEFORE the OtherSourceMatch path.
3018        let m = parse_quality_name("Show.HD-TV.AC3.foo");
3019        assert_eq!(m.quality, Quality::Hdtv720p);
3020        assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
3021    }
3022
3023    #[test]
3024    fn other_source_sd_tv_maps_to_sdtv() {
3025        // C# QualityParser.cs:589-594 + 661-664. Same x264-gating note.
3026        let m = parse_quality_name("Show.SD-TV.AC3.foo");
3027        assert_eq!(m.quality, Quality::Sdtv);
3028    }
3029
3030    // T8: extension fallback (C# QualityParser.cs:81-95).
3031
3032    #[test]
3033    fn extension_fallback_mkv_to_hdtv720p() {
3034        // C# QualityParser.cs:81-95: when ParseQualityName returns Unknown,
3035        // the outer ParseQuality wrapper looks up the file extension. `.mkv`
3036        // -> HDTV720p in MediaFileExtensions.
3037        let m = parse_quality("Show.S01E01.mkv");
3038        assert_eq!(m.quality, Quality::Hdtv720p);
3039        assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
3040        assert_eq!(
3041            m.resolution_detection_source,
3042            QualityDetectionSource::Extension
3043        );
3044    }
3045
3046    #[test]
3047    fn extension_fallback_mp4_to_sdtv() {
3048        // `.mp4` is mapped to Quality::SDTV in MediaFileExtensions.
3049        let m = parse_quality("Show.S01E01.mp4");
3050        assert_eq!(m.quality, Quality::Sdtv);
3051        assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
3052    }
3053
3054    #[test]
3055    fn extension_fallback_iso_to_dvd() {
3056        // `.iso` is mapped to Quality::DVD in MediaFileExtensions.
3057        let m = parse_quality("Show.S01E01.iso");
3058        assert_eq!(m.quality, Quality::Dvd);
3059    }
3060
3061    #[test]
3062    fn extension_fallback_m2ts_to_bluray720p() {
3063        // `.m2ts` is mapped to Quality::Bluray720p in MediaFileExtensions.
3064        let m = parse_quality("Show.S01E01.m2ts");
3065        assert_eq!(m.quality, Quality::Bluray720p);
3066    }
3067
3068    #[test]
3069    fn extension_fallback_webm_stays_unknown() {
3070        // C# MediaFileExtensions.cs:16: `.webm` -> Quality::Unknown. The
3071        // dictionary contains the key but maps to Unknown. SourceDetectionSource
3072        // and ResolutionDetectionSource are still flipped to Extension because
3073        // the C# branch sets them BEFORE inspecting the returned Quality.
3074        let m = parse_quality("Show.S01E01.webm");
3075        assert_eq!(m.quality, Quality::Unknown);
3076        assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
3077    }
3078
3079    #[test]
3080    fn extension_fallback_unknown_extension_stays_unknown() {
3081        // C# MediaFileExtensions.cs:76-83: an extension not in the dictionary
3082        // returns Quality::Unknown but the source/resolution detection
3083        // sources are still flipped to Extension (line 87-88 set them before
3084        // `GetQualityForExtension` returns Unknown). Verified against C#.
3085        let m = parse_quality("Show.S01E01.xyz");
3086        assert_eq!(m.quality, Quality::Unknown);
3087        assert_eq!(m.source_detection_source, QualityDetectionSource::Extension);
3088    }
3089
3090    #[test]
3091    fn extension_fallback_does_not_overwrite_resolved_quality() {
3092        // The extension fallback only fires when ParseQualityName returned
3093        // Quality::Unknown. A name that already classifies (e.g. has BluRay)
3094        // must NOT have its quality overwritten by the extension table.
3095        let m = parse_quality("Movie.2020.1080p.BluRay.x264.mp4");
3096        assert_eq!(m.quality, Quality::Bluray1080p);
3097        // Source detection comes from the BluRay regex hit, not the extension.
3098        assert_eq!(m.source_detection_source, QualityDetectionSource::Name);
3099    }
3100}