use std::fs;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};

use anyhow::{Result, anyhow};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use shuck_cache::{FileCacheKey, PackageCache};
use shuck_config::ConfigArguments;
use shuck_formatter::{
    FormatError, FormattedSource, ShellFormatOptions, format_source, source_is_formatted,
};
use similar::TextDiff;

use crate::ExitStatus;
use crate::args::FormatCommand;
use crate::cache::resolve_cache_root;
use crate::commands::project_runner::{PendingProjectFile, prepare_project_runs};
use crate::discover::{DiscoveryOptions, FileKind};
use crate::format_settings::{ResolvedFormatSettings, resolve_project_format_settings};

#[derive(Debug, Clone, PartialEq, Eq)]
struct DisplayedFormatError {
    path: PathBuf,
    line: usize,
    column: usize,
    message: String,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct FormatReport {
    errors: Vec<DisplayedFormatError>,
    changed_files: Vec<PathBuf>,
    cache_hits: usize,
    cache_misses: usize,
}

impl FormatReport {
    fn exit_status(&self, mode: FormatMode) -> ExitStatus {
        if !self.errors.is_empty() {
            return ExitStatus::Error;
        }

        if matches!(mode, FormatMode::Check | FormatMode::Diff) && !self.changed_files.is_empty() {
            ExitStatus::Failure
        } else {
            ExitStatus::Success
        }
    }
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub(crate) enum FormatMode {
    Write,
    Check,
    Diff,
}

impl FormatMode {
    pub(crate) fn from_cli(args: &FormatCommand) -> Self {
        if args.diff {
            Self::Diff
        } else if args.check {
            Self::Check
        } else {
            Self::Write
        }
    }

    pub(crate) fn is_write(self) -> bool {
        matches!(self, Self::Write)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
enum FormatCacheData {
    Unchanged,
    ParseError(ParseCacheFailure),
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ParseCacheFailure {
    message: String,
    line: usize,
    column: usize,
}

#[derive(Debug)]
struct PendingFormatOutcome {
    relative_path: PathBuf,
    cached_key: FileCacheKey,
    cached_result: Option<FormatCacheData>,
    error: Option<DisplayedFormatError>,
    changed_path: Option<PathBuf>,
    diff: Option<String>,
}

pub(crate) fn format(
    args: FormatCommand,
    config_arguments: &ConfigArguments,
    cache_dir: Option<&Path>,
) -> Result<ExitStatus> {
    let cwd = std::env::current_dir()?;
    let mode = FormatMode::from_cli(&args);
    let cache_root = resolve_cache_root(&cwd, cache_dir)?;
    let report = run_format_with_cwd(&args, config_arguments, &cwd, &cache_root, mode)?;
    print_report(&report)?;
    Ok(report.exit_status(mode))
}

pub(crate) fn write_parse_error_line(
    writer: &mut impl Write,
    path: &Path,
    line: usize,
    column: usize,
    message: &str,
) -> io::Result<()> {
    writeln!(
        writer,
        "{}:{}:{}: parse error {}",
        path.display(),
        line,
        column,
        message
    )
}

pub(crate) fn unified_diff(path: &Path, original: &str, formatted: &str) -> String {
    let old = format!("a/{}", path.display());
    let new = format!("b/{}", path.display());
    TextDiff::from_lines(original, formatted)
        .unified_diff()
        .header(&old, &new)
        .to_string()
}

fn print_report(report: &FormatReport) -> Result<()> {
    let mut stdout = BufWriter::new(io::stdout().lock());
    for error in &report.errors {
        write_parse_error_line(
            &mut stdout,
            &error.path,
            error.line,
            error.column,
            &error.message,
        )?;
    }
    Ok(())
}

fn run_format_with_cwd(
    args: &FormatCommand,
    config_arguments: &ConfigArguments,
    cwd: &Path,
    cache_root: &Path,
    mode: FormatMode,
) -> Result<FormatReport> {
    let cli_settings = args.format_settings_patch();
    let options = DiscoveryOptions {
        exclude_patterns: args.file_selection.exclude.clone(),
        extend_exclude_patterns: args.file_selection.extend_exclude.clone(),
        respect_gitignore: args.respect_gitignore(),
        force_exclude: args.force_exclude(),
        parallel: true,
        cache_root: Some(cache_root.to_path_buf()),
        use_config_roots: config_arguments.use_config_roots(),
        ..DiscoveryOptions::default()
    };
    let runs = prepare_project_runs::<FormatCacheData, ResolvedFormatSettings, _>(
        &args.files,
        cwd,
        &options,
        cache_root,
        args.no_cache,
        b"project-format-cache-key",
        |project_root| {
            resolve_project_format_settings(
                &project_root.storage_root,
                config_arguments,
                cli_settings,
            )
        },
    )?;
    let mut report = FormatReport::default();

    for mut run in runs {
        run.files.retain(|file| {
            file.kind == FileKind::Shell && !run.settings.is_file_excluded(&file.absolute_path)
        });
        let settings = run.settings.to_shell_format_options();
        let pending = run.take_pending_files(|file, cached| {
            report.cache_hits += 1;
            if let FormatCacheData::ParseError(error) = cached {
                report.errors.push(DisplayedFormatError {
                    path: file.display_path,
                    line: error.line,
                    column: error.column,
                    message: error.message,
                });
            }
            Ok(())
        })?;

        let batch_size = pending_format_batch_size();
        let mut pending = pending.into_iter();
        loop {
            let batch = pending.by_ref().take(batch_size).collect::<Vec<_>>();
            if batch.is_empty() {
                break;
            }

            let results = batch
                .into_par_iter()
                .map(|pending| format_pending_file(pending, &settings, mode))
                .collect::<Vec<_>>();

            for result in results {
                handle_format_outcome(result?, &mut run.cache, &mut report)?;
            }
        }

        run.persist_cache()?;
    }

    report.errors.sort_by(|left, right| {
        left.path
            .cmp(&right.path)
            .then(left.line.cmp(&right.line))
            .then(left.column.cmp(&right.column))
            .then(left.message.cmp(&right.message))
    });

    Ok(report)
}

fn pending_format_batch_size() -> usize {
    rayon::current_num_threads().max(1)
}

fn format_pending_file(
    pending: PendingProjectFile,
    settings: &ShellFormatOptions,
    mode: FormatMode,
) -> Result<PendingFormatOutcome> {
    let PendingProjectFile { file, file_key } = pending;
    let source = fs::read_to_string(&file.absolute_path)?;

    let mut outcome = PendingFormatOutcome {
        relative_path: file.relative_path,
        cached_key: file_key.clone(),
        cached_result: None,
        error: None,
        changed_path: None,
        diff: None,
    };

    if matches!(mode, FormatMode::Check) {
        match source_is_formatted(&source, Some(&file.absolute_path), settings) {
            Ok(true) => {
                outcome.cached_result = Some(FormatCacheData::Unchanged);
            }
            Ok(false) => {
                outcome.changed_path = Some(file.display_path);
            }
            Err(FormatError::Parse {
                message,
                line,
                column,
            }) => {
                outcome.error = Some(DisplayedFormatError {
                    path: file.display_path.clone(),
                    line,
                    column,
                    message: message.clone(),
                });

                outcome.cached_result = Some(FormatCacheData::ParseError(ParseCacheFailure {
                    message,
                    line,
                    column,
                }));
            }
            Err(FormatError::Internal(message)) => return Err(anyhow!(message)),
        }
    } else {
        match format_source(&source, Some(&file.absolute_path), settings) {
            Ok(FormattedSource::Unchanged) => {
                outcome.cached_result = Some(FormatCacheData::Unchanged);
            }
            Ok(FormattedSource::Formatted(formatted)) => {
                outcome.changed_path = Some(file.display_path.clone());
                match mode {
                    FormatMode::Write => fs::write(&file.absolute_path, formatted.as_bytes())?,
                    FormatMode::Check => {}
                    FormatMode::Diff => {
                        outcome.diff = Some(unified_diff(&file.display_path, &source, &formatted));
                    }
                }

                if mode.is_write() {
                    outcome.cached_key = FileCacheKey::from_path(&file.absolute_path)?;
                    outcome.cached_result = Some(FormatCacheData::Unchanged);
                }
            }
            Err(FormatError::Parse {
                message,
                line,
                column,
            }) => {
                outcome.error = Some(DisplayedFormatError {
                    path: file.display_path.clone(),
                    line,
                    column,
                    message: message.clone(),
                });

                outcome.cached_result = Some(FormatCacheData::ParseError(ParseCacheFailure {
                    message,
                    line,
                    column,
                }));
            }
            Err(FormatError::Internal(message)) => return Err(anyhow!(message)),
        }
    };

    Ok(outcome)
}

fn handle_format_outcome(
    outcome: PendingFormatOutcome,
    cache: &mut Option<PackageCache<FormatCacheData>>,
    report: &mut FormatReport,
) -> Result<()> {
    if let Some(error) = outcome.error {
        report.errors.push(error);
    }
    if let Some(path) = outcome.changed_path {
        report.changed_files.push(path);
    }
    if let Some(diff) = outcome.diff {
        let mut stdout = io::stdout().lock();
        write!(&mut stdout, "{diff}")?;
    }

    if let Some(cache) = cache.as_mut()
        && let Some(cached_result) = outcome.cached_result
    {
        cache.insert(outcome.relative_path, outcome.cached_key, cached_result);
    }
    report.cache_misses += 1;
    Ok(())
}

#[cfg(test)]
mod tests {
    use tempfile::tempdir;

    use super::*;
    use crate::args::FileSelectionArgs;
    use shuck_config::ConfigArguments;

    fn format_args(no_cache: bool) -> FormatCommand {
        FormatCommand {
            files: vec![PathBuf::from(".")],
            check: false,
            diff: false,
            no_cache,
            stdin_filename: None,
            file_selection: FileSelectionArgs::default(),
            dialect: None,
            indent_style: None,
            indent_width: None,
            binary_next_line: false,
            no_binary_next_line: false,
            switch_case_indent: false,
            no_switch_case_indent: false,
            space_redirects: false,
            no_space_redirects: false,
            keep_padding: false,
            no_keep_padding: false,
            function_next_line: false,
            no_function_next_line: false,
            never_split: false,
            no_never_split: false,
            simplify: false,
            minify: false,
        }
    }

    #[test]
    fn formatter_reports_parse_errors() {
        let tempdir = tempdir().unwrap();
        fs::write(tempdir.path().join("broken.sh"), "#!/bin/bash\nif true\n").unwrap();

        let report = run_format_with_cwd(
            &format_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Write,
        )
        .unwrap();

        assert_eq!(report.exit_status(FormatMode::Write), ExitStatus::Error);
        assert_eq!(report.errors.len(), 1);
        assert_eq!(report.cache_hits, 0);
        assert_eq!(report.cache_misses, 1);
    }

    #[test]
    fn reuses_cached_results() {
        let tempdir = tempdir().unwrap();
        fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

        let first = run_format_with_cwd(
            &format_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Write,
        )
        .unwrap();
        let second = run_format_with_cwd(
            &format_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Write,
        )
        .unwrap();

        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);
        assert_eq!(second.cache_hits, 1);
        assert_eq!(second.cache_misses, 0);
    }

    #[test]
    fn invalidates_cache_when_file_changes() {
        let tempdir = tempdir().unwrap();
        let script = tempdir.path().join("script.sh");
        fs::write(&script, "#!/bin/bash\necho ok\n").unwrap();

        let first = run_format_with_cwd(
            &format_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Write,
        )
        .unwrap();
        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);

        fs::write(&script, "#!/bin/bash\n echo ok\n").unwrap();

        let second = run_format_with_cwd(
            &format_args(false),
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Write,
        )
        .unwrap();
        assert_eq!(second.cache_hits, 0);
        assert_eq!(second.cache_misses, 1);
        assert!(second.errors.is_empty());
        assert_eq!(second.changed_files, vec![PathBuf::from("script.sh")]);
        assert_eq!(
            fs::read_to_string(&script).unwrap(),
            "#!/bin/bash\necho ok\n"
        );
    }

    #[test]
    fn no_cache_does_not_write_cache_files() {
        let tempdir = tempdir().unwrap();
        fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();

        let report = run_format_with_cwd(
            &format_args(true),
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Write,
        )
        .unwrap();

        assert_eq!(report.cache_hits, 0);
        assert_eq!(report.cache_misses, 1);
        assert!(!tempdir.path().join(".shuck_cache").exists());
    }

    #[test]
    fn check_mode_reports_unformatted_files_without_writing() {
        let tempdir = tempdir().unwrap();
        let script = tempdir.path().join("script.sh");
        let source = "#!/bin/bash\n echo ok\n";
        fs::write(&script, source).unwrap();

        let mut args = format_args(true);
        args.check = true;

        let report = run_format_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Check,
        )
        .unwrap();

        assert_eq!(report.exit_status(FormatMode::Check), ExitStatus::Failure);
        assert_eq!(report.changed_files, vec![PathBuf::from("script.sh")]);
        assert_eq!(fs::read_to_string(&script).unwrap(), source);
    }

    #[test]
    fn check_mode_reuses_cache_for_already_formatted_files() {
        let tempdir = tempdir().unwrap();
        fs::write(tempdir.path().join("script.sh"), "#!/bin/bash\necho ok\n").unwrap();

        let mut args = format_args(false);
        args.check = true;

        let first = run_format_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Check,
        )
        .unwrap();
        let second = run_format_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Check,
        )
        .unwrap();

        assert_eq!(first.cache_hits, 0);
        assert_eq!(first.cache_misses, 1);
        assert_eq!(second.cache_hits, 1);
        assert_eq!(second.cache_misses, 0);
    }

    #[test]
    fn skips_embedded_workflow_yaml_during_format() {
        let tempdir = tempdir().unwrap();
        fs::create_dir_all(tempdir.path().join(".github/workflows")).unwrap();
        fs::write(tempdir.path().join("script.sh"), "#!/bin/bash\necho ok\n").unwrap();
        fs::write(
            tempdir.path().join(".github/workflows/ci.yml"),
            r#"on: push
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: echo ok
"#,
        )
        .unwrap();

        let mut args = format_args(true);
        args.check = true;

        let report = run_format_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &tempdir.path().join("cache"),
            FormatMode::Check,
        )
        .unwrap();

        assert_eq!(report.exit_status(FormatMode::Check), ExitStatus::Success);
        assert!(report.errors.is_empty());
        assert!(report.changed_files.is_empty());
        assert_eq!(report.cache_misses, 1);
    }
}
