extern crate parse_zoneinfo;
#[cfg(feature = "filter-by-regex")]
extern crate regex;

use std::collections::BTreeSet;
use std::env;
use std::fs::File;
use std::io::{self, BufRead, BufReader, Write};
use std::path::Path;

use parse_zoneinfo::line::{Line, LineParser};
use parse_zoneinfo::structure::{Child, Structure};
use parse_zoneinfo::table::{Table, TableBuilder};
use parse_zoneinfo::transitions::FixedTimespan;
use parse_zoneinfo::transitions::TableTransitions;

/// The name of the environment variable which possibly holds the filter regex.
const FILTER_ENV_VAR_NAME: &str = "CHRONO_TZ_TIMEZONE_FILTER";

// This function is needed until zoneinfo_parse handles comments correctly.
// Technically a '#' symbol could occur between double quotes and should be
// ignored in this case, however this never happens in the tz database as it
// stands.
fn strip_comments(mut line: String) -> String {
    if let Some(pos) = line.find('#') {
        line.truncate(pos);
    };
    line
}

// Generate a list of the time zone periods beyond the first that apply
// to this zone, as a string representation of a static slice.
fn format_rest(rest: Vec<(i64, FixedTimespan)>) -> String {
    let mut ret = "&[\n".to_string();
    for (
        start,
        FixedTimespan {
            utc_offset,
            dst_offset,
            name,
        },
    ) in rest
    {
        let timespan_name = match name.as_ref() {
            "%z" => None,
            name => Some(name),
        };
        ret.push_str(&format!(
            "                    ({start}, FixedTimespan {{ \
             utc_offset: {utc}, dst_offset: {dst}, name: {name:?} \
             }}),\n",
            start = start,
            utc = utc_offset,
            dst = dst_offset,
            name = timespan_name,
        ));
    }
    ret.push_str("                ]");
    ret
}

// Convert all '/' to '__', all '+' to 'Plus' and '-' to 'Minus', unless
// it's a hyphen, in which case remove it. This is so the names can be used
// as rust identifiers.
fn convert_bad_chars(name: &str) -> String {
    let name = name.replace('/', "__").replace('+', "Plus");
    if let Some(pos) = name.find('-') {
        if name[pos + 1..]
            .chars()
            .next()
            .map(char::is_numeric)
            .unwrap_or(false)
        {
            name.replace('-', "Minus")
        } else {
            name.replace('-', "")
        }
    } else {
        name
    }
}

// The timezone file contains impls of `Timespans` for all timezones in the
// database. The `Wrap` wrapper in the `timezone_impl` module then implements
// TimeZone for any contained struct that implements `Timespans`.
fn write_timezone_file(timezone_file: &mut File, table: &Table) -> io::Result<()> {
    let zones = table
        .zonesets
        .keys()
        .chain(table.links.keys())
        .collect::<BTreeSet<_>>();
    writeln!(
        timezone_file,
        "use core::fmt::{{self, Debug, Display, Formatter}};",
    )?;
    writeln!(timezone_file, "use core::str::FromStr;\n",)?;
    writeln!(
        timezone_file,
        "use crate::timezone_impl::{{TimeSpans, FixedTimespanSet, FixedTimespan}};\n",
    )?;
    writeln!(
        timezone_file,
        "/// TimeZones built at compile time from the tz database
///
/// This implements [`chrono::TimeZone`] so that it may be used in and to
/// construct chrono's DateTime type. See the root module documentation
/// for details."
    )?;
    writeln!(timezone_file, "#[derive(Clone, Copy, PartialEq, Eq, Hash)]")?;
    writeln!(
        timezone_file,
        r#"#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]"#
    )?;
    writeln!(timezone_file, "pub enum Tz {{")?;
    for zone in &zones {
        let zone_name = convert_bad_chars(zone);
        writeln!(
            timezone_file,
            "    /// {raw_zone_name}\n    {zone},",
            zone = zone_name,
            raw_zone_name = zone
        )?;
    }
    writeln!(timezone_file, "}}")?;

    let mut map = phf_codegen::Map::new();
    for zone in &zones {
        map.entry(zone, &format!("Tz::{}", convert_bad_chars(zone)));
    }
    writeln!(
        timezone_file,
        "static TIMEZONES: ::phf::Map<&'static str, Tz> = \n{};",
        map.build()
    )?;

    #[cfg(feature = "case-insensitive")]
    {
        writeln!(timezone_file, "use uncased::UncasedStr;\n",)?;
        let mut map = phf_codegen::Map::new();
        for zone in &zones {
            map.entry(
                uncased::UncasedStr::new(zone),
                &format!("Tz::{}", convert_bad_chars(zone)),
            );
        }
        writeln!(
            timezone_file,
            "static TIMEZONES_UNCASED: ::phf::Map<&'static uncased::UncasedStr, Tz> = \n{};",
            map.build()
        )?;
    }

    writeln!(
        timezone_file,
        r#"#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct ParseError(());

impl Display for ParseError {{
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {{
        f.write_str("failed to parse timezone")
    }}
}}

#[cfg(feature = "std")]
impl std::error::Error for ParseError {{}}

impl FromStr for Tz {{
    type Err = ParseError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {{
        return TIMEZONES.get(s).cloned().ok_or(ParseError(()));
    }}
}}
"#
    )?;

    writeln!(
        timezone_file,
        "impl Tz {{
    pub fn name(self) -> &'static str {{
        match self {{"
    )?;
    for zone in &zones {
        let zone_name = convert_bad_chars(zone);
        writeln!(
            timezone_file,
            "            Tz::{zone} => \"{raw_zone_name}\",",
            zone = zone_name,
            raw_zone_name = zone
        )?;
    }
    writeln!(
        timezone_file,
        "        }}
    }}"
    )?;

    #[cfg(feature = "case-insensitive")]
    {
        writeln!(
            timezone_file,
            r#"
    #[cfg(feature = "case-insensitive")]
    /// Parses a timezone string in a case-insensitive way
    pub fn from_str_insensitive(s: &str) -> Result<Self, ParseError> {{
        return TIMEZONES_UNCASED.get(s.into()).cloned().ok_or(ParseError(()));
    }}"#
        )?;
    }

    writeln!(timezone_file, "}}")?;

    writeln!(
        timezone_file,
        "impl Debug for Tz {{
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {{
        f.write_str(self.name().as_ref())
    }}
}}\n"
    )?;
    writeln!(
        timezone_file,
        "impl Display for Tz {{
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {{
        f.write_str(self.name().as_ref())
    }}
}}\n"
    )?;
    writeln!(
        timezone_file,
        "impl TimeSpans for Tz {{
    fn timespans(&self) -> FixedTimespanSet {{
        match *self {{"
    )?;
    for zone in &zones {
        let timespans = table.timespans(zone).unwrap();
        let zone_name = convert_bad_chars(zone);
        let timespan_name = match timespans.first.name.as_ref() {
            "%z" => None,
            name => Some(name),
        };
        writeln!(
            timezone_file,
            "            Tz::{zone} => {{
                const REST: &[(i64, FixedTimespan)] = {rest};
                FixedTimespanSet {{
                    first: FixedTimespan {{
                        utc_offset: {utc},
                        dst_offset: {dst},
                        name: {name:?},
                    }},
                    rest: REST
                }}
            }},\n",
            zone = zone_name,
            rest = format_rest(timespans.rest),
            utc = timespans.first.utc_offset,
            dst = timespans.first.dst_offset,
            name = timespan_name,
        )?;
    }
    write!(
        timezone_file,
        "         }}
    }}
}}\n"
    )?;
    write!(
        timezone_file,
        "/// An array of every known variant
///
/// Useful for iterating over known timezones:
///
/// ```
/// use chrono_tz::{{TZ_VARIANTS, Tz}};
/// assert!(TZ_VARIANTS.iter().any(|v| *v == Tz::UTC));
/// ```
pub static TZ_VARIANTS: [Tz; {num}] = [
",
        num = zones.len()
    )?;
    for zone in &zones {
        writeln!(
            timezone_file,
            "    Tz::{zone},",
            zone = convert_bad_chars(zone)
        )?;
    }
    write!(timezone_file, "];")?;
    Ok(())
}

// Create a file containing nice-looking re-exports such as Europe::London
// instead of having to use chrono_tz::timezones::Europe__London
fn write_directory_file(directory_file: &mut File, table: &Table, version: &str) -> io::Result<()> {
    // expose the underlying IANA TZDB version
    writeln!(
        directory_file,
        "pub const IANA_TZDB_VERSION : &str = \"{version}\";\n"
    )?;
    // add the `loose' zone definitions first
    writeln!(directory_file, "use crate::timezones::Tz;\n")?;
    let zones = table
        .zonesets
        .keys()
        .chain(table.links.keys())
        .filter(|zone| !zone.contains('/'))
        .collect::<BTreeSet<_>>();
    for zone in zones {
        let zone = convert_bad_chars(zone);
        writeln!(
            directory_file,
            "pub const {name} : Tz = Tz::{name};",
            name = zone
        )?;
    }
    writeln!(directory_file)?;

    // now add the `structured' zone names in submodules
    for entry in table.structure() {
        if entry.name.contains('/') {
            continue;
        }
        let module_name = convert_bad_chars(entry.name);
        writeln!(directory_file, "pub mod {name} {{", name = module_name)?;
        writeln!(directory_file, "    use crate::timezones::Tz;\n",)?;
        for child in entry.children {
            match child {
                Child::Submodule(name) => {
                    let submodule_name = convert_bad_chars(name);
                    writeln!(
                        directory_file,
                        "    pub mod {name} {{",
                        name = submodule_name
                    )?;
                    writeln!(directory_file, "        use crate::timezones::Tz;\n",)?;
                    let full_name = entry.name.to_string() + "/" + name;
                    for entry in table.structure() {
                        if entry.name == full_name {
                            for child in entry.children {
                                match child {
                                    Child::Submodule(_) => {
                                        panic!("Depth of > 3 nested submodules not implemented!")
                                    }
                                    Child::TimeZone(name) => {
                                        let converted_name = convert_bad_chars(name);
                                        writeln!(directory_file,
                                    "        pub const {name} : Tz = Tz::{module_name}__{submodule_name}__{name};",
                                            module_name = module_name,
                                            submodule_name = submodule_name,
                                            name = converted_name,
                                        )?;
                                    }
                                }
                            }
                        }
                    }
                    writeln!(directory_file, "    }}\n")?;
                }
                Child::TimeZone(name) => {
                    let name = convert_bad_chars(name);
                    writeln!(
                        directory_file,
                        "    pub const {name} : Tz = Tz::{module_name}__{name};",
                        module_name = module_name,
                        name = name
                    )?;
                }
            }
        }
        writeln!(directory_file, "}}\n")?;
    }
    Ok(())
}

/// Stub module because filter-by-regex feature is not enabled
#[cfg(not(feature = "filter-by-regex"))]
mod filter {
    /// stub function because filter-by-regex feature is not enabled
    pub(crate) fn maybe_filter_timezone_table(_table: &mut super::Table) {}
}

/// Module containing code supporting filter-by-regex feature
///
/// The "GMT" and "UTC" time zones are always included.
#[cfg(feature = "filter-by-regex")]
mod filter {
    use std::collections::HashSet;
    use std::env;

    use regex::Regex;

    use crate::{Table, FILTER_ENV_VAR_NAME};

    /// Filter `table` by applying [`FILTER_ENV_VAR_NAME`].
    pub(crate) fn maybe_filter_timezone_table(table: &mut Table) {
        if let Some(filter_regex) = get_filter_regex() {
            filter_timezone_table(table, filter_regex);
        }
    }

    /// Checks the `CHRONO_TZ_TIMEZONE_FILTER` environment variable.
    /// Converts it to a regex if set. Panics if the regex is not valid, as we want
    /// to fail the build if that happens.
    fn get_filter_regex() -> Option<Regex> {
        match env::var(FILTER_ENV_VAR_NAME) {
            Ok(val) => {
                let val = val.trim();
                if val.is_empty() {
                    return None;
                }
                match Regex::new(val) {
                    Ok(regex) => Some(regex),
                    Err(err) => panic!(
                        "The value '{:?}' for environment variable {} is not a valid regex, err={}",
                        val, FILTER_ENV_VAR_NAME, err
                    ),
                }
            }
            Err(env::VarError::NotPresent) => None,
            Err(env::VarError::NotUnicode(s)) => panic!(
                "The value '{:?}' for environment variable {} is not valid Unicode",
                s, FILTER_ENV_VAR_NAME
            ),
        }
    }

    /// Insert a new name in the list of names to keep. If the name has 3
    /// parts, then also insert the 2-part prefix. If we don't do this we will lose
    /// half of Indiana in `directory.rs`. But we *don't* want to keep one-part names,
    /// otherwise we will inevitably end up with 'America' and include too much as
    /// a consequence.
    fn insert_keep_entry(keep: &mut HashSet<String>, new_value: &str) {
        let mut parts = new_value.split('/');
        if let (Some(p1), Some(p2), Some(_), None) =
            (parts.next(), parts.next(), parts.next(), parts.next())
        {
            keep.insert(format!("{}/{}", p1, p2));
        }

        keep.insert(new_value.to_string());
    }

    /// Filter `table` by applying `filter_regex`.
    fn filter_timezone_table(table: &mut Table, filter_regex: Regex) {
        // Compute the transitive closure of things to keep.
        // Doing this, instead of just filtering `zonesets` and `links` by the
        // regex, helps to keep the `structure()` intact.
        let mut keep = HashSet::new();
        for (k, v) in &table.links {
            if filter_regex.is_match(k) || k == "GMT" || k == "UTC" {
                insert_keep_entry(&mut keep, k);
            }
            if filter_regex.is_match(v) || k == "GMT" || k == "UTC" {
                insert_keep_entry(&mut keep, v);
            }
        }

        let mut n = 0;
        loop {
            let len = keep.len();

            for (k, v) in &table.links {
                if keep.contains(k) && !keep.contains(v) {
                    insert_keep_entry(&mut keep, v);
                }
                if keep.contains(v) && !keep.contains(k) {
                    insert_keep_entry(&mut keep, k);
                }
            }

            if keep.len() == len {
                break;
            }

            n += 1;
            if n == 50 {
                println!("cargo:warning=Recursion limit reached while building filter list");
                break;
            }
        }

        // Actually do the filtering.
        table
            .links
            .retain(|k, v| keep.contains(k) || keep.contains(v));

        table
            .zonesets
            .retain(|k, _| filter_regex.is_match(k) || keep.iter().any(|s| k.starts_with(s)));
    }
}

fn detect_iana_db_version() -> String {
    let root = env::var("CARGO_MANIFEST_DIR").expect("no Cargo build context");
    let path = Path::new(&root).join(Path::new("tz/NEWS"));
    let file = File::open(path).expect("failed to open file");

    let mut lines = BufReader::new(file).lines();
    while let Some(Ok(line)) = lines.next() {
        let line = match line.strip_prefix("Release ") {
            Some(line) => line,
            _ => continue,
        };

        match line.split_once(" - ") {
            Some((version, _)) => return version.to_owned(),
            _ => continue,
        }
    }

    unreachable!("no version found")
}

pub fn main() {
    println!("cargo:rerun-if-env-changed={}", FILTER_ENV_VAR_NAME);

    let parser = LineParser::default();
    let mut table = TableBuilder::new();

    let tzfiles = [
        "tz/africa",
        "tz/antarctica",
        "tz/asia",
        "tz/australasia",
        "tz/backward",
        "tz/etcetera",
        "tz/europe",
        "tz/northamerica",
        "tz/southamerica",
    ];

    let lines = tzfiles
        .iter()
        .map(Path::new)
        .map(|p| {
            Path::new(&env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| String::new())).join(p)
        })
        .map(|path| {
            File::open(&path).unwrap_or_else(|e| panic!("cannot open {}: {}", path.display(), e))
        })
        .map(BufReader::new)
        .flat_map(BufRead::lines)
        .map(Result::unwrap)
        .map(strip_comments);

    for line in lines {
        match parser.parse_str(&line).unwrap() {
            Line::Zone(zone) => table.add_zone_line(zone).unwrap(),
            Line::Continuation(cont) => table.add_continuation_line(cont).unwrap(),
            Line::Rule(rule) => table.add_rule_line(rule).unwrap(),
            Line::Link(link) => table.add_link_line(link).unwrap(),
            Line::Space => {}
        }
    }

    let mut table = table.build();
    filter::maybe_filter_timezone_table(&mut table);

    let timezone_path = Path::new(&env::var("OUT_DIR").unwrap()).join("timezones.rs");
    let mut timezone_file = File::create(timezone_path).unwrap();
    write_timezone_file(&mut timezone_file, &table).unwrap();

    let directory_path = Path::new(&env::var("OUT_DIR").unwrap()).join("directory.rs");
    let mut directory_file = File::create(directory_path).unwrap();
    let version = detect_iana_db_version();
    write_directory_file(&mut directory_file, &table, &version).unwrap();
}
