#![allow(clippy::print_stderr, reason = "The CLI is allowed to use stderr")]
#![allow(clippy::print_stdout, reason = "The CLI is allowed to use stdout")]
#![cfg_attr(doc, doc = include_str!("../README.md"))]

#[cfg(test)]
mod test;

mod explain;
mod guess_version;
mod lint;
mod opts;
mod price;
mod print;

use std::{
    env, fmt, fs,
    io::{self, IsTerminal as _, Read as _},
    path::{Path, PathBuf},
};

use clap::Parser;
use ocpi_tariffs::{json, tariff, warning};
use tracing::{debug, instrument};

/// When reading a CDR or tariff from `stdin` create a String with this default
/// capacity to avoid needless allocations.
pub const DEFAULT_STDIO_BUF_SIZE: usize = 1024;

#[doc(hidden)]
#[derive(Parser)]
#[command(version)]
pub struct Opts {
    #[clap(subcommand)]
    command: opts::Command,
}

impl Opts {
    pub fn run(self) -> Result<(), Error> {
        self.command.run()
    }
}

#[derive(Copy, Clone, clap::ValueEnum)]
pub enum ObjectKind {
    Cdr,
    Tariff,
}

impl fmt::Debug for ObjectKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Cdr => write!(f, "CDR"),
            Self::Tariff => write!(f, "Tariff"),
        }
    }
}

/// Load a CDR or tariff from file.
#[instrument]
pub fn load_object_file(path: &Path, object_kind: ObjectKind) -> Result<String, Error> {
    debug!("Loading {object_kind} from file");
    let mut content = fs::read_to_string(path).map_err(Error::then_file_io(path))?;
    json_strip_comments::strip(&mut content).map_err(Error::then_file_io(path))?;
    debug!(bytes_read = content.len(), "{object_kind} read from file");
    Ok(content)
}

/// Load a CDR or tariff from `stdin`.
pub fn load_object_from_stdin(object_kind: ObjectKind) -> Result<String, Error> {
    debug!("Loading {object_kind} from stdin");
    let mut stdin = io::stdin().lock();
    let mut content = String::with_capacity(DEFAULT_STDIO_BUF_SIZE);
    let bytes_read = stdin.read_to_string(&mut content).map_err(Error::stdin)?;
    debug!(bytes_read, "{object_kind} read from stdin");
    json_strip_comments::strip(&mut content).map_err(Error::stdin)?;
    Ok(content)
}

#[doc(hidden)]
#[derive(Debug)]
pub enum Error {
    Handled,

    /// When the process is a `tty` `--cdr` is required.
    CdrRequired,

    /// An `Error` occurred while parsing a CDR or tariff.
    Json(json::ParseError),

    /// The tariff could not be parsed well enough to explain it.
    Explain(warning::Error<tariff::Warning>),

    /// An `io::Error` occurred when opening or reading a file.
    FileIO {
        path: PathBuf,
        error: io::Error,
    },

    /// An internal error that is a bug.
    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),

    /// A timezone was provided by the user and it failed to parse.
    InvalidTimezone(chrono_tz::ParseError),

    /// The `CDR` or tariff path supplied is not a file.
    PathNotFile {
        path: PathBuf,
    },

    /// The `CDR` or tariff path supplied doesn't have a parent directory.
    PathNoParentDir {
        path: PathBuf,
    },

    /// An Error happened when calling the `cdr::price` fn.
    Price(warning::Error<ocpi_tariffs::price::Warning>),

    /// An Error happened when performing I/O.
    StdIn(io::Error),

    /// When the process is a `tty` `--tariff` is required.
    TariffRequired,
}

impl std::error::Error for Error {}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Handled => Ok(()),
            Self::CdrRequired => f.write_str("`--cdr` is required when the process is a TTY"),
            Self::Json(err) => write!(f, "{err}"),
            Self::Explain(err) => write!(f, "{err}"),
            Self::FileIO { path, error } => {
                write!(f, "File error `{}`: {}", path.display(), error)
            }
            Self::Internal(err) => {
                write!(f, "{err}")
            }
            Self::InvalidTimezone(err) => write!(f, "the timezone given is invalid; {err}"),
            Self::PathNotFile { path } => {
                write!(f, "The path given is not a file: `{}`", path.display())
            }
            Self::PathNoParentDir { path } => {
                write!(
                    f,
                    "The path given doesn't have a parent dir: `{}`",
                    path.display()
                )
            }
            Self::Price(err) => write!(f, "{err}"),
            Self::StdIn(err) => {
                write!(f, "Stdin error {err}")
            }
            Self::TariffRequired => f.write_str("`--tariff` is required when the process is a TTY"),
        }
    }
}

impl From<warning::Error<ocpi_tariffs::price::Warning>> for Error {
    fn from(value: warning::Error<ocpi_tariffs::price::Warning>) -> Self {
        Self::Price(value)
    }
}

impl From<json::ParseError> for Error {
    fn from(err: json::ParseError) -> Self {
        Self::Json(err)
    }
}

impl Error {
    /// Return a fn that can be used in `Result::map_err` to create a `FileIO`.
    pub fn then_file_io(path: &Path) -> impl FnOnce(io::Error) -> Error + use<'_> {
        |error| Self::FileIO {
            path: path.to_path_buf(),
            error,
        }
    }

    pub fn stdin(err: io::Error) -> Self {
        Self::StdIn(err)
    }
}

#[doc(hidden)]
pub fn setup_logging() -> Result<(), &'static str> {
    let stderr = io::stderr();
    let builder = tracing_subscriber::fmt()
        .without_time()
        .with_writer(io::stderr)
        .with_ansi(stderr.is_terminal());

    let level = match env::var("RUST_LOG") {
        Ok(s) => s.parse().unwrap_or(tracing::Level::INFO),
        Err(err) => match err {
            env::VarError::NotPresent => tracing::Level::INFO,
            env::VarError::NotUnicode(_) => {
                return Err("RUST_LOG is not unicode");
            }
        },
    };

    builder.with_max_level(level).init();

    Ok(())
}
