Skip to main content

sidereon_core/
data.rs

1//! Data product filename, cache path, and archive URL catalog.
2//!
3//! This module is sans-IO: it performs no network access, reads no files, and
4//! writes no cache entries. It only turns cataloged product inputs into
5//! canonical archive filenames, URLs, cache relative paths, and deterministic
6//! converted bytes for pure terrain ingestion.
7
8use core::fmt;
9use core::str::FromStr;
10use std::collections::{HashMap, HashSet};
11use std::hash::{DefaultHasher, Hash, Hasher};
12
13use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
14use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
15use crate::astro::time::model::TimeScale;
16use crate::astro::time::scales::julian_day_number;
17use crate::terrain;
18
19/// Analysis-center code supported by the data-product catalog.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub enum AnalysisCenter {
22    /// `igs`.
23    Igs,
24    /// `cod_rap`.
25    CodRap,
26    /// `cod_prd1`.
27    CodPrd1,
28    /// `cod_prd2`.
29    CodPrd2,
30    /// `esa`.
31    Esa,
32    /// `cod`.
33    Cod,
34    /// `gfz`.
35    Gfz,
36    /// `igs_ult`.
37    IgsUlt,
38    /// `cod_ult`.
39    CodUlt,
40    /// `esa_ult`.
41    EsaUlt,
42    /// `gfz_ult`.
43    GfzUlt,
44    /// `wum_nrt`.
45    WumNrt,
46}
47
48impl AnalysisCenter {
49    /// The lower-case catalog code.
50    #[must_use]
51    pub const fn code(self) -> &'static str {
52        match self {
53            Self::Igs => "igs",
54            Self::CodRap => "cod_rap",
55            Self::CodPrd1 => "cod_prd1",
56            Self::CodPrd2 => "cod_prd2",
57            Self::Esa => "esa",
58            Self::Cod => "cod",
59            Self::Gfz => "gfz",
60            Self::IgsUlt => "igs_ult",
61            Self::CodUlt => "cod_ult",
62            Self::EsaUlt => "esa_ult",
63            Self::GfzUlt => "gfz_ult",
64            Self::WumNrt => "wum_nrt",
65        }
66    }
67
68    /// Parse a lower-case catalog code.
69    #[must_use]
70    pub fn from_code(code: &str) -> Option<Self> {
71        match code {
72            "igs" => Some(Self::Igs),
73            "cod_rap" => Some(Self::CodRap),
74            "cod_prd1" => Some(Self::CodPrd1),
75            "cod_prd2" => Some(Self::CodPrd2),
76            "esa" => Some(Self::Esa),
77            "cod" => Some(Self::Cod),
78            "gfz" => Some(Self::Gfz),
79            "igs_ult" => Some(Self::IgsUlt),
80            "cod_ult" => Some(Self::CodUlt),
81            "esa_ult" => Some(Self::EsaUlt),
82            "gfz_ult" => Some(Self::GfzUlt),
83            "wum_nrt" => Some(Self::WumNrt),
84            _ => None,
85        }
86    }
87
88    /// Public publisher represented by this catalog product line.
89    #[must_use]
90    pub const fn publisher(self) -> ProductPublisher {
91        match self {
92            Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
93            Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
94                ProductPublisher::Code
95            }
96            Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
97            Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
98            Self::WumNrt => ProductPublisher::Whu,
99        }
100    }
101
102    /// Legacy center-wide solution class represented by this catalog code.
103    ///
104    /// Some centers publish more than one class. In particular, [`Self::Igs`]
105    /// serves both broadcast navigation and final orbit products. New code
106    /// should use [`product_solution_class`] when the product family is known.
107    #[must_use]
108    pub const fn solution_class(self) -> SolutionClass {
109        match self {
110            Self::Igs => SolutionClass::Broadcast,
111            Self::CodRap | Self::Gfz => SolutionClass::Rapid,
112            Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
113            Self::Esa | Self::Cod => SolutionClass::Final,
114            Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
115            Self::WumNrt => SolutionClass::NearRealTime,
116        }
117    }
118
119    /// Prediction horizon associated with a predicted product alias.
120    #[must_use]
121    pub const fn prediction_horizon_days(self) -> Option<u8> {
122        match self {
123            Self::CodPrd1 => Some(1),
124            Self::CodPrd2 => Some(2),
125            _ => None,
126        }
127    }
128}
129
130impl fmt::Display for AnalysisCenter {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        f.write_str(self.code())
133    }
134}
135
136impl FromStr for AnalysisCenter {
137    type Err = DataCatalogError;
138
139    fn from_str(s: &str) -> Result<Self, Self::Err> {
140        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
141    }
142}
143
144/// Product type supported by the data-product catalog.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
146pub enum ProductType {
147    /// Precise orbit SP3.
148    Sp3,
149    /// RINEX clock.
150    Clk,
151    /// Merged broadcast navigation.
152    Nav,
153    /// IONEX global ionosphere map.
154    Ionex,
155}
156
157impl ProductType {
158    /// The lower-case product code.
159    #[must_use]
160    pub const fn code(self) -> &'static str {
161        match self {
162            Self::Sp3 => "sp3",
163            Self::Clk => "clk",
164            Self::Nav => "nav",
165            Self::Ionex => "ionex",
166        }
167    }
168
169    /// Parse a lower-case product code.
170    #[must_use]
171    pub fn from_code(code: &str) -> Option<Self> {
172        match code {
173            "sp3" => Some(Self::Sp3),
174            "clk" => Some(Self::Clk),
175            "nav" => Some(Self::Nav),
176            "ionex" => Some(Self::Ionex),
177            _ => None,
178        }
179    }
180}
181
182impl fmt::Display for ProductType {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        f.write_str(self.code())
185    }
186}
187
188impl FromStr for ProductType {
189    type Err = DataCatalogError;
190
191    fn from_str(s: &str) -> Result<Self, Self::Err> {
192        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
193    }
194}
195
196/// Public organization that produced or combined a GNSS product.
197///
198/// This is intentionally separate from [`AnalysisCenter`]. Catalog center
199/// codes such as `cod`, `cod_rap`, and `cod_ult` select different product
200/// lines, but all three have the same publisher.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
202pub enum ProductPublisher {
203    /// International GNSS Service combination product.
204    Igs,
205    /// Center for Orbit Determination in Europe (CODE).
206    Code,
207    /// European Space Agency.
208    Esa,
209    /// GFZ German Research Centre for Geosciences.
210    Gfz,
211    /// Wuhan University IGS Analysis Center.
212    Whu,
213}
214
215impl ProductPublisher {
216    /// IGS long-filename publisher token.
217    #[must_use]
218    pub const fn code(self) -> &'static str {
219        match self {
220            Self::Igs => "IGS",
221            Self::Code => "COD",
222            Self::Esa => "ESA",
223            Self::Gfz => "GFZ",
224            Self::Whu => "WUM",
225        }
226    }
227}
228
229impl fmt::Display for ProductPublisher {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.write_str(self.code())
232    }
233}
234
235/// Public solution class encoded in a GNSS product name.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
237pub enum SolutionClass {
238    /// Final product.
239    Final,
240    /// Rapid product.
241    Rapid,
242    /// Ultra-rapid product, which may contain observed and predicted segments.
243    UltraRapid,
244    /// Predicted product.
245    Predicted,
246    /// Near-real-time product, published on a sub-ultra-rapid (hourly) rhythm.
247    NearRealTime,
248    /// Broadcast navigation product.
249    Broadcast,
250}
251
252impl SolutionClass {
253    /// Stable public code used in Sidereon provenance and cache paths.
254    #[must_use]
255    pub const fn code(self) -> &'static str {
256        match self {
257            Self::Final => "final",
258            Self::Rapid => "rapid",
259            Self::UltraRapid => "ultra_rapid",
260            Self::Predicted => "predicted",
261            Self::NearRealTime => "near_real_time",
262            Self::Broadcast => "broadcast",
263        }
264    }
265
266    /// IGS long-filename solution token where one exists.
267    #[must_use]
268    pub const fn filename_token(self) -> Option<&'static str> {
269        match self {
270            Self::Final => Some("FIN"),
271            Self::Rapid => Some("RAP"),
272            Self::UltraRapid => Some("ULT"),
273            Self::Predicted => Some("PRD"),
274            Self::NearRealTime => Some("NRT"),
275            Self::Broadcast => None,
276        }
277    }
278}
279
280impl fmt::Display for SolutionClass {
281    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282        f.write_str(self.code())
283    }
284}
285
286/// Public campaign or project encoded in a GNSS product name.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
288pub enum ProductCampaign {
289    /// Operational IGS product line.
290    Operational,
291    /// Multi-GNSS product line (`MGN`).
292    MultiGnss,
293    /// Multi-GNSS Experiment product line (`MGX`).
294    MultiGnssExperiment,
295    /// Broadcast navigation archive product.
296    Broadcast,
297}
298
299impl ProductCampaign {
300    /// Stable public code.
301    #[must_use]
302    pub const fn code(self) -> &'static str {
303        match self {
304            Self::Operational => "OPS",
305            Self::MultiGnss => "MGN",
306            Self::MultiGnssExperiment => "MGX",
307            Self::Broadcast => "BRD",
308        }
309    }
310}
311
312/// Public serialization format carried by a catalog product.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
314pub enum ProductFormat {
315    /// Standard Product 3 orbit format.
316    Sp3,
317    /// IONosphere map EXchange format.
318    Ionex,
319    /// RINEX clock format.
320    RinexClock,
321    /// RINEX navigation format.
322    RinexNavigation,
323}
324
325impl ProductFormat {
326    /// Stable public format code.
327    #[must_use]
328    pub const fn code(self) -> &'static str {
329        match self {
330            Self::Sp3 => "SP3",
331            Self::Ionex => "IONEX",
332            Self::RinexClock => "RINEX_CLK",
333            Self::RinexNavigation => "RINEX_NAV",
334        }
335    }
336}
337
338/// Explicit distributor used to obtain an exact public product.
339///
340/// Distributor selection never changes product publisher, solution class,
341/// issue, cadence, date, or family.
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
343pub enum DistributionSource {
344    /// The cataloged analysis-center or IGS direct archive.
345    Direct,
346    /// NASA CDDIS over HTTPS, optionally authenticated with Earthdata Login.
347    NasaCddis,
348    /// Bytes read from a caller-provided local file.
349    LocalFile,
350    /// Bytes supplied directly by the caller.
351    InMemory,
352}
353
354impl DistributionSource {
355    /// Stable public code used in provenance and cache paths.
356    #[must_use]
357    pub const fn code(self) -> &'static str {
358        match self {
359            Self::Direct => "direct",
360            Self::NasaCddis => "nasa_cddis",
361            Self::LocalFile => "local_file",
362            Self::InMemory => "in_memory",
363        }
364    }
365}
366
367/// CelesTrak space-weather product served by the data catalog.
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
369pub enum SpaceWeatherProduct {
370    /// `SW-All.csv`: full history plus daily and monthly predictions.
371    All,
372    /// `SW-Last5Years.csv`: observed rolling window.
373    Last5Years,
374}
375
376impl SpaceWeatherProduct {
377    /// The lower-case catalog code.
378    #[must_use]
379    pub const fn code(self) -> &'static str {
380        match self {
381            Self::All => "sw_all",
382            Self::Last5Years => "sw_last5",
383        }
384    }
385
386    /// Parse a lower-case catalog code.
387    #[must_use]
388    pub fn from_code(code: &str) -> Option<Self> {
389        match code {
390            "sw_all" => Some(Self::All),
391            "sw_last5" => Some(Self::Last5Years),
392            _ => None,
393        }
394    }
395}
396
397impl fmt::Display for SpaceWeatherProduct {
398    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399        f.write_str(self.code())
400    }
401}
402
403impl FromStr for SpaceWeatherProduct {
404    type Err = DataCatalogError;
405
406    fn from_str(s: &str) -> Result<Self, Self::Err> {
407        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
408    }
409}
410
411/// Archive transport protocol recorded by the catalog.
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
413pub enum ArchiveProtocol {
414    /// HTTP.
415    Http,
416    /// HTTPS.
417    Https,
418    /// Anonymous FTP. Wuhan University's IGS data center serves its open
419    /// archive over `ftp://` only; there is no HTTP surface for these paths.
420    ///
421    /// Transport support is per interface: the Elixir (OTP `:ftp`, from its
422    /// 0.36.1) and Python (stdlib `ftplib`, from its 0.36.2) interfaces
423    /// acquire `ftp://` archives natively with the same bounded semantics as
424    /// their HTTP paths; the C interface owns no transport, and WASM cannot
425    /// speak FTP from a browser - both derive these URLs for the caller.
426    Ftp,
427}
428
429impl ArchiveProtocol {
430    /// URI scheme text.
431    #[must_use]
432    pub const fn as_str(self) -> &'static str {
433        match self {
434            Self::Http => "http",
435            Self::Https => "https",
436            Self::Ftp => "ftp",
437        }
438    }
439}
440
441/// Archive compression for a cataloged product.
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
443pub enum ArchiveCompression {
444    /// Archive URL has a `.gz` suffix.
445    Gzip,
446    /// Archive URL uses the historical Unix-compress `.Z` suffix.
447    UnixCompress,
448    /// Archive URL is the plain product filename.
449    None,
450}
451
452impl ArchiveCompression {
453    /// Catalog text for the compression format.
454    #[must_use]
455    pub const fn as_str(self) -> &'static str {
456        match self {
457            Self::Gzip => "gzip",
458            Self::UnixCompress => "unix_compress",
459            Self::None => "none",
460        }
461    }
462
463    const fn suffix(self) -> &'static str {
464        match self {
465            Self::Gzip => ".gz",
466            Self::UnixCompress => ".Z",
467            Self::None => "",
468        }
469    }
470}
471
472/// Directory layout used below an archive root.
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
474pub enum ArchiveLayout {
475    /// `rapid/w<gps-week>`.
476    GfzRapidWeek,
477    /// `ultra/w<gps-week>`.
478    GfzUltraWeek,
479    /// `<gps-week>`.
480    GpsWeek,
481    /// `products/<gps-week>`.
482    BkgProductsWeek,
483    /// `BRDC/<year>/<day-of-year>`.
484    BkgBrdcYearDoy,
485    /// `obs/<year>/<day-of-year>`.
486    BkgObsYearDoy,
487    /// `CODE_MGEX/CODE/<year>`.
488    AiubCodeMgexYear,
489    /// `CODE/<year>`.
490    AiubCodeYear,
491    /// `CODE`.
492    AiubCodeRoot,
493}
494
495/// Product filename convention.
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497pub enum ProductFilenameKind {
498    /// `TOKEN_DATE_LEN_SAMPLE_CODE.EXT`.
499    Sampled,
500    /// `TOKEN_R_DATE_LEN_CODE.ext`.
501    Nav,
502}
503
504/// Product-type filename convention.
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
506pub struct ProductTypeConvention {
507    /// Product type.
508    pub product_type: ProductType,
509    /// Filename content code, for example `ORB`.
510    pub content_code: &'static str,
511    /// Filename extension, preserving archive case.
512    pub extension: &'static str,
513    /// Filename convention.
514    pub kind: ProductFilenameKind,
515}
516
517/// Per-center convention for one product type.
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519pub struct CenterProductConvention {
520    /// Product type.
521    pub product_type: ProductType,
522    /// IGS long-name token prefix.
523    pub token: &'static str,
524    /// Directory layout under the archive root.
525    pub layout: ArchiveLayout,
526    /// Product span token.
527    pub span: &'static str,
528    /// Default sampling token.
529    pub default_sample: &'static str,
530    /// Archive compression.
531    pub compression: ArchiveCompression,
532}
533
534/// Static catalog entry for one analysis-center code.
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536pub struct CenterCatalogEntry {
537    /// Analysis-center code.
538    pub center: AnalysisCenter,
539    /// Lower-case catalog code.
540    pub code: &'static str,
541    /// Archive URI scheme.
542    pub protocol: ArchiveProtocol,
543    /// Archive host.
544    pub host: &'static str,
545    /// Archive root URL without trailing slash.
546    pub root_url: &'static str,
547    /// Product conventions served by this center.
548    pub products: &'static [CenterProductConvention],
549    /// Valid issue times for sub-daily products.
550    pub issues: &'static [&'static str],
551}
552
553/// Static catalog entry for one terrain source.
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub struct TerrainSourceEntry {
556    /// Archive URI scheme.
557    pub protocol: ArchiveProtocol,
558    /// Archive host.
559    pub host: &'static str,
560    /// Archive compression.
561    pub compression: ArchiveCompression,
562    /// Archive root URL without trailing slash.
563    pub root_url: &'static str,
564}
565
566/// Static catalog entry for the CelesTrak space-weather source.
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
568pub struct SpaceWeatherSourceEntry {
569    /// Archive URI scheme.
570    pub protocol: ArchiveProtocol,
571    /// Archive host.
572    pub host: &'static str,
573    /// Archive compression.
574    pub compression: ArchiveCompression,
575    /// Archive root URL without trailing slash.
576    pub root_url: &'static str,
577}
578
579/// Product pair that is intentionally not offered because no open mirror exists.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
581pub struct NoOpenMirrorProduct {
582    /// Analysis-center code.
583    pub center: &'static str,
584    /// Product type code.
585    pub product_type: &'static str,
586}
587
588const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
589    ProductTypeConvention {
590        product_type: ProductType::Sp3,
591        content_code: "ORB",
592        extension: "SP3",
593        kind: ProductFilenameKind::Sampled,
594    },
595    ProductTypeConvention {
596        product_type: ProductType::Clk,
597        content_code: "CLK",
598        extension: "CLK",
599        kind: ProductFilenameKind::Sampled,
600    },
601    ProductTypeConvention {
602        product_type: ProductType::Nav,
603        content_code: "MN",
604        extension: "rnx",
605        kind: ProductFilenameKind::Nav,
606    },
607    ProductTypeConvention {
608        product_type: ProductType::Ionex,
609        content_code: "GIM",
610        extension: "INX",
611        kind: ProductFilenameKind::Sampled,
612    },
613];
614
615const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
616    product_type: ProductType::Ionex,
617    token: "COD0OPSRAP",
618    layout: ArchiveLayout::AiubCodeRoot,
619    span: "01D",
620    default_sample: "01H",
621    compression: ArchiveCompression::Gzip,
622}];
623
624const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
625    product_type: ProductType::Ionex,
626    token: "COD0OPSPRD",
627    layout: ArchiveLayout::AiubCodeRoot,
628    span: "01D",
629    default_sample: "01H",
630    compression: ArchiveCompression::Gzip,
631}];
632
633/// Wuhan University MGEX near-real-time orbit line.
634///
635/// Verified against the live archive on 2026-08-04
636/// (`ftp://igs.gnsswhu.cn/pub/gps/products/mgex/<gps-week>/`): hourly
637/// `WUM0MGXNRT_<YYYYDDDHHMM>_02D_05M_ORB.SP3.gz` objects, SP3-d, agency
638/// `WHU`, 576 epochs (a half-open two-day span at five minutes) starting at
639/// the filename epoch. The `WUM0MGXULA` name this line was once known by is
640/// no longer published: the archived hourly series switches from ULA (last
641/// observed in GPS week 2230) through a publication gap to NRT from
642/// 2024-07-03 (day 185, GPS week 2321) onward.
643const WUM_NRT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
644    product_type: ProductType::Sp3,
645    token: "WUM0MGXNRT",
646    layout: ArchiveLayout::GpsWeek,
647    span: "02D",
648    default_sample: "05M",
649    compression: ArchiveCompression::Gzip,
650}];
651
652/// Hourly issue times published by the WUM near-real-time line.
653const WUM_NRT_ISSUES: [&str; 24] = [
654    "0000", "0100", "0200", "0300", "0400", "0500", "0600", "0700", "0800", "0900", "1000", "1100",
655    "1200", "1300", "1400", "1500", "1600", "1700", "1800", "1900", "2000", "2100", "2200", "2300",
656];
657
658const ESA_PRODUCTS: [CenterProductConvention; 3] = [
659    CenterProductConvention {
660        product_type: ProductType::Sp3,
661        token: "ESA0MGNFIN",
662        layout: ArchiveLayout::GpsWeek,
663        span: "01D",
664        default_sample: "05M",
665        compression: ArchiveCompression::Gzip,
666    },
667    CenterProductConvention {
668        product_type: ProductType::Clk,
669        token: "ESA0MGNFIN",
670        layout: ArchiveLayout::GpsWeek,
671        span: "01D",
672        default_sample: "30S",
673        compression: ArchiveCompression::Gzip,
674    },
675    CenterProductConvention {
676        product_type: ProductType::Ionex,
677        token: "ESA0OPSFIN",
678        layout: ArchiveLayout::GpsWeek,
679        span: "01D",
680        default_sample: "02H",
681        compression: ArchiveCompression::Gzip,
682    },
683];
684
685const COD_PRODUCTS: [CenterProductConvention; 3] = [
686    CenterProductConvention {
687        product_type: ProductType::Sp3,
688        token: "COD0MGXFIN",
689        layout: ArchiveLayout::AiubCodeMgexYear,
690        span: "01D",
691        default_sample: "05M",
692        compression: ArchiveCompression::Gzip,
693    },
694    CenterProductConvention {
695        product_type: ProductType::Clk,
696        token: "COD0MGXFIN",
697        layout: ArchiveLayout::AiubCodeMgexYear,
698        span: "01D",
699        default_sample: "30S",
700        compression: ArchiveCompression::Gzip,
701    },
702    CenterProductConvention {
703        product_type: ProductType::Ionex,
704        token: "COD0OPSFIN",
705        layout: ArchiveLayout::AiubCodeYear,
706        span: "01D",
707        default_sample: "01H",
708        compression: ArchiveCompression::Gzip,
709    },
710];
711
712const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
713    CenterProductConvention {
714        product_type: ProductType::Sp3,
715        token: "GFZ0OPSRAP",
716        layout: ArchiveLayout::GfzRapidWeek,
717        span: "01D",
718        default_sample: "05M",
719        compression: ArchiveCompression::Gzip,
720    },
721    CenterProductConvention {
722        product_type: ProductType::Clk,
723        token: "GFZ0OPSRAP",
724        layout: ArchiveLayout::GfzRapidWeek,
725        span: "01D",
726        default_sample: "30S",
727        compression: ArchiveCompression::Gzip,
728    },
729];
730
731const IGS_PRODUCTS: [CenterProductConvention; 2] = [
732    CenterProductConvention {
733        product_type: ProductType::Sp3,
734        token: "IGS0OPSFIN",
735        layout: ArchiveLayout::BkgProductsWeek,
736        span: "01D",
737        default_sample: "15M",
738        compression: ArchiveCompression::Gzip,
739    },
740    CenterProductConvention {
741        product_type: ProductType::Nav,
742        token: "BRDC00WRD",
743        layout: ArchiveLayout::BkgBrdcYearDoy,
744        span: "01D",
745        default_sample: "01D",
746        compression: ArchiveCompression::Gzip,
747    },
748];
749
750const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
751    product_type: ProductType::Sp3,
752    token: "IGS0OPSULT",
753    layout: ArchiveLayout::BkgProductsWeek,
754    span: "02D",
755    default_sample: "15M",
756    compression: ArchiveCompression::Gzip,
757}];
758
759const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
760    product_type: ProductType::Sp3,
761    token: "COD0OPSULT",
762    layout: ArchiveLayout::AiubCodeRoot,
763    span: "01D",
764    default_sample: "05M",
765    compression: ArchiveCompression::None,
766}];
767
768const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
769    product_type: ProductType::Sp3,
770    token: "ESA0OPSULT",
771    layout: ArchiveLayout::GpsWeek,
772    span: "02D",
773    default_sample: "05M",
774    compression: ArchiveCompression::Gzip,
775}];
776
777const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
778    product_type: ProductType::Sp3,
779    token: "GFZ0OPSULT",
780    layout: ArchiveLayout::GfzUltraWeek,
781    span: "02D",
782    default_sample: "05M",
783    compression: ArchiveCompression::Gzip,
784}];
785
786const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
787const COD_ULT_ISSUES: [&str; 1] = ["0000"];
788const GFZ_ULT_ISSUES: [&str; 8] = [
789    "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
790];
791
792/// First GPS week covered by the official IGS rapid/final orbit combination.
793///
794/// The IGS rapid/final combination began on 1994-01-02, GPS week 0730.
795const IGS_COMBINED_FINAL_START_GPS_WEEK: u32 = 730;
796
797/// First GPS week in which IGS operational products use only long filenames.
798///
799/// IGS transitioned at the start of GPS week 2238 (2022-11-27). Long-name
800/// trial products from earlier weeks are distinct products and are therefore
801/// not aliases for the legacy final combination.
802const IGS_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
803
804/// First GPS week in which AIUB's supported CODE product families use the
805/// cataloged long filenames.
806const CODE_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
807
808/// First GFZ rapid-orbit date published with the five-minute sampling token.
809///
810/// GFZ's official week-2158 listing ends the 15-minute series at 2021 day 137
811/// and begins the five-minute series at 2021 day 138.
812const GFZ_RAPID_5M_START_DATE: ProductDate = ProductDate {
813    year: 2021,
814    month: 5,
815    day: 18,
816};
817
818/// First date in the cataloged ESA final-orbit and clock series.
819const ESA_FINAL_SERIES_START_DATE: ProductDate = ProductDate {
820    year: 2014,
821    month: 1,
822    day: 5,
823};
824
825/// First date in the cataloged GFZ rapid-orbit and clock series.
826const GFZ_RAPID_SERIES_START_DATE: ProductDate = ProductDate {
827    year: 2020,
828    month: 5,
829    day: 13,
830};
831
832/// First date in the cataloged ESA ultra-rapid SP3 series.
833const ESA_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
834    year: 2022,
835    month: 10,
836    day: 4,
837};
838
839/// First archived `WUM0MGXNRT` orbit date (2024 day 185, GPS week 2321),
840/// verified against the live Wuhan archive on 2026-08-04. The first issue on
841/// this date is `0300`; the catalog gates at the date level and leaves the
842/// two absent earlier issues to availability discovery. Earlier weeks carry
843/// either the discontinued `WUM0MGXULA` hourly line (last observed in GPS
844/// week 2230) or nothing, and are refused rather than assigned a filename
845/// that never existed.
846const WUM_NRT_SP3_START_DATE: ProductDate = ProductDate {
847    year: 2024,
848    month: 7,
849    day: 3,
850};
851
852/// Last ESA ultra-rapid issue that uses the 15-minute sampling token.
853const ESA_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
854    year: 2025,
855    month: 2,
856    day: 2,
857};
858const ESA_ULTRA_15M_LAST_ISSUE_MINUTES: u16 = 6 * 60;
859
860/// First date in the cataloged GFZ ultra-rapid SP3 series.
861const GFZ_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
862    year: 2020,
863    month: 10,
864    day: 6,
865};
866
867/// First date for which GFZ ultra-rapid SP3 defaults to five-minute sampling.
868const GFZ_ULTRA_5M_START_DATE: ProductDate = ProductDate {
869    year: 2021,
870    month: 5,
871    day: 16,
872};
873
874/// Final primarily 15-minute GFZ ultra-rapid date.
875///
876/// Its `0000` issue is the documented transition overlap and publishes both
877/// 15-minute and 5-minute objects; later issues that day publish only 15-minute
878/// objects.
879const GFZ_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
880    year: 2021,
881    month: 5,
882    day: 15,
883};
884
885/// First and last dates of GFZ's ultra-rapid content-start transition.
886///
887/// Before this window, the first SP3 epoch is one day before the epoch encoded
888/// by the filename. After the window, the two epochs are equal. GFZ's official
889/// objects show a non-monotonic, issue-by-issue transition inside the window,
890/// so those sixteen products are cataloged explicitly below.
891const GFZ_ULTRA_START_TRANSITION_FIRST_DATE: ProductDate = ProductDate {
892    year: 2022,
893    month: 9,
894    day: 7,
895};
896const GFZ_ULTRA_START_TRANSITION_LAST_DATE: ProductDate = ProductDate {
897    year: 2022,
898    month: 9,
899    day: 8,
900};
901
902/// Relationship between the epoch in an SP3 filename and its first content
903/// epoch, established from the cataloged public product line.
904///
905/// This is archive metadata, not a value inferred from product bytes. Exact
906/// validation uses it to select one required start instant without relaxing
907/// equality against that instant.
908#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
909#[non_exhaustive]
910pub enum Sp3ContentStartConvention {
911    /// The first content epoch equals the epoch encoded by the filename.
912    FilenameEpoch,
913    /// The first content epoch is exactly 24 hours before the filename epoch.
914    FilenameEpochMinusOneDay,
915}
916
917impl Sp3ContentStartConvention {
918    /// Stable catalog code for serialization by language interfaces.
919    #[must_use]
920    pub const fn code(self) -> &'static str {
921        match self {
922            Self::FilenameEpoch => "filename_epoch",
923            Self::FilenameEpochMinusOneDay => "filename_epoch_minus_one_day",
924        }
925    }
926
927    /// Whole seconds added to the filename epoch to obtain the first content
928    /// epoch.
929    #[must_use]
930    pub const fn content_start_offset_s(self) -> i64 {
931        match self {
932            Self::FilenameEpoch => 0,
933            Self::FilenameEpochMinusOneDay => -86_400,
934        }
935    }
936}
937
938/// Official GFZ objects for every issue in the two-day transition window.
939///
940/// The source URLs and access date are recorded in
941/// `docs/public-gnss-distribution-sources.md`. Keeping this as an exhaustive
942/// table prevents an appealing date/issue threshold from silently
943/// misclassifying the two reversions visible in the archive.
944const GFZ_ULTRA_START_TRANSITION: [(ProductDate, &str, Sp3ContentStartConvention); 16] = [
945    (
946        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
947        "0000",
948        Sp3ContentStartConvention::FilenameEpoch,
949    ),
950    (
951        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
952        "0300",
953        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
954    ),
955    (
956        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
957        "0600",
958        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
959    ),
960    (
961        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
962        "0900",
963        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
964    ),
965    (
966        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
967        "1200",
968        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
969    ),
970    (
971        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
972        "1500",
973        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
974    ),
975    (
976        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
977        "1800",
978        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
979    ),
980    (
981        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
982        "2100",
983        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
984    ),
985    (
986        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
987        "0000",
988        Sp3ContentStartConvention::FilenameEpoch,
989    ),
990    (
991        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
992        "0300",
993        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
994    ),
995    (
996        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
997        "0600",
998        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
999    ),
1000    (
1001        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1002        "0900",
1003        Sp3ContentStartConvention::FilenameEpoch,
1004    ),
1005    (
1006        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1007        "1200",
1008        Sp3ContentStartConvention::FilenameEpoch,
1009    ),
1010    (
1011        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1012        "1500",
1013        Sp3ContentStartConvention::FilenameEpoch,
1014    ),
1015    (
1016        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1017        "1800",
1018        Sp3ContentStartConvention::FilenameEpoch,
1019    ),
1020    (
1021        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1022        "2100",
1023        Sp3ContentStartConvention::FilenameEpoch,
1024    ),
1025];
1026
1027const CENTER_ORDER: [AnalysisCenter; 12] = [
1028    AnalysisCenter::CodRap,
1029    AnalysisCenter::CodPrd1,
1030    AnalysisCenter::CodPrd2,
1031    AnalysisCenter::Igs,
1032    AnalysisCenter::Esa,
1033    AnalysisCenter::Cod,
1034    AnalysisCenter::Gfz,
1035    AnalysisCenter::IgsUlt,
1036    AnalysisCenter::CodUlt,
1037    AnalysisCenter::EsaUlt,
1038    AnalysisCenter::GfzUlt,
1039    AnalysisCenter::WumNrt,
1040];
1041
1042const CATALOG: [CenterCatalogEntry; 12] = [
1043    CenterCatalogEntry {
1044        center: AnalysisCenter::CodRap,
1045        code: "cod_rap",
1046        protocol: ArchiveProtocol::Https,
1047        host: "www.aiub.unibe.ch",
1048        root_url: "https://www.aiub.unibe.ch/download",
1049        products: &COD_RAP_PRODUCTS,
1050        issues: &[],
1051    },
1052    CenterCatalogEntry {
1053        center: AnalysisCenter::CodPrd1,
1054        code: "cod_prd1",
1055        protocol: ArchiveProtocol::Https,
1056        host: "www.aiub.unibe.ch",
1057        root_url: "https://www.aiub.unibe.ch/download",
1058        products: &COD_PRD_PRODUCTS,
1059        issues: &[],
1060    },
1061    CenterCatalogEntry {
1062        center: AnalysisCenter::CodPrd2,
1063        code: "cod_prd2",
1064        protocol: ArchiveProtocol::Https,
1065        host: "www.aiub.unibe.ch",
1066        root_url: "https://www.aiub.unibe.ch/download",
1067        products: &COD_PRD_PRODUCTS,
1068        issues: &[],
1069    },
1070    CenterCatalogEntry {
1071        center: AnalysisCenter::Igs,
1072        code: "igs",
1073        protocol: ArchiveProtocol::Https,
1074        host: "igs.bkg.bund.de",
1075        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1076        products: &IGS_PRODUCTS,
1077        issues: &[],
1078    },
1079    CenterCatalogEntry {
1080        center: AnalysisCenter::Esa,
1081        code: "esa",
1082        protocol: ArchiveProtocol::Https,
1083        host: "navigation-office.esa.int",
1084        root_url: "https://navigation-office.esa.int/products/gnss-products",
1085        products: &ESA_PRODUCTS,
1086        issues: &[],
1087    },
1088    CenterCatalogEntry {
1089        center: AnalysisCenter::Cod,
1090        code: "cod",
1091        protocol: ArchiveProtocol::Https,
1092        host: "www.aiub.unibe.ch",
1093        root_url: "https://www.aiub.unibe.ch/download",
1094        products: &COD_PRODUCTS,
1095        issues: &[],
1096    },
1097    CenterCatalogEntry {
1098        center: AnalysisCenter::Gfz,
1099        code: "gfz",
1100        protocol: ArchiveProtocol::Https,
1101        host: "isdc-data.gfz.de",
1102        root_url: "https://isdc-data.gfz.de/gnss/products",
1103        products: &GFZ_PRODUCTS,
1104        issues: &[],
1105    },
1106    CenterCatalogEntry {
1107        center: AnalysisCenter::IgsUlt,
1108        code: "igs_ult",
1109        protocol: ArchiveProtocol::Https,
1110        host: "igs.bkg.bund.de",
1111        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1112        products: &IGS_ULT_PRODUCTS,
1113        issues: &OPSULT_ISSUES,
1114    },
1115    CenterCatalogEntry {
1116        center: AnalysisCenter::CodUlt,
1117        code: "cod_ult",
1118        protocol: ArchiveProtocol::Https,
1119        host: "www.aiub.unibe.ch",
1120        // AIUB retired the old ftp.aiub.unibe.ch HTTP tree. Its public file
1121        // browser links products through this stable HTTPS download surface,
1122        // which redirects to the current object store.
1123        root_url: "https://www.aiub.unibe.ch/download",
1124        products: &COD_ULT_PRODUCTS,
1125        issues: &COD_ULT_ISSUES,
1126    },
1127    CenterCatalogEntry {
1128        center: AnalysisCenter::EsaUlt,
1129        code: "esa_ult",
1130        protocol: ArchiveProtocol::Https,
1131        host: "navigation-office.esa.int",
1132        root_url: "https://navigation-office.esa.int/products/gnss-products",
1133        products: &ESA_ULT_PRODUCTS,
1134        issues: &OPSULT_ISSUES,
1135    },
1136    CenterCatalogEntry {
1137        center: AnalysisCenter::GfzUlt,
1138        code: "gfz_ult",
1139        protocol: ArchiveProtocol::Https,
1140        host: "isdc-data.gfz.de",
1141        root_url: "https://isdc-data.gfz.de/gnss/products",
1142        products: &GFZ_ULT_PRODUCTS,
1143        issues: &GFZ_ULT_ISSUES,
1144    },
1145    CenterCatalogEntry {
1146        center: AnalysisCenter::WumNrt,
1147        code: "wum_nrt",
1148        protocol: ArchiveProtocol::Ftp,
1149        host: "igs.gnsswhu.cn",
1150        root_url: "ftp://igs.gnsswhu.cn/pub/gps/products/mgex",
1151        products: &WUM_NRT_PRODUCTS,
1152        issues: &WUM_NRT_ISSUES,
1153    },
1154];
1155
1156const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
1157    protocol: ArchiveProtocol::Https,
1158    host: "s3.amazonaws.com",
1159    compression: ArchiveCompression::Gzip,
1160    root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
1161};
1162
1163const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
1164    protocol: ArchiveProtocol::Https,
1165    host: "celestrak.org",
1166    compression: ArchiveCompression::None,
1167    root_url: "https://celestrak.org/SpaceData",
1168};
1169
1170const ALLOWED_HOSTS: [&str; 11] = [
1171    "www.aiub.unibe.ch",
1172    "download.aiub.unibe.ch",
1173    "zhw-b.s3.cloud.switch.ch",
1174    "navigation-office.esa.int",
1175    "isdc-data.gfz.de",
1176    "igs.bkg.bund.de",
1177    "igs.gnsswhu.cn",
1178    "s3.amazonaws.com",
1179    "celestrak.org",
1180    "cddis.nasa.gov",
1181    "urs.earthdata.nasa.gov",
1182];
1183
1184const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
1185    NoOpenMirrorProduct {
1186        center: "grg",
1187        product_type: "sp3",
1188    },
1189    NoOpenMirrorProduct {
1190        center: "grg",
1191        product_type: "clk",
1192    },
1193    NoOpenMirrorProduct {
1194        center: "wum",
1195        product_type: "sp3",
1196    },
1197    NoOpenMirrorProduct {
1198        center: "wum",
1199        product_type: "clk",
1200    },
1201    NoOpenMirrorProduct {
1202        center: "grg_ult",
1203        product_type: "sp3",
1204    },
1205    NoOpenMirrorProduct {
1206        center: "grg_ult",
1207        product_type: "clk",
1208    },
1209    NoOpenMirrorProduct {
1210        center: "igs",
1211        product_type: "ionex",
1212    },
1213];
1214
1215/// Error returned by the pure data-product catalog.
1216#[derive(Debug, Clone, PartialEq, Eq)]
1217pub enum DataCatalogError {
1218    /// Unknown analysis-center code.
1219    UnknownCenter(String),
1220    /// Unknown product type code.
1221    UnknownProductType(String),
1222    /// The center does not serve the requested product type.
1223    UnsupportedProduct {
1224        /// Analysis center.
1225        center: AnalysisCenter,
1226        /// Product type.
1227        product_type: ProductType,
1228    },
1229    /// A distributor does not carry the requested product family.
1230    UnsupportedDistribution {
1231        /// Explicit distributor.
1232        source: DistributionSource,
1233        /// Requested product family.
1234        product_type: ProductType,
1235    },
1236    /// The catalog does not claim this product family's historical naming era.
1237    UnsupportedProductEra {
1238        /// Analysis center.
1239        center: AnalysisCenter,
1240        /// Product type.
1241        product_type: ProductType,
1242        /// Requested product date.
1243        date: ProductDate,
1244    },
1245    /// A distributor has no verified uniform layout for this product era.
1246    UnsupportedDistributionEra {
1247        /// Explicit distributor.
1248        source: DistributionSource,
1249        /// Analysis center.
1250        center: AnalysisCenter,
1251        /// Product type.
1252        product_type: ProductType,
1253        /// Requested product date.
1254        date: ProductDate,
1255    },
1256    /// An exact request did not include any acceptable distributor.
1257    NoDistributionSources,
1258    /// A caller-constructed identity contained an unsafe official filename.
1259    InvalidOfficialFilename(String),
1260    /// A caller-constructed identity disagrees with its official filename.
1261    InconsistentProductIdentity {
1262        /// Identity field that did not agree with the filename or catalog convention.
1263        field: &'static str,
1264    },
1265    /// The product has no verified anonymous HTTP(S) mirror.
1266    NoOpenMirror {
1267        /// Analysis-center code.
1268        center: String,
1269        /// Product type code.
1270        product_type: String,
1271    },
1272    /// Bad civil date.
1273    InvalidDate {
1274        /// Year.
1275        year: i32,
1276        /// Month.
1277        month: u8,
1278        /// Day.
1279        day: u8,
1280    },
1281    /// Date cannot be represented by this API.
1282    DateOutOfRange,
1283    /// Date precedes the GPS week epoch.
1284    DateBeforeGpsEpoch(ProductDate),
1285    /// GPS day-of-week must be `0..=6`.
1286    InvalidGpsDayOfWeek(u8),
1287    /// Sampling token is not a supported IGS period token.
1288    InvalidSample(String),
1289    /// A syntactically valid cadence is not published for this catalog line.
1290    UnsupportedSample {
1291        /// Analysis center.
1292        center: AnalysisCenter,
1293        /// Product type.
1294        product_type: ProductType,
1295        /// Requested sample token.
1296        sample: String,
1297    },
1298    /// Coverage-span token is not a supported IGS period token.
1299    InvalidSpan(String),
1300    /// Issue time is malformed.
1301    InvalidIssue(String),
1302    /// The center requires an issue time.
1303    MissingIssue {
1304        /// Analysis center.
1305        center: AnalysisCenter,
1306    },
1307    /// The center does not use issue times.
1308    UnexpectedIssue {
1309        /// Analysis center.
1310        center: AnalysisCenter,
1311    },
1312    /// Issue time is valid text but not published by this center.
1313    UnsupportedIssue {
1314        /// Analysis center.
1315        center: AnalysisCenter,
1316        /// Issue time.
1317        issue: String,
1318    },
1319    /// The target datetime was invalid.
1320    InvalidDateTime {
1321        /// Hour.
1322        hour: u8,
1323        /// Minute.
1324        minute: u8,
1325        /// Second.
1326        second: u8,
1327    },
1328    /// No ultra-rapid issue exists at or before the requested target.
1329    NoUltraIssue,
1330    /// No available ultra-rapid issue exists at or before the requested target.
1331    NoAvailableUltraIssue,
1332    /// The catalog carries the product line but has no pinned nominal due-time
1333    /// rule for it.
1334    UnsupportedNominalSchedule {
1335        /// Analysis center.
1336        center: AnalysisCenter,
1337        /// Product type.
1338        product_type: ProductType,
1339    },
1340    /// An archive listing body did not classify as any recognized listing
1341    /// dialect. Deliberately not best-effort: a silent empty parse would be
1342    /// indistinguishable from "nothing published".
1343    UnrecognizedArchiveListing {
1344        /// Why classification failed.
1345        reason: String,
1346    },
1347    /// Station identifier is not a 9-character upper-case alphanumeric token.
1348    InvalidStation(String),
1349    /// Terrain lookup coordinate is non-finite or outside the reader range.
1350    InvalidCoordinate {
1351        /// Latitude as `f64::to_bits()`.
1352        lat_deg_bits: u64,
1353        /// Longitude as `f64::to_bits()`.
1354        lon_deg_bits: u64,
1355    },
1356    /// Terrain tile index is outside the valid one-degree cell range.
1357    InvalidTileIndex {
1358        /// Latitude index.
1359        lat_index: i32,
1360        /// Longitude index.
1361        lon_index: i32,
1362    },
1363    /// Skadi tile identifier is malformed.
1364    InvalidTileId(String),
1365}
1366
1367impl fmt::Display for DataCatalogError {
1368    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1369        match self {
1370            Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1371            Self::UnknownProductType(product_type) => {
1372                write!(f, "unknown product type {product_type:?}")
1373            }
1374            Self::UnsupportedProduct {
1375                center,
1376                product_type,
1377            } => write!(f, "{center} does not serve {product_type}"),
1378            Self::UnsupportedDistribution {
1379                source,
1380                product_type,
1381            } => write!(
1382                f,
1383                "distributor {} does not serve {product_type}",
1384                source.code()
1385            ),
1386            Self::UnsupportedProductEra {
1387                center,
1388                product_type,
1389                date,
1390            } => write!(
1391                f,
1392                "{center}/{product_type} has no cataloged naming convention for {date}"
1393            ),
1394            Self::UnsupportedDistributionEra {
1395                source,
1396                center,
1397                product_type,
1398                date,
1399            } => write!(
1400                f,
1401                "distributor {} has no cataloged {center}/{product_type} layout for {date}",
1402                source.code()
1403            ),
1404            Self::NoDistributionSources => {
1405                write!(f, "exact product request has no distributors")
1406            }
1407            Self::InvalidOfficialFilename(filename) => {
1408                write!(f, "invalid official product filename {filename:?}")
1409            }
1410            Self::InconsistentProductIdentity { field } => {
1411                write!(
1412                    f,
1413                    "product identity field {field:?} disagrees with its official filename"
1414                )
1415            }
1416            Self::NoOpenMirror {
1417                center,
1418                product_type,
1419            } => write!(f, "{center}/{product_type} has no open mirror"),
1420            Self::InvalidDate { year, month, day } => {
1421                write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1422            }
1423            Self::DateOutOfRange => write!(f, "product date is out of range"),
1424            Self::DateBeforeGpsEpoch(date) => {
1425                write!(f, "product date {date} is before the GPS week epoch")
1426            }
1427            Self::InvalidGpsDayOfWeek(day) => {
1428                write!(f, "invalid GPS day-of-week {day}")
1429            }
1430            Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1431            Self::UnsupportedSample {
1432                center,
1433                product_type,
1434                sample,
1435            } => write!(
1436                f,
1437                "{center}/{product_type} does not publish sample interval {sample:?}"
1438            ),
1439            Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
1440            Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1441            Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1442            Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1443            Self::UnsupportedIssue { center, issue } => {
1444                write!(f, "{center} does not publish issue {issue:?}")
1445            }
1446            Self::InvalidDateTime {
1447                hour,
1448                minute,
1449                second,
1450            } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1451            Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1452            Self::NoAvailableUltraIssue => {
1453                write!(f, "no available ultra-rapid issue at or before target")
1454            }
1455            Self::UnsupportedNominalSchedule {
1456                center,
1457                product_type,
1458            } => write!(
1459                f,
1460                "{center}/{product_type} has no nominal due-time schedule"
1461            ),
1462            Self::UnrecognizedArchiveListing { reason } => {
1463                write!(f, "unrecognized archive listing: {reason}")
1464            }
1465            Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1466            Self::InvalidCoordinate {
1467                lat_deg_bits,
1468                lon_deg_bits,
1469            } => write!(
1470                f,
1471                "invalid terrain coordinate lat={} lon={}",
1472                f64::from_bits(*lat_deg_bits),
1473                f64::from_bits(*lon_deg_bits)
1474            ),
1475            Self::InvalidTileIndex {
1476                lat_index,
1477                lon_index,
1478            } => write!(
1479                f,
1480                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1481            ),
1482            Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1483        }
1484    }
1485}
1486
1487impl std::error::Error for DataCatalogError {}
1488
1489/// Error returned by SRTM HGT to DTED conversion.
1490#[derive(Debug, Clone, PartialEq, Eq)]
1491pub enum HgtConversionError {
1492    /// The decompressed HGT payload is not the SRTM1 byte length.
1493    BadLength {
1494        /// Expected byte length.
1495        expected: usize,
1496        /// Actual byte length.
1497        got: usize,
1498    },
1499    /// Terrain tile index is outside the valid one-degree cell range.
1500    InvalidTileIndex {
1501        /// Latitude index.
1502        lat_index: i32,
1503        /// Longitude index.
1504        lon_index: i32,
1505    },
1506}
1507
1508impl fmt::Display for HgtConversionError {
1509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1510        match self {
1511            Self::BadLength { expected, got } => {
1512                write!(
1513                    f,
1514                    "invalid SRTM1 HGT length: expected {expected}, got {got}"
1515                )
1516            }
1517            Self::InvalidTileIndex {
1518                lat_index,
1519                lon_index,
1520            } => write!(
1521                f,
1522                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1523            ),
1524        }
1525    }
1526}
1527
1528impl std::error::Error for HgtConversionError {}
1529
1530const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1531const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1532const MIN_TERRAIN_LON_INDEX: i32 = -180;
1533const MAX_TERRAIN_LON_INDEX: i32 = 179;
1534const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1535const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1536const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1537const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1538const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1539const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1540const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1541const DTED_SRTM1_LEN: usize =
1542    terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1543
1544/// Civil UTC date used by product archive names.
1545#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1546pub struct ProductDate {
1547    /// Year.
1548    pub year: i32,
1549    /// Month in `1..=12`.
1550    pub month: u8,
1551    /// Day of month.
1552    pub day: u8,
1553}
1554
1555impl ProductDate {
1556    /// Build and validate a civil date.
1557    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1558        let days = days_in_month(i64::from(year), i64::from(month));
1559        if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1560            return Err(DataCatalogError::InvalidDate { year, month, day });
1561        }
1562        Ok(Self { year, month, day })
1563    }
1564
1565    /// Build a date from GPS week and day-of-week (`0` = Sunday).
1566    pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1567        if day_of_week > 6 {
1568            return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1569        }
1570        let epoch_jdn =
1571            week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1572        let offset_days = i64::from(week)
1573            .checked_mul(7)
1574            .and_then(|days| days.checked_add(i64::from(day_of_week)))
1575            .ok_or(DataCatalogError::DateOutOfRange)?;
1576        product_date_from_jdn(
1577            epoch_jdn
1578                .checked_add(offset_days)
1579                .ok_or(DataCatalogError::DateOutOfRange)?,
1580        )
1581    }
1582
1583    /// GPS week for this date.
1584    pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1585        week_from_calendar(
1586            TimeScale::Gpst,
1587            i64::from(self.year),
1588            i64::from(self.month),
1589            i64::from(self.day),
1590        )
1591        .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1592    }
1593
1594    /// GPS day of week (`0` = Sunday, `6` = Saturday) for this date.
1595    pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
1596        let epoch_jdn =
1597            week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1598        let days = self
1599            .julian_day_number()
1600            .checked_sub(epoch_jdn)
1601            .ok_or(DataCatalogError::DateOutOfRange)?;
1602        if days < 0 {
1603            return Err(DataCatalogError::DateBeforeGpsEpoch(self));
1604        }
1605        u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
1606    }
1607
1608    /// Day-of-year in `1..=366`.
1609    #[must_use]
1610    pub fn day_of_year(self) -> u16 {
1611        day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1612    }
1613
1614    fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1615        product_date_from_jdn(
1616            self.julian_day_number()
1617                .checked_add(days)
1618                .ok_or(DataCatalogError::DateOutOfRange)?,
1619        )
1620    }
1621
1622    fn julian_day_number(self) -> i64 {
1623        julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1624    }
1625}
1626
1627impl fmt::Display for ProductDate {
1628    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1629        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1630    }
1631}
1632
1633/// Civil UTC date and time used for ultra-rapid issue selection.
1634#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1635pub struct ProductDateTime {
1636    /// Date.
1637    pub date: ProductDate,
1638    /// Hour in `0..=23`.
1639    pub hour: u8,
1640    /// Minute in `0..=59`.
1641    pub minute: u8,
1642    /// Second in `0..=59`.
1643    pub second: u8,
1644}
1645
1646impl ProductDateTime {
1647    /// Build and validate a civil date and time.
1648    pub fn new(
1649        date: ProductDate,
1650        hour: u8,
1651        minute: u8,
1652        second: u8,
1653    ) -> Result<Self, DataCatalogError> {
1654        if hour > 23 || minute > 59 || second > 59 {
1655            return Err(DataCatalogError::InvalidDateTime {
1656                hour,
1657                minute,
1658                second,
1659            });
1660        }
1661        Ok(Self {
1662            date,
1663            hour,
1664            minute,
1665            second,
1666        })
1667    }
1668
1669    fn ordering_minutes(self) -> i64 {
1670        self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1671    }
1672
1673    fn ordering_seconds(self) -> i64 {
1674        self.date.julian_day_number() * 86_400
1675            + i64::from(self.hour) * 3_600
1676            + i64::from(self.minute) * 60
1677            + i64::from(self.second)
1678    }
1679}
1680
1681impl fmt::Display for ProductDateTime {
1682    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1683        write!(
1684            f,
1685            "{}T{:02}:{:02}:{:02}Z",
1686            self.date, self.hour, self.minute, self.second
1687        )
1688    }
1689}
1690
1691/// Half-open nominal coverage interval, `[from, until)`.
1692#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1693pub struct NominalCoverageInterval {
1694    /// First covered instant.
1695    pub from: ProductDateTime,
1696    /// First instant not covered.
1697    pub until: ProductDateTime,
1698}
1699
1700/// Nominal observed and predicted portions of one issue.
1701///
1702/// Ultra-rapid SP3 identities populate both fields. Ordinary final and rapid
1703/// products populate only `observed`; predicted IONEX products populate only
1704/// `predicted`.
1705#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1706pub struct NominalCoverage {
1707    /// Observed-data interval, when the line carries one.
1708    pub observed: Option<NominalCoverageInterval>,
1709    /// Predicted-data interval, when the line carries one.
1710    pub predicted: Option<NominalCoverageInterval>,
1711}
1712
1713/// The next catalog issue nominally due at or after a query instant.
1714#[derive(Debug, Clone, PartialEq, Eq)]
1715pub struct NominalIssue {
1716    /// Exact product identity expected at the due time.
1717    pub identity: ProductIdentity,
1718    /// Nominal publication deadline in UTC.
1719    pub due_at: ProductDateTime,
1720    /// Nominal observed and predicted coverage named by the identity.
1721    pub covers: NominalCoverage,
1722}
1723
1724/// Ultra-rapid issue date and `HHMM` issue time.
1725#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1726pub struct UltraIssue {
1727    /// Product date.
1728    pub date: ProductDate,
1729    /// Issue time.
1730    pub issue: String,
1731}
1732
1733impl UltraIssue {
1734    /// Build and validate an ultra-rapid issue.
1735    pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1736        validate_issue(issue)?;
1737        Ok(Self {
1738            date,
1739            issue: issue.to_string(),
1740        })
1741    }
1742}
1743
1744/// One generated ultra-rapid SP3 archive candidate.
1745#[derive(Debug, Clone, PartialEq, Eq)]
1746pub struct UltraSp3Location {
1747    /// Stable catalog label identifying the primary or overlapping dated rule.
1748    pub pattern: String,
1749    /// Product span token used by the candidate.
1750    pub span: String,
1751    /// Sampling token used by the candidate.
1752    pub sample: String,
1753    /// Archive filename without a transport compression suffix.
1754    pub filename: String,
1755    /// Full archive URL, including its compression suffix when applicable.
1756    pub url: String,
1757    /// Archive compression for this candidate.
1758    pub compression: ArchiveCompression,
1759}
1760
1761/// Exact identity of one public GNSS product, independent of distributor.
1762///
1763/// The official filename is part of the identity. Transport compression and
1764/// URL belong to [`DistributionLocation`] because two distributors may package
1765/// the same decompressed product differently.
1766#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1767pub struct ProductIdentity {
1768    /// Product family.
1769    pub family: ProductType,
1770    /// Catalog analysis-center product line.
1771    pub analysis_center: AnalysisCenter,
1772    /// Producing or combining organization.
1773    pub publisher: ProductPublisher,
1774    /// Solution class or tier.
1775    pub solution: SolutionClass,
1776    /// Campaign or project.
1777    pub campaign: ProductCampaign,
1778    /// Product-line version encoded by the long filename.
1779    pub version: u8,
1780    /// Product epoch encoded by the official filename.
1781    ///
1782    /// This is also the content start for most SP3 products. Cataloged
1783    /// historical products whose first epoch differs retain the filename epoch
1784    /// here; exact validation derives their required content start separately.
1785    pub date: ProductDate,
1786    /// Optional `HHMM` issue/epoch time encoded by the official filename.
1787    pub issue: Option<String>,
1788    /// Intended coverage period token, for example `01D`.
1789    pub span: String,
1790    /// Sampling interval token, for example `05M`.
1791    pub sample: String,
1792    /// Official filename without transport compression suffix.
1793    pub official_filename: String,
1794    /// Public serialization format.
1795    pub format: ProductFormat,
1796    /// Parsed serialization revision when the request constrains one.
1797    ///
1798    /// Catalog identities leave this unset because the revision is carried by
1799    /// product content rather than the official filename. A resolved identity
1800    /// may set it after parsing the product.
1801    pub format_version: Option<String>,
1802    /// Prediction horizon when the product line encodes one.
1803    pub prediction_horizon_days: Option<u8>,
1804}
1805
1806impl ProductIdentity {
1807    /// Validate that every identity field agrees with the official filename.
1808    ///
1809    /// This is required for caller-constructed values before using them in a
1810    /// request, URL, or cache path. Catalog-produced identities are validated
1811    /// before they are returned.
1812    pub fn validate(&self) -> Result<(), DataCatalogError> {
1813        validate_official_filename(&self.official_filename)?;
1814        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1815        validate_sample(&self.sample)?;
1816        validate_span(&self.span)?;
1817        if let Some(issue) = self.issue.as_deref() {
1818            validate_issue(issue)?;
1819        }
1820
1821        // Establish catalog support before deriving any URL. A syntactically
1822        // plausible caller-built identity is not evidence that the selected
1823        // center publishes that product family.
1824        let convention = product_convention(self.analysis_center, self.family)?;
1825        validate_product_date(self.analysis_center, self.family, self.date)?;
1826        if self.span != convention.span {
1827            return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
1828        }
1829        validate_catalog_sample(
1830            self.analysis_center,
1831            self.family,
1832            self.date,
1833            &self.sample,
1834            self.issue.as_deref(),
1835        )?;
1836
1837        if self.format != product_format(self.family) {
1838            return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1839        }
1840
1841        if self
1842            .format_version
1843            .as_deref()
1844            .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1845        {
1846            return Err(DataCatalogError::InconsistentProductIdentity {
1847                field: "format_version",
1848            });
1849        }
1850
1851        let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1852            (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1853            (_, SolutionClass::Predicted, _) => false,
1854            (_, _, None) => true,
1855            (_, _, Some(_)) => false,
1856        };
1857        if !horizon_valid {
1858            return Err(DataCatalogError::InconsistentProductIdentity {
1859                field: "prediction_horizon_days",
1860            });
1861        }
1862        let descriptor = product_type_convention(self.family);
1863        let legacy_igs_final =
1864            uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
1865        if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1866            let entry = center_catalog(self.analysis_center)
1867                .expect("validated analysis center has a catalog entry");
1868            let issue_valid = if entry.issues.is_empty() {
1869                self.issue.as_deref() == Some("0000")
1870            } else {
1871                self.issue
1872                    .as_deref()
1873                    .is_some_and(|issue| entry.issues.contains(&issue))
1874            };
1875            if !issue_valid {
1876                return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
1877            }
1878        }
1879        let expected = if legacy_igs_final {
1880            let fields_valid = self.publisher == ProductPublisher::Igs
1881                && self.solution == SolutionClass::Final
1882                && self.campaign == ProductCampaign::Operational
1883                && self.version == 0
1884                && self.issue.as_deref() == Some("0000")
1885                && self.span == convention.span
1886                && self.sample == convention.default_sample;
1887            if !fields_valid {
1888                return Err(DataCatalogError::InconsistentProductIdentity {
1889                    field: "legacy_igs_final",
1890                });
1891            }
1892            format!(
1893                "igs{:04}{}.sp3",
1894                self.date.gps_week()?,
1895                self.date.gps_day_of_week()?
1896            )
1897        } else {
1898            match descriptor.kind {
1899                ProductFilenameKind::Sampled => {
1900                    let solution_token = self.solution.filename_token().ok_or(
1901                        DataCatalogError::InconsistentProductIdentity { field: "solution" },
1902                    )?;
1903                    format!(
1904                        "{}{}{}{}_{}_{}_{}_{}.{}",
1905                        self.publisher.code(),
1906                        self.version,
1907                        self.campaign.code(),
1908                        solution_token,
1909                        date_block(self.date, self.issue.as_deref()),
1910                        self.span,
1911                        self.sample,
1912                        descriptor.content_code,
1913                        descriptor.extension
1914                    )
1915                }
1916                ProductFilenameKind::Nav => {
1917                    let nav_fields_valid = self.publisher == ProductPublisher::Igs
1918                        && self.solution == SolutionClass::Broadcast
1919                        && self.campaign == ProductCampaign::Broadcast
1920                        && self.version == 0
1921                        && self.issue.is_none()
1922                        && self.span == "01D"
1923                        && self.sample == "01D";
1924                    if !nav_fields_valid {
1925                        return Err(DataCatalogError::InconsistentProductIdentity {
1926                            field: "broadcast_navigation",
1927                        });
1928                    }
1929                    format!(
1930                        "BRDC00WRD_R_{}_{}_{}.{}",
1931                        date_block(self.date, None),
1932                        self.span,
1933                        descriptor.content_code,
1934                        descriptor.extension
1935                    )
1936                }
1937            }
1938        };
1939        if expected != self.official_filename {
1940            return Err(DataCatalogError::InconsistentProductIdentity {
1941                field: "official_filename",
1942            });
1943        }
1944        if self.publisher != self.analysis_center.publisher()
1945            || self.solution != product_solution_class(self.analysis_center, self.family)?
1946            || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1947        {
1948            return Err(DataCatalogError::InconsistentProductIdentity {
1949                field: "analysis_center",
1950            });
1951        }
1952
1953        if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1954            let expected_catalog_filename = format!(
1955                "{}_{}_{}_{}_{}.{}",
1956                convention.token,
1957                date_block(self.date, self.issue.as_deref()),
1958                self.span,
1959                self.sample,
1960                descriptor.content_code,
1961                descriptor.extension
1962            );
1963            if expected_catalog_filename != self.official_filename {
1964                return Err(DataCatalogError::InconsistentProductIdentity {
1965                    field: "analysis_center",
1966                });
1967            }
1968        }
1969        Ok(())
1970    }
1971
1972    /// Deterministic identity key suitable for a portable cache layout.
1973    pub fn key(&self) -> Result<String, DataCatalogError> {
1974        use sha2::{Digest, Sha256};
1975
1976        let canonical = self.canonical_bytes()?;
1977        let digest = Sha256::digest(canonical);
1978        Ok(format!(
1979            "{}-{}-{}",
1980            self.publisher.code().to_ascii_lowercase(),
1981            self.solution.code(),
1982            digest[..10]
1983                .iter()
1984                .map(|byte| format!("{byte:02x}"))
1985                .collect::<String>()
1986        ))
1987    }
1988
1989    /// Canonical, unambiguous bytes containing every exact identity field.
1990    ///
1991    /// The encoding is ASCII/UTF-8 field text separated by NUL bytes. It is a
1992    /// stable cross-interface input to cache identity hashing, not a display
1993    /// or interchange document.
1994    pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1995        self.validate()?;
1996        let date = format!(
1997            "{:04}-{:02}-{:02}",
1998            self.date.year, self.date.month, self.date.day
1999        );
2000        let version = self.version.to_string();
2001        let prediction = self
2002            .prediction_horizon_days
2003            .map(|days| days.to_string())
2004            .unwrap_or_default();
2005        let fields = [
2006            self.family.code(),
2007            self.analysis_center.code(),
2008            self.publisher.code(),
2009            self.solution.code(),
2010            self.campaign.code(),
2011            version.as_str(),
2012            date.as_str(),
2013            self.issue.as_deref().unwrap_or_default(),
2014            self.span.as_str(),
2015            self.sample.as_str(),
2016            self.official_filename.as_str(),
2017            self.format.code(),
2018            self.format_version.as_deref().unwrap_or_default(),
2019            prediction.as_str(),
2020        ];
2021        if fields.iter().any(|field| field.as_bytes().contains(&0)) {
2022            return Err(DataCatalogError::InconsistentProductIdentity {
2023                field: "canonical_encoding",
2024            });
2025        }
2026        Ok(fields.join("\0").into_bytes())
2027    }
2028
2029    /// Deterministic cache path for this identity and distributor.
2030    pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
2031        Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
2032    }
2033}
2034
2035/// Required first-content offset for a validated exact SP3 identity.
2036///
2037/// This is catalog data, not a property inferred from the bytes under test.
2038/// Keeping the lookup identity-based prevents a caller from weakening exact
2039/// start validation with an arbitrary override.
2040pub(crate) fn exact_sp3_content_start_offset_s(
2041    identity: &ProductIdentity,
2042) -> Result<i64, DataCatalogError> {
2043    identity.validate()?;
2044    if identity.family != ProductType::Sp3 {
2045        return Err(DataCatalogError::InconsistentProductIdentity { field: "family" });
2046    }
2047
2048    let entry =
2049        center_catalog(identity.analysis_center).expect("a validated identity has a catalog entry");
2050    // Sampled non-issue identities encode midnight as `Some("0000")`, while
2051    // the public catalog query follows ProductSpec construction and accepts no
2052    // issue for those centers.
2053    let catalog_issue = if entry.issues.is_empty() {
2054        None
2055    } else {
2056        identity.issue.as_deref()
2057    };
2058    Ok(
2059        sp3_content_start_convention(identity.analysis_center, identity.date, catalog_issue)?
2060            .content_start_offset_s(),
2061    )
2062}
2063
2064/// Return the official content-start convention for one cataloged SP3 product.
2065///
2066/// `issue` follows the same rules as [`product`]: it is required for
2067/// ultra-rapid centers, must be one of that center's published issues, and must
2068/// be absent for product lines without issue times. Sampling cadence is not an
2069/// input because the cataloged content-start convention is shared by every
2070/// supported cadence of the same product issue.
2071pub fn sp3_content_start_convention(
2072    center: AnalysisCenter,
2073    date: ProductDate,
2074    issue: Option<&str>,
2075) -> Result<Sp3ContentStartConvention, DataCatalogError> {
2076    ProductDate::new(date.year, date.month, date.day)?;
2077    product_convention(center, ProductType::Sp3)?;
2078    validate_product_date(center, ProductType::Sp3, date)?;
2079    validate_issue_for_center(center, issue)?;
2080
2081    sp3_content_start_convention_inner(center, date, issue).ok_or_else(|| {
2082        DataCatalogError::UnsupportedIssue {
2083            center,
2084            issue: issue.unwrap_or_default().to_owned(),
2085        }
2086    })
2087}
2088
2089fn sp3_content_start_convention_inner(
2090    center: AnalysisCenter,
2091    date: ProductDate,
2092    issue: Option<&str>,
2093) -> Option<Sp3ContentStartConvention> {
2094    if center != AnalysisCenter::GfzUlt {
2095        return Some(Sp3ContentStartConvention::FilenameEpoch);
2096    }
2097    if date < GFZ_ULTRA_START_TRANSITION_FIRST_DATE {
2098        return Some(Sp3ContentStartConvention::FilenameEpochMinusOneDay);
2099    }
2100    if date > GFZ_ULTRA_START_TRANSITION_LAST_DATE {
2101        return Some(Sp3ContentStartConvention::FilenameEpoch);
2102    }
2103
2104    let issue = issue?;
2105    GFZ_ULTRA_START_TRANSITION
2106        .iter()
2107        .find(|(entry_date, entry_issue, _)| *entry_date == date && *entry_issue == issue)
2108        .map(|(_, _, convention)| *convention)
2109}
2110
2111/// Distribution metadata for an exact product identity.
2112#[derive(Debug, Clone, PartialEq, Eq)]
2113pub struct DistributionLocation {
2114    /// Selected distributor.
2115    pub source: DistributionSource,
2116    /// Original public URL. Local and in-memory sources have no URL.
2117    pub original_url: Option<String>,
2118    /// Archive filename as served, including transport compression suffix.
2119    pub archive_filename: String,
2120    /// Compression applied by this distributor.
2121    pub compression: ArchiveCompression,
2122}
2123
2124/// Exact product request with an ordered, caller-controlled distributor list.
2125#[derive(Debug, Clone, PartialEq, Eq)]
2126pub struct ProductRequest {
2127    /// Exact requested identity.
2128    pub identity: ProductIdentity,
2129    /// Ordered acceptable distributors for that identity only.
2130    pub distributors: Vec<DistributionSource>,
2131}
2132
2133/// Complete-set validation failure for exact product identities.
2134#[derive(Debug, Clone, PartialEq, Eq)]
2135pub enum ExactProductSetError {
2136    /// A complete set must declare at least one expected product.
2137    EmptyExpected,
2138    /// One expected identity was not internally consistent.
2139    InvalidExpected {
2140        /// Zero-based position in the expected identity list.
2141        index: usize,
2142        /// Identity validation failure.
2143        source: DataCatalogError,
2144    },
2145    /// One available identity was not internally consistent.
2146    InvalidAvailable {
2147        /// Zero-based position in the available identity list.
2148        index: usize,
2149        /// Identity validation failure.
2150        source: DataCatalogError,
2151    },
2152    /// The available identities were not exactly the expected set.
2153    Mismatch {
2154        /// Expected identities that were not available.
2155        missing: Vec<ProductIdentity>,
2156        /// Available identities that were not expected.
2157        unexpected: Vec<ProductIdentity>,
2158        /// Identities declared more than once in the expected list.
2159        duplicate_expected: Vec<ProductIdentity>,
2160        /// Identities declared more than once in the available list.
2161        duplicate_available: Vec<ProductIdentity>,
2162    },
2163}
2164
2165impl fmt::Display for ExactProductSetError {
2166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2167        match self {
2168            Self::EmptyExpected => write!(f, "exact product set has no expected products"),
2169            Self::InvalidExpected { index, source } => {
2170                write!(f, "expected product {index} is invalid: {source}")
2171            }
2172            Self::InvalidAvailable { index, source } => {
2173                write!(f, "available product {index} is invalid: {source}")
2174            }
2175            Self::Mismatch {
2176                missing,
2177                unexpected,
2178                duplicate_expected,
2179                duplicate_available,
2180            } => write!(
2181                f,
2182                "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
2183                identity_list(missing),
2184                identity_list(unexpected),
2185                identity_list(duplicate_expected),
2186                identity_list(duplicate_available),
2187            ),
2188        }
2189    }
2190}
2191
2192impl std::error::Error for ExactProductSetError {}
2193
2194/// Require an available product inventory to match an expected exact set.
2195///
2196/// Every identity is validated before comparison. The expected list must be
2197/// non-empty, neither list may contain duplicates, every expected identity must
2198/// be available, and no undeclared identity may be present. Comparison uses the
2199/// complete [`ProductIdentity`], not only its filename, so metadata that
2200/// distinguishes otherwise identical archive names remains authoritative.
2201///
2202/// This function is a sans-IO completion gate: pass only identities from
2203/// successfully validated acquisitions, and do not start dependent processing
2204/// unless it returns `Ok(())`. For SP3 observed/predicted timing, use
2205/// [`crate::sp3::Sp3::prediction_summary`]; issue times and catalog fields are
2206/// not substitutes for the record flags in the product itself.
2207pub fn validate_exact_product_set(
2208    expected: &[ProductIdentity],
2209    available: &[ProductIdentity],
2210) -> Result<(), ExactProductSetError> {
2211    if expected.is_empty() {
2212        return Err(ExactProductSetError::EmptyExpected);
2213    }
2214    for (index, identity) in expected.iter().enumerate() {
2215        identity
2216            .validate()
2217            .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
2218    }
2219    for (index, identity) in available.iter().enumerate() {
2220        identity
2221            .validate()
2222            .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
2223    }
2224
2225    let expected_counts = identity_counts(expected);
2226    let available_counts = identity_counts(available);
2227    let missing = unique_matching(expected, |identity| {
2228        !available_counts.contains_key(identity)
2229    });
2230    let unexpected = unique_matching(available, |identity| {
2231        !expected_counts.contains_key(identity)
2232    });
2233    let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
2234    let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
2235
2236    if missing.is_empty()
2237        && unexpected.is_empty()
2238        && duplicate_expected.is_empty()
2239        && duplicate_available.is_empty()
2240    {
2241        Ok(())
2242    } else {
2243        Err(ExactProductSetError::Mismatch {
2244            missing,
2245            unexpected,
2246            duplicate_expected,
2247            duplicate_available,
2248        })
2249    }
2250}
2251
2252fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
2253    let mut counts = HashMap::with_capacity(identities.len());
2254    for identity in identities {
2255        *counts.entry(identity).or_insert(0) += 1;
2256    }
2257    counts
2258}
2259
2260fn unique_matching(
2261    identities: &[ProductIdentity],
2262    mut predicate: impl FnMut(&ProductIdentity) -> bool,
2263) -> Vec<ProductIdentity> {
2264    let mut seen = HashSet::with_capacity(identities.len());
2265    identities
2266        .iter()
2267        .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
2268        .cloned()
2269        .collect()
2270}
2271
2272fn identity_list(identities: &[ProductIdentity]) -> String {
2273    if identities.is_empty() {
2274        return "none".to_string();
2275    }
2276    identities
2277        .iter()
2278        .map(|identity| {
2279            identity
2280                .key()
2281                .unwrap_or_else(|_| identity.official_filename.clone())
2282        })
2283        .collect::<Vec<_>>()
2284        .join(", ")
2285}
2286
2287impl ProductRequest {
2288    /// Build an exact request. At least one distributor is required.
2289    pub fn new(
2290        identity: ProductIdentity,
2291        distributors: Vec<DistributionSource>,
2292    ) -> Result<Self, DataCatalogError> {
2293        if distributors.is_empty() {
2294            return Err(DataCatalogError::NoDistributionSources);
2295        }
2296        identity.validate()?;
2297        Ok(Self {
2298            identity,
2299            distributors,
2300        })
2301    }
2302}
2303
2304/// A pure product specification that resolves to one archive filename and URL.
2305#[derive(Debug, Clone, PartialEq, Eq)]
2306pub struct ProductSpec {
2307    /// Analysis center.
2308    pub center: AnalysisCenter,
2309    /// Product type.
2310    pub product_type: ProductType,
2311    /// Product date.
2312    pub date: ProductDate,
2313    /// Sampling token.
2314    pub sample: String,
2315    /// Optional issue time for ultra-rapid products.
2316    pub issue: Option<String>,
2317}
2318
2319impl ProductSpec {
2320    /// Build a product specification and validate it against the catalog.
2321    pub fn new(
2322        center: AnalysisCenter,
2323        product_type: ProductType,
2324        date: ProductDate,
2325        sample: &str,
2326        issue: Option<&str>,
2327    ) -> Result<Self, DataCatalogError> {
2328        ProductDate::new(date.year, date.month, date.day)?;
2329        validate_product(center, product_type, date, sample, issue)?;
2330        Ok(Self {
2331            center,
2332            product_type,
2333            date,
2334            sample: sample.to_string(),
2335            issue: issue.map(ToOwned::to_owned),
2336        })
2337    }
2338
2339    /// GPS week for the product date.
2340    pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
2341        self.date.gps_week()
2342    }
2343
2344    /// Day-of-year for the product date.
2345    #[must_use]
2346    pub fn day_of_year(&self) -> u16 {
2347        self.date.day_of_year()
2348    }
2349
2350    /// Canonical official filename without archive compression suffix.
2351    ///
2352    /// IGS combined final SP3 products use the historical
2353    /// `igs<week><day>.sp3` convention before GPS week 2238 and the IGS long
2354    /// filename convention from week 2238 onward.
2355    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2356        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2357        let convention = validate_product(
2358            self.center,
2359            self.product_type,
2360            self.date,
2361            &self.sample,
2362            self.issue.as_deref(),
2363        )?;
2364        if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2365            return Ok(format!(
2366                "igs{:04}{}.sp3",
2367                self.date.gps_week()?,
2368                self.date.gps_day_of_week()?
2369            ));
2370        }
2371        let descriptor = product_type_convention(self.product_type);
2372        Ok(match descriptor.kind {
2373            ProductFilenameKind::Sampled => format!(
2374                "{}_{}_{}_{}_{}.{}",
2375                convention.token,
2376                date_block(self.date, self.issue.as_deref()),
2377                convention.span,
2378                self.sample,
2379                descriptor.content_code,
2380                descriptor.extension
2381            ),
2382            ProductFilenameKind::Nav => format!(
2383                "{}_R_{}_{}_{}.{}",
2384                convention.token,
2385                date_block(self.date, None),
2386                convention.span,
2387                descriptor.content_code,
2388                descriptor.extension
2389            ),
2390        })
2391    }
2392
2393    /// Full archive URL, including its cataloged transport-compression suffix.
2394    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2395        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2396        let convention = validate_product(
2397            self.center,
2398            self.product_type,
2399            self.date,
2400            &self.sample,
2401            self.issue.as_deref(),
2402        )?;
2403        if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2404            return Err(DataCatalogError::UnsupportedDistributionEra {
2405                source: DistributionSource::Direct,
2406                center: self.center,
2407                product_type: self.product_type,
2408                date: self.date,
2409            });
2410        }
2411        let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
2412        let filename = self.canonical_filename()?;
2413        let compression = product_archive_compression(
2414            self.center,
2415            self.product_type,
2416            self.date,
2417            convention.compression,
2418        )?;
2419        Ok(format!(
2420            "{}/{}/{}{}",
2421            entry.root_url,
2422            product_dir_path(self.center, convention.layout, self.date)?,
2423            filename,
2424            compression.suffix()
2425        ))
2426    }
2427
2428    /// Exact product identity, independent of distributor.
2429    pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
2430        let convention = validate_product(
2431            self.center,
2432            self.product_type,
2433            self.date,
2434            &self.sample,
2435            self.issue.as_deref(),
2436        )?;
2437        let descriptor = product_type_convention(self.product_type);
2438        let campaign = match descriptor.kind {
2439            ProductFilenameKind::Nav => ProductCampaign::Broadcast,
2440            ProductFilenameKind::Sampled => match convention.token.get(4..7) {
2441                Some("OPS") => ProductCampaign::Operational,
2442                Some("MGN") => ProductCampaign::MultiGnss,
2443                Some("MGX") => ProductCampaign::MultiGnssExperiment,
2444                _ => {
2445                    return Err(DataCatalogError::InconsistentProductIdentity {
2446                        field: "campaign",
2447                    });
2448                }
2449            },
2450        };
2451        let identity = ProductIdentity {
2452            family: self.product_type,
2453            analysis_center: self.center,
2454            publisher: self.center.publisher(),
2455            solution: product_solution_class(self.center, self.product_type)?,
2456            campaign,
2457            version: 0,
2458            date: self.date,
2459            issue: match descriptor.kind {
2460                ProductFilenameKind::Sampled => {
2461                    Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
2462                }
2463                ProductFilenameKind::Nav => None,
2464            },
2465            span: convention.span.to_string(),
2466            sample: self.sample.clone(),
2467            official_filename: self.canonical_filename()?,
2468            format: product_format(self.product_type),
2469            format_version: None,
2470            prediction_horizon_days: self.center.prediction_horizon_days(),
2471        };
2472        identity.validate()?;
2473        Ok(identity)
2474    }
2475
2476    /// Resolve one explicit distributor without changing product identity.
2477    pub fn distribution_location(
2478        &self,
2479        source: DistributionSource,
2480    ) -> Result<DistributionLocation, DataCatalogError> {
2481        let identity = self.identity()?;
2482        distribution_location_for_identity(&identity, source)
2483    }
2484}
2485
2486/// A pure station observation specification.
2487#[derive(Debug, Clone, PartialEq, Eq)]
2488pub struct StationObservationSpec {
2489    /// 9-character RINEX 3 site identifier.
2490    pub station: String,
2491    /// Observation date.
2492    pub date: ProductDate,
2493    /// Sampling token.
2494    pub sample: String,
2495}
2496
2497impl StationObservationSpec {
2498    /// Build and validate a daily station observation product.
2499    pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
2500        validate_station(station)?;
2501        validate_sample(sample)?;
2502        Ok(Self {
2503            station: station.to_string(),
2504            date,
2505            sample: sample.to_string(),
2506        })
2507    }
2508
2509    /// Canonical RINEX 3 CRINEX filename without archive compression suffix.
2510    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2511        station_obs_filename(&self.station, self.date, &self.sample)
2512    }
2513
2514    /// Full archive URL, including `.gz`.
2515    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2516        station_obs_url(&self.station, self.date, &self.sample)
2517    }
2518}
2519
2520/// Static catalog entries, in the same order as the binding data catalog.
2521#[must_use]
2522pub const fn catalog() -> &'static [CenterCatalogEntry] {
2523    &CATALOG
2524}
2525
2526/// Supported center codes, in catalog order.
2527#[must_use]
2528pub const fn centers() -> &'static [AnalysisCenter] {
2529    &CENTER_ORDER
2530}
2531
2532/// Supported product types.
2533#[must_use]
2534pub const fn product_types() -> &'static [ProductTypeConvention] {
2535    &PRODUCT_TYPE_CONVENTIONS
2536}
2537
2538/// Archive hosts present in the catalog.
2539#[must_use]
2540pub const fn allowed_hosts() -> &'static [&'static str] {
2541    &ALLOWED_HOSTS
2542}
2543
2544/// Catalog entry for the Skadi SRTM terrain source.
2545#[must_use]
2546pub const fn skadi_source_entry() -> TerrainSourceEntry {
2547    SKADI_SOURCE
2548}
2549
2550/// Catalog entry for the CelesTrak CSSI space-weather source.
2551#[must_use]
2552pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2553    CELESTRAK_SPACE_WEATHER_SOURCE
2554}
2555
2556/// Filename for a CelesTrak space-weather product.
2557#[must_use]
2558pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2559    match product {
2560        SpaceWeatherProduct::All => "SW-All.csv",
2561        SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2562    }
2563}
2564
2565/// Build the CelesTrak archive URL for a space-weather product.
2566#[must_use]
2567pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2568    format!(
2569        "{}/{}",
2570        CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2571        space_weather_filename(product)
2572    )
2573}
2574
2575/// Build the cache relative path for a space-weather product.
2576#[must_use]
2577pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2578    format!("space-weather/{}", space_weather_filename(product))
2579}
2580
2581/// Build the Skadi SRTM tile id, for example `N36W107`.
2582pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2583    validate_terrain_tile_index(lat_index, lon_index)?;
2584    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2585    let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2586    Ok(format!(
2587        "{lat_hemi}{:02}{lon_hemi}{:03}",
2588        lat_index.abs(),
2589        lon_index.abs()
2590    ))
2591}
2592
2593/// Build the Skadi latitude band directory, for example `N36`.
2594pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2595    validate_terrain_lat_index(lat_index)?;
2596    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2597    Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2598}
2599
2600/// Build the Skadi SRTM archive URL for a tile.
2601pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2602    let band = skadi_band(lat_index)?;
2603    let tile_id = skadi_tile_id(lat_index, lon_index)?;
2604    Ok(format!(
2605        "{}/skadi/{}/{}.hgt{}",
2606        SKADI_SOURCE.root_url,
2607        band,
2608        tile_id,
2609        SKADI_SOURCE.compression.suffix()
2610    ))
2611}
2612
2613/// Build the DTED tile filename read by the terrain module.
2614pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2615    validate_terrain_tile_index(lat_index, lon_index)?;
2616    Ok(format!(
2617        "{}_{}{}",
2618        terrain::format_lat(lat_index),
2619        terrain::format_lon(lon_index),
2620        terrain::DTED_SUFFIX
2621    ))
2622}
2623
2624/// Build the DTED ten-degree cache block directory read by the terrain module.
2625pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2626    validate_terrain_tile_index(lat_index, lon_index)?;
2627    Ok(terrain::terrain_block_dir(lat_index, lon_index))
2628}
2629
2630/// Build the DTED cache relative path read by the terrain module.
2631pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2632    Ok(format!(
2633        "{}/{}",
2634        dted_block_dir(lat_index, lon_index)?,
2635        dted_tile_filename(lat_index, lon_index)?
2636    ))
2637}
2638
2639/// Parse a Skadi SRTM tile id into `(lat_index, lon_index)`.
2640pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2641    let bytes = id.as_bytes();
2642    if bytes.len() != 7
2643        || !matches!(bytes[0], b'N' | b'S')
2644        || !matches!(bytes[3], b'E' | b'W')
2645        || !bytes[1..3].iter().all(u8::is_ascii_digit)
2646        || !bytes[4..7].iter().all(u8::is_ascii_digit)
2647    {
2648        return Err(DataCatalogError::InvalidTileId(id.to_string()));
2649    }
2650
2651    let lat_abs = id[1..3]
2652        .parse::<i32>()
2653        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2654    let lon_abs = id[4..7]
2655        .parse::<i32>()
2656        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2657    if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2658        return Err(DataCatalogError::InvalidTileId(id.to_string()));
2659    }
2660
2661    let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2662    let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2663    validate_terrain_tile_index(lat_index, lon_index)?;
2664    Ok((lat_index, lon_index))
2665}
2666
2667/// Derive the terrain tile index covering a latitude/longitude coordinate.
2668pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2669    if !lat_deg.is_finite()
2670        || !lon_deg.is_finite()
2671        || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2672        || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2673    {
2674        return Err(DataCatalogError::InvalidCoordinate {
2675            lat_deg_bits: lat_deg.to_bits(),
2676            lon_deg_bits: lon_deg.to_bits(),
2677        });
2678    }
2679
2680    let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2681    if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2682        lat_index = MAX_TERRAIN_LAT_INDEX;
2683    }
2684    if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2685        lon_index = MAX_TERRAIN_LON_INDEX;
2686    }
2687    validate_terrain_tile_index(lat_index, lon_index)?;
2688    Ok((lat_index, lon_index))
2689}
2690
2691/// Convert decompressed SRTM1 HGT bytes into deterministic DTED `.dt2` bytes.
2692///
2693/// The HGT payload must be 3601 by 3601 big-endian `i16` samples in row-major
2694/// order. HGT rows run north to south; DTED data records are longitude columns
2695/// with postings south to north, so output posting `(i, j)` reads source sample
2696/// `hgt[r = 3600 - i][c = j]`. SRTM void samples (`-32768`) are written as sea
2697/// level (`0`) so the existing terrain reader returns `0` for those postings.
2698pub fn hgt_to_dted(
2699    lat_index: i32,
2700    lon_index: i32,
2701    hgt: &[u8],
2702) -> Result<Vec<u8>, HgtConversionError> {
2703    validate_hgt_tile_index(lat_index, lon_index)?;
2704    if hgt.len() != SRTM1_HGT_LEN {
2705        return Err(HgtConversionError::BadLength {
2706            expected: SRTM1_HGT_LEN,
2707            got: hgt.len(),
2708        });
2709    }
2710
2711    let mut out = vec![b' '; DTED_SRTM1_LEN];
2712    out[0..4].copy_from_slice(b"UHL1");
2713    out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2714    out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2715    out[47..51].copy_from_slice(b"3601");
2716    out[51..55].copy_from_slice(b"3601");
2717
2718    for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2719        let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2720        let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2721        out[block_start] = terrain::DATA_SENTINEL;
2722
2723        let count = (lon_posting as u32).to_be_bytes();
2724        out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2725        out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2726        out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2727
2728        for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2729            let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2730            let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2731            let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2732            let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2733            let dted_sample_start = block_start + 8 + 2 * lat_posting;
2734            out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2735        }
2736
2737        let checksum = out[block_start..checksum_start]
2738            .iter()
2739            .fold(0i32, |acc, byte| acc + i32::from(*byte));
2740        out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2741    }
2742
2743    debug_assert_eq!(out.len(), 25_981_042);
2744    Ok(out)
2745}
2746
2747/// Product pairs intentionally withheld because no open mirror is known.
2748#[must_use]
2749pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2750    &NO_OPEN_MIRRORS
2751}
2752
2753/// Confirm that a center/product pair has an open catalog mirror.
2754pub fn open_mirror(
2755    center: AnalysisCenter,
2756    product_type: ProductType,
2757) -> Result<(), DataCatalogError> {
2758    open_mirror_code(center.code(), product_type.code())
2759}
2760
2761/// Confirm that a center/product code pair is not in the no-open-mirror list.
2762pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2763    if NO_OPEN_MIRRORS
2764        .iter()
2765        .any(|entry| entry.center == center && entry.product_type == product_type)
2766    {
2767        Err(DataCatalogError::NoOpenMirror {
2768            center: center.to_string(),
2769            product_type: product_type.to_string(),
2770        })
2771    } else {
2772        Ok(())
2773    }
2774}
2775
2776/// Look up a center's static catalog entry.
2777#[must_use]
2778pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2779    CATALOG.iter().find(|entry| entry.center == center)
2780}
2781
2782/// Look up the convention for one center and product type.
2783pub fn product_convention(
2784    center: AnalysisCenter,
2785    product_type: ProductType,
2786) -> Result<&'static CenterProductConvention, DataCatalogError> {
2787    open_mirror(center, product_type)?;
2788    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2789    entry
2790        .products
2791        .iter()
2792        .find(|product| product.product_type == product_type)
2793        .ok_or(DataCatalogError::UnsupportedProduct {
2794            center,
2795            product_type,
2796        })
2797}
2798
2799/// Return the solution class for a supported center/product family.
2800///
2801/// This product-aware API resolves the ambiguity in the legacy
2802/// [`AnalysisCenter::solution_class`] method. For example, IGS merged
2803/// broadcast navigation is [`SolutionClass::Broadcast`], while IGS combined
2804/// final SP3 is [`SolutionClass::Final`]. Unsupported combinations are
2805/// rejected before callers derive a filename or attempt acquisition.
2806pub fn product_solution_class(
2807    center: AnalysisCenter,
2808    product_type: ProductType,
2809) -> Result<SolutionClass, DataCatalogError> {
2810    product_convention(center, product_type)?;
2811    Ok(match (center, product_type) {
2812        (AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
2813        _ => center.solution_class(),
2814    })
2815}
2816
2817/// Current default sampling token for a center/product pair.
2818///
2819/// This preserves the original date-free query and reports the current catalog
2820/// convention. Use [`default_sample_for_date`] when deriving a historical
2821/// product whose published cadence may have changed.
2822pub fn default_sample(
2823    center: AnalysisCenter,
2824    product_type: ProductType,
2825) -> Result<&'static str, DataCatalogError> {
2826    Ok(product_convention(center, product_type)?.default_sample)
2827}
2828
2829/// Published default sampling token for a center/product pair on a date.
2830///
2831/// Most catalog families use one sampling token across their modeled history.
2832/// For issue-based products this date-only query represents the `0000` issue;
2833/// product construction uses its actual issue and can therefore select a
2834/// within-day transition. GFZ rapid and ultra-rapid SP3 and ESA ultra-rapid SP3
2835/// have cataloged cadence transitions.
2836pub fn default_sample_for_date(
2837    center: AnalysisCenter,
2838    product_type: ProductType,
2839    date: ProductDate,
2840) -> Result<&'static str, DataCatalogError> {
2841    default_sample_for_product_issue(center, product_type, date, None)
2842}
2843
2844/// GPS week number for a product date.
2845pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2846    date.gps_week()
2847}
2848
2849/// Day-of-year in `1..=366` for a product date.
2850#[must_use]
2851pub fn day_of_year(date: ProductDate) -> u16 {
2852    date.day_of_year()
2853}
2854
2855/// Build a product specification for any center/product/date combination.
2856pub fn product(
2857    center: AnalysisCenter,
2858    product_type: ProductType,
2859    date: ProductDate,
2860    sample: Option<&str>,
2861    issue: Option<&str>,
2862) -> Result<ProductSpec, DataCatalogError> {
2863    let sample = match sample {
2864        Some(sample) => sample,
2865        None => default_sample_for_product_issue(center, product_type, date, issue)?,
2866    };
2867    ProductSpec::new(center, product_type, date, sample, issue)
2868}
2869
2870/// Build the canonical IGS long-name filename for a product.
2871pub fn canonical_filename(
2872    center: AnalysisCenter,
2873    product_type: ProductType,
2874    date: ProductDate,
2875    sample: Option<&str>,
2876    issue: Option<&str>,
2877) -> Result<String, DataCatalogError> {
2878    product(center, product_type, date, sample, issue)?.canonical_filename()
2879}
2880
2881/// Build the full archive URL for a product.
2882pub fn archive_url(
2883    center: AnalysisCenter,
2884    product_type: ProductType,
2885    date: ProductDate,
2886    sample: Option<&str>,
2887    issue: Option<&str>,
2888) -> Result<String, DataCatalogError> {
2889    product(center, product_type, date, sample, issue)?.archive_url()
2890}
2891
2892/// Build the exact identity for a catalog product.
2893pub fn product_identity(
2894    center: AnalysisCenter,
2895    product_type: ProductType,
2896    date: ProductDate,
2897    sample: Option<&str>,
2898    issue: Option<&str>,
2899) -> Result<ProductIdentity, DataCatalogError> {
2900    product(center, product_type, date, sample, issue)?.identity()
2901}
2902
2903/// Resolve an explicit distributor for a catalog product.
2904pub fn distribution_location(
2905    center: AnalysisCenter,
2906    product_type: ProductType,
2907    date: ProductDate,
2908    sample: Option<&str>,
2909    issue: Option<&str>,
2910    source: DistributionSource,
2911) -> Result<DistributionLocation, DataCatalogError> {
2912    product(center, product_type, date, sample, issue)?.distribution_location(source)
2913}
2914
2915/// Resolve one explicit distributor from a complete exact product identity.
2916///
2917/// Unlike [`distribution_location`], this retains the already validated exact
2918/// center, date, issue, cadence, span, and filename carried by `identity`
2919/// instead of reconstructing a default product specification. The function
2920/// performs no network or file IO.
2921pub fn distribution_location_for_identity(
2922    identity: &ProductIdentity,
2923    source: DistributionSource,
2924) -> Result<DistributionLocation, DataCatalogError> {
2925    identity.validate()?;
2926    match source {
2927        DistributionSource::Direct => {
2928            let convention = product_convention(identity.analysis_center, identity.family)?;
2929            if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
2930            {
2931                return Err(DataCatalogError::UnsupportedDistributionEra {
2932                    source,
2933                    center: identity.analysis_center,
2934                    product_type: identity.family,
2935                    date: identity.date,
2936                });
2937            }
2938            let entry = center_catalog(identity.analysis_center)
2939                .expect("validated analysis center has a catalog entry");
2940            let compression = product_archive_compression(
2941                identity.analysis_center,
2942                identity.family,
2943                identity.date,
2944                convention.compression,
2945            )?;
2946            let url = format!(
2947                "{}/{}/{}{}",
2948                entry.root_url,
2949                product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2950                identity.official_filename,
2951                compression.suffix()
2952            );
2953            Ok(DistributionLocation {
2954                source,
2955                original_url: Some(url),
2956                archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2957                compression,
2958            })
2959        }
2960        DistributionSource::NasaCddis => {
2961            validate_cddis_distribution_era(identity)?;
2962            let compression = product_archive_compression(
2963                identity.analysis_center,
2964                identity.family,
2965                identity.date,
2966                ArchiveCompression::Gzip,
2967            )?;
2968            Ok(DistributionLocation {
2969                source,
2970                original_url: Some(cddis_archive_url(identity)?),
2971                archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2972                compression,
2973            })
2974        }
2975        DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2976            source,
2977            original_url: None,
2978            archive_filename: identity.official_filename.clone(),
2979            compression: ArchiveCompression::None,
2980        }),
2981    }
2982}
2983
2984/// Build the official NASA CDDIS HTTPS URL for an exact SP3 or IONEX identity.
2985///
2986/// CDDIS stores supported current SP3 products by GPS week and current IONEX
2987/// products by year/day-of-year. The decompressed official filename is
2988/// unchanged. Before GPS week 2238, only the modeled IGS combined-final legacy
2989/// short-name SP3 series has a verified mapping; unmodeled long-name SP3 and
2990/// IONEX identities are rejected. ESA's `ESA0MGNFIN` final SP3 line is not
2991/// projected onto CDDIS because no exact CDDIS mapping is cataloged for it.
2992pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2993    identity.validate()?;
2994    validate_cddis_distribution_era(identity)?;
2995    match identity.family {
2996        ProductType::Sp3 => {
2997            let compression = product_archive_compression(
2998                identity.analysis_center,
2999                identity.family,
3000                identity.date,
3001                ArchiveCompression::Gzip,
3002            )?;
3003            Ok(format!(
3004                "https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
3005                identity.date.gps_week()?,
3006                identity.official_filename,
3007                compression.suffix()
3008            ))
3009        }
3010        ProductType::Ionex => Ok(format!(
3011            "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
3012            identity.date.year,
3013            identity.date.day_of_year(),
3014            identity.official_filename
3015        )),
3016        product_type => Err(DataCatalogError::UnsupportedDistribution {
3017            source: DistributionSource::NasaCddis,
3018            product_type,
3019        }),
3020    }
3021}
3022
3023/// Build a clock product for a center and date.
3024pub fn mgex_clk(
3025    center: AnalysisCenter,
3026    date: ProductDate,
3027    sample: Option<&str>,
3028) -> Result<ProductSpec, DataCatalogError> {
3029    product(center, ProductType::Clk, date, sample, None)
3030}
3031
3032/// Build a merged broadcast-navigation product for a center and date.
3033pub fn mgex_nav(
3034    center: AnalysisCenter,
3035    date: ProductDate,
3036    sample: Option<&str>,
3037) -> Result<ProductSpec, DataCatalogError> {
3038    product(center, ProductType::Nav, date, sample, None)
3039}
3040
3041/// Build an IONEX product for a center and date.
3042pub fn mgex_ionex(
3043    center: AnalysisCenter,
3044    date: ProductDate,
3045    sample: Option<&str>,
3046) -> Result<ProductSpec, DataCatalogError> {
3047    product(center, ProductType::Ionex, date, sample, None)
3048}
3049
3050/// Build the CODE rapid IONEX product for a date.
3051pub fn rapid_ionex(
3052    date: ProductDate,
3053    sample: Option<&str>,
3054) -> Result<ProductSpec, DataCatalogError> {
3055    product(
3056        AnalysisCenter::CodRap,
3057        ProductType::Ionex,
3058        date,
3059        sample,
3060        None,
3061    )
3062}
3063
3064/// Day offset for predicted IONEX aliases.
3065#[must_use]
3066pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
3067    match center {
3068        AnalysisCenter::CodPrd2 => 1,
3069        _ => 0,
3070    }
3071}
3072
3073/// Build a CODE predicted IONEX product for a target date.
3074pub fn predicted_ionex(
3075    center: AnalysisCenter,
3076    date: ProductDate,
3077    sample: Option<&str>,
3078) -> Result<ProductSpec, DataCatalogError> {
3079    match center {
3080        AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
3081            let target = date.add_days(predicted_day_offset(center))?;
3082            product(center, ProductType::Ionex, target, sample, None)
3083        }
3084        other => Err(DataCatalogError::UnsupportedProduct {
3085            center: other,
3086            product_type: ProductType::Ionex,
3087        }),
3088    }
3089}
3090
3091/// Build an SP3 product for a center and date.
3092pub fn mgex_sp3(
3093    center: AnalysisCenter,
3094    date: ProductDate,
3095    sample: Option<&str>,
3096) -> Result<ProductSpec, DataCatalogError> {
3097    product(center, ProductType::Sp3, date, sample, None)
3098}
3099
3100/// Build an ultra-rapid OPS SP3 product for a date and issue time.
3101pub fn ops_ultra_sp3(
3102    center: AnalysisCenter,
3103    date: ProductDate,
3104    sample: Option<&str>,
3105    issue: Option<&str>,
3106) -> Result<ProductSpec, DataCatalogError> {
3107    let issue = issue.unwrap_or("0000");
3108    product(center, ProductType::Sp3, date, sample, Some(issue))
3109}
3110
3111/// Generate the officially cataloged ultra-rapid SP3 locations for one issue.
3112///
3113/// The dated locations use only spans and sampling intervals evidenced for the
3114/// exact center, date, and issue. A second dated location is returned only for
3115/// an archive-observed overlap such as GFZ's 2021-05-15 `0000` issue. Moving
3116/// latest-product snapshots are not exact dated identities and are therefore
3117/// outside this API. Callers should try the next location only when the prior
3118/// URL is absent; transport and retry policy remain outside the pure core
3119/// catalog.
3120pub fn ultra_sp3_locations(
3121    center: AnalysisCenter,
3122    date: ProductDate,
3123    issue: &str,
3124) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
3125    validate_issue_for_center(center, Some(issue))?;
3126    validate_product_date(center, ProductType::Sp3, date)?;
3127    match center {
3128        AnalysisCenter::IgsUlt
3129        | AnalysisCenter::CodUlt
3130        | AnalysisCenter::EsaUlt
3131        | AnalysisCenter::GfzUlt
3132        | AnalysisCenter::WumNrt => {}
3133        other => {
3134            return Err(DataCatalogError::UnsupportedProduct {
3135                center: other,
3136                product_type: ProductType::Sp3,
3137            })
3138        }
3139    };
3140    let default_sample =
3141        default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
3142    let mut samples = supported_samples(center, ProductType::Sp3, date, Some(issue))?.to_vec();
3143    samples.sort_by_key(|sample| *sample != default_sample);
3144
3145    samples
3146        .into_iter()
3147        .map(|sample| {
3148            // Reuse the single-product catalog path so candidate enumeration
3149            // cannot drift from canonical filename, URL, identity, or
3150            // date-dependent compression derivation.
3151            let spec = ops_ultra_sp3(center, date, Some(sample), Some(issue))?;
3152            let identity = spec.identity()?;
3153            let filename = spec.canonical_filename()?;
3154            let url = spec.archive_url()?;
3155            let convention = product_convention(center, ProductType::Sp3)?;
3156            let compression = product_archive_compression(
3157                center,
3158                ProductType::Sp3,
3159                date,
3160                convention.compression,
3161            )?;
3162            Ok(UltraSp3Location {
3163                pattern: if sample == default_sample {
3164                    format!("primary_{}_{}", identity.span, sample)
3165                } else {
3166                    format!("alternate_{}_{}", identity.span, sample)
3167                },
3168                span: identity.span,
3169                sample: sample.to_string(),
3170                url,
3171                filename,
3172                compression,
3173            })
3174        })
3175        .collect()
3176}
3177
3178/// Build an ultra-rapid OPS clock product for a date and issue time.
3179pub fn ops_ultra_clk(
3180    center: AnalysisCenter,
3181    date: ProductDate,
3182    sample: Option<&str>,
3183    issue: Option<&str>,
3184) -> Result<ProductSpec, DataCatalogError> {
3185    let issue = issue.unwrap_or("0000");
3186    product(center, ProductType::Clk, date, sample, Some(issue))
3187}
3188
3189/// Select the latest ultra-rapid OPS SP3 issue at or before a target time.
3190pub fn latest_ops_ultra_sp3(
3191    center: AnalysisCenter,
3192    target: ProductDateTime,
3193    sample: Option<&str>,
3194    available_issues: Option<&[UltraIssue]>,
3195) -> Result<ProductSpec, DataCatalogError> {
3196    let selected = latest_ultra_issue(center, target, available_issues)?;
3197    ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
3198}
3199
3200/// Candidate ultra-rapid issues at or before a target time, newest first.
3201pub fn ultra_issue_candidates(
3202    center: AnalysisCenter,
3203    target: ProductDateTime,
3204) -> Result<Vec<UltraIssue>, DataCatalogError> {
3205    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3206    let _ = product_convention(center, ProductType::Sp3)?;
3207    if entry.issues.is_empty() {
3208        return Err(DataCatalogError::UnsupportedProduct {
3209            center,
3210            product_type: ProductType::Sp3,
3211        });
3212    }
3213    validate_product_date(center, ProductType::Sp3, target.date)?;
3214
3215    let mut candidates = Vec::new();
3216    for date in [target.date, target.date.add_days(-1)?] {
3217        match validate_product_date(center, ProductType::Sp3, date) {
3218            Ok(()) => {}
3219            Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3220            Err(error) => return Err(error),
3221        }
3222        for issue in entry.issues.iter().rev() {
3223            if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
3224                candidates.push(UltraIssue::new(date, issue)?);
3225            }
3226        }
3227    }
3228    Ok(candidates)
3229}
3230
3231/// Latest ultra-rapid issue at or before a target time.
3232pub fn latest_ultra_issue(
3233    center: AnalysisCenter,
3234    target: ProductDateTime,
3235    available_issues: Option<&[UltraIssue]>,
3236) -> Result<UltraIssue, DataCatalogError> {
3237    let candidates = ultra_issue_candidates(center, target)?;
3238    if candidates.is_empty() {
3239        return Err(DataCatalogError::NoUltraIssue);
3240    }
3241    if let Some(available) = available_issues {
3242        candidates
3243            .into_iter()
3244            .find(|candidate| {
3245                available
3246                    .iter()
3247                    .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
3248            })
3249            .ok_or(DataCatalogError::NoAvailableUltraIssue)
3250    } else {
3251        Ok(candidates[0].clone())
3252    }
3253}
3254
3255/// Ordered cross-line candidates for one predicted IONEX map date.
3256///
3257/// CODE publishes two predicted global ionosphere lines for every map date:
3258/// the one-day prediction under `CODE/IONO/P1/` and the two-day prediction
3259/// under `CODE/IONO/P2/`. Both files carry the same official filename (the
3260/// filename date is the map date in both lines) but are distinct artifacts
3261/// with distinct exact identities and cache paths. Because the two-day line
3262/// for map date `M` is produced a day earlier than the one-day line, `P2/M`
3263/// is routinely published while `P1/M` is still absent whenever CODE runs
3264/// behind schedule.
3265///
3266/// This walk mirrors [`ultra_issue_candidates`]: it enumerates genuine
3267/// artifacts, ordered by preference (`P1` first, `P2` second), and the caller
3268/// acquires the first available one, cache-first. Hard rules:
3269///
3270/// - Every candidate is for the SAME map date. The walk never substitutes a
3271///   neighboring date's map; date fallback remains a separate, explicit
3272///   decision via [`gim_date_candidates`].
3273/// - Each candidate keeps its own exact identity ([`AnalysisCenter::CodPrd1`]
3274///   or [`AnalysisCenter::CodPrd2`] with its prediction horizon), so resolved
3275///   provenance names the line actually served and a cached `P2` artifact is
3276///   never re-labelled as `P1`.
3277/// - The walk is opt-in. A single-line request through [`predicted_ionex`]
3278///   keeps its fail-closed behavior.
3279///
3280/// Note the argument is the map date itself, unlike [`predicted_ionex`],
3281/// whose date argument is offset by [`predicted_day_offset`] for the two-day
3282/// line. A map date whose two-day production day falls outside the supported
3283/// calendar (its previous civil day is invalid) is rejected rather than
3284/// silently narrowed to one candidate.
3285pub fn predicted_ionex_line_candidates(
3286    map_date: ProductDate,
3287    sample: Option<&str>,
3288) -> Result<Vec<ProductSpec>, DataCatalogError> {
3289    let one_day = predicted_ionex(AnalysisCenter::CodPrd1, map_date, sample)?;
3290    let two_day_production_date = map_date.add_days(-1)?;
3291    let two_day = predicted_ionex(AnalysisCenter::CodPrd2, two_day_production_date, sample)?;
3292    // The same-map-date rule is the walk's contract, so it is enforced at
3293    // runtime in every build - not debug-asserted - even though only a
3294    // catalog bug could violate it.
3295    if one_day.date != map_date || two_day.date != map_date {
3296        return Err(DataCatalogError::InconsistentProductIdentity {
3297            field: "predicted_ionex_map_date",
3298        });
3299    }
3300    Ok(vec![one_day, two_day])
3301}
3302
3303/// Candidate IONEX dates at or before a target date, newest first.
3304pub fn gim_date_candidates(
3305    center: AnalysisCenter,
3306    target: ProductDate,
3307    lookback: u32,
3308) -> Result<Vec<ProductDate>, DataCatalogError> {
3309    let _ = product_convention(center, ProductType::Ionex)?;
3310    let base = target.add_days(predicted_day_offset(center))?;
3311    let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
3312    for back in 0..=lookback {
3313        out.push(base.add_days(-i64::from(back))?);
3314    }
3315    Ok(out)
3316}
3317
3318// --- Publication status -----------------------------------------------------
3319//
3320// Doctrine for this section, deliberate and load-bearing:
3321//
3322// - The purity split is the design, not an accident. Everything here -
3323//   listing parsing, newest-issue selection, listing-URL derivation, age
3324//   arithmetic - is pure and network-free, exactly like the rest of the
3325//   catalog. The one networked call composing these pieces lives in the
3326//   scoreboard (`sidereon-scoreboard::publication_status`), behind its
3327//   existing fetcher trait so tests inject recorded bodies. Do not "fix"
3328//   this by adding transport here: the purity boundary is what lets every
3329//   interface reuse these semantics with its own acquisition stack, and
3330//   what keeps CI free of live-network dependence.
3331// - `observed_at` stays verbatim archive text forever. The archives disagree
3332//   on format and time zone (Apache indexes report server-local wall time
3333//   with no zone, AIUB's CSV reports ISO-8601 UTC, FTP LIST reports
3334//   `Mon DD HH:MM` with a year only for old files); parsing these into
3335//   instants would fabricate precision the archive never published. Lag
3336//   arithmetic therefore uses the filename's nominal issue epoch
3337//   ([`published_issue_age_minutes`]), which IS well-defined, and callers
3338//   who want the archive's own text get it untouched.
3339//
3340/// One object observed in an archive listing.
3341///
3342/// `path` is the object path exactly as the listing reported it: a bare
3343/// filename for an HTML autoindex or FTP directory listing, a slash-separated
3344/// archive path for a whole-tree listing such as AIUB's `full_listing.csv`.
3345/// `observed_at` is the archive-reported modification text, verbatim; archives
3346/// disagree on format and time zone, so Sidereon never reinterprets it (see
3347/// the section doctrine above).
3348#[derive(Debug, Clone, PartialEq, Eq)]
3349pub struct PublishedObject {
3350    /// Object path as listed.
3351    pub path: String,
3352    /// Archive-reported modification text, verbatim, when the listing has one.
3353    pub observed_at: Option<String>,
3354}
3355
3356/// Newest published issue of one center + product line, as evidenced by an
3357/// archive listing.
3358#[derive(Debug, Clone, PartialEq, Eq)]
3359pub struct PublishedProduct {
3360    /// Product date encoded by the newest published official filename.
3361    pub date: ProductDate,
3362    /// `HHMM` issue time encoded by that filename.
3363    pub issue: String,
3364    /// Official filename without transport compression suffix.
3365    pub filename: String,
3366    /// Archive-reported publication text for that object, verbatim.
3367    pub observed_at: Option<String>,
3368}
3369
3370/// Parse the object entries out of an archive listing body.
3371///
3372/// Dialect detection is closed: the body must classify as exactly one of the
3373/// listing surfaces the catalog's archives actually serve, each verified live
3374/// on 2026-08-04 and recorded as a fixture, and a body that fits none of them
3375/// is [`DataCatalogError::UnrecognizedArchiveListing`] - never a best-effort
3376/// empty result. An error page, a login interstitial, or a format change at
3377/// an archive must surface as "this is not a listing I understand", because a
3378/// silent empty parse is indistinguishable from "nothing published" and would
3379/// convert an archive change into a false publication-gap report.
3380///
3381/// - Apache `<pre>` autoindex and its older table flavor (GFZ
3382///   `isdc-data.gfz.de`, BKG `igs.bkg.bund.de`) and the ESA XHTML table
3383///   autoindex (`navigation-office.esa.int`): recognized by the autoindex
3384///   `Index of` marker; objects are relative anchors, with the row's
3385///   `YYYY-MM-DD HH:MM` text captured verbatim.
3386/// - AIUB whole-tree CSV (`www.aiub.unibe.ch/download/full_listing.csv`):
3387///   `path;bytes;ISO-8601;md5` rows; every non-empty row must fit that
3388///   grammar.
3389/// - Anonymous-FTP `LIST` output (WHU `igs.gnsswhu.cn`): Unix `ls -l` rows
3390///   (an optional leading `total` line allowed); every other non-empty row
3391///   must fit that grammar.
3392///
3393/// Within a recognized dialect, rows that by the dialect's own rules do not
3394/// name an object (parent links, sort links, directories, symlinks) are
3395/// skipped. The result preserves nothing but object paths and verbatim
3396/// modification text; interpretation belongs to
3397/// [`newest_published_product`].
3398/// Position index backing archive-listing deduplication.
3399///
3400/// Keyed by the hash of the path rather than by an owned copy of it, so a
3401/// 426k-row listing does not pay for a second copy of every archive path. A
3402/// hash is not an identity: every candidate is confirmed against the actual
3403/// stored path, so two distinct paths that hash alike remain distinct objects
3404/// and only a genuine repeat is merged.
3405#[derive(Default)]
3406struct ListingIndex {
3407    buckets: HashMap<u64, Vec<u32>>,
3408}
3409
3410impl ListingIndex {
3411    /// Record `path`, or merge it into the first occurrence already recorded.
3412    ///
3413    /// The first occurrence keeps its output position. A later duplicate only
3414    /// supplies `observed_at` when the first occurrence lacks one; it never
3415    /// overwrites an `observed_at` already present.
3416    fn push(
3417        &mut self,
3418        seen: &mut Vec<PublishedObject>,
3419        hash_of: fn(&str) -> u64,
3420        path: String,
3421        observed_at: Option<String>,
3422    ) {
3423        let candidates = self.buckets.entry(hash_of(&path)).or_default();
3424        if let Some(&at) = candidates
3425            .iter()
3426            .find(|&&at| seen[at as usize].path == path)
3427        {
3428            let existing = &mut seen[at as usize];
3429            if existing.observed_at.is_none() {
3430                existing.observed_at = observed_at;
3431            }
3432        } else {
3433            candidates.push(seen.len() as u32);
3434            seen.push(PublishedObject { path, observed_at });
3435        }
3436    }
3437}
3438
3439fn default_path_hash(path: &str) -> u64 {
3440    let mut hasher = DefaultHasher::new();
3441    path.hash(&mut hasher);
3442    hasher.finish()
3443}
3444
3445pub fn parse_archive_listing(body: &str) -> Result<Vec<PublishedObject>, DataCatalogError> {
3446    let mut seen: Vec<PublishedObject> = Vec::new();
3447    // Listings are large (AIUB's whole-tree CSV is ~426k rows) and a row may
3448    // repeat a path already listed. Deduplicating by scanning `seen` is
3449    // quadratic and takes minutes on that listing, so each path's position is
3450    // indexed instead. `seen` still carries listing order; the index only
3451    // answers "have I already pushed this path, and where".
3452    //
3453    // The index is keyed by the path's hash rather than by an owned copy of
3454    // the path, so a 426k-row listing does not pay for a second copy of every
3455    // archive path. A hash is not an identity: each candidate is confirmed by
3456    // comparing the actual path, so two distinct paths that hash alike stay
3457    // distinct objects.
3458    let mut positions: ListingIndex = ListingIndex::default();
3459    let mut push = |path: String, observed_at: Option<String>| {
3460        positions.push(&mut seen, default_path_hash, path, observed_at);
3461    };
3462    let unrecognized = |reason: &str| DataCatalogError::UnrecognizedArchiveListing {
3463        reason: reason.to_string(),
3464    };
3465
3466    let non_empty: Vec<&str> = body
3467        .lines()
3468        .map(str::trim_end)
3469        .filter(|line| !line.trim().is_empty())
3470        .collect();
3471    if non_empty.is_empty() {
3472        return Err(unrecognized("empty body"));
3473    }
3474    let has_markup = body.contains('<');
3475
3476    // AIUB whole-tree CSV.
3477    if !has_markup && non_empty[0].matches(';').count() >= 3 {
3478        for line in &non_empty {
3479            if line.matches(';').count() < 3 {
3480                return Err(unrecognized("CSV row without its four fields"));
3481            }
3482            let mut fields = line.split(';');
3483            let (Some(path), Some(_bytes), Some(observed)) =
3484                (fields.next(), fields.next(), fields.next())
3485            else {
3486                return Err(unrecognized("CSV row without its four fields"));
3487            };
3488            if path.is_empty() {
3489                return Err(unrecognized("CSV row without an archive path"));
3490            }
3491            // A space is legal path content: `;` is the field delimiter, so
3492            // the four-field structure above is the malformed-row signal.
3493            // AIUB's live whole-tree listing carries unrelated objects with
3494            // spaces in their names (conference PDFs, tarballs); rejecting
3495            // them rejected the entire 426k-row listing. They are ordinary
3496            // listed objects that simply never match a product filename.
3497            // Directory rows carry `-1` sentinels and a trailing slash.
3498            if path.ends_with('/') {
3499                continue;
3500            }
3501            let observed_at =
3502                (!observed.is_empty() && observed != "-1").then(|| observed.to_string());
3503            push(path.to_string(), observed_at);
3504        }
3505        return Ok(seen);
3506    }
3507
3508    // Anonymous-FTP `LIST` (Unix `ls -l`) output.
3509    if !has_markup && non_empty[0].starts_with(['-', 'd', 'l']) {
3510        for (index, line) in non_empty.iter().enumerate() {
3511            if index == 0 && line.starts_with("total ") {
3512                continue;
3513            }
3514            let mode_shaped = line.len() > 10
3515                && line.starts_with(['-', 'd', 'l'])
3516                && line.as_bytes()[1..10]
3517                    .iter()
3518                    .all(|byte| matches!(byte, b'r' | b'w' | b'x' | b'-' | b's' | b't'));
3519            if !mode_shaped {
3520                return Err(unrecognized("FTP LIST row without a Unix mode field"));
3521            }
3522            // Directories and symlinks are not objects.
3523            if !line.starts_with('-') {
3524                continue;
3525            }
3526            let fields: Vec<&str> = line.split_whitespace().collect();
3527            if fields.len() < 9 {
3528                return Err(unrecognized("FTP LIST file row without nine fields"));
3529            }
3530            push(fields[8..].join(" "), Some(fields[5..8].join(" ")));
3531        }
3532        return Ok(seen);
3533    }
3534
3535    // HTML autoindex flavors, recognized by the shared `Index of` marker.
3536    if has_markup && body.contains("Index of") {
3537        for line in &non_empty {
3538            // Anchors, one or more per physical row. Sort links (`?C=`),
3539            // absolute parent links, and directories are not objects.
3540            let mut rest = *line;
3541            while let Some(start) = rest.find("<a href=\"") {
3542                rest = &rest[start + 9..];
3543                let Some(end) = rest.find('"') else { break };
3544                let target = &rest[..end];
3545                rest = &rest[end..];
3546                if target.is_empty()
3547                    || target.starts_with('?')
3548                    || target.starts_with('/')
3549                    || target.starts_with('#')
3550                    || target.contains("://")
3551                    || target.ends_with('/')
3552                {
3553                    continue;
3554                }
3555                let observed_at = find_listing_datetime(rest).map(str::to_string);
3556                push(target.to_string(), observed_at);
3557            }
3558        }
3559        return Ok(seen);
3560    }
3561
3562    Err(unrecognized(if has_markup {
3563        "markup without an autoindex marker"
3564    } else {
3565        "no known listing grammar"
3566    }))
3567}
3568
3569/// First `YYYY-MM-DD HH:MM` datetime text in the remainder of a listing row.
3570fn find_listing_datetime(rest: &str) -> Option<&str> {
3571    let bytes = rest.as_bytes();
3572    let is_digit = |index: usize| bytes.get(index).is_some_and(u8::is_ascii_digit);
3573    for start in 0..bytes.len().saturating_sub(15) {
3574        let shape_matches = is_digit(start)
3575            && is_digit(start + 1)
3576            && is_digit(start + 2)
3577            && is_digit(start + 3)
3578            && bytes[start + 4] == b'-'
3579            && is_digit(start + 5)
3580            && is_digit(start + 6)
3581            && bytes[start + 7] == b'-'
3582            && is_digit(start + 8)
3583            && is_digit(start + 9)
3584            && bytes[start + 10] == b' '
3585            && is_digit(start + 11)
3586            && is_digit(start + 12)
3587            && bytes[start + 13] == b':'
3588            && is_digit(start + 14)
3589            && is_digit(start + 15);
3590        if shape_matches {
3591            return Some(&rest[start..start + 16]);
3592        }
3593    }
3594    None
3595}
3596
3597/// Archive path marker that attributes a listed object to one catalog line
3598/// when several lines share an official filename convention.
3599const fn center_path_marker(center: AnalysisCenter) -> Option<&'static str> {
3600    match center {
3601        AnalysisCenter::CodPrd1 => Some("/IONO/P1/"),
3602        AnalysisCenter::CodPrd2 => Some("/IONO/P2/"),
3603        _ => None,
3604    }
3605}
3606
3607fn object_matches_center(center: AnalysisCenter, path: &str) -> bool {
3608    match center_path_marker(center) {
3609        // Whole-tree paths must carry the line's directory; a bare filename
3610        // cannot be attributed to either line and is never accepted.
3611        Some(marker) => {
3612            let slashed = format!("/{path}");
3613            slashed.contains(marker)
3614        }
3615        None => true,
3616    }
3617}
3618
3619/// Newest published issue for one center + product line among listed objects.
3620///
3621/// An object counts only when its name is exactly the line's official
3622/// filename convention (token, span, catalog-supported sample, content code,
3623/// extension, and the line's archive-compression suffix) and, for lines that
3624/// share a filename convention (the CODE predicted `P1`/`P2` ionosphere
3625/// lines), when its listed path carries the line's directory. The newest
3626/// object is selected by filename date and issue time; the archive-reported
3627/// modification text rides along verbatim.
3628///
3629/// `Ok(None)` means the listing was readable but contained no published
3630/// object of this line - the "nothing published here" answer, distinct from
3631/// an unreachable archive, which the transport layer reports instead.
3632pub fn newest_published_product(
3633    center: AnalysisCenter,
3634    product_type: ProductType,
3635    objects: &[PublishedObject],
3636) -> Result<Option<PublishedProduct>, DataCatalogError> {
3637    let convention = product_convention(center, product_type)?;
3638    let descriptor = product_type_convention(product_type);
3639    let suffix = format!(".{}", descriptor.extension);
3640    let tail = format!("_{}{}", descriptor.content_code, suffix);
3641
3642    let mut newest: Option<(i64, PublishedProduct)> = None;
3643    for object in objects {
3644        if !object_matches_center(center, &object.path) {
3645            continue;
3646        }
3647        let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3648        let stripped = listed_name
3649            .strip_suffix(".gz")
3650            .or_else(|| listed_name.strip_suffix(".Z"))
3651            .unwrap_or(listed_name);
3652        let Some(after_token) = stripped
3653            .strip_prefix(convention.token)
3654            .and_then(|rest| rest.strip_prefix('_'))
3655        else {
3656            continue;
3657        };
3658        let Some(middle) = after_token.strip_suffix(&tail) else {
3659            continue;
3660        };
3661        let mut parts = middle.split('_');
3662        let (Some(block), Some(span), Some(sample), None) =
3663            (parts.next(), parts.next(), parts.next(), parts.next())
3664        else {
3665            continue;
3666        };
3667        if span != convention.span || block.len() != 11 {
3668            continue;
3669        }
3670        let (Ok(year), Ok(day_of_year)) = (block[0..4].parse::<i32>(), block[4..7].parse::<u16>())
3671        else {
3672            continue;
3673        };
3674        let issue = &block[7..11];
3675        let Ok(date) = product_date_from_year_day(year, day_of_year) else {
3676            continue;
3677        };
3678        if validate_issue(issue).is_err() {
3679            continue;
3680        }
3681        // The object must be one the catalog can re-derive for that date and
3682        // issue; anything else is not this line's product.
3683        let issue_argument = (!center_catalog(center)
3684            .expect("catalog entry exists for enum variant")
3685            .issues
3686            .is_empty())
3687        .then_some(issue);
3688        match product(center, product_type, date, Some(sample), issue_argument) {
3689            Ok(spec) => {
3690                if spec.canonical_filename()? != stripped {
3691                    continue;
3692                }
3693            }
3694            Err(_) => continue,
3695        }
3696        let ordering = issue_ordering_minutes(date, issue)?;
3697        let replace = newest
3698            .as_ref()
3699            .is_none_or(|(newest_ordering, _)| ordering > *newest_ordering);
3700        if replace {
3701            newest = Some((
3702                ordering,
3703                PublishedProduct {
3704                    date,
3705                    issue: issue.to_string(),
3706                    filename: stripped.to_string(),
3707                    observed_at: object.observed_at.clone(),
3708                },
3709            ));
3710        }
3711    }
3712    Ok(newest.map(|(_, product)| product))
3713}
3714
3715/// Whole minutes from a published issue's nominal epoch to `now`.
3716///
3717/// This is the "N hours behind nominal" number for lag alerting: the newest
3718/// published issue's filename epoch compared with the caller's clock. It says
3719/// nothing about when the archive actually wrote the object; the verbatim
3720/// [`PublishedProduct::observed_at`] text carries that where the archive
3721/// exposes one.
3722pub fn published_issue_age_minutes(
3723    published: &PublishedProduct,
3724    now: ProductDateTime,
3725) -> Result<i64, DataCatalogError> {
3726    Ok(now.ordering_minutes() - issue_ordering_minutes(published.date, &published.issue)?)
3727}
3728
3729/// Return the next catalog issue nominally due at or after `now`.
3730///
3731/// This is a pure catalog query. It performs no listing fetch and does not say
3732/// whether an archive has published the issue. The schedule rules are pinned
3733/// to the IGS product descriptions, the IGS Analysis Center Coordinator
3734/// schedule, and the relevant center descriptions in the committed nominal
3735/// schedule provenance fixture.
3736///
3737/// Rules represented here:
3738///
3739/// - IGS combined ultra-rapid SP3 is released 27 hours after the filename's
3740///   coverage start: 24 observed hours followed by the 03:00, 09:00, 15:00,
3741///   or 21:00 UTC release. Analysis-center ultra products use the 26 h 50 min
3742///   submission deadline. A two-day ultra identity names a 24-hour observed
3743///   interval followed by a 24-hour predicted interval.
3744/// - GFZ rapid SP3 and CLK use the daily 15:45 UTC analysis-center deadline for
3745///   the preceding UTC day. CODE rapid IONEX uses the published less-than-24 h
3746///   latency boundary, represented as 00:00 UTC following the map date.
3747/// - Final SP3 and CLK lines represent the newest daily identity in each weekly
3748///   batch. Analysis-center batches are due Wednesday at 05:00 UTC, 11 days
3749///   after the GPS week ends. The IGS combined final batch is due by Friday,
3750///   represented as 23:59:59 UTC, 13 days after the week ends. Final IONEX uses
3751///   the published approximately 11-day weekly latency, with a date-only
3752///   deadline represented as 23:59:59 UTC.
3753/// - CODE predicted IONEX is due at 00:00 UTC one or two days before its map
3754///   date, matching the line's cataloged prediction horizon.
3755///
3756/// Near-real-time WUM and broadcast navigation lines are outside these pinned
3757/// rules and return [`DataCatalogError::UnsupportedNominalSchedule`].
3758pub fn next_issue_due(
3759    center: AnalysisCenter,
3760    product_type: ProductType,
3761    now: ProductDateTime,
3762) -> Result<NominalIssue, DataCatalogError> {
3763    ProductDate::new(now.date.year, now.date.month, now.date.day)?;
3764    ProductDateTime::new(now.date, now.hour, now.minute, now.second)?;
3765    product_convention(center, product_type)?;
3766    if center == AnalysisCenter::WumNrt || product_type == ProductType::Nav {
3767        return Err(DataCatalogError::UnsupportedNominalSchedule {
3768            center,
3769            product_type,
3770        });
3771    }
3772
3773    let mut next: Option<NominalIssue> = None;
3774    for offset_days in -28_i64..=42 {
3775        let Ok(identity_date) = now.date.add_days(offset_days) else {
3776            continue;
3777        };
3778        for candidate in nominal_issues_for_date(center, product_type, identity_date)? {
3779            if candidate.due_at < now {
3780                continue;
3781            }
3782            let replace = next.as_ref().is_none_or(|current| {
3783                candidate.due_at < current.due_at
3784                    || (candidate.due_at == current.due_at
3785                        && candidate.identity.official_filename
3786                            < current.identity.official_filename)
3787            });
3788            if replace {
3789                next = Some(candidate);
3790            }
3791        }
3792    }
3793    next.ok_or(DataCatalogError::DateOutOfRange)
3794}
3795
3796fn nominal_issues_for_date(
3797    center: AnalysisCenter,
3798    product_type: ProductType,
3799    identity_date: ProductDate,
3800) -> Result<Vec<NominalIssue>, DataCatalogError> {
3801    let solution = product_solution_class(center, product_type)?;
3802    if solution == SolutionClass::Final && identity_date.gps_day_of_week()? != 6 {
3803        return Ok(Vec::new());
3804    }
3805
3806    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3807    let issues: Vec<&str> = if matches!(solution, SolutionClass::UltraRapid) {
3808        entry.issues.to_vec()
3809    } else {
3810        vec!["0000"]
3811    };
3812    let mut out = Vec::with_capacity(issues.len());
3813    for issue in issues {
3814        let issue_argument = (!entry.issues.is_empty()).then_some(issue);
3815        let identity =
3816            match product_identity(center, product_type, identity_date, None, issue_argument) {
3817                Ok(identity) => identity,
3818                Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3819                Err(error) => return Err(error),
3820            };
3821        let filename_epoch = ProductDateTime::new(
3822            identity_date,
3823            (issue_minutes(issue)? / 60) as u8,
3824            (issue_minutes(issue)? % 60) as u8,
3825            0,
3826        )?;
3827        let covers = nominal_coverage(&identity, filename_epoch)?;
3828        let due_at = nominal_due_at(center, product_type, solution, filename_epoch, covers)?;
3829        out.push(NominalIssue {
3830            identity,
3831            due_at,
3832            covers,
3833        });
3834    }
3835    Ok(out)
3836}
3837
3838fn nominal_due_at(
3839    center: AnalysisCenter,
3840    product_type: ProductType,
3841    solution: SolutionClass,
3842    filename_epoch: ProductDateTime,
3843    covers: NominalCoverage,
3844) -> Result<ProductDateTime, DataCatalogError> {
3845    match solution {
3846        SolutionClass::UltraRapid => {
3847            let observed_until = covers
3848                .observed
3849                .ok_or(DataCatalogError::InconsistentProductIdentity {
3850                    field: "nominal_ultra_observed_coverage",
3851                })?
3852                .until;
3853            add_product_seconds(
3854                observed_until,
3855                if center == AnalysisCenter::IgsUlt {
3856                    3 * 3_600
3857                } else {
3858                    2 * 3_600 + 50 * 60
3859                },
3860            )
3861        }
3862        SolutionClass::Rapid if product_type == ProductType::Ionex => {
3863            add_product_seconds(filename_epoch, 24 * 3_600)
3864        }
3865        SolutionClass::Rapid => {
3866            add_product_seconds(filename_epoch, 24 * 3_600 + 15 * 3_600 + 45 * 60)
3867        }
3868        SolutionClass::Final if center == AnalysisCenter::Igs => {
3869            add_product_seconds(filename_epoch, 13 * 86_400 + 23 * 3_600 + 59 * 60 + 59)
3870        }
3871        SolutionClass::Final if product_type == ProductType::Ionex => {
3872            add_product_seconds(filename_epoch, 11 * 86_400 + 23 * 3_600 + 59 * 60 + 59)
3873        }
3874        SolutionClass::Final => add_product_seconds(filename_epoch, 11 * 86_400 + 5 * 3_600),
3875        SolutionClass::Predicted => {
3876            let horizon = center.prediction_horizon_days().ok_or(
3877                DataCatalogError::UnsupportedNominalSchedule {
3878                    center,
3879                    product_type,
3880                },
3881            )?;
3882            add_product_seconds(filename_epoch, -i64::from(horizon) * 86_400)
3883        }
3884        SolutionClass::NearRealTime | SolutionClass::Broadcast => {
3885            Err(DataCatalogError::UnsupportedNominalSchedule {
3886                center,
3887                product_type,
3888            })
3889        }
3890    }
3891}
3892
3893fn nominal_coverage(
3894    identity: &ProductIdentity,
3895    filename_epoch: ProductDateTime,
3896) -> Result<NominalCoverage, DataCatalogError> {
3897    let content_offset_s = if identity.family == ProductType::Sp3 {
3898        let entry = center_catalog(identity.analysis_center)
3899            .expect("validated identity has a catalog entry");
3900        let issue = if entry.issues.is_empty() {
3901            None
3902        } else {
3903            identity.issue.as_deref()
3904        };
3905        sp3_content_start_convention(identity.analysis_center, identity.date, issue)?
3906            .content_start_offset_s()
3907    } else {
3908        0
3909    };
3910    let from = add_product_seconds(filename_epoch, content_offset_s)?;
3911    let duration_s = match identity.span.as_str() {
3912        "01D" => 86_400,
3913        "02D" => 172_800,
3914        _ => {
3915            return Err(DataCatalogError::InconsistentProductIdentity {
3916                field: "nominal_coverage_span",
3917            })
3918        }
3919    };
3920    let until = add_product_seconds(from, duration_s)?;
3921
3922    if identity.solution == SolutionClass::UltraRapid && duration_s == 172_800 {
3923        let split = add_product_seconds(from, 86_400)?;
3924        Ok(NominalCoverage {
3925            observed: Some(NominalCoverageInterval { from, until: split }),
3926            predicted: Some(NominalCoverageInterval { from: split, until }),
3927        })
3928    } else if identity.solution == SolutionClass::Predicted {
3929        Ok(NominalCoverage {
3930            observed: None,
3931            predicted: Some(NominalCoverageInterval { from, until }),
3932        })
3933    } else {
3934        Ok(NominalCoverage {
3935            observed: Some(NominalCoverageInterval { from, until }),
3936            predicted: None,
3937        })
3938    }
3939}
3940
3941fn add_product_seconds(
3942    datetime: ProductDateTime,
3943    seconds: i64,
3944) -> Result<ProductDateTime, DataCatalogError> {
3945    let total = datetime
3946        .ordering_seconds()
3947        .checked_add(seconds)
3948        .ok_or(DataCatalogError::DateOutOfRange)?;
3949    let jdn = total.div_euclid(86_400);
3950    let seconds_of_day = total.rem_euclid(86_400);
3951    ProductDateTime::new(
3952        product_date_from_jdn(jdn)?,
3953        u8::try_from(seconds_of_day / 3_600).map_err(|_| DataCatalogError::DateOutOfRange)?,
3954        u8::try_from((seconds_of_day % 3_600) / 60)
3955            .map_err(|_| DataCatalogError::DateOutOfRange)?,
3956        u8::try_from(seconds_of_day % 60).map_err(|_| DataCatalogError::DateOutOfRange)?,
3957    )
3958}
3959
3960/// Archive listing URLs that can answer "what is the newest published issue"
3961/// for one center + product line, ordered newest-directory-first.
3962///
3963/// This is a bounded enumeration, not a poll: at most two URLs. Week-layout
3964/// archives get the week directory containing `around` plus the previous week
3965/// (a late archive may not have created the current week's directory yet -
3966/// the recorded 2026-08-04 BKG state). Year-layout archives are served
3967/// through AIUB's whole-tree CSV listing, one URL. The caller fetches in
3968/// order and interprets each body with [`parse_archive_listing`] and
3969/// [`newest_published_product`].
3970///
3971/// Transport note for callers wiring their own fetch: AIUB's listing URL
3972/// 302-redirects to its object store (the same redirect its product
3973/// downloads use), so the fetch must follow bounded redirects; treating the
3974/// 3xx as failure misreports every CODE line as unreachable. WUM listing
3975/// URLs are `ftp://` directory listings whose `LIST` text
3976/// [`parse_archive_listing`] parses directly.
3977pub fn publication_listing_urls(
3978    center: AnalysisCenter,
3979    product_type: ProductType,
3980    around: ProductDate,
3981) -> Result<Vec<String>, DataCatalogError> {
3982    let convention = product_convention(center, product_type)?;
3983    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3984    match convention.layout {
3985        ArchiveLayout::AiubCodeRoot
3986        | ArchiveLayout::AiubCodeYear
3987        | ArchiveLayout::AiubCodeMgexYear => {
3988            Ok(vec![format!("{}/full_listing.csv", entry.root_url)])
3989        }
3990        _ => {
3991            let current = format!(
3992                "{}/{}/",
3993                entry.root_url,
3994                product_dir_path(center, convention.layout, around)?
3995            );
3996            let previous_week_date = around.add_days(-7)?;
3997            let previous = format!(
3998                "{}/{}/",
3999                entry.root_url,
4000                product_dir_path(center, convention.layout, previous_week_date)?
4001            );
4002            let mut urls = vec![current];
4003            if !urls.contains(&previous) {
4004                urls.push(previous);
4005            }
4006            Ok(urls)
4007        }
4008    }
4009}
4010
4011/// Index of the first candidate whose exact archive object is present among
4012/// listed objects.
4013///
4014/// This is the pure availability step of a candidate walk such as
4015/// [`predicted_ionex_line_candidates`]: candidates stay in preference order,
4016/// an object counts only when it is exactly the candidate's official archive
4017/// filename (with the line's compression suffix) on the candidate's line, and
4018/// the returned index preserves the candidate's own identity - resolved
4019/// provenance therefore names the line actually served.
4020pub fn resolve_first_published(
4021    candidates: &[ProductSpec],
4022    objects: &[PublishedObject],
4023) -> Result<Option<usize>, DataCatalogError> {
4024    for (index, candidate) in candidates.iter().enumerate() {
4025        let filename = candidate.canonical_filename()?;
4026        let convention = product_convention(candidate.center, candidate.product_type)?;
4027        let compression = product_archive_compression(
4028            candidate.center,
4029            candidate.product_type,
4030            candidate.date,
4031            convention.compression,
4032        )?;
4033        let archive_name = format!("{filename}{}", compression.suffix());
4034        let found = objects.iter().any(|object| {
4035            if !object_matches_center(candidate.center, &object.path) {
4036                return false;
4037            }
4038            let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
4039            listed_name == archive_name || listed_name == filename
4040        });
4041        if found {
4042            return Ok(Some(index));
4043        }
4044    }
4045    Ok(None)
4046}
4047
4048fn product_date_from_year_day(
4049    year: i32,
4050    day_of_year: u16,
4051) -> Result<ProductDate, DataCatalogError> {
4052    if day_of_year == 0 {
4053        return Err(DataCatalogError::DateOutOfRange);
4054    }
4055    ProductDate::new(year, 1, 1)?
4056        .add_days(i64::from(day_of_year) - 1)
4057        .and_then(|date| {
4058            if date.year == year {
4059                Ok(date)
4060            } else {
4061                Err(DataCatalogError::DateOutOfRange)
4062            }
4063        })
4064}
4065
4066/// Build a daily station observation product.
4067pub fn station_obs(
4068    station: &str,
4069    date: ProductDate,
4070    sample: Option<&str>,
4071) -> Result<StationObservationSpec, DataCatalogError> {
4072    StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
4073}
4074
4075/// Build the canonical RINEX 3 CRINEX filename for a daily station observation.
4076pub fn station_obs_filename(
4077    station: &str,
4078    date: ProductDate,
4079    sample: &str,
4080) -> Result<String, DataCatalogError> {
4081    validate_station(station)?;
4082    validate_sample(sample)?;
4083    Ok(format!(
4084        "{}_R_{}_01D_{}_MO.crx",
4085        station,
4086        date_block(date, None),
4087        sample
4088    ))
4089}
4090
4091/// Build the full BKG IGS archive URL for a daily station observation.
4092pub fn station_obs_url(
4093    station: &str,
4094    date: ProductDate,
4095    sample: &str,
4096) -> Result<String, DataCatalogError> {
4097    let filename = station_obs_filename(station, date, sample)?;
4098    Ok(format!(
4099        "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
4100        dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
4101        filename
4102    ))
4103}
4104
4105/// The transfer protocol for the daily station observation archive.
4106#[must_use]
4107pub const fn station_obs_protocol() -> ArchiveProtocol {
4108    ArchiveProtocol::Https
4109}
4110
4111fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
4112    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
4113        Ok(())
4114    } else {
4115        Err(DataCatalogError::InvalidTileIndex {
4116            lat_index,
4117            lon_index: 0,
4118        })
4119    }
4120}
4121
4122fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
4123    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
4124        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
4125    {
4126        Ok(())
4127    } else {
4128        Err(DataCatalogError::InvalidTileIndex {
4129            lat_index,
4130            lon_index,
4131        })
4132    }
4133}
4134
4135fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
4136    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
4137        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
4138    {
4139        Ok(())
4140    } else {
4141        Err(HgtConversionError::InvalidTileIndex {
4142            lat_index,
4143            lon_index,
4144        })
4145    }
4146}
4147
4148fn dted_coord_field(index: i32, is_longitude: bool) -> String {
4149    let hemi = match (is_longitude, index >= 0) {
4150        (true, true) => 'E',
4151        (true, false) => 'W',
4152        (false, true) => 'N',
4153        (false, false) => 'S',
4154    };
4155    format!("{:03}0000{hemi}", index.abs())
4156}
4157
4158fn encode_dted_signed_magnitude(sample: i16) -> u16 {
4159    if sample == i16::MIN {
4160        0
4161    } else if sample >= 0 {
4162        sample as u16
4163    } else {
4164        0x8000 | (-i32::from(sample) as u16)
4165    }
4166}
4167
4168fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
4169    PRODUCT_TYPE_CONVENTIONS
4170        .iter()
4171        .find(|descriptor| descriptor.product_type == product_type)
4172        .expect("product descriptor exists for enum variant")
4173}
4174
4175const fn product_format(product_type: ProductType) -> ProductFormat {
4176    match product_type {
4177        ProductType::Sp3 => ProductFormat::Sp3,
4178        ProductType::Ionex => ProductFormat::Ionex,
4179        ProductType::Clk => ProductFormat::RinexClock,
4180        ProductType::Nav => ProductFormat::RinexNavigation,
4181    }
4182}
4183
4184fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
4185    if filename.is_empty()
4186        || filename == "."
4187        || filename == ".."
4188        || filename.contains('/')
4189        || filename.contains('\\')
4190        || filename.contains('\0')
4191        || filename.contains("..")
4192    {
4193        Err(DataCatalogError::InvalidOfficialFilename(
4194            filename.to_string(),
4195        ))
4196    } else {
4197        Ok(())
4198    }
4199}
4200
4201fn validate_product(
4202    center: AnalysisCenter,
4203    product_type: ProductType,
4204    date: ProductDate,
4205    sample: &str,
4206    issue: Option<&str>,
4207) -> Result<&'static CenterProductConvention, DataCatalogError> {
4208    let convention = product_convention(center, product_type)?;
4209    validate_sample(sample)?;
4210    validate_issue_for_center(center, issue)?;
4211    validate_product_date(center, product_type, date)?;
4212    validate_catalog_sample(center, product_type, date, sample, issue)?;
4213    Ok(convention)
4214}
4215
4216fn validate_catalog_sample(
4217    center: AnalysisCenter,
4218    product_type: ProductType,
4219    date: ProductDate,
4220    sample: &str,
4221    issue: Option<&str>,
4222) -> Result<(), DataCatalogError> {
4223    let supported = supported_samples_inner(center, product_type, date, issue)?;
4224    if supported.contains(&sample) {
4225        return Ok(());
4226    }
4227    Err(DataCatalogError::UnsupportedSample {
4228        center,
4229        product_type,
4230        sample: sample.to_string(),
4231    })
4232}
4233
4234/// Officially evidenced sampling tokens for one exact catalog product.
4235///
4236/// Syntax alone is not publication evidence: this query reports only cadences
4237/// backed by the official product line for the selected center, family, date,
4238/// and issue. Constructors enforce the same result before deriving a filename,
4239/// URL, identity, or cache key.
4240///
4241/// For issue-based product lines, omitting `issue` selects the `0000` issue,
4242/// matching [`default_sample_for_date`]. Product construction itself still
4243/// requires an explicit issue.
4244pub fn supported_samples(
4245    center: AnalysisCenter,
4246    product_type: ProductType,
4247    date: ProductDate,
4248    issue: Option<&str>,
4249) -> Result<&'static [&'static str], DataCatalogError> {
4250    ProductDate::new(date.year, date.month, date.day)?;
4251    product_convention(center, product_type)?;
4252    validate_product_date(center, product_type, date)?;
4253
4254    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4255    if entry.issues.is_empty() {
4256        validate_issue_for_center(center, issue)?;
4257    } else {
4258        validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
4259    }
4260    supported_samples_inner(center, product_type, date, issue)
4261}
4262
4263fn supported_samples_inner(
4264    center: AnalysisCenter,
4265    product_type: ProductType,
4266    date: ProductDate,
4267    issue: Option<&str>,
4268) -> Result<&'static [&'static str], DataCatalogError> {
4269    if product_type != ProductType::Sp3 {
4270        let convention = product_convention(center, product_type)?;
4271        return Ok(match convention.default_sample {
4272            "30S" => &["30S"],
4273            "01H" => &["01H"],
4274            "02H" => &["02H"],
4275            "01D" => &["01D"],
4276            _ => &[],
4277        });
4278    }
4279
4280    Ok(match center {
4281        AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
4282        AnalysisCenter::Esa
4283        | AnalysisCenter::Cod
4284        | AnalysisCenter::CodUlt
4285        | AnalysisCenter::WumNrt => &["05M"],
4286        AnalysisCenter::Gfz => {
4287            if date < GFZ_RAPID_5M_START_DATE {
4288                &["15M"]
4289            } else {
4290                &["05M"]
4291            }
4292        }
4293        AnalysisCenter::EsaUlt => {
4294            let issue = issue.unwrap_or("0000");
4295            let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4296                || (date == ESA_ULTRA_15M_LAST_DATE
4297                    && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4298            if at_or_before_last_15m {
4299                &["15M"]
4300            } else {
4301                &["05M"]
4302            }
4303        }
4304        AnalysisCenter::GfzUlt => {
4305            if date < GFZ_ULTRA_15M_LAST_DATE {
4306                &["15M"]
4307            } else if date == GFZ_ULTRA_15M_LAST_DATE {
4308                if issue.unwrap_or("0000") == "0000" {
4309                    &["15M", "05M"]
4310                } else {
4311                    &["15M"]
4312                }
4313            } else {
4314                &["05M"]
4315            }
4316        }
4317        AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
4318    })
4319}
4320
4321fn validate_product_date(
4322    center: AnalysisCenter,
4323    product_type: ProductType,
4324    date: ProductDate,
4325) -> Result<(), DataCatalogError> {
4326    // The official IGS rapid/final orbit combination began at GPS week 0730.
4327    // Earlier dates must not be assigned a syntactically plausible legacy
4328    // filename for a combined final product that did not yet exist.
4329    if center == AnalysisCenter::Igs
4330        && product_type == ProductType::Sp3
4331        && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
4332    {
4333        return Err(DataCatalogError::UnsupportedProductEra {
4334            center,
4335            product_type,
4336            date,
4337        });
4338    }
4339
4340    // AIUB documents different short-name CODE products through week 2237.
4341    // This catalog intentionally refuses those dates until their distinct
4342    // identities and distributor rules are modeled; it must not emit a
4343    // post-transition long filename that never existed.
4344    if center == AnalysisCenter::Cod
4345        && matches!(
4346            product_type,
4347            ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
4348        )
4349        && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
4350    {
4351        return Err(DataCatalogError::UnsupportedProductEra {
4352            center,
4353            product_type,
4354            date,
4355        });
4356    }
4357
4358    let start_date = match (center, product_type) {
4359        (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
4360            Some(ESA_FINAL_SERIES_START_DATE)
4361        }
4362        (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
4363            Some(GFZ_RAPID_SERIES_START_DATE)
4364        }
4365        (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
4366        (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
4367        (AnalysisCenter::WumNrt, ProductType::Sp3) => Some(WUM_NRT_SP3_START_DATE),
4368        _ => None,
4369    };
4370    let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
4371        && product_type == ProductType::Sp3
4372        && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
4373    if before_long_name_start || start_date.is_some_and(|start| date < start) {
4374        return Err(DataCatalogError::UnsupportedProductEra {
4375            center,
4376            product_type,
4377            date,
4378        });
4379    }
4380    Ok(())
4381}
4382
4383fn default_sample_for_product_issue(
4384    center: AnalysisCenter,
4385    product_type: ProductType,
4386    date: ProductDate,
4387    issue: Option<&str>,
4388) -> Result<&'static str, DataCatalogError> {
4389    ProductDate::new(date.year, date.month, date.day)?;
4390    let current = default_sample(center, product_type)?;
4391    validate_product_date(center, product_type, date)?;
4392
4393    if product_type != ProductType::Sp3 {
4394        return Ok(current);
4395    }
4396    match center {
4397        AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
4398        AnalysisCenter::EsaUlt => {
4399            // A date-only query represents the 0000/start-of-day issue. Product
4400            // construction supplies the actual issue and therefore observes
4401            // the within-day transition on 2025-02-02.
4402            let issue = issue.unwrap_or("0000");
4403            validate_issue_for_center(center, Some(issue))?;
4404            let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4405                || (date == ESA_ULTRA_15M_LAST_DATE
4406                    && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4407            if at_or_before_last_15m {
4408                Ok("15M")
4409            } else {
4410                Ok(current)
4411            }
4412        }
4413        AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
4414        _ => Ok(current),
4415    }
4416}
4417
4418fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
4419    let gps_week = identity.date.gps_week()?;
4420    let esa_mgex_final_sp3 =
4421        identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
4422    // The Wuhan near-real-time hourly line is served from the WHU archive
4423    // only; no exact CDDIS mapping is cataloged for it, so it is not
4424    // projected onto CDDIS (same rule as the ESA `ESA0MGNFIN` line).
4425    if identity.analysis_center == AnalysisCenter::WumNrt {
4426        return Err(DataCatalogError::UnsupportedDistributionEra {
4427            source: DistributionSource::NasaCddis,
4428            center: identity.analysis_center,
4429            product_type: identity.family,
4430            date: identity.date,
4431        });
4432    }
4433    let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
4434        && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
4435        && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
4436    let unmodeled_pretransition_ionex =
4437        identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
4438    if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
4439        Err(DataCatalogError::UnsupportedDistributionEra {
4440            source: DistributionSource::NasaCddis,
4441            center: identity.analysis_center,
4442            product_type: identity.family,
4443            date: identity.date,
4444        })
4445    } else {
4446        Ok(())
4447    }
4448}
4449
4450fn validate_issue_for_center(
4451    center: AnalysisCenter,
4452    issue: Option<&str>,
4453) -> Result<(), DataCatalogError> {
4454    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4455    match (entry.issues.is_empty(), issue) {
4456        (true, None) => Ok(()),
4457        (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
4458        (false, None) => Err(DataCatalogError::MissingIssue { center }),
4459        (false, Some(issue)) => {
4460            validate_issue(issue)?;
4461            if entry.issues.contains(&issue) {
4462                Ok(())
4463            } else {
4464                Err(DataCatalogError::UnsupportedIssue {
4465                    center,
4466                    issue: issue.to_string(),
4467                })
4468            }
4469        }
4470    }
4471}
4472
4473fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
4474    if validate_period_token(sample) {
4475        Ok(())
4476    } else {
4477        Err(DataCatalogError::InvalidSample(sample.to_string()))
4478    }
4479}
4480
4481fn validate_span(span: &str) -> Result<(), DataCatalogError> {
4482    if validate_period_token(span) {
4483        Ok(())
4484    } else {
4485        Err(DataCatalogError::InvalidSpan(span.to_string()))
4486    }
4487}
4488
4489fn validate_period_token(token: &str) -> bool {
4490    let bytes = token.as_bytes();
4491    if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
4492        return false;
4493    }
4494    let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
4495    match bytes[2] {
4496        // Reject exact smaller-unit spellings where the public guideline
4497        // unambiguously provides the next sub-day unit. Do not normalize D to
4498        // W or L to Y: official IGS filenames use values such as 07D, and the
4499        // public convention treats those calendar-oriented units as valid.
4500        b'S' | b'M' => amount > 0 && amount % 60 != 0,
4501        b'H' => amount > 0 && amount % 24 != 0,
4502        b'D' | b'W' | b'L' | b'Y' => amount > 0,
4503        // IGS reserves 00U for an unspecified interval. Exact-SP3 validation
4504        // rejects it because it cannot represent a positive cadence.
4505        b'U' => amount == 0,
4506        _ => false,
4507    }
4508}
4509
4510fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
4511    let bytes = issue.as_bytes();
4512    let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
4513    if !valid_digits {
4514        return Err(DataCatalogError::InvalidIssue(issue.to_string()));
4515    }
4516    let hour = issue[0..2]
4517        .parse::<u8>()
4518        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4519    let minute = issue[2..4]
4520        .parse::<u8>()
4521        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4522    if hour <= 23 && minute <= 59 {
4523        Ok(())
4524    } else {
4525        Err(DataCatalogError::InvalidIssue(issue.to_string()))
4526    }
4527}
4528
4529fn validate_station(station: &str) -> Result<(), DataCatalogError> {
4530    let bytes = station.as_bytes();
4531    let valid = bytes.len() == 9
4532        && bytes
4533            .iter()
4534            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
4535    if valid {
4536        Ok(())
4537    } else {
4538        Err(DataCatalogError::InvalidStation(station.to_string()))
4539    }
4540}
4541
4542fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
4543    validate_issue(issue)?;
4544    let hour = issue[0..2]
4545        .parse::<u16>()
4546        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4547    let minute = issue[2..4]
4548        .parse::<u16>()
4549        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4550    Ok(hour * 60 + minute)
4551}
4552
4553fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
4554    Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
4555}
4556
4557fn date_block(date: ProductDate, issue: Option<&str>) -> String {
4558    format!(
4559        "{}{:03}{}",
4560        date.year,
4561        date.day_of_year(),
4562        issue.unwrap_or("0000")
4563    )
4564}
4565
4566fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
4567    Ok(match layout {
4568        ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
4569        ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
4570        ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
4571        ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
4572        ArchiveLayout::BkgBrdcYearDoy => {
4573            format!("BRDC/{}/{:03}", date.year, date.day_of_year())
4574        }
4575        ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
4576        ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
4577        ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
4578        ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
4579    })
4580}
4581
4582fn product_dir_path(
4583    center: AnalysisCenter,
4584    layout: ArchiveLayout,
4585    date: ProductDate,
4586) -> Result<String, DataCatalogError> {
4587    match center {
4588        AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
4589        AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
4590        _ => dir_path(layout, date),
4591    }
4592}
4593
4594fn uses_legacy_igs_final_name(
4595    center: AnalysisCenter,
4596    product_type: ProductType,
4597    date: ProductDate,
4598) -> Result<bool, DataCatalogError> {
4599    Ok(center == AnalysisCenter::Igs
4600        && product_type == ProductType::Sp3
4601        && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
4602}
4603
4604fn product_archive_compression(
4605    center: AnalysisCenter,
4606    product_type: ProductType,
4607    date: ProductDate,
4608    default: ArchiveCompression,
4609) -> Result<ArchiveCompression, DataCatalogError> {
4610    if uses_legacy_igs_final_name(center, product_type, date)? {
4611        Ok(ArchiveCompression::UnixCompress)
4612    } else {
4613        Ok(default)
4614    }
4615}
4616
4617fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
4618    let (year, month, day) = civil_from_julian_day_number(jdn);
4619    let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
4620    let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
4621    let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
4622    ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
4623}
4624
4625#[cfg(test)]
4626mod content_start_tests {
4627    use super::*;
4628
4629    const GFZ_ISSUES: [&str; 8] = [
4630        "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
4631    ];
4632
4633    fn date(year: i32, month: u8, day: u8) -> ProductDate {
4634        ProductDate::new(year, month, day).expect("test date")
4635    }
4636
4637    fn offset(
4638        center: AnalysisCenter,
4639        product_date: ProductDate,
4640        sample: &str,
4641        issue: Option<&str>,
4642    ) -> i64 {
4643        let identity =
4644            product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
4645                .expect("cataloged SP3 identity");
4646        exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
4647    }
4648
4649    #[test]
4650    fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
4651        for issue in GFZ_ISSUES {
4652            assert_eq!(
4653                offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
4654                -86_400,
4655                "2022-09-06 issue {issue}"
4656            );
4657        }
4658    }
4659
4660    #[test]
4661    fn gfz_ultra_transition_is_cataloged_per_issue() {
4662        let day_seven = [
4663            0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
4664        ];
4665        let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
4666
4667        for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
4668            for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
4669                assert_eq!(
4670                    offset(
4671                        AnalysisCenter::GfzUlt,
4672                        date(2022, 9, product_day),
4673                        "05M",
4674                        Some(issue)
4675                    ),
4676                    expected_offset,
4677                    "2022-09-{product_day:02} issue {issue}"
4678                );
4679            }
4680        }
4681    }
4682
4683    #[test]
4684    fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
4685        for issue in GFZ_ISSUES {
4686            assert_eq!(
4687                offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
4688                0,
4689                "2022-09-09 issue {issue}"
4690            );
4691        }
4692
4693        let current = date(2026, 7, 20);
4694        let cases = [
4695            (AnalysisCenter::Igs, "15M", None),
4696            (AnalysisCenter::Esa, "05M", None),
4697            (AnalysisCenter::Cod, "05M", None),
4698            (AnalysisCenter::Gfz, "05M", None),
4699            (AnalysisCenter::IgsUlt, "15M", Some("1200")),
4700            (AnalysisCenter::CodUlt, "05M", Some("0000")),
4701            (AnalysisCenter::EsaUlt, "05M", Some("1800")),
4702            (AnalysisCenter::GfzUlt, "05M", Some("2100")),
4703        ];
4704        for (center, sample, issue) in cases {
4705            assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
4706        }
4707    }
4708
4709    #[test]
4710    fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
4711        assert_eq!(
4712            offset(
4713                AnalysisCenter::GfzUlt,
4714                date(2021, 5, 15),
4715                "15M",
4716                Some("0000")
4717            ),
4718            -86_400
4719        );
4720        assert_eq!(
4721            offset(
4722                AnalysisCenter::GfzUlt,
4723                date(2021, 5, 16),
4724                "05M",
4725                Some("0000")
4726            ),
4727            -86_400
4728        );
4729    }
4730
4731    #[test]
4732    fn public_content_start_query_enforces_center_issue_rules() {
4733        assert_eq!(
4734            sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
4735            Err(DataCatalogError::UnsupportedIssue {
4736                center: AnalysisCenter::GfzUlt,
4737                issue: "0130".to_owned(),
4738            })
4739        );
4740        assert_eq!(
4741            sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
4742            Err(DataCatalogError::UnexpectedIssue {
4743                center: AnalysisCenter::Gfz,
4744            })
4745        );
4746        assert_eq!(
4747            sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
4748            Err(DataCatalogError::MissingIssue {
4749                center: AnalysisCenter::GfzUlt,
4750            })
4751        );
4752    }
4753
4754    /// Every path hashes to the same bucket here, which is the worst case a
4755    /// hash-keyed index can face. Distinct paths must still be distinct
4756    /// objects: the index confirms each candidate against the stored path, so
4757    /// a collision costs a comparison and never merges two archive objects.
4758    #[test]
4759    fn listing_index_keeps_distinct_paths_apart_under_total_hash_collision() {
4760        fn collide_everything(_path: &str) -> u64 {
4761            0
4762        }
4763
4764        let mut seen: Vec<PublishedObject> = Vec::new();
4765        let mut index = ListingIndex::default();
4766        for (path, observed_at) in [
4767            ("CODE/a.SP3", None),
4768            ("CODE/b.SP3", Some("2026-01-02 03:04:05".to_string())),
4769            ("CODE/c.SP3", None),
4770        ] {
4771            index.push(&mut seen, collide_everything, path.to_string(), observed_at);
4772        }
4773        assert_eq!(seen.len(), 3, "distinct paths merged under collision");
4774
4775        // A genuine repeat still merges, and still fills only an absent mtime.
4776        index.push(
4777            &mut seen,
4778            collide_everything,
4779            "CODE/a.SP3".to_string(),
4780            Some("2026-05-05 05:05:05".to_string()),
4781        );
4782        index.push(
4783            &mut seen,
4784            collide_everything,
4785            "CODE/b.SP3".to_string(),
4786            Some("2026-09-09 09:09:09".to_string()),
4787        );
4788        assert_eq!(seen.len(), 3, "repeat was not merged");
4789
4790        let paths: Vec<&str> = seen.iter().map(|object| object.path.as_str()).collect();
4791        assert_eq!(paths, ["CODE/a.SP3", "CODE/b.SP3", "CODE/c.SP3"]);
4792        assert_eq!(seen[0].observed_at.as_deref(), Some("2026-05-05 05:05:05"));
4793        assert_eq!(seen[1].observed_at.as_deref(), Some("2026-01-02 03:04:05"));
4794        assert_eq!(seen[2].observed_at, None);
4795    }
4796}