#![allow(
    clippy::match_same_arms,
    clippy::missing_errors_doc,
    clippy::module_name_repetitions,
    clippy::too_many_lines
)]
#![forbid(unsafe_code)]

use std::io::{self};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::mpsc::channel;

use ::ruff::logging::{set_up_logging, LogLevel};
use ::ruff::resolver::{
    resolve_settings_with_processor, ConfigProcessor, FileDiscovery, PyprojectDiscovery, Relativity,
};
use ::ruff::settings::configuration::Configuration;
use ::ruff::settings::pyproject;
use ::ruff::settings::types::SerializationFormat;
use ::ruff::{fix, fs, warn_user_once};
use anyhow::Result;
use clap::{CommandFactory, Parser};
use cli::{extract_log_level, Cli, Overrides};
use colored::Colorize;
use notify::{recommended_watcher, RecursiveMode, Watcher};
use path_absolutize::path_dedot;
use printer::{Printer, Violations};
use ruff::settings::{AllSettings, CliSettings};

mod cache;
mod cli;
mod commands;
mod diagnostics;
mod iterators;
mod printer;
#[cfg(all(feature = "update-informer"))]
pub mod updates;

/// Resolve the relevant settings strategy and defaults for the current
/// invocation.
fn resolve(
    isolated: bool,
    config: Option<&Path>,
    overrides: &Overrides,
    stdin_filename: Option<&Path>,
) -> Result<PyprojectDiscovery> {
    if isolated {
        // First priority: if we're running in isolated mode, use the default settings.
        let mut config = Configuration::default();
        overrides.process_config(&mut config);
        let settings = AllSettings::from_configuration(config, &path_dedot::CWD)?;
        Ok(PyprojectDiscovery::Fixed(settings))
    } else if let Some(pyproject) = config {
        // Second priority: the user specified a `pyproject.toml` file. Use that
        // `pyproject.toml` for _all_ configuration, and resolve paths relative to the
        // current working directory. (This matches ESLint's behavior.)
        let settings = resolve_settings_with_processor(pyproject, &Relativity::Cwd, overrides)?;
        Ok(PyprojectDiscovery::Fixed(settings))
    } else if let Some(pyproject) = pyproject::find_settings_toml(
        stdin_filename
            .as_ref()
            .unwrap_or(&path_dedot::CWD.as_path()),
    )? {
        // Third priority: find a `pyproject.toml` file in either an ancestor of
        // `stdin_filename` (if set) or the current working path all paths relative to
        // that directory. (With `Strategy::Hierarchical`, we'll end up finding
        // the "closest" `pyproject.toml` file for every Python file later on,
        // so these act as the "default" settings.)
        let settings = resolve_settings_with_processor(&pyproject, &Relativity::Parent, overrides)?;
        Ok(PyprojectDiscovery::Hierarchical(settings))
    } else if let Some(pyproject) = pyproject::find_user_settings_toml() {
        // Fourth priority: find a user-specific `pyproject.toml`, but resolve all paths
        // relative the current working directory. (With `Strategy::Hierarchical`, we'll
        // end up the "closest" `pyproject.toml` file for every Python file later on, so
        // these act as the "default" settings.)
        let settings = resolve_settings_with_processor(&pyproject, &Relativity::Cwd, overrides)?;
        Ok(PyprojectDiscovery::Hierarchical(settings))
    } else {
        // Fallback: load Ruff's default settings, and resolve all paths relative to the
        // current working directory. (With `Strategy::Hierarchical`, we'll end up the
        // "closest" `pyproject.toml` file for every Python file later on, so these act
        // as the "default" settings.)
        let mut config = Configuration::default();
        overrides.process_config(&mut config);
        let settings = AllSettings::from_configuration(config, &path_dedot::CWD)?;
        Ok(PyprojectDiscovery::Hierarchical(settings))
    }
}

pub fn main() -> Result<ExitCode> {
    // Extract command-line arguments.
    let (cli, overrides) = Cli::parse().partition();

    let default_panic_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        eprintln!(
            r#"
{}: `ruff` crashed. This indicates a bug in `ruff`. If you could open an issue at:

https://github.com/charliermarsh/ruff/issues/new?title=%5BPanic%5D

quoting the executed command, along with the relevant file contents and `pyproject.toml` settings, we'd be very appreciative!
"#,
            "error".red().bold(),
        );
        default_panic_hook(info);
    }));

    let log_level = extract_log_level(&cli);
    set_up_logging(&log_level)?;

    if let Some(shell) = cli.generate_shell_completion {
        shell.generate(&mut Cli::command(), &mut io::stdout());
        return Ok(ExitCode::SUCCESS);
    }
    if cli.clean {
        commands::clean(&log_level)?;
        return Ok(ExitCode::SUCCESS);
    }

    // Construct the "default" settings. These are used when no `pyproject.toml`
    // files are present, or files are injected from outside of the hierarchy.
    let pyproject_strategy = resolve(
        cli.isolated,
        cli.config.as_deref(),
        &overrides,
        cli.stdin_filename.as_deref(),
    )?;

    // Validate the `Settings` and return any errors.
    match &pyproject_strategy {
        PyprojectDiscovery::Fixed(settings) => settings.lib.validate()?,
        PyprojectDiscovery::Hierarchical(settings) => settings.lib.validate()?,
    };

    // Extract options that are included in `Settings`, but only apply at the top
    // level.
    let file_strategy = FileDiscovery {
        force_exclude: match &pyproject_strategy {
            PyprojectDiscovery::Fixed(settings) => settings.lib.force_exclude,
            PyprojectDiscovery::Hierarchical(settings) => settings.lib.force_exclude,
        },
        respect_gitignore: match &pyproject_strategy {
            PyprojectDiscovery::Fixed(settings) => settings.lib.respect_gitignore,
            PyprojectDiscovery::Hierarchical(settings) => settings.lib.respect_gitignore,
        },
    };
    let CliSettings {
        fix,
        fix_only,
        format,
        update_check,
        ..
    } = match &pyproject_strategy {
        PyprojectDiscovery::Fixed(settings) => settings.cli.clone(),
        PyprojectDiscovery::Hierarchical(settings) => settings.cli.clone(),
    };

    if let Some(code) = cli.explain {
        commands::explain(&code, format)?;
        return Ok(ExitCode::SUCCESS);
    }
    if cli.show_settings {
        commands::show_settings(&cli.files, &pyproject_strategy, &file_strategy, &overrides)?;
        return Ok(ExitCode::SUCCESS);
    }
    if cli.show_files {
        commands::show_files(&cli.files, &pyproject_strategy, &file_strategy, &overrides)?;
        return Ok(ExitCode::SUCCESS);
    }

    // Autofix rules are as follows:
    // - If `--fix` or `--fix-only` is set, always apply fixes to the filesystem (or
    //   print them to stdout, if we're reading from stdin).
    // - Otherwise, if `--format json` is set, generate the fixes (so we print them
    //   out as part of the JSON payload), but don't write them to disk.
    // - If `--diff` or `--fix-only` are set, don't print any violations (only
    //   fixes).
    // TODO(charlie): Consider adding ESLint's `--fix-dry-run`, which would generate
    // but not apply fixes. That would allow us to avoid special-casing JSON
    // here.
    let autofix = if cli.diff {
        fix::FixMode::Diff
    } else if fix || fix_only {
        fix::FixMode::Apply
    } else if matches!(format, SerializationFormat::Json) {
        fix::FixMode::Generate
    } else {
        fix::FixMode::None
    };
    let violations = if cli.diff || fix_only {
        Violations::Hide
    } else {
        Violations::Show
    };
    let cache = !cli.no_cache;

    #[cfg(debug_assertions)]
    if cache {
        // `--no-cache` doesn't respect code changes, and so is often confusing during
        // development.
        warn_user_once!("debug build without --no-cache.");
    }

    let printer = Printer::new(&format, &log_level, &autofix, &violations);
    if cli.watch {
        if !matches!(autofix, fix::FixMode::None) {
            warn_user_once!("--fix is not enabled in watch mode.");
        }
        if format != SerializationFormat::Text {
            warn_user_once!("--format 'text' is used in watch mode.");
        }

        // Perform an initial run instantly.
        Printer::clear_screen()?;
        printer.write_to_user("Starting linter in watch mode...\n");

        let messages = commands::run(
            &cli.files,
            &pyproject_strategy,
            &file_strategy,
            &overrides,
            cache.into(),
            fix::FixMode::None,
        )?;
        printer.write_continuously(&messages)?;

        // Configure the file watcher.
        let (tx, rx) = channel();
        let mut watcher = recommended_watcher(tx)?;
        for file in &cli.files {
            watcher.watch(file, RecursiveMode::Recursive)?;
        }

        loop {
            match rx.recv() {
                Ok(event) => {
                    let paths = event?.paths;
                    let py_changed = paths.iter().any(|path| {
                        path.extension()
                            .map(|ext| ext == "py" || ext == "pyi")
                            .unwrap_or_default()
                    });
                    if py_changed {
                        Printer::clear_screen()?;
                        printer.write_to_user("File change detected...\n");

                        let messages = commands::run(
                            &cli.files,
                            &pyproject_strategy,
                            &file_strategy,
                            &overrides,
                            cache.into(),
                            fix::FixMode::None,
                        )?;
                        printer.write_continuously(&messages)?;
                    }
                }
                Err(err) => return Err(err.into()),
            }
        }
    } else if cli.add_noqa {
        let modifications =
            commands::add_noqa(&cli.files, &pyproject_strategy, &file_strategy, &overrides)?;
        if modifications > 0 && log_level >= LogLevel::Default {
            println!("Added {modifications} noqa directives.");
        }
    } else {
        let is_stdin = cli.files == vec![PathBuf::from("-")];

        // Generate lint violations.
        let diagnostics = if is_stdin {
            commands::run_stdin(
                cli.stdin_filename.map(fs::normalize_path).as_deref(),
                &pyproject_strategy,
                &file_strategy,
                &overrides,
                autofix,
            )?
        } else {
            commands::run(
                &cli.files,
                &pyproject_strategy,
                &file_strategy,
                &overrides,
                cache.into(),
                autofix,
            )?
        };

        // Always try to print violations (the printer itself may suppress output),
        // unless we're writing fixes via stdin (in which case, the transformed
        // source code goes to stdout).
        if !(is_stdin && matches!(autofix, fix::FixMode::Apply | fix::FixMode::Diff)) {
            printer.write_once(&diagnostics)?;
        }

        // Check for updates if we're in a non-silent log level.
        #[cfg(feature = "update-informer")]
        if update_check
            && !is_stdin
            && log_level >= LogLevel::Default
            && atty::is(atty::Stream::Stdout)
        {
            drop(updates::check_for_updates());
        }

        if !cli.exit_zero {
            if cli.diff || fix_only {
                if diagnostics.fixed > 0 {
                    return Ok(ExitCode::FAILURE);
                }
            } else if !diagnostics.messages.is_empty() {
                return Ok(ExitCode::FAILURE);
            }
        }
    }

    Ok(ExitCode::SUCCESS)
}
