fits-well 0.1.4

A blazing-fast reader and writer for FITS (Flexible Image Transport System) files, targeting the full FITS 4.0 standard.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
//! Typed time coordinates (§9).
//!
//! Covers the computational core: ISO-8601 datetimes ↔ Julian Date / MJD
//! (proleptic-Gregorian calendar math), `J`/`B` epochs → JD, time-scale
//! conversions ([`TimeScale`]) among `UTC`/`TAI`/`TT`/`GPS`/`TCG`/`TDB`/`TCB`
//! (UTC↔TAI via an embedded leap-second table; TDB via the standard periodic
//! approximation), and a [`FitsTime`] view over a header's time keywords
//! (`TIMESYS`, `MJDREF*`/`JDREF*`/`DATEREF`, `TIMEUNIT`, `TREFPOS`, and the global
//! `DATE-OBS`/`MJD-OBS`/`TSTART`/… set). Validated against `astropy.time`.

use crate::error::FitsError;
use crate::error::Result;
use crate::header::Header;
use crate::keyword::key;

/// JD of the MJD zero point (1858-11-17T00:00 UTC).
const MJD0: f64 = 2_400_000.5;
/// 1977-01-01T00:00:00 TAI — the defining epoch where TT/TCG/TCB/TDB coincide.
const T1977_JD: f64 = 2_443_144.5;
/// TT − TAI, seconds (exact, by definition).
const TT_TAI: f64 = 32.184;
/// GPS = TAI − 19 s (GPS time is offset from TAI by a fixed 19 s).
const TAI_GPS: f64 = 19.0;
/// TCG rate: `(TCG − TT) = L_G · (TT − 1977.0)` (IAU 2000 Resolution B1.9).
const L_G: f64 = 6.969_290_134e-10;
/// TCB rate: `(TCB − TDB) ≈ L_B · (TDB − 1977.0)` (IAU 2006 Resolution B3).
const L_B: f64 = 1.550_519_768e-8;
/// `TDB_0`: the constant term of the `TDB − TT` relation (IAU 2006 Resolution B3).
const TDB_0: f64 = -6.55e-5;
const SEC_PER_DAY: f64 = 86_400.0;

/// A calendar datetime (proleptic Gregorian, UTC-agnostic). `second` may reach
/// 60.x to represent a leap second; the JD arithmetic rolls it over naturally.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Datetime {
    pub year: i64,
    pub month: u32,
    pub day: u32,
    pub hour: u32,
    pub minute: u32,
    pub second: f64,
}

impl Datetime {
    /// Parse an ISO-8601 `FITS` datetime: `YYYY-MM-DD` or
    /// `YYYY-MM-DDThh:mm:ss[.sss…]` (§9.1.1). No component defaulting; the date is
    /// required, the time part optional (then midnight).
    pub fn parse(s: &str) -> Result<Datetime> {
        let invalid = || FitsError::InvalidValue {
            card: format!("DATE '{s}'"),
        };
        let s = s.trim();
        // §9.1.1: no timezone designator is permitted (`Z` or a numeric offset).
        if s.contains(['Z', 'z']) {
            return Err(invalid());
        }
        let (date, time) = match s.split_once('T') {
            Some((d, t)) => (d, Some(t)),
            None => (s, None),
        };
        // `[±]CCYY-MM-DD`: the year has ≥4 digits and an optional sign; month/day
        // are exactly two digits (§9.1.1 — leading zeros may not be omitted).
        let (sign, rest) = match date.strip_prefix('-') {
            Some(r) => (-1, r),
            None => (1, date.strip_prefix('+').unwrap_or(date)),
        };
        let mut dp = rest.split('-');
        let y_str = dp.next().ok_or_else(invalid)?;
        let m_str = dp.next().ok_or_else(invalid)?;
        let d_str = dp.next().ok_or_else(invalid)?;
        if dp.next().is_some() || y_str.len() < 4 || !all_digits(y_str) {
            return Err(invalid());
        }
        let year = sign * y_str.parse::<i64>().map_err(|_| invalid())?;
        // `to_jd` computes `365·(year + 4800)` in `i64`; bound the year so a hostile
        // DATE can't overflow that. Any real observation/epoch date is far inside ±10⁶.
        if year.unsigned_abs() > 1_000_000 {
            return Err(invalid());
        }
        let month = parse_fixed(m_str, 2).ok_or_else(invalid)?;
        let day = parse_fixed(d_str, 2).ok_or_else(invalid)?;
        if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
            return Err(invalid());
        }

        let (mut hour, mut minute, mut second) = (0u32, 0u32, 0.0f64);
        if let Some(t) = time {
            let mut tp = t.split(':');
            hour = parse_fixed(tp.next().ok_or_else(invalid)?, 2).ok_or_else(invalid)?;
            minute = parse_fixed(tp.next().ok_or_else(invalid)?, 2).ok_or_else(invalid)?;
            if let Some(sec) = tp.next() {
                second = parse_seconds(sec).ok_or_else(invalid)?;
            }
            // Second 60 is the leap second; this type is scale-agnostic, so the
            // "only in UTC" restriction is left to the caller.
            if tp.next().is_some() || hour >= 24 || minute >= 60 || !(0.0..61.0).contains(&second) {
                return Err(invalid());
            }
        }
        Ok(Datetime {
            year,
            month,
            day,
            hour,
            minute,
            second,
        })
    }

    /// Julian Date of this datetime, interpreting the fields in their own time
    /// scale (no scale conversion is applied here).
    pub fn to_jd(&self) -> f64 {
        let day_fraction =
            (self.hour as f64 * 3600.0 + self.minute as f64 * 60.0 + self.second) / SEC_PER_DAY;
        gregorian_to_jdn(self.year, self.month as i64, self.day as i64) as f64 - 0.5 + day_fraction
    }

    /// Modified Julian Date (`JD − 2400000.5`).
    pub fn to_mjd(&self) -> f64 {
        self.to_jd() - MJD0
    }

    /// Build a datetime from a JD (inverse of [`Datetime::to_jd`]). A single `f64`
    /// JD at present epochs (~2.46e6) resolves to ~0.1 ms, so the recovered second
    /// carries that much rounding — fine for display, not for sub-ms timing.
    pub fn from_jd(jd: f64) -> Datetime {
        // Split into integer day (JDN at noon) and the fraction past midnight.
        let z = (jd + 0.5).floor();
        let frac = (jd + 0.5) - z;
        let date = jdn_to_gregorian(z as i64);
        let mut secs = frac * SEC_PER_DAY;
        let hour = (secs / 3600.0).floor();
        secs -= hour * 3600.0;
        let minute = (secs / 60.0).floor();
        secs -= minute * 60.0;
        Datetime {
            year: date.year,
            month: date.month,
            day: date.day,
            hour: hour as u32,
            minute: minute as u32,
            second: secs,
        }
    }
}

/// True if `s` is non-empty and all ASCII digits.
fn all_digits(s: &str) -> bool {
    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}

/// Parse a fixed-width all-digit field (§9.1.1 forbids omitted leading zeros, so
/// the length must be exact).
fn parse_fixed(s: &str, width: usize) -> Option<u32> {
    (s.len() == width && all_digits(s))
        .then(|| s.parse().ok())
        .flatten()
}

/// Parse a `ss[.s…]` seconds field: exactly two integer digits, optional fraction.
fn parse_seconds(s: &str) -> Option<f64> {
    let (int, frac) = s.split_once('.').map_or((s, None), |(i, f)| (i, Some(f)));
    if int.len() != 2 || !all_digits(int) || frac.is_some_and(|f| !all_digits(f)) {
        return None;
    }
    s.parse().ok()
}

/// A reference epoch: Julian (`J2000.0`) or Besselian (`B1950.0`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Epoch {
    /// Julian epoch in years (e.g. `2000.0`).
    Julian(f64),
    /// Besselian epoch in years (e.g. `1950.0`).
    Besselian(f64),
}

impl Epoch {
    /// Parse `J<year>` or `B<year>` (e.g. `'J2000.0'`, `'B1950.0'`).
    pub fn parse(s: &str) -> Result<Epoch> {
        let s = s.trim();
        let invalid = || FitsError::InvalidValue {
            card: format!("epoch '{s}'"),
        };
        let (tag, rest) = s.split_at(
            s.char_indices()
                .next()
                .map(|(_, c)| c.len_utf8())
                .unwrap_or(0),
        );
        let year: f64 = rest.parse().map_err(|_| invalid())?;
        match tag {
            "J" | "j" => Ok(Epoch::Julian(year)),
            "B" | "b" => Ok(Epoch::Besselian(year)),
            _ => Err(invalid()),
        }
    }

    /// Julian Date of the epoch. Julian: `2451545.0 + (y−2000)·365.25`; Besselian:
    /// `2415020.31352 + (y−1900)·365.242198781` (the tropical year).
    pub fn to_jd(self) -> f64 {
        match self {
            Epoch::Julian(y) => 2_451_545.0 + (y - 2000.0) * 365.25,
            Epoch::Besselian(y) => 2_415_020.313_52 + (y - 1900.0) * 365.242_198_781,
        }
    }

    /// Modified Julian Date of the epoch.
    pub fn to_mjd(self) -> f64 {
        self.to_jd() - MJD0
    }
}

/// A FITS time scale (`TIMESYS` / `CTYPEi`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeScale {
    /// Coordinated Universal Time.
    Utc,
    /// Universal Time (UT1) — treated as UTC here (ΔUT1 needs an external table).
    Ut1,
    /// International Atomic Time.
    Tai,
    /// Terrestrial Time.
    Tt,
    /// Geocentric Coordinate Time.
    Tcg,
    /// Barycentric Dynamical Time.
    Tdb,
    /// Barycentric Coordinate Time.
    Tcb,
    /// GPS time.
    Gps,
    /// An unspecified local clock (`LOCAL`); no conversion is possible.
    Local,
}

impl TimeScale {
    /// Parse a `TIMESYS`/`CTYPE` time-scale string (case-insensitive); accepts the
    /// `TDT`/`ET` → `TT` and `IAT` → `TAI` aliases. Unknown values map to `LOCAL`.
    pub fn parse(s: &str) -> TimeScale {
        // A high-precision value appends a realization in parentheses — `TT(TAI)`,
        // `UTC(NIST)` (§9.2.1); strip it before matching the scale name.
        let base = s.trim().split('(').next().unwrap_or("").trim();
        match base.to_ascii_uppercase().as_str() {
            "UTC" | "GMT" => TimeScale::Utc, // §9.2.1: GMT is continuous with UTC
            "UT1" | "UT" => TimeScale::Ut1,
            "TAI" | "IAT" => TimeScale::Tai,
            "TT" | "TDT" | "ET" => TimeScale::Tt,
            "TCG" => TimeScale::Tcg,
            "TDB" => TimeScale::Tdb,
            "TCB" => TimeScale::Tcb,
            "GPS" => TimeScale::Gps,
            _ => TimeScale::Local,
        }
    }

    /// Convert a Julian Date in this scale to a JD in `target`, treating `UT1` as
    /// `UTC` (ΔUT1 = 0). Use [`TimeScale::convert_dut1`] to supply a real ΔUT1.
    pub fn convert(self, jd: f64, target: TimeScale) -> f64 {
        self.convert_dut1(jd, target, 0.0)
    }

    /// Convert a Julian Date with an explicit `dut1 = UT1 − UTC` (seconds, from
    /// IERS) so conversions to/from `UT1` are exact. `Local` passes through.
    pub fn convert_dut1(self, jd: f64, target: TimeScale, dut1: f64) -> f64 {
        if self == target || self == TimeScale::Local || target == TimeScale::Local {
            return jd;
        }
        from_tt(self.to_tt(jd, dut1), target, dut1)
    }

    /// This scale's JD expressed as TT (the common pivot). `dut1 = UT1 − UTC` (s).
    fn to_tt(self, jd: f64, dut1: f64) -> f64 {
        match self {
            TimeScale::Tt => jd,
            TimeScale::Tai => jd + TT_TAI / SEC_PER_DAY,
            TimeScale::Gps => jd + (TT_TAI + TAI_GPS) / SEC_PER_DAY,
            TimeScale::Utc => jd + (TT_TAI + leap_seconds(jd - MJD0)) / SEC_PER_DAY,
            // UT1 → UTC (subtract ΔUT1) → TT.
            TimeScale::Ut1 => {
                let utc = jd - dut1 / SEC_PER_DAY;
                utc + (TT_TAI + leap_seconds(utc - MJD0)) / SEC_PER_DAY
            }
            TimeScale::Tcg => jd - L_G * (jd - T1977_JD),
            TimeScale::Tdb => jd - tdb_minus_tt(jd) / SEC_PER_DAY,
            TimeScale::Tcb => {
                let tdb = jd - L_B * (jd - T1977_JD) + TDB_0 / SEC_PER_DAY;
                tdb - tdb_minus_tt(tdb) / SEC_PER_DAY
            }
            // convert_dut1 returns early for Local, so it never reaches the pivot.
            TimeScale::Local => unreachable!("convert_dut1 short-circuits Local"),
        }
    }
}

/// TT (as a JD) expressed in `target` — the inverse of [`TimeScale::to_tt`].
fn from_tt(tt: f64, target: TimeScale, dut1: f64) -> f64 {
    match target {
        TimeScale::Tt => tt,
        TimeScale::Tai => tt - TT_TAI / SEC_PER_DAY,
        TimeScale::Gps => tt - (TT_TAI + TAI_GPS) / SEC_PER_DAY,
        TimeScale::Utc => {
            let tai = tt - TT_TAI / SEC_PER_DAY;
            // leap is a function of UTC; one lookup at the TAI date suffices away
            // from the ≤1 s boundary ambiguity inherent to UTC.
            tai - leap_seconds(tai - MJD0) / SEC_PER_DAY
        }
        // TT → UTC → UT1 (add ΔUT1).
        TimeScale::Ut1 => {
            let tai = tt - TT_TAI / SEC_PER_DAY;
            let utc = tai - leap_seconds(tai - MJD0) / SEC_PER_DAY;
            utc + dut1 / SEC_PER_DAY
        }
        TimeScale::Tcg => tt + L_G * (tt - T1977_JD),
        TimeScale::Tdb => tt + tdb_minus_tt(tt) / SEC_PER_DAY,
        TimeScale::Tcb => {
            let tdb = tt + tdb_minus_tt(tt) / SEC_PER_DAY;
            tdb + L_B * (tdb - T1977_JD) - TDB_0 / SEC_PER_DAY
        }
        // convert_dut1 returns early for Local, so it never reaches the pivot.
        TimeScale::Local => unreachable!("convert_dut1 short-circuits Local"),
    }
}

/// `TDB − TT` in seconds — the standard periodic approximation (~10 µs accuracy):
/// `0.001658·sin g + 0.000014·sin 2g`, `g = 357.53° + 0.9856003°·(JD_TT − J2000)`.
fn tdb_minus_tt(jd_tt: f64) -> f64 {
    let g = (357.53 + 0.985_600_3 * (jd_tt - 2_451_545.0)).to_radians();
    0.001_658 * g.sin() + 0.000_014 * (2.0 * g).sin()
}

/// `TAI − UTC` in seconds for a given UTC MJD: the integer leap-second count from
/// the IERS table (1972–2017).
///
/// Outside that range the value is **frozen at the nearest table end**, which the
/// caller should be aware of: past the last entry it returns the latest count
/// (correct until a new leap second is announced), and before 1972 it returns the
/// first entry (10 s) even though pre-1972 UTC used fractional "rubber seconds", so
/// UTC↔TAI for pre-1972 dates is approximate, not exact.
fn leap_seconds(mjd: f64) -> f64 {
    // (year, month, day, TAI−UTC) at each step, 1972 onward.
    const TABLE: &[(i64, i64, i64, f64)] = &[
        (1972, 1, 1, 10.0),
        (1972, 7, 1, 11.0),
        (1973, 1, 1, 12.0),
        (1974, 1, 1, 13.0),
        (1975, 1, 1, 14.0),
        (1976, 1, 1, 15.0),
        (1977, 1, 1, 16.0),
        (1978, 1, 1, 17.0),
        (1979, 1, 1, 18.0),
        (1980, 1, 1, 19.0),
        (1981, 7, 1, 20.0),
        (1982, 7, 1, 21.0),
        (1983, 7, 1, 22.0),
        (1985, 7, 1, 23.0),
        (1988, 1, 1, 24.0),
        (1990, 1, 1, 25.0),
        (1991, 1, 1, 26.0),
        (1992, 7, 1, 27.0),
        (1993, 7, 1, 28.0),
        (1994, 7, 1, 29.0),
        (1996, 1, 1, 30.0),
        (1997, 7, 1, 31.0),
        (1999, 1, 1, 32.0),
        (2006, 1, 1, 33.0),
        (2009, 1, 1, 34.0),
        (2012, 7, 1, 35.0),
        (2015, 7, 1, 36.0),
        (2017, 1, 1, 37.0),
    ];
    let mut leap = TABLE[0].3;
    for &(y, m, d, l) in TABLE {
        let threshold = gregorian_to_jdn(y, m, d) as f64 - 0.5 - MJD0;
        if mjd >= threshold {
            leap = l;
        } else {
            break;
        }
    }
    leap
}

/// Julian Day Number at noon of a proleptic-Gregorian calendar date (the standard
/// integer formula).
/// Whether `year` is a leap year in the proleptic Gregorian calendar.
fn is_leap_year(year: i64) -> bool {
    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}

/// Number of days in `month` (1–12) of `year`; `0` for an out-of-range month.
fn days_in_month(year: i64, month: u32) -> u32 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if is_leap_year(year) => 29,
        2 => 28,
        _ => 0,
    }
}

fn gregorian_to_jdn(year: i64, month: i64, day: i64) -> i64 {
    let a = (14 - month) / 12;
    let y = year + 4800 - a;
    let m = month + 12 * a - 3;
    day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
}

/// A proleptic-Gregorian calendar date — the result of [`jdn_to_gregorian`].
#[derive(Debug, Clone, Copy, PartialEq)]
struct CalendarDate {
    year: i64,
    month: u32,
    day: u32,
}

/// Inverse of [`gregorian_to_jdn`]: JDN → calendar date.
fn jdn_to_gregorian(jdn: i64) -> CalendarDate {
    let a = jdn + 32044;
    let b = (4 * a + 3) / 146097;
    let c = a - (146097 * b) / 4;
    let d = (4 * c + 3) / 1461;
    let e = c - (1461 * d) / 4;
    let m = (5 * e + 2) / 153;
    let day = e - (153 * m + 2) / 5 + 1;
    let month = m + 3 - 12 * (m / 10);
    let year = 100 * b + d - 4800 + m / 10;
    CalendarDate {
        year,
        month: month as u32,
        day: day as u32,
    }
}

/// A time from a `JEPOCH`/`BEPOCH` keyword: its MJD and the scale the keyword
/// implies (TDB for `JEPOCH`, ET ≈ TT for `BEPOCH`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EpochTime {
    pub mjd: f64,
    pub scale: TimeScale,
}

/// The global bound / duration / error time keywords (§9.4, §9.5, §9.7), as read
/// by [`Header::time_bounds`](crate::Header::time_bounds). Start/end are absolute
/// MJD; the rest are in `TIMEUNIT`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TimeBounds {
    /// Observation start: `MJD-BEG`, else `DATE-BEG` → MJD.
    pub beg_mjd: Option<f64>,
    /// Observation end: `MJD-END`, else `DATE-END` → MJD.
    pub end_mjd: Option<f64>,
    /// Observation midpoint: `MJD-AVG`, else `DATE-AVG` → MJD (§9.5, Table 35).
    pub avg_mjd: Option<f64>,
    /// `XPOSURE` — effective exposure time.
    pub xposure: Option<f64>,
    /// `TELAPSE` — total elapsed time.
    pub telapse: Option<f64>,
    /// `TIMEDEL` — time resolution / bin width.
    pub timedel: Option<f64>,
    /// `TIMEPIXR` — pixel position within a bin (0–1, default 0.5).
    pub timepixr: f64,
    /// `TIMSYER` — systematic time error.
    pub timsyer: Option<f64>,
    /// `TIMRDER` — random time error.
    pub timrder: Option<f64>,
}

/// A Good Time Interval as absolute MJD (§9.7).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GtiInterval {
    pub start_mjd: f64,
    pub stop_mjd: f64,
}

/// The §9.6 `'PHASE'` axis folding parameters: the zero-phase reference time
/// `CZPHSia` and the period `CPERIia`, in `TIMEUNIT` relative to the time
/// reference (the `TSTART` convention).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PhaseAxis {
    pub zero_phase: f64,
    pub period: f64,
}

impl PhaseAxis {
    /// Fold a relative time (in `TIMEUNIT`) to a phase in `[0, 1)`; `0.0` if the
    /// period is zero.
    pub fn fold(&self, time: f64) -> f64 {
        if self.period == 0.0 {
            return 0.0;
        }
        ((time - self.zero_phase) / self.period).rem_euclid(1.0)
    }
}

/// A header's time coordinate frame (§9): the reference epoch, scale, unit, and
/// the resolved global time keywords.
#[derive(Debug, Clone)]
pub struct FitsTime {
    /// `TIMESYS` time scale (default `UTC`).
    pub scale: TimeScale,
    /// Reference epoch as MJD (from `MJDREF`/`MJDREFI`+`MJDREFF`, `JDREF*`, or
    /// `DATEREF`); `0.0` if none is given.
    pub mjdref: f64,
    /// `TIMEUNIT` (default `'s'`).
    pub timeunit: String,
    /// `TIMEOFFS` (§9.4.1): a uniform additive clock correction in `TIMEUNIT`,
    /// equivalent to shifting the reference time. Default `0.0`.
    pub timeoffs: f64,
    /// `TREFPOS` (reference position, e.g. `'TOPOCENTER'`), if present.
    pub trefpos: Option<String>,
}

impl FitsTime {
    /// Parse the time frame from a header. The public entry point is
    /// [`Header::time`](crate::Header::time), which forwards here.
    pub(crate) fn from_header(header: &Header) -> FitsTime {
        let scale = header
            .get_text("TIMESYS")
            .map(TimeScale::parse)
            .unwrap_or(TimeScale::Utc);
        let timeunit = header.get_text("TIMEUNIT").unwrap_or("s").to_string();
        let trefpos = header.get_text("TREFPOS").map(str::to_string);
        FitsTime {
            scale,
            mjdref: reference_mjd(header),
            timeunit,
            timeoffs: header.get_real("TIMEOFFS").unwrap_or(0.0),
            trefpos,
        }
    }

    /// `TIMEUNIT` expressed in seconds (`s`, `d`/day, `a`/`yr` Julian year).
    pub fn unit_seconds(&self) -> f64 {
        // Table 34. The deprecated tropical/Besselian years use their conventional
        // lengths; a truly unknown unit falls back to seconds (the default).
        match self.timeunit.trim() {
            "s" => 1.0,
            "min" => 60.0,
            "h" => 3600.0,
            "d" | "day" => SEC_PER_DAY,
            "a" | "yr" | "y" => 365.25 * SEC_PER_DAY, // Julian year
            "cy" => 36525.0 * SEC_PER_DAY,            // Julian century = 100 a
            "ta" => 365.24219 * SEC_PER_DAY,          // tropical year (deprecated)
            "Ba" => 365.2421988 * SEC_PER_DAY,        // Besselian year (deprecated)
            _ => 1.0,
        }
    }

    /// Resolve a time value measured *relative* to `MJDREF` (e.g. `TSTART`,
    /// `TSTOP`), in `TIMEUNIT`, to an absolute MJD in the frame's own scale. The
    /// `TIMEOFFS` clock correction (§9.4.1) is added before scaling.
    pub fn relative_to_mjd(&self, value: f64) -> f64 {
        self.mjdref + (value + self.timeoffs) * self.unit_seconds() / SEC_PER_DAY
    }

    /// The observation MJD from `MJD-OBS`, else `DATE-OBS`, else `None`. Reads only
    /// the header (not the parsed frame). The public entry point is
    /// [`Header::obs_mjd`](crate::Header::obs_mjd), which forwards here.
    pub(crate) fn obs_mjd(header: &Header) -> Option<f64> {
        if let Some(mjd) = header.get_real("MJD-OBS") {
            return Some(mjd);
        }
        if let Some(d) = header
            .get_text("DATE-OBS")
            .and_then(|s| Datetime::parse(s).ok())
        {
            return Some(d.to_mjd());
        }
        // §9.5: with no DATE-OBS/MJD-OBS, JEPOCH/BEPOCH stand in for the observation time.
        Self::epoch(header).map(|e| e.mjd)
    }

    /// The Julian (`JEPOCH`, implied scale TDB) or Besselian (`BEPOCH`, implied
    /// scale ET ≈ TT) epoch keyword as an [`EpochTime`], if present (§9.1.2, §9.5).
    /// `JEPOCH` wins if both appear. Reads only the header, so it takes no `self`.
    pub(crate) fn epoch(header: &Header) -> Option<EpochTime> {
        if let Some(j) = header.get_real("JEPOCH") {
            return Some(EpochTime {
                mjd: Epoch::Julian(j).to_mjd(),
                scale: TimeScale::Tdb,
            });
        }
        let b = header.get_real("BEPOCH")?;
        Some(EpochTime {
            mjd: Epoch::Besselian(b).to_mjd(),
            scale: TimeScale::Tt, // ET ≈ TT
        })
    }

    /// The global bound / duration / error time keywords (§9.4, §9.5, §9.7). The
    /// start/end are resolved to absolute MJD (`MJD-BEG`/`-END`, else `DATE-BEG`/
    /// `-END`); durations and errors are returned as stored, in `TIMEUNIT`. Reads
    /// only the header, so it takes no `self`.
    pub(crate) fn bounds(header: &Header) -> TimeBounds {
        let mjd_or_date = |mjd: &str, date: &str| {
            header.get_real(mjd).or_else(|| {
                header
                    .get_text(date)
                    .and_then(|s| Datetime::parse(s).ok())
                    .map(|d| d.to_mjd())
            })
        };
        TimeBounds {
            beg_mjd: mjd_or_date("MJD-BEG", "DATE-BEG"),
            end_mjd: mjd_or_date("MJD-END", "DATE-END"),
            avg_mjd: mjd_or_date("MJD-AVG", "DATE-AVG"),
            xposure: header.get_real("XPOSURE"),
            telapse: header.get_real("TELAPSE"),
            timedel: header.get_real("TIMEDEL"),
            timepixr: header.get_real("TIMEPIXR").unwrap_or(0.5), // §9.4.2 default
            timsyer: header.get_real("TIMSYER"),
            timrder: header.get_real("TIMRDER"),
        }
    }

    /// Convert Good Time Interval `START`/`STOP` column values (relative to
    /// `MJDREF`, in `TIMEUNIT`) to absolute-MJD intervals (§9.7). Pairs are taken
    /// element-wise up to the shorter slice.
    pub fn gti_intervals(&self, starts: &[f64], stops: &[f64]) -> Vec<GtiInterval> {
        starts
            .iter()
            .zip(stops)
            .map(|(&s, &e)| GtiInterval {
                start_mjd: self.relative_to_mjd(s),
                stop_mjd: self.relative_to_mjd(e),
            })
            .collect()
    }

    /// If WCS axis `axis` (1-based) is a time axis (`CTYPEi = 'TIME'` or a
    /// time-scale name, §9.2.3), convert a 1-based pixel coordinate along it to an
    /// absolute MJD in the frame's scale: the linear axis value (elapsed time in
    /// `TIMEUNIT` from `MJDREF`) plus the reference. `None` if not a time axis.
    pub fn time_axis_mjd(&self, header: &Header, axis: usize, pixel: f64) -> Option<f64> {
        let ctype = header.get_text(key!("CTYPE{axis}").as_str())?;
        if !is_time_ctype(ctype) {
            return None;
        }
        let crpix = header.get_real(key!("CRPIX{axis}").as_str()).unwrap_or(0.0);
        let crval = header.get_real(key!("CRVAL{axis}").as_str()).unwrap_or(0.0);
        let cdelt = header.get_real(key!("CDELT{axis}").as_str()).unwrap_or(1.0);
        Some(self.relative_to_mjd(crval + cdelt * (pixel - crpix)))
    }

    /// The §9.6 `'PHASE'` axis parameters (`CZPHSia` zero-phase time, `CPERIia`
    /// period) for WCS axis `axis` (1-based), or `None` if it is not a phase axis.
    /// Reads only the header, so it takes no `self`.
    pub(crate) fn phase_axis(header: &Header, axis: usize) -> Option<PhaseAxis> {
        let ctype = header.get_text(key!("CTYPE{axis}").as_str())?;
        if TimeAxisKind::from_ctype(ctype) != Some(TimeAxisKind::Phase) {
            return None;
        }
        Some(PhaseAxis {
            zero_phase: header.get_real(key!("CZPHS{axis}").as_str()).unwrap_or(0.0),
            period: header.get_real(key!("CPERI{axis}").as_str()).unwrap_or(0.0),
        })
    }
}

/// A time-related WCS axis type (§9.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeAxisKind {
    /// `'TIME'` or a time-scale name — an absolute time axis (→ MJD).
    Time,
    /// `'PHASE'` — phase folded on a period (`CPERIia`, zero `CZPHSia`).
    Phase,
    /// `'TIMELAG'` — a correlation/cross-spectral time lag.
    Timelag,
    /// `'FREQUENCY'` — a frequency axis.
    Frequency,
}

impl TimeAxisKind {
    /// Classify a `CTYPE` as a time-related axis (§9.6), or `None` if it is not one.
    pub fn from_ctype(ctype: &str) -> Option<TimeAxisKind> {
        let head = ctype.split('-').next().unwrap_or("").trim();
        match head {
            "TIME" => Some(TimeAxisKind::Time),
            "PHASE" => Some(TimeAxisKind::Phase),
            "TIMELAG" => Some(TimeAxisKind::Timelag),
            "FREQUENCY" => Some(TimeAxisKind::Frequency),
            _ if !matches!(TimeScale::parse(head), TimeScale::Local) => Some(TimeAxisKind::Time),
            _ => None,
        }
    }
}

/// True if a `CTYPE` denotes an absolute *time* axis (`'TIME'` or a time-scale
/// name) — the kind [`FitsTime::time_axis_mjd`] resolves to an MJD.
fn is_time_ctype(ctype: &str) -> bool {
    TimeAxisKind::from_ctype(ctype) == Some(TimeAxisKind::Time)
}

/// The reference epoch as MJD: `MJDREF` (or `MJDREFI`+`MJDREFF`), else `JDREF`
/// (or `JDREFI`+`JDREFF`), else `DATEREF`, else `0.0`.
fn reference_mjd(header: &Header) -> f64 {
    if let Some(mjd) = resolve_split_ref(header, "MJDREF", "MJDREFI", "MJDREFF") {
        return mjd;
    }
    if let Some(jd) = resolve_split_ref(header, "JDREF", "JDREFI", "JDREFF") {
        return jd - MJD0;
    }
    header
        .get_text("DATEREF")
        .and_then(|s| Datetime::parse(s).ok())
        .map(|d| d.to_mjd())
        .unwrap_or(0.0)
}

/// Resolve a reference epoch from its single (`MJDREF`) and split-precision
/// (`MJDREFI`+`MJDREFF`) keywords. Per §9.2.2 a *full* integer+fractional split
/// takes precedence over the single value; otherwise the single value is used,
/// falling back to a lone split part.
fn resolve_split_ref(header: &Header, single: &str, int: &str, frac: &str) -> Option<f64> {
    let i = header.get_real(int);
    let f = header.get_real(frac);
    match (i, f) {
        (Some(i), Some(f)) => Some(i + f),
        _ => header.get_real(single).or_else(|| match (i, f) {
            (None, None) => None,
            _ => Some(i.unwrap_or(0.0) + f.unwrap_or(0.0)),
        }),
    }
}

#[cfg(test)]
mod tests;