// Copyright 2015-2023 Benjamin Fry <benjaminfry@me.com>
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// https://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// https://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.

use alloc::{
    borrow::Cow,
    collections::btree_map::{BTreeMap, Entry},
    string::String,
    vec::Vec,
};
use core::{mem, str::FromStr};
use std::{
    fs,
    path::{Path, PathBuf},
};

use crate::{
    rr::{DNSClass, LowerName, Name, RData, Record, RecordSet, RecordType, RrKey},
    serialize::txt::{
        ParseError, ParseResult, parse_ttl,
        zone_lex::{Lexer, Token},
    },
};

/// ```text
/// 5. ZONE FILES
///
/// Zone files are text files that contain RRs in text form.  Since the
/// contents of a zone can be expressed in the form of a list of RRs a
/// Zone File is most often used to define a zone, though it can be used
/// to list a cache's contents.  Hence, this section first discusses the
/// format of RRs in a Zone File, and then the special considerations when
/// a Zone File is used to create a zone in some name server.
///
/// 5.1. Format
///
/// The format of these files is a sequence of entries.  Entries are
/// predominantly line-oriented, though parentheses can be used to continue
/// a list of items across a line boundary, and text literals can contain
/// CRLF within the text.  Any combination of tabs and spaces act as a
/// delimiter between the separate items that make up an entry.  The end of
/// any line in the Zone File can end with a comment.  The comment starts
/// with a ";" (semicolon).
///
/// The following entries are defined:
///
///     <blank>[<comment>]
///
///     $ORIGIN <domain-name> [<comment>]
///
///     $INCLUDE <file-name> [<domain-name>] [<comment>]
///
///     <domain-name><rr> [<comment>]
///
///     <blank><rr> [<comment>]
///
/// Blank lines, with or without comments, are allowed anywhere in the file.
///
/// Two control entries are defined: $ORIGIN and $INCLUDE.  $ORIGIN is
/// followed by a domain name, and resets the current origin for relative
/// domain names to the stated name.  $INCLUDE inserts the named file into
/// the current file, and may optionally specify a domain name that sets the
/// relative domain name origin for the included file.  $INCLUDE may also
/// have a comment.  Note that a $INCLUDE entry never changes the relative
/// origin of the parent file, regardless of changes to the relative origin
/// made within the included file.
///
/// The last two forms represent RRs.  If an entry for an RR begins with a
/// blank, then the RR is assumed to be owned by the last stated owner.  If
/// an RR entry begins with a <domain-name>, then the owner name is reset.
///
/// <rr> contents take one of the following forms:
///
///     [<TTL>] [<class>] <type> <RDATA>
///
///     [<class>] [<TTL>] <type> <RDATA>
///
/// The RR begins with optional TTL and class fields, followed by a type and
/// RDATA field appropriate to the type and class.  Class and type use the
/// standard mnemonics, TTL is a decimal integer.  Omitted class and TTL
/// values are default to the last explicitly stated values.  Since type and
/// class mnemonics are disjoint, the parse is unique.  (Note that this
/// order is different from the order used in examples and the order used in
/// the actual RRs; the given order allows easier parsing and defaulting.)
///
/// <domain-name>s make up a large share of the data in the Zone File.
/// The labels in the domain name are expressed as character strings and
/// separated by dots.  Quoting conventions allow arbitrary characters to be
/// stored in domain names.  Domain names that end in a dot are called
/// absolute, and are taken as complete.  Domain names which do not end in a
/// dot are called relative; the actual domain name is the concatenation of
/// the relative part with an origin specified in a $ORIGIN, $INCLUDE, or as
/// an argument to the Zone File loading routine.  A relative name is an
/// error when no origin is available.
///
/// <character-string> is expressed in one or two ways: as a contiguous set
/// of characters without interior spaces, or as a string beginning with a "
/// and ending with a ".  Inside a " delimited string any character can
/// occur, except for a " itself, which must be quoted using \ (back slash).
///
/// Because these files are text files several special encodings are
/// necessary to allow arbitrary data to be loaded.  In particular:
///
///                 of the root.
///
/// @               A free standing @ is used to denote the current origin.
///
/// \X              where X is any character other than a digit (0-9), is
///                 used to quote that character so that its special meaning
///                 does not apply.  For example, "\." can be used to place
///                 a dot character in a label.
///
/// \DDD            where each D is a digit is the octet corresponding to
///                 the decimal number described by DDD.  The resulting
///                 octet is assumed to be text and is not checked for
///                 special meaning.
///
/// ( )             Parentheses are used to group data that crosses a line
///                 boundary.  In effect, line terminations are not
///                 recognized within parentheses.
///
/// ;               Semicolon is used to start a comment; the remainder of
///                 the line is ignored.
/// ```
pub struct Parser<'a> {
    lexers: Vec<(Lexer<'a>, Option<PathBuf>)>,
    origin: Option<Name>,
}

impl<'a> Parser<'a> {
    /// Returns a new Zone file parser
    ///
    /// The `path` argument's parent directory is used to resolve relative `$INCLUDE` paths.
    /// Relative `$INCLUDE` paths will yield an error if `path` is `None`.
    pub fn new(
        input: impl Into<Cow<'a, str>>,
        path: Option<PathBuf>,
        mut origin: Option<Name>,
    ) -> Self {
        if let Some(origin) = &mut origin {
            origin.set_fqdn(true);
        }
        Self {
            lexers: vec![(Lexer::new(input), path)],
            origin,
        }
    }

    /// Parse a file from the Lexer
    ///
    /// # Return
    ///
    /// A pair of the Zone origin name and a map of all Keys to RecordSets
    pub fn parse(mut self) -> ParseResult<(Name, BTreeMap<RrKey, RecordSet>)> {
        let mut cx = Context::new(self.origin);
        let mut state = State::StartLine;
        let mut stack = self.lexers.len();

        'outer: while let Some((lexer, path)) = self.lexers.last_mut() {
            while let Some(t) = lexer.next_token()? {
                state = match state {
                    State::StartLine => {
                        // current_name is not reset on the next line b/c it might be needed from the previous
                        cx.rtype = None;

                        match t {
                            // if Dollar, then $INCLUDE or $ORIGIN
                            Token::Include => State::Include(None),
                            Token::Origin => State::Origin,
                            Token::Ttl => State::Ttl,

                            // if CharData, then Name then ttl_class_type
                            Token::CharData(data) => {
                                cx.current_name = Some(Name::parse(&data, cx.origin.as_ref())?);
                                State::TtlClassType
                            }

                            // @ is a placeholder for specifying the current origin
                            Token::At => {
                                cx.current_name.clone_from(&cx.origin); // TODO a COW or RC would reduce copies...
                                State::TtlClassType
                            }

                            // if blank, then nothing or ttl_class_type
                            Token::Blank => State::TtlClassType,
                            Token::EOL => State::StartLine, // probably a comment
                            _ => return Err(ParseError::UnexpectedToken(t)),
                        }
                    }
                    State::Ttl => match t {
                        Token::CharData(data) => {
                            cx.ttl.default = Some(parse_ttl(&data)?);
                            State::StartLine
                        }
                        _ => return Err(ParseError::UnexpectedToken(t)),
                    },
                    State::Origin => {
                        match t {
                            Token::CharData(data) => {
                                // TODO an origin was specified, should this be legal? definitely confusing...
                                cx.origin = Some(Name::parse(&data, None)?);
                                State::StartLine
                            }
                            _ => return Err(ParseError::UnexpectedToken(t)),
                        }
                    }
                    State::Include(include_path) => match (t, include_path) {
                        (Token::CharData(data), None) => State::Include(Some(data)),
                        (Token::EOL, Some(include_path)) => {
                            // RFC1035 (section 5) does not specify how filename for $INCLUDE
                            // should be resolved into file path. The underlying code implements the
                            // following:
                            // * if the path is absolute (relies on Path::is_absolute), it uses normalized path
                            // * otherwise, it joins the path with parent root of the current file
                            //
                            // TODO: Inlining files specified using non-relative path might potentially introduce
                            // security issue in some cases (e.g. when working with zone files from untrusted sources)
                            // and should probably be configurable by user.

                            if stack > MAX_INCLUDE_LEVEL {
                                return Err(ParseError::Message(
                                    "Max depth level for nested $INCLUDE is reached",
                                ));
                            }

                            let include = Path::new(&include_path);
                            let include = match (include.is_absolute(), path) {
                                (true, _) => include.to_path_buf(),
                                (false, Some(path)) => path
                                    .parent()
                                    .expect("file has to have parent folder")
                                    .join(include),
                                (false, None) => {
                                    return Err(ParseError::Message(
                                        "Relative $INCLUDE is not supported",
                                    ));
                                }
                            };

                            let input = fs::read_to_string(&include)?;
                            let lexer = Lexer::new(input);
                            self.lexers.push((lexer, Some(include)));
                            stack += 1;
                            state = State::StartLine;
                            continue 'outer;
                        }
                        (Token::CharData(_), Some(_)) => {
                            return Err(ParseError::Message(
                                "Domain name for $INCLUDE is not supported",
                            ));
                        }
                        (t, _) => {
                            return Err(ParseError::UnexpectedToken(t));
                        }
                    },
                    State::TtlClassType => {
                        match t {
                            // if number, TTL
                            // Token::Number(num) => ttl = Some(*num),
                            // One of Class or Type (these cannot be overlapping!)
                            Token::CharData(mut data) => {
                                // if it's a number it's a ttl
                                let result: ParseResult<u32> = parse_ttl(&data);
                                if let Ok(ttl) = result {
                                    cx.ttl.this = Some(ttl);
                                    State::TtlClassType // hm, should this go to just ClassType?
                                } else {
                                    // if can parse DNSClass, then class
                                    data.make_ascii_uppercase();
                                    let result = DNSClass::from_str(&data);
                                    if let Ok(parsed) = result {
                                        cx.class = parsed;
                                        State::TtlClassType
                                    } else {
                                        // if can parse RecordType, then RecordType
                                        cx.rtype = Some(RecordType::from_str(&data)?);
                                        State::Record(vec![])
                                    }
                                }
                            }
                            // could be nothing if started with blank and is a comment, i.e. EOL
                            Token::EOL => {
                                State::StartLine // next line
                            }
                            _ => return Err(ParseError::UnexpectedToken(t)),
                        }
                    }
                    State::Record(record_parts) => {
                        // b/c of ownership rules, perhaps, just collect all the RData components as a list of
                        //  tokens to pass into the processor
                        match t {
                            Token::EOL => {
                                cx.insert(record_parts)?;
                                State::StartLine
                            }
                            Token::CharData(part) => {
                                let mut record_parts = record_parts;
                                record_parts.push(part);
                                State::Record(record_parts)
                            }
                            // TODO: we should not tokenize the list...
                            Token::List(list) => {
                                let mut record_parts = record_parts;
                                record_parts.extend(list);
                                State::Record(record_parts)
                            }
                            _ => return Err(ParseError::UnexpectedToken(t)),
                        }
                    }
                };
            }

            // Extra flush at the end for the case of missing endline
            if let State::Record(record_parts) = mem::replace(&mut state, State::StartLine) {
                cx.insert(record_parts)?;
            }

            stack -= 1;
            self.lexers.pop();
        }

        //
        // build the Authority and return.
        let origin = cx
            .origin
            .ok_or(ParseError::Message("$ORIGIN was not specified"))?;
        Ok((origin, cx.records))
    }
}

#[derive(Default)]
struct Ttl {
    default: Option<u32>,
    last: Option<u32>,
    this: Option<u32>,
}

impl Ttl {
    fn take(&mut self) -> Option<u32> {
        if let Some(ttl) = self.this.take() {
            self.last.replace(ttl);
            return Some(ttl);
        }
        if let Some(ttl) = self.default {
            return Some(ttl);
        }
        if let Some(ttl) = self.last {
            return Some(ttl);
        }

        None
    }
}

struct Context {
    origin: Option<Name>,
    records: BTreeMap<RrKey, RecordSet>,
    class: DNSClass,
    current_name: Option<Name>,
    rtype: Option<RecordType>,
    ttl: Ttl,
}

impl Context {
    fn new(origin: Option<Name>) -> Self {
        Self {
            origin,
            records: BTreeMap::default(),
            class: DNSClass::IN,
            current_name: None,
            rtype: None,
            ttl: Ttl::default(),
        }
    }

    fn insert(&mut self, record_parts: Vec<String>) -> ParseResult<()> {
        // call out to parsers for difference record types
        // all tokens as part of the Record should be chardata...
        let rtype = self
            .rtype
            .ok_or_else(|| ParseError::from("record type not specified"))?;

        let rdata = RData::from_tokens(
            rtype,
            record_parts.iter().map(AsRef::as_ref),
            self.origin.as_ref(),
        )?;

        // verify that we have everything we need for the record
        // TODO COW or RC would reduce mem usage, perhaps Name should have an intern()...
        //  might want to wait until RC.weak() stabilizes, as that would be needed for global
        //  memory where you want
        let mut name = self
            .current_name
            .clone()
            .ok_or_else(|| ParseError::from("record name not specified"))?;

        let ttl = self
            .ttl
            .take()
            .ok_or_else(|| ParseError::from("record ttl not specified"))?;

        // TODO: validate record, e.g. the name of SRV record allows _ but others do not.

        // move the rdata into record...
        name.set_fqdn(true);
        let mut record = Record::from_rdata(name, ttl, rdata);
        record.dns_class = self.class;

        // add to the map
        let entry = self.records.entry(RrKey::new(
            LowerName::new(&record.name),
            record.record_type(),
        ));
        match (rtype, entry) {
            (RecordType::SOA, Entry::Occupied(_)) => {
                return Err(ParseError::from("SOA is already specified"));
            }
            (_, Entry::Vacant(entry)) => {
                entry.insert(RecordSet::from(record));
            }
            (_, Entry::Occupied(mut entry)) => {
                entry.get_mut().insert(record, 0);
            }
        };

        Ok(())
    }
}

enum State {
    StartLine,    // start of line, @, $<WORD>, Name, Blank
    TtlClassType, // [<TTL>] [<class>] <type>,
    Ttl,          // $TTL <time>
    Record(Vec<String>),
    Include(Option<String>), // $INCLUDE <filename>
    Origin,
}

/// Max traversal depth for $INCLUDE files
const MAX_INCLUDE_LEVEL: usize = 256;

#[cfg(test)]
mod tests {
    use alloc::string::ToString;

    use super::*;

    #[test]
    #[allow(clippy::uninlined_format_args)]
    fn test_zone_parse() {
        let domain = Name::from_str("parameter.origin.org.").unwrap();

        let zone_data = r#"$ORIGIN parsed.zone.origin.org.
 faulty-record-type 60 IN A 1.2.3.4
"#;

        let result = Parser::new(zone_data, None, Some(domain)).parse();
        assert!(
            result.is_err()
                & result
                    .as_ref()
                    .unwrap_err()
                    .to_string()
                    .contains("FAULTY-RECORD-TYPE"),
            "unexpected success: {:#?}",
            result
        );
    }
}
