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
//! Time zone data generated at compile time from the IANA timezone database
//!
//! Because the rules for timezone transitions around DST are frequently defined in terms of somewhat arcane rules such as "the last Sunday on or after October 20th", the build step uses the [`parse-zoneinfo`](https://crates.io/crates/parse-zoneinfo) crate to build a series of concrete timestamps and associated offsets for each timezone.  
//! Unfortunately, this finite series of concrete timestamps covers only a finite period of time, even though the rules are usually defined for eternity.
//! Since this crate is wholly reliant on `parse-zoneinfo`, it inherits the decision made there to be **restricted to the time from 1800 to 2100**.
//! As a consolation, they offer that "it is not necessarily advisable to rely on offset changes so far into the future".
//! This is fairly reasonable, considering that changes to timezones are occasionally announced just days in advance.
//!
//! Finally, it must be said that without `parse-zoneinfo`, which is part of [`chrono`](https://crates.io/crates/chrono), this crate, and by extension [`greg`] would not exist.

#![allow(clippy::zero_prefixed_literal)]

mod data;
pub use data::ZONES;
#[cfg(test)]
mod tests;

use std::slice::Iter;
use std::ops::RangeBounds;

use greg::{
	Point,
	Frame,
	utc
};
use greg::calendar::{
	Utc,
	DateTime,
};
use greg::calendar::zone::{
	Shift,
	Steady,
	Unsteady
};

/// An [`Unsteady`] time zone
///
/// *All* the time zones in the IANA database are implemented as associated constants, including the ones that are actually just a fixed [`Offset`].
#[derive(Copy, Clone, Eq, PartialEq)]
pub struct Zone {
	name: &'static str,
	lmt: Offset,
	offsets: &'static [(Point, Offset)]
}

/// A [`Steady`] (i.e. fixed) offset from UTC
///
/// There is a handful of timezones in the IANA database that are always at the same offset (i.e. no DST, no time zone transitions ever), and these are implemented as associated constants here.
/// Most of these are fixed "by definition", that is their name literally is `Etc/GMTPlus3` or something to that effect -- these are unlikely to ever change between versions of the database.
/// However, for the time zones where that is not immediately obvious, a word of caution: if any of these, according to the timezone database, cease being fixed offsets, the associated constants will be removed unceremoniously from the next version of this library.
/// As such, **no semantic versioning guarantees can be made about these constants**.
/// Finally, as a reminder: the timezone database reflects the arbitrary and frequently insane decisions made by politicians.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct Offset {
	name: &'static str,
	seconds: i64
}

/// Ambiguity that can arise when reverting the [`Unsteady`] [`Zone`] offset
///
/// As described in the documentation for [`Unsteady`], there are two edge cases when trying to revert the offset:
/// - [`Point`]s may be skipped
/// - [`Point`]s may be repeated
///
/// This enum captures both of these.
#[derive(Debug, PartialEq, Eq)]
pub enum Ambiguity {
	/// Contains the [`Point`] when the transition occurred that skipped the requested [`Point`] / [`DateTime`]
	Skipped(Point),
	/// Both [`Point`]s have the same [`DateTime`] (i.e. they "map" to the same [`Point`] in UTC)
	Repeated([(Point, Offset); 2])
}

/// An [`Iterator`] yielding all the [`Offset`]s in a [`Zone`] and the [`Frame`]s when they apply
pub struct OffsetFrames {
	offsets: Iter<'static, (Point, Offset)>,
	current: Option<(Point, Offset)>
}

impl Iterator for OffsetFrames {
	type Item = (Frame, Offset);
	fn next(&mut self) -> Option<Self::Item> {
		match (self.current.take(), self.offsets.next()) {
			(Some((curr_p, curr_off)), Some(&(next_p, next_off))) => {
				self.current = Some((next_p, next_off));
				let frame = Frame {
					start: curr_p,
					stop: next_p
				};
				Some((frame, curr_off))
			},
			(Some((last_p, last_off)), None) => Some((
				Frame {start: last_p, stop: Point::from_epoch(i64::MAX)},
				last_off
			)),
			(None, _) => None
		}
	}
}
impl ExactSizeIterator for OffsetFrames {
	fn len(&self) -> usize {
		self.offsets.len() + self.current.iter().count()
	}
}

/*
 *	OFFSET
 */

impl Offset {
	const fn new(name: &'static str, seconds: i64) -> Self {
		Self {name, seconds}
	}
	/// Name of the [`Offset`] (e.g. `CEST`)
	pub const fn name(self) -> &'static str {self.name}
}

impl Shift for Offset {
	fn apply(&self, point: Point) -> Point {
		let timestamp = point.timestamp + self.seconds;
		Point {timestamp}
	}
}

impl Steady for Offset {
	fn revert(&self, point: Point) -> Point {
		let timestamp = point.timestamp - self.seconds;
		Point {timestamp}
	}
}

/*
 *	ZONE
 */


impl Zone {
	const fn define(
		name: &'static str,
		lmt: Offset,
		offsets: &'static [(Point, Offset)])
		-> Self
	{
		Self {name, lmt, offsets}
	}
	const fn alias(name: &'static str, alias: Self) -> Self {
		Self {name, ..alias}
	}

	/// Name of the [`Zone`] (e.g. `Europe/Berlin`)
	pub const fn name(self) -> &'static str {self.name}

	fn contains(point: Point) -> bool {
		(utc!(1800-01-01)..utc!(2100-01-01)).contains(&point)
	}

	/// Format a [`Point`] as `YYYY-MM-DD hh:mm:ss Z` (i.e. `2022-12-31 12:34:56 CET`)
	///
	/// Same as doing [`greg::Calendar::lookup`] and calling [`ToString::to_string`] on the [`DateTime`], but with the [`Offset`] name appended.
	pub fn format_with_name(&self, point: Point) -> String {
		let offset = self.offset_at(point);
		let DateTime(date, time) = Utc::lookup(offset.apply(point));
		format!("{date} {time} {}", offset.name())
	}

	/// Get the [`Offset`] that applies for a [`Point`]
	pub fn offset_at(&self, point: Point) -> Offset {
		assert!(
			Self::contains(point),
			"Point out of valid time zone range 1800..2100"
		);
		self.offsets.iter()
			.rev()
			.find(|&&(p, _offset)| p <= point)
			.map(|&(_p, offset)| offset)
			.unwrap_or(self.lmt)
	}

	/// Access the array of [`Offset`]s and when they first apply
	///
	/// Every [`Offset`] applies from the [`Point`] in the tuple until the next one begins.
	/// In theory, the last [`Offset`] doesn't have an end [`Point`], but remember that this crate only defines time zones up until the year 2100.
	///
	/// Note that this doesn't include the base [`Offset`] (LMT).
	/// As such, the slice may be empty.
	pub fn offsets(&self) -> &'static [(Point, Offset)] {self.offsets}

	/// Iterate over all the [`Offset`]s and the [`Frame`] during which they're active
	pub fn offset_frames(&self) -> OffsetFrames {
		let offsets = self.offsets.iter();
		let current = Some((Point::from_epoch(i64::MIN), self.lmt));
		OffsetFrames {offsets, current}
	}
}

impl Shift for Zone {
	fn apply(&self, point: Point) -> Point {
		self.offset_at(point).apply(point)
	}
}


impl Unsteady for Zone {
	type Ambiguity = Ambiguity;

	fn revert_lossy(&self, utc_point: Point) -> Point {
		assert!(
			Self::contains(utc_point),
			"Point out of valid time zone range 1800..2100"
		);

		for (frame, offset) in self.offset_frames() {
			let reverted = offset.revert(utc_point);
			if frame.contains(&reverted) {
				return reverted;
			}
			else if frame.start > reverted {
				//skipped
				return frame.start;
			}
		}
		unreachable!()
	}

	fn try_revert(&self, utc_point: Point) -> Result<Point, Ambiguity> {
		assert!(
			Self::contains(utc_point),
			"Point out of valid time zone range 1800..2100"
		);
		let mut found = None;
		for (frame, offset) in self.offset_frames() {
			let reverted = offset.revert(utc_point);
			match frame.contains(&reverted) {
				true => match found {
					Some(previous) => return Err(Ambiguity::Repeated(
						[previous, (reverted, offset)]
					)),
					None => found = Some((reverted, offset))
				},
				false => match found {
					Some((point, _)) => return Ok(point),
					None if frame.start > reverted => return Err(
						Ambiguity::Skipped(frame.start)
					),
					None => continue
				}
			}
		}
		unreachable!()
	}
}