use argh::FromArgs;
use bstr::ByteSlice;
use format_bytes::format_bytes;
use patchpal::common::apply::{Applied, AppliedFuzz, Apply};
use patchpal::common::{commit_iter, diff_meta, git, lines::TypedLines};
use rayon::iter::{ParallelBridge, ParallelIterator};
use std::{cmp::max, collections::HashMap, ffi::OsString, fs::File, io::Write, sync::mpsc};
use std::time::{Duration, Instant};

#[derive(FromArgs)]
/// Benchmarks the patch application algorithm.
struct Args {
    /// the optional output file with individual commit stats
    #[argh(option, short = 'o')]
    output: Option<String>,
    /// show progress
    #[argh(switch, short = 'v')]
    verbose: bool,
    /// path to the repository; must contain also upstream objects
    #[argh(positional)]
    repo: OsString,
    /// starting commit (exclusive)
    #[argh(positional)]
    start: String,
    /// ending commit (inclusive)
    #[argh(positional)]
    end: String,
}

#[derive(derive_more::AddAssign, Default)]
struct Stats {
    commits_too_long: usize,
    commits_processed: usize,
    files_processed: usize,
    files_skipped: usize,
    hunks_processed: usize,
    hunks_too_long: usize,
    hunks_not_applied: usize,
    hunks_split: usize,
    hunks_fuzz_context: usize,
    hunks_fuzz_content: usize,
    offset_delta: usize,
    duration: Duration,
    max_duration: Duration,
}

impl Stats {
    fn write_to(&self, f: &mut impl Write) {
        let hunks_total = self.hunks_processed + self.hunks_not_applied + self.hunks_too_long;
        let hunks_unclean = self.hunks_fuzz_context + self.hunks_fuzz_content;
        writeln!(f, "raw numbers:").unwrap();
        writeln!(f, "  too long commits: {}", self.commits_too_long).unwrap();
        writeln!(f, "  files total: {}, processed: {}, skipped: {}",
                 self.files_processed + self.files_skipped,
                 self.files_processed, self.files_skipped).unwrap();
        writeln!(f, "  hunks total: {}, too long: {}, split: {}",
                 hunks_total, self.hunks_too_long, self.hunks_split).unwrap();
        writeln!(f, "  hunks unclean: {}, unapplied: {}",
                 hunks_unclean, self.hunks_not_applied).unwrap();
        writeln!(f, "  offset delta: {} (from {} hunks)",
                 self.offset_delta, self.hunks_processed).unwrap();
        writeln!(f, "  fuzz context: {}, fuzz content: {}",
                 self.hunks_fuzz_context, self.hunks_fuzz_content).unwrap();
        writeln!(f, "summary:").unwrap();
        writeln!(f, "  unclean hunks:   {:.2}% ({}), context: {:.2}%, content: {:.2}%",
                 hunks_unclean as f64 / hunks_total as f64 * 100.,
                 hunks_unclean,
                 self.hunks_fuzz_context as f64 / hunks_total as f64 * 100.,
                 self.hunks_fuzz_content as f64 / hunks_total as f64 * 100.).unwrap();
        writeln!(f, "  unapplied hunks: {:.2}% ({})",
                 self.hunks_not_applied as f64 / hunks_total as f64 * 100.,
                 self.hunks_not_applied).unwrap();
        writeln!(f, "  average offset delta: {:.4}",
                 self.offset_delta as f64 / self.hunks_processed as f64).unwrap();
        writeln!(f, "time per commit:").unwrap();
        writeln!(f, "  average: {:.3}s, max: {:.3}s",
                 self.duration.as_secs_f64() / self.commits_processed as f64,
                 self.max_duration.as_secs_f64()).unwrap();
    }
}

fn process(
    tx: mpsc::Sender<(Stats, Vec<u8>)>,
    downstream: Vec<u8>,
    upstream: Vec<u8>,
    repo: git::Git,
) {
    let start_time = Instant::now();
    let mut stats = Stats::default();
    let mut file_desc = Vec::new();
    let file_changes = repo.raw_diff(&upstream)
        .unwrap_or_else(|err|
            panic!("cannot get patch {}: {}", upstream.as_bstr(), err.to_string())
        );
    if file_changes.len() == 0 {
        // skip backports of merges (a merge commit produces an empty diff)
        return;
    }
    let mut files = Vec::with_capacity(file_changes.len());
    for change in file_changes {
        match change.metadata.op {
            diff_meta::Op::Modified { .. } => (),
            _ => {
                stats.files_skipped += 1;
                continue;
            }
        }
        let old_name = change.metadata.name.clone();
        let lines: TypedLines = match repo.file(
            &format_bytes!(b"{}^", &downstream),
            &old_name
        ) {
            Ok(r) => TypedLines::from_vec_and_name(r, &old_name),
            Err(_) => {
                stats.files_skipped += 1;
                continue;
            }
        };
        let hunks = change.data.diff();
        files.push((hunks, lines, old_name));
    }
    if files.iter().map(|(hunks, _, _)| hunks.len()).sum::<usize>() > 350 {
        // Skip commits with too many hunks. Their processing takes too long.
        let stats = Stats {
            commits_too_long: 1,
            ..Default::default()
        };
        tx.send((stats, file_desc)).unwrap();
        return;
    }
    for (hunks, lines, old_name) in files {
        stats.files_processed += 1;
        let mut apply = Apply::new(lines);
        apply.add_hunks(hunks);

        stats.hunks_processed += apply.collected_hunks();
        stats.hunks_too_long += apply.too_long;
        stats.hunks_not_applied += apply.missed;
        stats.hunks_split += apply.split;

        let applied: Applied = apply.apply();
        let offset_delta: usize = applied.changes.iter()
            .map(|ch| ch.range.start.delta(ch.hunk.old.start))
            .sum();
        stats.offset_delta += offset_delta;

        let fuzz_context: usize = applied.changes.iter()
            .map(|ch| if let AppliedFuzz::Context = ch.fuzz {
                1
            } else {
                0
            })
            .sum();
        let fuzz_content: usize = applied.changes.iter()
            .map(|ch| if AppliedFuzz::Content == ch.fuzz {
                1
            } else {
                0
            })
            .sum();

        if fuzz_context == 0 && fuzz_content == 0 && applied.unapplied.len() == 0 {
            // applied cleanly
            continue;
        }

        stats.hunks_fuzz_context += fuzz_context;
        stats.hunks_fuzz_content += fuzz_content;

        file_desc.write(&downstream).unwrap();
        file_desc.write(b" / ").unwrap();
        file_desc.write(&upstream).unwrap();
        file_desc.write(b" ").unwrap();
        file_desc.write(&old_name).unwrap();
        file_desc.write(
            format!(" !{} @ {} {}\n", applied.unapplied.len(), fuzz_context, fuzz_content)
                .as_bytes()
        ).unwrap();
    }
    stats.commits_processed = 1;
    stats.duration = start_time.elapsed();
    tx.send((stats, file_desc)).unwrap();
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args: Args = argh::from_env();
    let repo = git::Git::open(&args.repo)
        .map_err(|e| format!("Invalid git repository path: {}", e))?;

    // the health check thread
    let (tx_health, rx_health) = mpsc::channel::<(bool, Vec<u8>)>();
    let health_thread = std::thread::spawn(move || {
        let mut running: HashMap<Vec<u8>, Instant> = HashMap::new();
        loop {
            match rx_health.recv_timeout(Duration::from_secs(1)) {
                Ok((false, hash)) => {
                    running.insert(hash, Instant::now());
                }
                Ok((true, hash)) => {
                    running.remove(&hash);
                }
                Err(mpsc::RecvTimeoutError::Timeout) => {
                    let mut to_remove = Vec::new();
                    for (hash, start) in running.iter() {
                        if start.elapsed().as_secs() >= 10 {
                            println!(
                                "WARNING: processing of {} takes too long.",
                                hash.as_bstr(),
                            );
                            to_remove.push(hash.clone());
                        }
                    }
                    for hash in to_remove {
                        running.remove(&hash);
                    }
                }
                Err(mpsc::RecvTimeoutError::Disconnected) => break,
            }
        }
    });
    // the stats and commit list recording thread
    let (tx, rx) = mpsc::channel::<(Stats, Vec<u8>)>();
    let verbose = args.verbose;
    let output = args.output;
    let recording_thread = std::thread::spawn(move || {
        let mut stats = Stats::default();
        let mut f = output.map(|name| File::create(name).unwrap());
        for (substats, file_desc) in rx.iter() {
            let prev_files_processed = stats.files_processed;
            let max_duration = max(stats.max_duration, substats.duration);
            stats += substats;
            stats.max_duration = max_duration;
            if verbose && prev_files_processed / 1000 != stats.files_processed / 1000 {
                stats.write_to(&mut std::io::stdout());
            }
            if let Some(f) = &mut f {
                f.write_all(&file_desc).unwrap();
            }
        }
        stats.write_to(&mut std::io::stdout());
    });

    // the compute threads
    commit_iter::ResolvedCommitsIterator::new(
        &repo.walk(args.end.as_bytes()).base(Some(args.start.as_bytes())),
    )
        .ignore_reverts()
        .verify_in(repo.clone())
        .map_while(|v| v.ok())
        .filter_map(|(d_sha, r)| r.map(|r_sha| (d_sha, r_sha)))
        .par_bridge()
        .for_each(|(downstream, upstream)| {
            let tx_health = tx_health.clone();
            tx_health.send((false, downstream.clone())).unwrap();
            process(
                tx.clone(),
                downstream.clone(),
                upstream,
                repo.clone(),
            );
            tx_health.send((true, downstream)).unwrap();
        });
    drop(tx);
    recording_thread.join().unwrap();
    drop(tx_health);
    health_thread.join().unwrap();
    Ok(())
}
