/*
 * Hurl (https://hurl.dev)
 * Copyright (C) 2026 Orange
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *          http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
use std::thread;
use std::time::Instant;

use chrono::Utc;
use hurl_core::ast::{Entry, OptionKind, SourceInfo};
use hurl_core::error::{DisplaySourceError, OutputFormat};
use hurl_core::input::Input;
use hurl_core::parser;
use hurl_core::types::{Count, Index};

use crate::http::{Call, Client, CredentialForwarding, FollowLocation};
use crate::util::logger::{ErrorFormat, Logger, LoggerOptions};
use crate::util::term::{Stderr, Stdout, WriteMode};

use super::event::EventListener;
use super::options;
use super::result::{EntryResult, HurlResult};
use super::runner_options::RunnerOptions;
use super::variable::VariableSet;
use super::{Output, entry};

/// Runs a Hurl `content` and returns a [`HurlResult`] upon completion.
///
/// If `content` is a syntactically correct Hurl file, an [`HurlResult`] is always returned on
/// run completion. The success or failure of the run (due to assertions checks, runtime failures
/// etc...) can be read in the [`HurlResult`] `success` field. If `content` is not syntactically
/// correct, a parsing error is returned. This is the only possible way for this function to fail.
///
/// The caller must ensure that `content` do not start with [BOM](https://en.wikipedia.org/wiki/Byte_order_mark).
///
/// `filename` indicates an optional file source, used when displaying errors.
///
/// # Example
///
/// ```
/// use std::collections::HashMap;
/// use hurl::http::{CredentialForwarding, FollowLocation};
/// use hurl::runner;
/// use hurl::runner::{Value, RunnerOptionsBuilder, VariableSet};
/// use hurl::util::logger::{LoggerOptionsBuilder, Verbosity};
/// use hurl_core::input::Input;
///
/// // A simple Hurl sample
/// let content = r#"
/// GET http://localhost:8000/hello
/// HTTP 200
/// "#;
///
/// let filename = Input::new("sample.hurl");
///
/// // Define runner and logger options
/// let runner_opts = RunnerOptionsBuilder::new()
///     .follow_location(FollowLocation::Follow(CredentialForwarding::AllHosts))
///     .build();
/// let logger_opts = LoggerOptionsBuilder::new()
///     .verbosity(Some(Verbosity::Verbose))
///     .build();
///
/// // Set variables
/// let mut variables = VariableSet::new();
/// variables.insert("name".to_string(), Value::String("toto".to_string()));
///
/// // Run the Hurl sample
/// let result = runner::run(
///     content,
///     Some(filename).as_ref(),
///     &runner_opts,
///     &variables,
///     &logger_opts
/// );
/// assert!(result.unwrap().success);
/// ```
pub fn run(
    content: &str,
    filename: Option<&Input>,
    runner_options: &RunnerOptions,
    variables: &VariableSet,
    logger_options: &LoggerOptions,
) -> Result<HurlResult, String> {
    // In this method, we run Hurl content sequentially. Standard output and standard error messages
    // are written immediately (in parallel mode, we'll use buffered standard output and error).
    let mut stdout = Stdout::new(WriteMode::Immediate);
    let stderr = Stderr::new(WriteMode::Immediate);

    // We also create a common logger for this run (logger verbosity can eventually be mutated on
    // each entry).
    let secrets = variables.secrets();
    let mut logger = Logger::new(logger_options, stderr, &secrets);

    // Try to parse the content
    let hurl_file = parser::parse_hurl_file(content);
    let hurl_file = match hurl_file {
        Ok(h) => h,
        Err(error) => {
            let filename = filename.map_or(String::new(), |f| f.to_string());
            let message = error.render(
                &filename,
                content,
                None,
                OutputFormat::Terminal(logger.color),
            );
            logger.error_rich(&message);
            return Err(error.description());
        }
    };

    // Now, we have a syntactically correct HurlFile instance, we can run it.
    let result = run_entries(
        &hurl_file.entries,
        content,
        filename,
        runner_options,
        variables,
        &mut stdout,
        None,
        &mut logger,
    );

    if result.success && result.entries.last().is_none() {
        let filename = filename.map_or(String::new(), |f| f.to_string());
        logger.warning(&format!("No entry have been executed for file {filename}"));
    }

    Ok(result)
}

#[allow(clippy::too_many_arguments)]
/// Runs a list of `entries` and returns a [`HurlResult`] upon completion.
///
/// `content` is the original source content, used to construct `entries`. It is used to construct
/// rich error messages with annotated source code.
/// New entry run events are reported to `progress` and are usually used to display a progress bar
/// in test mode.
pub fn run_entries(
    entries: &[Entry],
    content: &str,
    filename: Option<&Input>,
    runner_options: &RunnerOptions,
    variables: &VariableSet,
    stdout: &mut Stdout,
    listener: Option<&dyn EventListener>,
    logger: &mut Logger,
) -> HurlResult {
    // Early returns for empty entries.
    if entries.is_empty() {
        return HurlResult {
            success: true,
            variables: variables.clone(),
            ..Default::default()
        };
    }

    let mut http_client = Client::new();
    let mut entries_result = vec![];
    let mut variables = variables.clone();
    let mut current = Index::new(runner_options.from_entry.unwrap_or(1));
    let mut repeat_count = 0;
    let last = Index::new(runner_options.to_entry.unwrap_or(entries.len()));
    let default_verbosity = logger.verbosity;
    let start = Instant::now();
    let timestamp = Utc::now().timestamp();

    log_run_info(entries, runner_options, &variables, logger);

    // Main loop processing each entry.
    // The `entry_index` is not always incremented of each loop tick: an entry can be retried upon
    // errors for instance. Each entry is executed with options that are computed from the global
    // runner options and the "overridden" request options.
    // See <docs/spec/runner/run_cycle.md>
    loop {
        if current > last {
            break;
        }
        let entry = &entries[current.to_zero_based()];

        // We compute the new logger verbosity for this entry, before entering into the `run`
        // function because entry options can modify the logger verbosity and we want the preamble
        // "Executing entry..." to be displayed based on the entry level verbosity.
        logger.verbosity = default_verbosity;
        let entry_verbosity = options::get_entry_verbosity(entry, default_verbosity, &variables);
        if let Ok(entry_verbosity) = entry_verbosity {
            logger.verbosity = entry_verbosity;
        }

        log_run_entry(current, logger);

        // We can report the progression of the run for --test mode. Fo this call, the retry count is
        // zero, it will be potentially incremented in `run_request`.
        if let Some(listener) = listener {
            listener.on_entry_running(current, last, 0);
        }

        // The real execution of the entry happens here, first: we compute the overridden request
        // options.
        let options = options::get_entry_options(entry, runner_options, &mut variables, logger);
        if let Err(error) = &options {
            // If we have error evaluating request options, we consider it as a non retryable error
            // and either break the runner or go to the next entries.
            let entry_result = EntryResult {
                entry_index: current,
                source_info: entry.source_info(),
                errors: vec![error.clone()],
                ..Default::default()
            };
            log_errors(&entry_result, content, filename, false, logger);
            entries_result.push(entry_result);
            if runner_options.continue_on_error {
                current += 1;
                continue;
            } else {
                break;
            }
        }

        let options = options.unwrap();

        // Should we skip?
        if options.skip {
            logger.debug("");
            logger.debug_important(&format!("Entry {current} has been skipped"));
            current += 1;
            continue;
        }

        // Repeat 0 is equivalent to skip.
        if options.repeat == Some(Count::Finite(0)) {
            logger.debug("");
            logger.debug_important(&format!("Entry {current} is skipped (repeat 0 times)"));
            current += 1;
            continue;
        }

        // Should we delay?
        let delay = options.delay;
        let delay_ms = delay.as_millis();
        if delay_ms > 0 {
            logger.debug("");
            logger.debug_important(&format!("Delay entry {current} (pause {delay_ms} ms)"));
            thread::sleep(delay);
        };

        // Loop for executing HTTP run requests, with optional retry. Only "HTTP" errors in options
        // are taken into account for retry (errors while computing entry options and output error
        // are not retried).
        let results = run_request(
            entry,
            current,
            last,
            content,
            filename,
            &mut http_client,
            &options,
            &mut variables,
            stdout,
            listener,
            logger,
        );

        let has_error = results.last().is_some_and(|r| !r.errors.is_empty());

        entries_result.extend(results);

        if !runner_options.continue_on_error && has_error {
            break;
        }

        // We pass to the next entry if the repeat count is reached.
        repeat_count += 1;
        match options.repeat {
            None => {
                repeat_count = 0;
                current += 1;
            }
            Some(Count::Finite(n)) => {
                if repeat_count >= n {
                    repeat_count = 0;
                    current += 1;
                } else {
                    logger
                        .debug_important(&format!("Repeat entry {current} (x{repeat_count}/{n})"));
                }
            }
            Some(Count::Infinite) => {
                logger.debug_important(&format!("Repeat entry {current} (x{repeat_count})"));
            }
        }
    }

    let duration = start.elapsed();
    let cookie_store = http_client.cookie_store(logger);
    let success = is_success(&entries_result);
    HurlResult {
        entries: entries_result,
        duration,
        success,
        cookie_store,
        timestamp,
        variables,
    }
}

/// Runs an HTTP request and optionally retry it until there are no HTTP errors or the maximum retry
/// count is reached. Returns a list of [`EntryResult`]. This list contains the results for each retry;
/// so if there are no retry, this list contains a single entry.
///
/// `current` is the current index of the entry run, `last` is the index of the last entry to be run
#[allow(clippy::too_many_arguments)]
fn run_request(
    entry: &Entry,
    current: Index,
    last: Index,
    content: &str,
    filename: Option<&Input>,
    http_client: &mut Client,
    options: &RunnerOptions,
    variables: &mut VariableSet,
    stdout: &mut Stdout,
    listener: Option<&dyn EventListener>,
    logger: &mut Logger,
) -> Vec<EntryResult> {
    let mut results = vec![];
    let mut retry_count = 0;

    loop {
        let mut result = entry::run(entry, current, http_client, variables, options, logger);

        // Check if we need to retry.
        let has_error = !result.errors.is_empty();

        // The retry threshold can only be reached with a finite positive number of retries
        let retry_max_reached = if let Some(Count::Finite(r)) = options.retry {
            retry_count >= r
        } else {
            false
        };
        // If `retry_max_reached` is true, we print now a warning, before displaying any assert
        // error so any potential error is the last thing displayed to the user.
        // If `retry_max_reached` is not true (for instance `retry`is true, or there is no error
        // we first log the error and a potential warning about retrying.
        if retry_max_reached {
            logger.debug_important("Retry max count reached, no more retry");
            logger.debug("");
        }

        // We log eventual errors, only if we're not retrying the current entry...
        // The retry does not take into account a possible output Error
        let retry = options.retry.is_some() && !retry_max_reached && has_error;

        // When --output is overridden on a request level, we output the HTTP response only if the
        // call has succeeded. Output errors are not taken into account for retrying requests.
        if !has_error && let Some(output) = &options.output {
            write_entry_response(
                entry,
                &mut result,
                content,
                filename,
                output,
                options,
                stdout,
                logger,
            );
        } else if has_error {
            log_errors(&result, content, filename, retry, logger);
        }

        results.push(result);

        // No retry, we leave the HTTP run requests loop.
        if !retry {
            break;
        }
        retry_count += 1;
        let delay = options.retry_interval.as_millis();
        let retry_max = match options.retry.unwrap() {
            Count::Finite(max) => max.to_string(),
            Count::Infinite => "ꝏ".to_string(),
        };
        logger.debug("");
        logger.debug_important(&format!(
            "Retry on entry {current} (count: {retry_count}/{retry_max}, interval: {delay} ms)"
        ));

        // If we're retrying, we report the running event just before sleeping in case the retry
        // interval is too high: this way the visual retry hint will take the interval into account.
        // (user will see "retry x" before going into sleep).
        if let Some(listener) = listener {
            listener.on_entry_running(current, last, retry_count);
        }

        thread::sleep(options.retry_interval);

        // TODO: We keep this log because we don't want to change stderr with the changes
        // introduced by <https://github.com/Orange-OpenSource/hurl/issues/1973>
        log_run_entry(current, logger);
    }

    results
}

/// Writes this `entry_result` HTTP body response to the file `output`. This function
/// can possibly mut this `entry_result` errors with I/O or unauthorized access errors.
///
/// A context directory (through `option`) is provided to check if write access is authorized.
/// If there is a write error (either I/O, or unauthorized access), `content` and `input` are used
/// to print a rich error (assert like) and `entry_result` errors are appended.
#[allow(clippy::too_many_arguments)]
fn write_entry_response(
    entry: &Entry,
    entry_result: &mut EntryResult,
    content: &str,
    input: Option<&Input>,
    output: &Output,
    options: &RunnerOptions,
    stdout: &mut Stdout,
    logger: &mut Logger,
) {
    let context_dir = &options.context_dir;
    let source_info = get_output_source_info(entry);

    // Check if write access is allowed for this context dir.
    let output = match output.clone().try_with(context_dir, source_info) {
        Ok(o) => o,
        Err(error) => {
            let filename = input.map_or(String::new(), |f| f.to_string());
            let message = error.render(
                &filename,
                content,
                Some(entry_result.source_info),
                OutputFormat::Terminal(logger.color),
            );
            logger.error_rich(&message);
            entry_result.errors.push(error);
            return;
        }
    };

    // Hard-codes `include` for the moment
    let include_headers = false;
    let color = options.color_stdout;
    let pretty = options.pretty;
    // When we override output in a entry, we truncate existing files.
    let append = false;
    if let Err(error) = entry_result.write_response(
        Some(&output),
        stdout,
        include_headers,
        color,
        pretty,
        append,
        source_info,
    ) {
        let filename = input.map_or(String::new(), |f| f.to_string());
        let message = error.render(
            &filename,
            content,
            Some(entry_result.source_info),
            OutputFormat::Terminal(logger.color),
        );
        logger.error_rich(&message);
        entry_result.errors.push(error);
    }
}

/// Use source_info from output option if this option has been defined
fn get_output_source_info(entry: &Entry) -> SourceInfo {
    let mut source_info = entry.source_info();
    for option_entry in entry.request.options() {
        if let OptionKind::Output(value) = &option_entry.kind {
            source_info = value.source_info;
        }
    }
    source_info
}

/// Returns `true` if all the entries results are successful, `false` otherwise.
///
/// For a given list of entry results, only the last one on the same index is checked.
///
/// For instance:
///
/// - `entry result a`: `entry index 1` (retried)
/// - `entry result b`: `entry index 1`
/// - `entry result c`: `entry index 2`
/// - `entry result d`: `entry index 3` (retried)
/// - `entry result e`: `entry index 3`
///
/// Only the entry result b, c and e are checked for the success state.
fn is_success(entries: &[EntryResult]) -> bool {
    let mut next_entries = entries.iter().skip(1);
    for entry in entries.iter() {
        match next_entries.next() {
            None => return entry.errors.is_empty(),
            Some(next) => {
                if next.entry_index != entry.entry_index && !entry.errors.is_empty() {
                    return false;
                }
            }
        }
    }
    true
}

// Returns the list of options that have non-default values.
fn get_non_default_options(options: &RunnerOptions) -> Vec<(&'static str, String)> {
    let default_options = RunnerOptions::default();

    let mut non_default_options = vec![];

    if options.continue_on_error != default_options.continue_on_error {
        non_default_options.push(("continue_on_error", options.continue_on_error.to_string()));
    }

    if options.delay != default_options.delay {
        // FIXME: the cast to u64 seems not necessary.
        //  If we dont cast from u128 and try to format! or println!
        //  we have a segfault on Alpine Docker images and Rust 1.68.0, whereas it was
        //  ok with Rust >= 1.67.0.
        non_default_options.push(("delay", format!("{}ms", options.delay.as_millis() as u64)));
    }

    if options.follow_location != default_options.follow_location {
        match options.follow_location {
            FollowLocation::No => {}
            FollowLocation::Follow(CredentialForwarding::OnlyInitialHost) => {
                non_default_options.push(("follow redirect", "true".to_string()));
            }
            FollowLocation::Follow(CredentialForwarding::AllHosts) => {
                non_default_options.push(("follow redirect", "true (trusted)".to_string()));
            }
        }
    }

    if options.insecure != default_options.insecure {
        non_default_options.push(("insecure", options.insecure.to_string()));
    }

    if options.max_redirect != default_options.max_redirect {
        non_default_options.push(("max redirect", options.max_redirect.to_string()));
    }

    if options.proxy != default_options.proxy
        && let Some(proxy) = &options.proxy
    {
        non_default_options.push(("proxy", proxy.to_string()));
    }

    if options.retry != default_options.retry {
        let value = match options.retry {
            Some(retry) => retry.to_string(),
            None => "none".to_string(),
        };
        non_default_options.push(("retry", value));
    }

    if options.unix_socket != default_options.unix_socket
        && let Some(unix_socket) = &options.unix_socket
    {
        non_default_options.push(("unix socket", unix_socket.to_string()));
    }

    non_default_options
}

/// Logs various debug information at the start of `hurl_file` run.
fn log_run_info(
    entries: &[Entry],
    runner_options: &RunnerOptions,
    variables: &VariableSet,
    logger: &mut Logger,
) {
    if logger.verbosity.is_some() {
        let non_default_options = get_non_default_options(runner_options);
        if !non_default_options.is_empty() {
            logger.debug_important("Options:");
            for (name, value) in non_default_options.iter() {
                logger.debug(&format!("    {name}: {value}"));
            }
        }
    }

    let variables = variables
        .iter()
        .filter(|(_, variable)| !variable.is_secret())
        .collect::<Vec<_>>();
    if !variables.is_empty() {
        logger.debug_important("Variables:");
        for (name, variable) in variables.iter() {
            logger.debug(&format!("    {name}: {}", variable.value()));
        }
    }
    if let Some(to_entry) = runner_options.to_entry {
        logger.debug(&format!("Executing {to_entry}/{} entries", entries.len()));
    }
}

/// Logs runner `errors`.
/// If we're going to `retry` the entry, we log errors only in verbose. Otherwise, we log error on stderr.
fn log_errors(
    entry_result: &EntryResult,
    content: &str,
    filename: Option<&Input>,
    retry: bool,
    logger: &mut Logger,
) {
    if retry {
        entry_result.errors.iter().for_each(|error| {
            logger.debug_error(content, filename, error, entry_result.source_info);
        });
        return;
    }

    if logger.error_format == ErrorFormat::Long
        && let Some(Call { response, .. }) = entry_result.calls.last()
    {
        logger.info_curl_cmd(&entry_result.curl_cmd.to_string());
        logger.info("");

        response.log_info_all(logger);
    }

    entry_result.errors.iter().for_each(|error| {
        let filename = filename.map_or(String::new(), |f| f.to_string());
        let message = error.render(
            &filename,
            content,
            Some(entry_result.source_info),
            OutputFormat::Terminal(logger.color),
        );
        logger.error_rich(&message);
    });
}

/// Logs the header indicating the begin of the entry run.
fn log_run_entry(entry_index: Index, logger: &mut Logger) {
    logger.debug_important(
        "------------------------------------------------------------------------------",
    );
    logger.debug_important(&format!("Executing entry {entry_index}"));
}

#[cfg(test)]
mod test {
    use super::*;
    use crate::runner::RunnerOptionsBuilder;

    #[test]
    fn get_non_default_options_returns_empty_when_default() {
        let options = RunnerOptions::default();
        assert!(get_non_default_options(&options).is_empty());
    }

    #[test]
    fn get_non_default_options_returns_only_non_default_options() {
        let options = RunnerOptionsBuilder::new()
            .delay(std::time::Duration::from_millis(500))
            .build();

        let non_default_options = get_non_default_options(&options);
        assert_eq!(non_default_options.len(), 1);

        let first_non_default = non_default_options.first().unwrap();

        assert_eq!(first_non_default.0, "delay");
        assert_eq!(first_non_default.1, "500ms");
    }
}
