photom 0.1.0

Rust library for loading, structuring and querying astronomical observation datasets — with trajectory grouping, multi-observer support, and efficient lookups.
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
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
//! MPC observatory code types and the network-fetch routine.
//!
//! This module defines the type aliases used to identify observatories by
//! their Minor Planet Center (MPC) three-character code, provides the lookup
//! table type that maps each code to an [`Observer`], and exposes
//! `init_observatories`, which fetches and parses the official MPC
//! observatory list from the network.
//!
//! ## Public items
//!
//! | Item | Kind | Description |
//! |------|------|-------------|
//! | [`MpcCode`] | type alias | Three-byte ASCII MPC observatory code |
//! | [`MpcCodeObs`] | type alias | Hash map from [`MpcCode`] to [`Observer`] |
//! | [`MPCError`] | enum | Errors arising from the MPC network request |
//! | `init_observatories` | fn | Fetch and parse the MPC observatory list |

use std::{path::PathBuf, time::Duration};

use ahash::AHashMap;
use thiserror::Error;
use ureq::Agent;

use crate::observer::{
    Observer,
    error_model::{ErrorModelData, get_bias_rms},
};

/// Three-byte ASCII MPC observatory code (e.g. `b"I41"`, `b"500"`, `b"G96"`).
///
/// All observatory codes in the MPC catalogue are exactly three ASCII
/// characters.  The byte-array representation avoids heap allocation and
/// enables efficient use as a hash-map key.
pub type MpcCode = [u8; 3];

/// Hash map from [`MpcCode`] to [`Observer`] metadata.
///
/// Built by `init_observatories` from the official MPC observatory list.
/// Uses [`ahash`] for fast, non-cryptographic hashing.
pub type MpcCodeObs = AHashMap<MpcCode, Observer>;

/// Errors that can arise when fetching or processing the MPC observatory list.
#[derive(Error, Debug)]
pub enum MPCError {
    /// The HTTP request to the MPC website failed.
    #[error(transparent)]
    UreqError(#[from] ureq::Error),

    /// A filesystem I/O error occurred while reading or writing the cache.
    #[error("cache I/O error: {0}")]
    Io(#[from] std::io::Error),

    /// The platform cache directory could not be determined.
    #[error("could not determine platform cache directory")]
    CacheDirUnavailable,
}

/// Parse a fixed-width `f32` field from a slice of an MPC observatory line.
///
/// # Arguments
///
/// - `s` — fixed-width tail of the MPC line (the part after the three-char code).
/// - `slice` — byte range selecting the numeric field within `s`.
/// - `code` — MPC code string used in the panic message for diagnostics.
///
/// # Returns
///
/// `Ok(f32)` with the parsed value, or a [`std::num::ParseFloatError`] if
/// the trimmed slice is not a valid floating-point literal.
///
/// # Panics
///
/// Panics if `slice` is out of bounds for `s` (i.e. the line is shorter than
/// expected for the given field).
fn parse_f32(
    s: &str,
    slice: std::ops::Range<usize>,
    code: &str,
) -> Result<f32, std::num::ParseFloatError> {
    s.get(slice)
        .unwrap_or_else(|| panic!("Failed to parse float for observer code: {code}"))
        .trim()
        .parse()
}

/// Extract longitude, $\rho\cos\phi'$, $\rho\sin\phi'$, and name from a
/// fixed-width MPC observatory row.
///
/// The MPC fixed-width format places the fields at the following byte offsets
/// within the tail that follows the three-character code:
///
/// | Field | Bytes |
/// |-------|-------|
/// | longitude (deg) | 1–9 |
/// | $\rho\cos\phi'$ | 10–17 |
/// | $\rho\sin\phi'$ | 18–26 |
/// | site name | 27– |
///
/// Numeric fields that fail to parse fall back to `0.0`.
///
/// # Arguments
///
/// - `remain` — fixed-width tail of the line (after the three-char MPC code),
///   trailing whitespace already stripped.
/// - `code` — MPC code string used in diagnostic messages.
///
/// # Returns
///
/// `Some((longitude_deg, rho_cos_phi, rho_sin_phi, name))` on success, or
/// `None` if the line is too short to contain a site name (byte offset 27).
fn parse_remain(remain: &str, code: &str) -> Option<(f32, f32, f32, String)> {
    let name = remain.get(27..)?.to_string();

    let longitude = parse_f32(remain, 1..10, code).unwrap_or(0.0);
    let cos = parse_f32(remain, 10..18, code).unwrap_or(0.0);
    let sin = parse_f32(remain, 18..27, code).unwrap_or(0.0);

    Some((longitude, cos, sin, name))
}

/// Return the path to the MPC observatory cache file.
///
/// Resolution order:
/// 1. `cache_dir_override` — explicit path supplied by the caller (used in tests).
/// 2. `PHOTOM_MPC_CACHE_DIR` environment variable (used for CI overrides).
/// 3. The platform cache directory reported by [`directories::BaseDirs`],
///    under the sub-path `photom/mpc_obs.html`.
///
/// Returns [`MPCError::CacheDirUnavailable`] when no source is usable.
fn cache_file_path(cache_dir_override: Option<&std::path::Path>) -> Result<PathBuf, MPCError> {
    if let Some(dir) = cache_dir_override {
        return Ok(dir.join("mpc_obs.html"));
    }

    if let Ok(dir) = std::env::var("PHOTOM_MPC_CACHE_DIR") {
        return Ok(PathBuf::from(dir).join("mpc_obs.html"));
    }

    let base = directories::BaseDirs::new().ok_or(MPCError::CacheDirUnavailable)?;
    Ok(base.cache_dir().join("photom").join("mpc_obs.html"))
}

/// Fetch the MPC observatory list from the network and build the lookup table.
///
/// Issues an HTTP GET to `https://minorplanetcenter.net/iau/lists/ObsCodes.html`,
/// skips the two-line HTML header, then parses each fixed-width data line into
/// an [`Observer`] via [`Observer::from_parallax`].  Astrometric uncertainties
/// are populated from `error_model` using [`get_bias_rms`] with catalog code
/// `"c"` as the default.  Lines that are malformed or whose three-character
/// code cannot be converted to a [`MpcCode`] are silently skipped.
///
/// ## Caching
///
/// The raw MPC document is cached on disk so subsequent calls avoid a network
/// round-trip.  On the first call (or when the cache is missing / unreadable)
/// the document is fetched via `ureq_agent`, then written atomically to:
///
/// * `<platform-cache-dir>/photom/mpc_obs.html` (via [`directories::BaseDirs`]), or
/// * the directory pointed to by the `PHOTOM_MPC_CACHE_DIR` environment
///   variable when set (useful for CI overrides).
///
/// The cache stores the raw HTML/text body so that a different `error_model`
/// can still be applied at construction time.  If the cache file cannot be
/// written the error is silently ignored and the in-memory document is still
/// returned.
///
/// The returned map is pre-allocated with capacity for 2 048 entries to avoid
/// rehashing on a typical MPC list (currently ~2 000 observatories).
///
/// # Arguments
///
/// - `error_model` — pre-loaded [`ErrorModelData`] table used to assign
///   astrometric uncertainties to each observatory.
///
/// # Returns
///
/// An [`MpcCodeObs`] map on success.
///
/// # Errors
///
/// | Variant | Cause |
/// |---------|-------|
/// | [`MPCError::UreqError`] | HTTP request failed or body unreadable |
/// | [`MPCError::CacheDirUnavailable`] | platform cache dir cannot be determined |
pub fn init_observatories(error_model: &ErrorModelData) -> Result<MpcCodeObs, MPCError> {
    // configure a ureq Agent with a global timeout to avoid hanging indefinitely on network issues
    let config = Agent::config_builder()
        .timeout_global(Some(Duration::from_secs(10)))
        .build();
    let agent: Agent = config.into();

    init_observatories_impl(agent, error_model, None)
}

/// Internal implementation; `cache_dir_override` is used by tests to avoid
/// touching the real system cache directory.
fn init_observatories_impl(
    ureq_agent: Agent,
    error_model: &ErrorModelData,
    cache_dir_override: Option<&std::path::Path>,
) -> Result<MpcCodeObs, MPCError> {
    let cache_path = cache_file_path(cache_dir_override)?;

    // --- Try reading from cache -----------------------------------------------
    let mpc_document = match std::fs::read_to_string(&cache_path) {
        Ok(content) => content,
        Err(_) => mpc_obs_request(ureq_agent, &cache_path)?,
    };

    // --- Parse ----------------------------------------------------------------
    parse_mpc_obs_result(&mpc_document, error_model)
}

/// Fetch the raw MPC observatory document from the network and write it to
/// `cache_path` atomically.
///
/// Issues an HTTP GET to `https://minorplanetcenter.net/iau/lists/ObsCodes.html`
/// and reads the full response body into a `String`.  The body is then written
/// to a sibling `.html.tmp` file in the same directory as `cache_path` and
/// atomically renamed into place so readers never observe a partial file.
/// Parent directories are created with [`std::fs::create_dir_all`] if they do
/// not yet exist.  Any filesystem error during the write is returned as an error.
///
/// # Arguments
///
/// - `ureq_agent` — a configured [`ureq::Agent`] used to perform the HTTP GET.
/// - `cache_path` — destination path for the cached document; the parent
///   directory and a sibling `.html.tmp` file are derived from this path.
///
/// # Returns
///
/// The raw HTTP response body as a `String` on success.
///
/// # Errors
///
/// | Variant | Cause |
/// |---------|-------|
/// | [`MPCError::UreqError`] | HTTP request failed or response body could not be read |
fn mpc_obs_request(ureq_agent: Agent, cache_path: &std::path::Path) -> Result<String, MPCError> {
    let fetched = ureq_agent
        .get("https://minorplanetcenter.net/iau/lists/ObsCodes.html")
        .call()?
        .body_mut()
        .read_to_string()?;

    // --- Write cache atomically ----------------------------------------
    // Write to a sibling .tmp file first, then rename so readers never
    // see a partial file.
    if let Some(parent) = cache_path.parent() {
        std::fs::create_dir_all(parent)?;

        let tmp_path = cache_path.with_extension("html.tmp");
        std::fs::write(&tmp_path, fetched.as_bytes())?;

        if let Err(e) = std::fs::rename(&tmp_path, cache_path) {
            // Best-effort cleanup; ignore secondary errors.
            let _ = std::fs::remove_file(&tmp_path);
            return Err(MPCError::Io(e));
        }
    }

    Ok(fetched)
}

/// Parse a raw MPC observatory document into an [`MpcCodeObs`] lookup table.
///
/// Iterates over `mpc_document` line by line, skipping the first two header
/// lines (the HTML/text preamble of the MPC list).  Each remaining line is
/// expected to follow the MPC fixed-width format: a three-character observatory
/// code followed by longitude, $\rho\cos\phi'$, $\rho\sin\phi'$, and site name
/// fields.  Lines that are too short to split at byte 3, whose three-character
/// prefix cannot be converted to an [`MpcCode`], or whose numeric fields cannot
/// be parsed into a valid [`Observer`] are silently skipped.
///
/// Astrometric uncertainties are looked up in `error_model` via [`get_bias_rms`]
/// using catalog code `"c"` as the default for every observatory.  The returned
/// map is pre-allocated with a capacity of 2 048 entries to avoid rehashing on
/// a typical MPC list (currently ~2 000 observatories).
///
/// # Arguments
///
/// - `mpc_document` — raw text body of the MPC observatory list (e.g. as
///   fetched by [`mpc_obs_request`] or read from the on-disk cache).
/// - `error_model` — pre-loaded [`ErrorModelData`] table used to assign
///   astrometric uncertainties to each observatory entry.
///
/// # Returns
///
/// An [`MpcCodeObs`] map from [`MpcCode`] to [`Observer`] on success.
fn parse_mpc_obs_result(
    mpc_document: &str,
    error_model: &ErrorModelData,
) -> Result<MpcCodeObs, MPCError> {
    // The MPC list currently has ~2 000 entries; pre-allocate to avoid rehashing.
    let mut observatories = MpcCodeObs::with_capacity(2048);

    for line in mpc_document.lines().skip(2) {
        let line = line.trim();

        let Some((code, remain)) = line.split_at_checked(3) else {
            continue;
        };
        let Ok(mpc_code) = code.as_bytes().try_into() else {
            continue;
        };
        let Some((longitude, cos, sin, name)) = parse_remain(remain.trim_end(), code) else {
            continue;
        };

        // TODO: support per-site catalog codes (not always "c")
        let (ra_acc, dec_acc) = get_bias_rms(error_model, mpc_code, "c")
            .map(|(ra, dec)| (Some(ra as f64), Some(dec as f64)))
            .unwrap_or((None, None));

        if let Ok(observer) = Observer::from_parallax(
            (longitude as f64).to_radians(),
            cos as f64,
            sin as f64,
            Some(name),
            ra_acc,
            dec_acc,
        ) {
            observatories.insert(mpc_code, observer);
        }
    }
    Ok(observatories)
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod mpc_obs_tests {
    use super::*;
    use crate::observer::Observer;
    use approx::assert_relative_eq;

    // -----------------------------------------------------------------------
    // parse_f32 tests
    // -----------------------------------------------------------------------

    /// Verifies that a padded integer string parses to the expected f32.
    #[test]
    fn parse_f32_valid_integer() {
        // "  42  " at range 0..6 should trim to "42" and parse as 42.0
        let result = parse_f32("  42  ", 0..6, "TST");
        assert!(result.is_ok(), "Expected Ok but got: {:?}", result);
        assert_relative_eq!(result.unwrap(), 42.0_f32);
    }

    /// Verifies that a padded decimal string parses to the expected f32.
    #[test]
    fn parse_f32_valid_float() {
        // " 1.5  " at range 0..6 should trim to "1.5" and parse as 1.5
        let result = parse_f32(" 1.5  ", 0..6, "TST");
        assert!(result.is_ok(), "Expected Ok but got: {:?}", result);
        assert_relative_eq!(result.unwrap(), 1.5_f32);
    }

    /// Verifies that a negative decimal string parses correctly.
    ///
    /// Float comparison uses relative epsilon because -1.23 is not exactly
    /// representable in IEEE 754 single precision.
    #[test]
    fn parse_f32_negative() {
        // "-1.23 " at range 0..6 should trim to "-1.23" and parse as -1.23
        let result = parse_f32("-1.23 ", 0..6, "TST");
        assert!(result.is_ok(), "Expected Ok but got: {:?}", result);
        assert_relative_eq!(result.unwrap(), -1.23_f32, epsilon = 1e-6_f32);
    }

    /// Verifies that an alphabetic string returns a ParseFloatError.
    #[test]
    fn parse_f32_invalid_returns_error() {
        // "  abc " cannot be parsed as a float; must return Err
        let result = parse_f32("  abc ", 0..6, "TST");
        assert!(
            result.is_err(),
            "Expected Err for non-numeric input, but got: {:?}",
            result
        );
    }

    // -----------------------------------------------------------------------
    // parse_remain tests
    // -----------------------------------------------------------------------

    /// Verifies that a well-formed MPC observatory tail parses to correct values.
    ///
    /// The MPC fixed-width layout within `remain` (the part after the 3-char code):
    ///   byte 0        : leading space (separator)
    ///   bytes  1..10  : longitude in degrees   (9 chars)
    ///   bytes 10..18  : rho_cos_phi             (8 chars)
    ///   bytes 18..27  : rho_sin_phi             (9 chars)
    ///   bytes 27..    : site name
    ///
    /// Float comparisons use a tight relative epsilon because the source
    /// values have at most 6 significant digits.
    #[test]
    fn parse_remain_valid_line() {
        // Construct remain from known fields so the test is self-documenting.
        //           [0][ 1..10 ][10..18][18..27][27..]
        let remain = " 289.265770.864977-0.500219Observatoire de Haute-Provence";

        let result = parse_remain(remain, "I41");
        assert!(result.is_some(), "Expected Some but got None");

        let (longitude, cos, sin, name) = result.unwrap();

        assert_relative_eq!(longitude, 289.26577_f32, epsilon = 1e-4_f32);
        assert_relative_eq!(cos, 0.864977_f32, epsilon = 1e-5_f32);
        assert_relative_eq!(sin, -0.500219_f32, epsilon = 1e-5_f32);
        assert!(
            name.contains("Observatoire"),
            "Expected name to contain 'Observatoire', got: {name:?}"
        );
    }

    /// Verifies that a `remain` string shorter than 28 bytes returns `None`.
    ///
    /// `remain.get(27..)` is `None` when the string is too short, so the
    /// function must propagate that with the `?` operator and return `None`.
    #[test]
    fn parse_remain_too_short_returns_none() {
        // Only 20 bytes — cannot possibly contain the name field at offset 27.
        let remain = " 289.265770.864977";
        let result = parse_remain(remain, "TST");
        assert!(
            result.is_none(),
            "Expected None for too-short input, got: {:?}",
            result
        );
    }

    /// Verifies that the site name is exactly the substring starting at byte 27.
    ///
    /// The MPC format places the observatory name at a fixed offset; this test
    /// uses a string with a recognisable sentinel starting precisely at byte 27.
    #[test]
    fn parse_remain_name_is_trimmed_correctly() {
        // Layout: 1 + 9 + 8 + 9 = 27 bytes before the name.
        //           [0][  1..10 ][10..18][18..27][27..]
        let remain = " 000.000000.0000000.000000 TestSiteName";
        //                                        ^-- byte 27

        let result = parse_remain(remain, "TST");
        assert!(result.is_some(), "Expected Some but got None");

        let (_, _, _, name) = result.unwrap();
        assert_eq!(
            name, "TestSiteName",
            "Name should be exactly the substring from byte 27; got: {name:?}"
        );
    }

    /// Verifies that unparseable numeric fields fall back to 0.0 while still
    /// returning `Some` as long as the string is at least 28 bytes long.
    ///
    /// The MPC parser calls `parse_f32(…).unwrap_or(0.0)`, so invalid numeric
    /// content (pure whitespace here) should silently yield 0.0.
    #[test]
    fn parse_remain_zero_fallback() {
        // All numeric slots are spaces (unparseable); only the name field is present.
        //           [0][  1..10 ][10..18][ 18..27 ][27..]
        let remain = "                           Spacewatch";
        //                                          ^-- byte 27

        let result = parse_remain(remain, "TST");
        assert!(result.is_some(), "Expected Some but got None");

        let (longitude, cos, sin, name) = result.unwrap();

        assert_relative_eq!(longitude, 0.0_f32);
        assert_relative_eq!(cos, 0.0_f32);
        assert_relative_eq!(sin, 0.0_f32);
        assert_eq!(
            name, "Spacewatch",
            "Name should be the text at byte 27; got: {name:?}"
        );
    }

    // -----------------------------------------------------------------------
    // MpcCode type alias and MpcCodeObs map
    // -----------------------------------------------------------------------

    /// Verifies that an `MpcCodeObs` map correctly stores and retrieves an
    /// `Observer` keyed by a `MpcCode` (`[u8; 3]`).
    ///
    /// This exercises the type aliases end-to-end: insertion uses the
    /// byte-array key, and lookup must return the exact same `Observer`.
    #[test]
    fn mpc_code_key_lookup() {
        let key: MpcCode = *b"G96";

        // Construct a valid Observer using known parallax constants for G96
        // (Catalina Sky Survey). unwrap() is safe: none of the inputs are NaN.
        let observer = Observer::from_parallax(
            110.789_f64, // longitude (degrees)
            0.836_f64,   // rho_cos_phi
            0.547_f64,   // rho_sin_phi
            Some("Catalina Sky Survey".to_string()),
            None,
            None,
        )
        .unwrap(); // safe: all inputs are finite, non-NaN values

        let mut map = MpcCodeObs::new();
        map.insert(key, observer.clone());

        let found = map.get(&key);
        assert!(
            found.is_some(),
            "Expected to find observer under key b\"G96\", but got None"
        );
        assert_eq!(
            found.unwrap(),
            &observer,
            "Retrieved observer does not match the inserted one"
        );
    }

    // -----------------------------------------------------------------------
    // init_observatories — integration test with a ureq Middleware mock
    // -----------------------------------------------------------------------

    /// Synthetic MPC observatory list used by the mock middleware.
    ///
    /// The format follows the real MPC fixed-width layout:
    ///   - First two lines are skipped (HTML header rows).
    ///   - Each data line: 3-char code, then fixed-width fields.
    ///
    /// Lines taken from the real MPC list provided in the task description.
    const MOCK_MPC_DOCUMENT: &str = "\
Code  Long.   cos      sin    Name\n\
\n\
000   0.0000 0.62411 +0.77873 Greenwich\n\
001   0.1542 0.62992 +0.77411 Crowborough\n\
002   0.62   0.622   +0.781   Rayleigh\n\
005   2.231000.659891+0.748875Meudon\n\
006   2.124170.751042+0.658129Fabra Observatory, Barcelona\n\
";

    /// A ureq [`Middleware`] that short-circuits every HTTP request and returns
    /// the fixed `MOCK_MPC_DOCUMENT` string as a 200 OK response, without
    /// touching the network.
    struct MockMpcMiddleware;

    impl ureq::middleware::Middleware for MockMpcMiddleware {
        fn handle(
            &self,
            _request: ureq::http::Request<ureq::SendBody>,
            _next: ureq::middleware::MiddlewareNext,
        ) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
            let body = ureq::Body::builder()
                .mime_type("text/plain")
                .data(MOCK_MPC_DOCUMENT.as_bytes().to_vec());

            Ok(ureq::http::Response::builder()
                .status(200)
                .body(body)
                .expect("valid response"))
        }
    }

    /// Build a [`ureq::Agent`] that intercepts every request with
    /// [`MockMpcMiddleware`] so no real network call is made.
    fn mock_agent() -> ureq::Agent {
        ureq::config::Config::builder()
            .middleware(MockMpcMiddleware)
            .build()
            .new_agent()
    }

    /// Verifies that [`init_observatories`] correctly parses the mock MPC
    /// document and returns a map with the expected entries.
    ///
    /// The mock skips the first two header lines, then parses five data lines.
    /// Lines with parseable parallax constants should produce `Observer`
    /// entries; lines whose numeric fields parse as `0.0` are still inserted
    /// as long as `Observer::from_parallax` succeeds.
    #[test]
    fn init_observatories_parses_mock_document() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let agent = mock_agent();
        let error_model: crate::observer::error_model::ErrorModelData =
            std::collections::HashMap::new(); // empty → all accuracies None

        let result = init_observatories_impl(agent, &error_model, Some(tmp.path()));
        assert!(
            result.is_ok(),
            "Expected Ok from init_observatories, got: {:?}",
            result
        );

        let map = result.unwrap();

        // All five data lines have codes long enough for split_at_checked(3)
        // and remain strings long enough for parse_remain to return Some.
        assert!(
            map.contains_key(b"000"),
            "Expected code '000' (Greenwich) to be in the map"
        );
        assert!(
            map.contains_key(b"001"),
            "Expected code '001' (Crowborough) to be in the map"
        );
        assert!(
            map.contains_key(b"005"),
            "Expected code '005' (Meudon) to be in the map"
        );
        assert!(
            map.contains_key(b"006"),
            "Expected code '006' (Fabra Observatory) to be in the map"
        );
    }

    /// Verifies that the `Observer` for Greenwich (code `000`) has the correct
    /// site name parsed from the mock document.
    #[test]
    fn init_observatories_observer_name_is_correct() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let agent = mock_agent();
        let error_model: crate::observer::error_model::ErrorModelData =
            std::collections::HashMap::new();

        let map = init_observatories_impl(agent, &error_model, Some(tmp.path())).unwrap();

        let greenwich = map.get(b"000").expect("Greenwich must be present");
        assert_eq!(
            greenwich.name.as_deref(),
            Some("Greenwich"),
            "Observer name for code '000' should be 'Greenwich'"
        );
    }

    /// Verifies that the `Observer` for Meudon (code `005`) has the correct
    /// longitude, $\rho\cos\phi'$, and $\rho\sin\phi'$ parsed from the mock document.
    /// Longitude should be stored as radians in the `Observer` struct.
    #[test]
    fn init_observatories_observer_parallax_is_correct() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let agent = mock_agent();
        let error_model: crate::observer::error_model::ErrorModelData =
            std::collections::HashMap::new();

        let map = init_observatories_impl(agent, &error_model, Some(tmp.path())).unwrap();

        let meudon = map.get(b"005").expect("Meudon must be present");
        assert_relative_eq!(
            meudon.longitude.into_inner(),
            0.03893829467988711_f64,
            epsilon = 1e-6_f64
        );
        assert_relative_eq!(
            meudon.rho_cos_phi.into_inner(),
            0.659891_f64,
            epsilon = 1e-6_f64
        );
        assert_relative_eq!(
            meudon.rho_sin_phi.into_inner(),
            0.748875_f64,
            epsilon = 1e-6_f64
        );
    }

    /// Verifies that a document containing only the two header lines (no data)
    /// produces an empty map rather than an error.
    #[test]
    fn init_observatories_empty_document_produces_empty_map() {
        struct EmptyMpcMiddleware;

        impl ureq::middleware::Middleware for EmptyMpcMiddleware {
            fn handle(
                &self,
                _request: ureq::http::Request<ureq::SendBody>,
                _next: ureq::middleware::MiddlewareNext,
            ) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
                let body = ureq::Body::builder()
                    .mime_type("text/plain")
                    .data("header line 1\nheader line 2\n".as_bytes().to_vec());

                Ok(ureq::http::Response::builder()
                    .status(200)
                    .body(body)
                    .expect("valid response"))
            }
        }

        let agent = ureq::config::Config::builder()
            .middleware(EmptyMpcMiddleware)
            .build()
            .new_agent();

        let tmp = tempfile::tempdir().expect("tempdir");
        let map =
            init_observatories_impl(agent, &std::collections::HashMap::new(), Some(tmp.path()))
                .unwrap();
        assert!(
            map.is_empty(),
            "Expected empty map for a document with only header lines, got {} entries",
            map.len()
        );
    }

    /// Verifies that malformed lines (too short to split at byte 3) are
    /// silently skipped and do not cause a panic or error.
    #[test]
    fn init_observatories_skips_malformed_lines() {
        struct MalformedMpcMiddleware;

        impl ureq::middleware::Middleware for MalformedMpcMiddleware {
            fn handle(
                &self,
                _request: ureq::http::Request<ureq::SendBody>,
                _next: ureq::middleware::MiddlewareNext,
            ) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
                // Mix of: 2 header lines, a 1-char line (too short), a blank,
                // and one valid line.
                let content = "hdr\nhdr\nX\n\n000   0.0000 0.62411 +0.77873 Greenwich\n";
                let body = ureq::Body::builder()
                    .mime_type("text/plain")
                    .data(content.as_bytes().to_vec());

                Ok(ureq::http::Response::builder()
                    .status(200)
                    .body(body)
                    .expect("valid response"))
            }
        }

        let agent = ureq::config::Config::builder()
            .middleware(MalformedMpcMiddleware)
            .build()
            .new_agent();

        let tmp = tempfile::tempdir().expect("tempdir");
        let map =
            init_observatories_impl(agent, &std::collections::HashMap::new(), Some(tmp.path()))
                .unwrap();
        // Only '000' should survive; the short line must be silently skipped.
        assert_eq!(map.len(), 1, "Expected exactly 1 entry, got {}", map.len());
        assert!(
            map.contains_key(b"000"),
            "Expected code '000' to be present"
        );
    }

    // -----------------------------------------------------------------------
    // Cache hit / miss tests
    // -----------------------------------------------------------------------

    /// A ureq middleware that panics if called — used to assert no network
    /// request is made when the cache is warm.
    struct PanicMiddleware;

    impl ureq::middleware::Middleware for PanicMiddleware {
        fn handle(
            &self,
            _request: ureq::http::Request<ureq::SendBody>,
            _next: ureq::middleware::MiddlewareNext,
        ) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
            panic!("network request was made despite a warm cache");
        }
    }

    fn panic_agent() -> ureq::Agent {
        ureq::config::Config::builder()
            .middleware(PanicMiddleware)
            .build()
            .new_agent()
    }

    /// Verifies that when a valid cache file exists, `init_observatories`
    /// reads from it and never calls the network (the panic agent would
    /// explode if the network were touched).
    #[test]
    fn init_observatories_uses_cache_when_warm() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let cache_file = tmp.path().join("mpc_obs.html");

        // Pre-populate the cache with the mock document.
        std::fs::write(&cache_file, MOCK_MPC_DOCUMENT).expect("write cache");

        let map = init_observatories_impl(
            panic_agent(),
            &std::collections::HashMap::new(),
            Some(tmp.path()),
        )
        .expect("init_observatories must succeed with warm cache");

        assert!(
            map.contains_key(b"000"),
            "Expected '000' (Greenwich) from cached document"
        );
        assert!(
            map.contains_key(b"005"),
            "Expected '005' (Meudon) from cached document"
        );
    }

    /// Verifies that when the cache is absent, `init_observatories` calls the
    /// network (via mock agent) and then writes the cache file to disk.
    #[test]
    fn init_observatories_writes_cache_on_miss() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let cache_file = tmp.path().join("mpc_obs.html");

        // Ensure no pre-existing cache.
        assert!(!cache_file.exists(), "cache must not exist before the test");

        let map = init_observatories_impl(
            mock_agent(),
            &std::collections::HashMap::new(),
            Some(tmp.path()),
        )
        .expect("init_observatories must succeed on cache miss");

        // The network was called (mock returned MOCK_MPC_DOCUMENT).
        assert!(
            map.contains_key(b"000"),
            "Expected '000' from network response"
        );

        // Cache file must now exist and contain the document.
        assert!(
            cache_file.exists(),
            "cache file must be written after a miss"
        );
        let cached = std::fs::read_to_string(&cache_file).expect("read cache");
        assert!(
            cached.contains("Greenwich"),
            "Cache file should contain the network response body"
        );
    }
}