1use once_cell::sync::Lazy;
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7
8static ISRC_PATTERN: Lazy<Regex> =
10    Lazy::new(|| Regex::new(r"^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$").unwrap());
11
12static UPC_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{12,14}$").unwrap());
13
14#[allow(dead_code)]
15static ISWC_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^T\d{10}$").unwrap());
16
17#[allow(dead_code)]
18static ISNI_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\d{15}[\dX]$").unwrap());
19
20pub struct PreflightValidator {
22    config: ValidationConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ValidationConfig {
28    pub level: PreflightLevel,
30
31    pub validate_identifiers: bool,
33
34    pub validate_checksums: bool,
36
37    pub check_required_fields: bool,
39
40    pub validate_dates: bool,
42
43    pub validate_references: bool,
45
46    pub profile: Option<String>,
48}
49
50impl Default for ValidationConfig {
51    fn default() -> Self {
52        Self {
53            level: PreflightLevel::Warn,
54            validate_identifiers: true,
55            validate_checksums: true,
56            check_required_fields: true,
57            validate_dates: true,
58            validate_references: true,
59            profile: None,
60        }
61    }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub enum PreflightLevel {
67    Strict,
69    Warn,
71    None,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ValidationResult {
78    pub errors: Vec<ValidationError>,
80    pub warnings: Vec<ValidationWarning>,
82    pub info: Vec<ValidationInfo>,
84    pub passed: bool,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ValidationError {
91    pub code: String,
93    pub field: String,
95    pub message: String,
97    pub location: String,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ValidationWarning {
104    pub code: String,
106    pub field: String,
108    pub message: String,
110    pub location: String,
112    pub suggestion: Option<String>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ValidationInfo {
119    pub code: String,
121    pub message: String,
123}
124
125impl PreflightValidator {
126    pub fn new(config: ValidationConfig) -> Self {
128        Self { config }
129    }
130
131    pub fn validate(
133        &self,
134        request: &super::builder::BuildRequest,
135    ) -> Result<ValidationResult, super::error::BuildError> {
136        let mut result = ValidationResult {
137            errors: Vec::new(),
138            warnings: Vec::new(),
139            info: Vec::new(),
140            passed: true,
141        };
142
143        if self.config.level == PreflightLevel::None {
144            return Ok(result);
145        }
146
147        for (idx, release) in request.releases.iter().enumerate() {
149            self.validate_release(release, idx, &mut result)?;
150        }
151
152        for (idx, deal) in request.deals.iter().enumerate() {
154            self.validate_deal(deal, idx, &mut result)?;
155        }
156
157        if self.config.validate_references {
159            self.validate_references(request, &mut result)?;
160        }
161
162        if let Some(profile) = &self.config.profile {
164            self.validate_profile(request, profile, &mut result)?;
165        }
166
167        result.passed = result.errors.is_empty()
169            && (self.config.level != PreflightLevel::Strict || result.warnings.is_empty());
170
171        Ok(result)
172    }
173
174    fn validate_release(
175        &self,
176        release: &super::builder::ReleaseRequest,
177        idx: usize,
178        result: &mut ValidationResult,
179    ) -> Result<(), super::error::BuildError> {
180        let location = format!("/releases[{}]", idx);
181
182        if self.config.check_required_fields {
184            if release.title.is_empty() {
185                result.errors.push(ValidationError {
186                    code: "MISSING_TITLE".to_string(),
187                    field: "title".to_string(),
188                    message: "Release title is required".to_string(),
189                    location: format!("{}/title", location),
190                });
191            }
192
193            if release.artist.is_empty() {
194                result.warnings.push(ValidationWarning {
195                    code: "MISSING_ARTIST".to_string(),
196                    field: "artist".to_string(),
197                    message: "Release artist is recommended".to_string(),
198                    location: format!("{}/artist", location),
199                    suggestion: Some("Add display artist name".to_string()),
200                });
201            }
202        }
203
204        if self.config.validate_identifiers {
206            if let Some(upc) = &release.upc {
207                if !self.validate_upc(upc) {
208                    result.errors.push(ValidationError {
209                        code: "INVALID_UPC".to_string(),
210                        field: "upc".to_string(),
211                        message: format!("Invalid UPC format: {}", upc),
212                        location: format!("{}/upc", location),
213                    });
214                }
215            }
216        }
217
218        for (track_idx, track) in release.tracks.iter().enumerate() {
220            self.validate_track(track, idx, track_idx, result)?;
221        }
222
223        Ok(())
224    }
225
226    fn validate_track(
227        &self,
228        track: &super::builder::TrackRequest,
229        release_idx: usize,
230        track_idx: usize,
231        result: &mut ValidationResult,
232    ) -> Result<(), super::error::BuildError> {
233        let location = format!("/releases[{}]/tracks[{}]", release_idx, track_idx);
234
235        if self.config.validate_identifiers {
237            if !self.validate_isrc(&track.isrc) {
238                result.errors.push(ValidationError {
239                    code: "INVALID_ISRC".to_string(),
240                    field: "isrc".to_string(),
241                    message: format!("Invalid ISRC format: {}", track.isrc),
242                    location: format!("{}/isrc", location),
243                });
244            }
245        }
246
247        if !track.duration.is_empty() && !self.validate_duration(&track.duration) {
249            result.warnings.push(ValidationWarning {
250                code: "INVALID_DURATION".to_string(),
251                field: "duration".to_string(),
252                message: format!("Invalid ISO 8601 duration: {}", track.duration),
253                location: format!("{}/duration", location),
254                suggestion: Some("Use format PT3M45S for 3:45".to_string()),
255            });
256        }
257
258        Ok(())
259    }
260
261    fn validate_deal(
262        &self,
263        deal: &super::builder::DealRequest,
264        idx: usize,
265        result: &mut ValidationResult,
266    ) -> Result<(), super::error::BuildError> {
267        let location = format!("/deals[{}]", idx);
268
269        for (t_idx, territory) in deal.deal_terms.territory_code.iter().enumerate() {
271            if !self.validate_territory_code(territory) {
272                result.warnings.push(ValidationWarning {
273                    code: "INVALID_TERRITORY".to_string(),
274                    field: "territory_code".to_string(),
275                    message: format!("Invalid territory code: {}", territory),
276                    location: format!("{}/territory_code[{}]", location, t_idx),
277                    suggestion: Some("Use ISO 3166-1 alpha-2 codes".to_string()),
278                });
279            }
280        }
281
282        Ok(())
283    }
284
285    fn validate_references(
286        &self,
287        request: &super::builder::BuildRequest,
288        result: &mut ValidationResult,
289    ) -> Result<(), super::error::BuildError> {
290        let mut release_refs = indexmap::IndexSet::new();
292        let mut resource_refs = indexmap::IndexSet::new();
293
294        for release in &request.releases {
295            if let Some(ref_val) = &release.release_reference {
296                release_refs.insert(ref_val.clone());
297            }
298
299            for track in &release.tracks {
300                if let Some(ref_val) = &track.resource_reference {
301                    resource_refs.insert(ref_val.clone());
302                }
303            }
304        }
305
306        for (idx, deal) in request.deals.iter().enumerate() {
308            for (r_idx, release_ref) in deal.release_references.iter().enumerate() {
309                if !release_refs.contains(release_ref) {
310                    result.errors.push(ValidationError {
311                        code: "UNKNOWN_REFERENCE".to_string(),
312                        field: "release_reference".to_string(),
313                        message: format!("Unknown release reference: {}", release_ref),
314                        location: format!("/deals[{}]/release_references[{}]", idx, r_idx),
315                    });
316                }
317            }
318        }
319
320        Ok(())
321    }
322
323    fn validate_profile(
324        &self,
325        request: &super::builder::BuildRequest,
326        profile: &str,
327        result: &mut ValidationResult,
328    ) -> Result<(), super::error::BuildError> {
329        match profile {
330            "AudioAlbum" => self.validate_audio_album_profile(request, result),
331            "AudioSingle" => self.validate_audio_single_profile(request, result),
332            _ => {
333                result.info.push(ValidationInfo {
334                    code: "UNKNOWN_PROFILE".to_string(),
335                    message: format!("Profile '{}' validation not implemented", profile),
336                });
337                Ok(())
338            }
339        }
340    }
341
342    fn validate_audio_album_profile(
343        &self,
344        request: &super::builder::BuildRequest,
345        result: &mut ValidationResult,
346    ) -> Result<(), super::error::BuildError> {
347        for (idx, release) in request.releases.iter().enumerate() {
349            if release.tracks.len() < 2 {
351                result.warnings.push(ValidationWarning {
352                    code: "ALBUM_TRACK_COUNT".to_string(),
353                    field: "tracks".to_string(),
354                    message: format!(
355                        "AudioAlbum typically has 2+ tracks, found {}",
356                        release.tracks.len()
357                    ),
358                    location: format!("/releases[{}]/tracks", idx),
359                    suggestion: Some("Consider using AudioSingle profile".to_string()),
360                });
361            }
362
363            if release.upc.is_none() {
365                result.errors.push(ValidationError {
366                    code: "MISSING_UPC".to_string(),
367                    field: "upc".to_string(),
368                    message: "UPC is required for AudioAlbum profile".to_string(),
369                    location: format!("/releases[{}]/upc", idx),
370                });
371            }
372        }
373
374        Ok(())
375    }
376
377    fn validate_audio_single_profile(
378        &self,
379        request: &super::builder::BuildRequest,
380        result: &mut ValidationResult,
381    ) -> Result<(), super::error::BuildError> {
382        for (idx, release) in request.releases.iter().enumerate() {
384            if release.tracks.len() > 3 {
386                result.warnings.push(ValidationWarning {
387                    code: "SINGLE_TRACK_COUNT".to_string(),
388                    field: "tracks".to_string(),
389                    message: format!(
390                        "AudioSingle typically has 1-3 tracks, found {}",
391                        release.tracks.len()
392                    ),
393                    location: format!("/releases[{}]/tracks", idx),
394                    suggestion: Some("Consider using AudioAlbum profile".to_string()),
395                });
396            }
397        }
398
399        Ok(())
400    }
401
402    fn validate_isrc(&self, isrc: &str) -> bool {
404        ISRC_PATTERN.is_match(isrc)
405    }
406
407    fn validate_upc(&self, upc: &str) -> bool {
408        if !UPC_PATTERN.is_match(upc) {
409            return false;
410        }
411
412        self.validate_upc_checksum(upc)
414    }
415
416    fn validate_upc_checksum(&self, upc: &str) -> bool {
417        let digits: Vec<u32> = upc.chars().filter_map(|c| c.to_digit(10)).collect();
418
419        if digits.len() < 12 {
420            return false;
421        }
422
423        let mut sum = 0;
424        for (i, &digit) in digits.iter().take(digits.len() - 1).enumerate() {
425            if i % 2 == 0 {
426                sum += digit;
427            } else {
428                sum += digit * 3;
429            }
430        }
431
432        let check_digit = (10 - (sum % 10)) % 10;
433        digits[digits.len() - 1] == check_digit
434    }
435
436    fn validate_duration(&self, duration: &str) -> bool {
437        duration.starts_with("PT") && (duration.contains('M') || duration.contains('S'))
439    }
440
441    fn validate_territory_code(&self, code: &str) -> bool {
442        code.len() == 2 && code.chars().all(|c| c.is_ascii_uppercase())
444    }
445}