//! Parse INI-style configuration files.

/*
 * Copyright (c) 2021, 2022  Peter Pentchev <roam@ringlet.net>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
 * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
 * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
 * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
 * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
 * SUCH DAMAGE.
 */
use std::borrow::ToOwned;
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::io::{self, Read};

use crate::backend::{Backend, DataRead};
use crate::defs::Config;

use expect_exit::ExpectedResult;
use regex::{Captures, Regex};

/// A backend type for parsing INI-style configuration files.
#[derive(Debug)]
#[non_exhaustive]
#[allow(clippy::module_name_repetitions)]
pub struct IniBackend<'cfg> {
    /// Configuration settings, e.g. filename and section.
    pub config: &'cfg Config,
}

/// The current state of the INI-style file parser.
#[derive(Debug)]
struct State {
    /// The regular expression to use to detect comments.
    re_comment: Regex,
    /// The regular expression to use to detect a section header.
    re_section: Regex,
    /// The regular expression to use to detect a variable definition.
    re_variable: Regex,
    /// The name of the input file to read.
    filename: String,
    /// The name of the first section in the file, if there was one at all.
    first_section: Option<String>,
    /// The name of the current section.
    section: String,
    /// If this is a continuation line, the name and value of the current variable.
    cont: Option<(String, String)>,
    /// Have we found any variables or sections at all already?
    /// Used when determining whether the first section should be the default one
    /// or there were any variables defined before that.
    found: bool,
}

impl State {
    /// Process the next line of input, return the updated parser state.
    fn feed_line(
        self,
        line: &str,
        res: &mut HashMap<String, HashMap<String, String>>,
    ) -> Result<Self, Box<dyn Error>> {
        match self.cont {
            Some((name, value)) => match line.strip_suffix('\\') {
                Some(stripped) => Ok(Self {
                    cont: Some((name, format!("{}{}", value, stripped))),
                    ..self
                }),
                None => {
                    res.get_mut(&self.section)
                        .expect_result(|| {
                            format!("Internal error: no data for section {}", self.section)
                        })?
                        .insert(name, format!("{}{}", value, line));
                    Ok(Self { cont: None, ..self })
                }
            },
            None => {
                if self.re_comment.is_match(line) {
                    Ok(self)
                } else {
                    /// Extract a regex capture group that we know must be there.
                    fn extr<'data>(
                        caps: &'data Captures<'_>,
                        name: &str,
                    ) -> Result<&'data str, Box<dyn Error>> {
                        Ok(caps
                            .name(name)
                            .expect_result(|| {
                                format!("Internal error: no '{}' in {:?}", name, caps)
                            })?
                            .as_str())
                    }

                    match self.re_section.captures(line) {
                        Some(caps) => {
                            let name = extr(&caps, "name")?;
                            res.entry(name.to_owned()).or_insert_with(HashMap::new);
                            Ok(Self {
                                first_section: if self.first_section.is_none() && !self.found {
                                    Some(name.to_owned())
                                } else {
                                    self.first_section
                                },
                                section: name.to_owned(),
                                found: true,
                                ..self
                            })
                        }
                        None => {
                            let caps = self.re_variable.captures(line).expect_result(|| {
                                format!("Unexpected line in {}: {}", self.filename, line)
                            })?;
                            let name = extr(&caps, "name")?;
                            let value = extr(&caps, "value")?;
                            let cont = caps.name("cont").is_some();
                            if !cont {
                                res.get_mut(&self.section)
                                    .expect_result(|| {
                                        format!(
                                            "Internal error: no data for section {}",
                                            self.section
                                        )
                                    })?
                                    .insert(name.to_owned(), value.to_owned());
                            }
                            Ok(Self {
                                cont: cont.then(|| (name.to_owned(), value.to_owned())),
                                found: true,
                                ..self
                            })
                        }
                    }
                }
            }
        }
    }
}

/// The regular expression to use for matching comment lines.
static RE_COMMENT: &str = r"(?x) ^ \s* (?: [\#;] .* )?  $ ";

/// The regular expression to use for matching section headers.
static RE_SECTION: &str = r"(?x)
    ^ \s*
    \[ \s*
    (?P<name> [^\]]+? )
    \s* \]
    \s* $ ";

/// The regular expression to use for matching var=value lines.
static RE_VARIABLE: &str = r"(?x)
    ^ \s*
    (?P<name> [^\s=]+ )
    \s* = \s*
    (?P<value> .*? )
    \s*
    (?P<cont> [\\] )?
    $ ";

impl Backend for IniBackend<'_> {
    /// Parse an INI-style file consisting of zero or more sections.
    ///
    /// # Errors
    ///
    /// Returns an [`expect_exit::ExpectationFailed`] error on
    /// configuration errors or if the file's contents does not
    /// follow the expected format.
    /// Propagates errors returned by filesystem operations.
    #[inline]
    fn read_file(&self) -> Result<DataRead, Box<dyn Error>> {
        /// Read all the input lines, either from the standard input or from a file.
        ///
        /// # Errors
        /// I/O or decoding errors reading the input file (or stream).
        fn get_file_lines(filename: &str) -> Result<Vec<String>, Box<dyn Error>> {
            let mut contents = String::new();
            if filename == "-" {
                io::stdin().lock().read_to_string(&mut contents)?;
            } else {
                contents = fs::read_to_string(&filename)
                    .expect_result(|| format!("Could not read the contents of {}", filename))?;
            };
            Ok(contents.lines().map(ToOwned::to_owned).collect())
        }

        let filename = self
            .config
            .filename
            .as_ref()
            .expect_result_("No filename supplied")?;
        let mut res = HashMap::new();
        res.insert("".to_owned(), HashMap::new());

        let init_state = State {
            re_comment: Regex::new(RE_COMMENT).expect_result(|| {
                format!(
                    "Internal error: could not compile the '{}' regular expression",
                    RE_COMMENT
                )
            })?,
            re_section: Regex::new(RE_SECTION).expect_result(|| {
                format!(
                    "Internal error: could not compile the '{}' regular expression",
                    RE_SECTION
                )
            })?,
            re_variable: Regex::new(RE_VARIABLE).expect_result(|| {
                format!(
                    "Internal error: could not compile the '{}' regular expression",
                    RE_VARIABLE
                )
            })?,
            filename: filename.to_string(),
            first_section: self
                .config
                .section_specified
                .then(|| self.config.section.clone()),
            section: "".to_owned(),
            cont: None,
            found: false,
        };

        let final_state = get_file_lines(filename)?
            .iter()
            .try_fold(init_state, |state, line| state.feed_line(line, &mut res))?;
        final_state
            .cont
            .is_none()
            .expect_result(|| format!("Line continuation on the last line of {}", filename))?;
        Ok((
            res,
            match final_state.first_section {
                Some(section) => section,
                None => self.config.section.clone(),
            },
        ))
    }
}
