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