//! Wall-clock `HH:MM:SS` in local time, from std alone.
//!
//! The Python logs `time.strftime('%H:%M:%S')`, i.e. *local* time, and the
//! launcher interleaves these lines with its own log, so UTC would misreport the
//! timestamps by the machine's offset. std has no local-time support, so the
//! UTC offset is taken from the system's TZif database (`TZ` or `/etc/localtime`),
//! parsed here — a bounded, well-specified format (RFC 8536).
//!
//! LIMITATION, stated rather than hidden: only the TZif transition table is
//! evaluated, not the trailing POSIX-TZ footer string. With the "fat" tzdata
//! that Debian-family systems ship, transitions run to 2037, so the offset —
//! including DST — is exact. Two cases fall back to the last known transition's
//! offset (so a DST-observing zone could read one hour off) and one falls back
//! to UTC:
//!   * "slim" tzdata, or dates past the last transition -> last transition;
//!   * `TZ` holding a bare POSIX rule (`EST5EDT`) with no such zone file, or an
//!     unreadable/corrupt zone file -> UTC.
//! The timestamp is diagnostic; no line the launcher parses carries a time.

use std::fs;
use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};

/// Resolved UTC offsets over time: an offset before the first transition, then
/// `(transition instant, offset from that instant on)` in ascending order.
#[derive(Debug, PartialEq, Eq)]
pub struct TzData {
    initial: i32,
    transitions: Vec<(i64, i32)>,
}

impl TzData {
    /// UTC offset in seconds applying at `unix_secs`.
    pub fn offset_at(&self, unix_secs: i64) -> i32 {
        let idx = self
            .transitions
            .partition_point(|(start, _)| *start <= unix_secs);
        if idx == 0 {
            self.initial
        } else {
            self.transitions[idx - 1].1
        }
    }
}

/// `[HH:MM:SS]`-worthy time-of-day for a Unix timestamp at a given UTC offset.
pub fn hms(unix_secs: i64, utc_offset_secs: i32) -> (u32, u32, u32) {
    let local = unix_secs + i64::from(utc_offset_secs);
    // rem_euclid keeps pre-epoch and negative-offset instants on a sane clock.
    let day = local.rem_euclid(86_400);
    ((day / 3600) as u32, (day % 3600 / 60) as u32, (day % 60) as u32)
}

/// Current local time of day, `(hour, minute, second)`.
pub fn now_hms() -> (u32, u32, u32) {
    let unix = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        // Before 1970 the clock is broken anyway; keep logging rather than panic.
        .unwrap_or(0);
    hms(unix, local_offset_at(unix))
}

/// UTC offset in seconds for `unix_secs`, or 0 when no zone data is usable.
///
/// The zone file is read and parsed once; the offset is then recomputed per call
/// so a DST transition during a long-running session is picked up.
pub fn local_offset_at(unix_secs: i64) -> i32 {
    static TZ: LazyLock<Option<TzData>> = LazyLock::new(load_system_tz);
    TZ.as_ref().map_or(0, |tz| tz.offset_at(unix_secs))
}

/// Read and parse the zone file named by `TZ`, else `/etc/localtime`.
fn load_system_tz() -> Option<TzData> {
    let path = match std::env::var("TZ") {
        Ok(tz) if !tz.is_empty() => {
            // glibc accepts a leading ':' and either an absolute path or a name
            // relative to the zoneinfo directory.
            let name = tz.strip_prefix(':').unwrap_or(&tz);
            if name.starts_with('/') {
                name.to_string()
            } else {
                format!("/usr/share/zoneinfo/{name}")
            }
        }
        _ => "/etc/localtime".to_string(),
    };
    parse_tzif(&fs::read(path).ok()?)
}

/// Parse a TZif (RFC 8536) file into resolved offsets.
///
/// For version 2+ files the 64-bit data block is used; the legacy 32-bit block
/// is skipped, because modern tzdata leaves it minimal.
pub fn parse_tzif(bytes: &[u8]) -> Option<TzData> {
    let (version, counts) = parse_header(bytes, 0)?;
    if version >= b'2' {
        // Skip the v1 header + v1 data block, then re-read the 64-bit header.
        let v1_end = 44 + data_block_len(&counts, 4)?;
        let (_, counts64) = parse_header(bytes, v1_end)?;
        parse_data(bytes, v1_end + 44, &counts64, 8)
    } else {
        parse_data(bytes, 44, &counts, 4)
    }
}

/// `(isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt)`.
type Counts = [u32; 6];

fn parse_header(bytes: &[u8], off: usize) -> Option<(u8, Counts)> {
    let head = bytes.get(off..off + 44)?;
    if &head[0..4] != b"TZif" {
        return None;
    }
    let version = head[4];
    let mut counts = [0u32; 6];
    for (i, slot) in counts.iter_mut().enumerate() {
        let at = 20 + i * 4;
        *slot = u32::from_be_bytes(head[at..at + 4].try_into().ok()?);
    }
    Some((version, counts))
}

/// Byte length of a data block with `time_len`-wide transition times.
fn data_block_len(counts: &Counts, time_len: usize) -> Option<usize> {
    let [isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt] = counts.map(|c| c as usize);
    Some(
        timecnt * time_len
            + timecnt
            + typecnt * 6
            + charcnt
            + leapcnt * (time_len + 4)
            + isstdcnt
            + isutcnt,
    )
}

fn parse_data(bytes: &[u8], off: usize, counts: &Counts, time_len: usize) -> Option<TzData> {
    let [_, _, _, timecnt, typecnt, _] = counts.map(|c| c as usize);
    if typecnt == 0 {
        return None;
    }
    let block = bytes.get(off..off + data_block_len(counts, time_len)?)?;

    let times = block.get(..timecnt * time_len)?;
    let type_idx = block.get(timecnt * time_len..timecnt * time_len + timecnt)?;
    let ttinfo_at = timecnt * time_len + timecnt;
    let ttinfo = block.get(ttinfo_at..ttinfo_at + typecnt * 6)?;

    // utoff + isdst per local-time type.
    let mut offsets = Vec::with_capacity(typecnt);
    for i in 0..typecnt {
        let rec = &ttinfo[i * 6..i * 6 + 6];
        let utoff = i32::from_be_bytes(rec[0..4].try_into().ok()?);
        offsets.push((utoff, rec[4] != 0));
    }

    // Before the first transition, RFC 8536 says to use the first non-DST type,
    // falling back to the first type. This is also the whole answer for a
    // fixed-offset zone (typecnt 1, timecnt 0), e.g. Etc/UTC.
    let initial = offsets
        .iter()
        .find(|(_, isdst)| !*isdst)
        .unwrap_or(&offsets[0])
        .0;

    let mut transitions = Vec::with_capacity(timecnt);
    for i in 0..timecnt {
        let raw = &times[i * time_len..(i + 1) * time_len];
        let at = if time_len == 8 {
            i64::from_be_bytes(raw.try_into().ok()?)
        } else {
            i64::from(i32::from_be_bytes(raw.try_into().ok()?))
        };
        let (utoff, _) = *offsets.get(*type_idx.get(i)? as usize)?;
        transitions.push((at, utoff));
    }

    Some(TzData {
        initial,
        transitions,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Minimal TZif builder: `transitions` are `(instant, type index)`.
    fn tzif(version: u8, types: &[(i32, bool)], transitions: &[(i64, u8)]) -> Vec<u8> {
        fn block(types: &[(i32, bool)], transitions: &[(i64, u8)], time_len: usize) -> Vec<u8> {
            let mut out = Vec::new();
            for (at, _) in transitions {
                if time_len == 8 {
                    out.extend_from_slice(&at.to_be_bytes());
                } else {
                    out.extend_from_slice(&(*at as i32).to_be_bytes());
                }
            }
            for (_, idx) in transitions {
                out.push(*idx);
            }
            for (utoff, isdst) in types {
                out.extend_from_slice(&utoff.to_be_bytes());
                out.push(u8::from(*isdst));
                out.push(0); // abbreviation index
            }
            out.push(0); // one NUL abbreviation byte
            out
        }
        fn header(version: u8, types: usize, transitions: usize) -> Vec<u8> {
            let mut out = Vec::from(*b"TZif");
            out.push(version);
            out.extend_from_slice(&[0u8; 15]);
            for count in [0u32, 0, 0, transitions as u32, types as u32, 1] {
                out.extend_from_slice(&count.to_be_bytes());
            }
            out
        }
        let mut out = header(version, types.len(), transitions.len());
        if version >= b'2' {
            // Modern "slim-ish" shape: an empty v1 block, then the 64-bit block.
            out.truncate(0);
            out.extend(header(version, types.len(), 0));
            out.extend(block(types, &[], 4));
            out.extend(header(version, types.len(), transitions.len()));
            out.extend(block(types, transitions, 8));
        } else {
            out.extend(block(types, transitions, 4));
        }
        out
    }

    #[test]
    fn fixed_offset_zone_has_no_transitions() {
        let tz = parse_tzif(&tzif(b'2', &[(0, false)], &[])).unwrap();
        assert_eq!(tz.offset_at(0), 0);
        assert_eq!(tz.offset_at(1_800_000_000), 0);

        let kolkata = parse_tzif(&tzif(b'2', &[(19_800, false)], &[])).unwrap();
        assert_eq!(kolkata.offset_at(1_800_000_000), 19_800);
    }

    #[test]
    fn dst_transitions_select_the_right_offset() {
        // CET/CEST with two transitions.
        let tz = parse_tzif(&tzif(
            b'2',
            &[(3600, false), (7200, true)],
            &[(1_000_000_000, 1), (1_100_000_000, 0)],
        ))
        .unwrap();
        assert_eq!(tz.offset_at(999_999_999), 3600); // before the first transition
        assert_eq!(tz.offset_at(1_000_000_000), 7200); // exactly at it
        assert_eq!(tz.offset_at(1_050_000_000), 7200);
        assert_eq!(tz.offset_at(1_100_000_000), 3600);
        assert_eq!(tz.offset_at(i64::MAX), 3600); // past the table: last known
    }

    #[test]
    fn version_1_files_parse_from_the_32_bit_block() {
        let tz = parse_tzif(&tzif(b'\0', &[(-18_000, false)], &[(100, 0)])).unwrap();
        assert_eq!(tz.offset_at(0), -18_000);
        assert_eq!(tz.offset_at(1_000), -18_000);
    }

    #[test]
    fn initial_offset_skips_a_leading_dst_type() {
        let tz = parse_tzif(&tzif(b'2', &[(7200, true), (3600, false)], &[])).unwrap();
        assert_eq!(tz.offset_at(0), 3600);
    }

    #[test]
    fn garbage_is_rejected_rather_than_guessed() {
        assert_eq!(parse_tzif(b""), None);
        assert_eq!(parse_tzif(b"not a tzif file at all, truncated"), None);
        let mut truncated = tzif(b'2', &[(3600, false)], &[(1, 0)]);
        truncated.truncate(truncated.len() - 5);
        assert_eq!(parse_tzif(&truncated), None);
        // Well-formed header claiming zero local-time types is unusable.
        assert_eq!(parse_tzif(&tzif(b'2', &[], &[])), None);
    }

    #[test]
    fn hms_matches_known_instants() {
        assert_eq!(hms(0, 0), (0, 0, 0));
        // 2026-08-18T04:52:08Z
        assert_eq!(hms(1_787_028_728, 0), (4, 52, 8));
        // …the same instant at +02:00 and at -05:00 (the latter is the day before).
        assert_eq!(hms(1_787_028_728, 7200), (6, 52, 8));
        assert_eq!(hms(1_787_028_728, -18_000), (23, 52, 8));
        // Offsets that cross midnight in either direction stay on the clock.
        assert_eq!(hms(86_399, 1), (0, 0, 0));
        assert_eq!(hms(0, -1), (23, 59, 59));
    }

    #[test]
    fn the_system_zone_resolves_to_a_plausible_offset() {
        // Whatever this machine's zone is, the offset must be a real one.
        let offset = local_offset_at(1_786_697_528);
        assert!((-50_400..=50_400).contains(&offset), "implausible {offset}");
        assert_eq!(offset % 60, 0);
    }
}
