use crate::{DiagnosticsPayload, Execution, Reporter, ReporterVisitor, TraversalSummary};
use biome_console::fmt::{Display, Formatter};
use biome_console::{Console, ConsoleExt, markup};
use biome_diagnostics::display::SourceFile;
use biome_diagnostics::{Error, PrintDescription, Resource, Severity};
use biome_rowan::{TextRange, TextSize};
use camino::{Utf8Path, Utf8PathBuf};
use path_absolutize::Absolutize;
use serde::Serialize;
use std::sync::RwLock;
use std::{
    collections::HashSet,
    hash::{DefaultHasher, Hash, Hasher},
    path::Path,
};

pub struct GitLabReporter {
    pub(crate) execution: Execution,
    pub(crate) diagnostics: DiagnosticsPayload,
    pub(crate) verbose: bool,
    pub(crate) working_directory: Option<Utf8PathBuf>,
}

impl Reporter for GitLabReporter {
    fn write(self, visitor: &mut dyn ReporterVisitor) -> std::io::Result<()> {
        visitor.report_diagnostics(
            &self.execution,
            self.diagnostics,
            self.verbose,
            self.working_directory.as_deref(),
        )?;
        Ok(())
    }
}

pub(crate) struct GitLabReporterVisitor<'a> {
    console: &'a mut dyn Console,
    repository_root: Option<Utf8PathBuf>,
}

#[derive(Default)]
struct GitLabHasher(HashSet<u64>);

impl GitLabHasher {
    /// Enforces uniqueness of generated fingerprints in the context of a
    /// single report.
    fn rehash_until_unique(&mut self, fingerprint: u64) -> u64 {
        let mut current = fingerprint;
        while self.0.contains(&current) {
            let mut hasher = DefaultHasher::new();
            current.hash(&mut hasher);
            current = hasher.finish();
        }

        self.0.insert(current);
        current
    }
}

impl<'a> GitLabReporterVisitor<'a> {
    pub fn new(console: &'a mut dyn Console, repository_root: Option<Utf8PathBuf>) -> Self {
        Self {
            console,
            repository_root,
        }
    }
}

impl ReporterVisitor for GitLabReporterVisitor<'_> {
    fn report_summary(
        &mut self,
        _: &Execution,
        _: TraversalSummary,
        _verbose: bool,
    ) -> std::io::Result<()> {
        Ok(())
    }

    fn report_diagnostics(
        &mut self,
        _execution: &Execution,
        payload: DiagnosticsPayload,
        verbose: bool,
        _working_directory: Option<&Utf8Path>,
    ) -> std::io::Result<()> {
        let hasher = RwLock::default();
        let diagnostics = GitLabDiagnostics {
            payload,
            lock: &hasher,
            path: self.repository_root.as_deref(),
            verbose,
        };
        self.console.log(markup!({ diagnostics }));
        Ok(())
    }
}

struct GitLabDiagnostics<'a> {
    payload: DiagnosticsPayload,
    verbose: bool,
    lock: &'a RwLock<GitLabHasher>,
    path: Option<&'a Utf8Path>,
}

impl GitLabDiagnostics<'_> {
    fn attempt_to_relativize(&self, subject: &str) -> Option<Utf8PathBuf> {
        let Ok(resolved) = Path::new(subject).absolutize() else {
            return None;
        };

        let Ok(relativized) = resolved.strip_prefix(self.path?) else {
            return None;
        };

        Some(Utf8PathBuf::from_path_buf(relativized.to_path_buf()).expect("To be UTF-8 path"))
    }

    fn compute_initial_fingerprint(&self, diagnostic: &Error, path: &str) -> u64 {
        let location = diagnostic.location();
        let code = match location.span {
            Some(span) => match location.source_code {
                Some(source_code) => &source_code.text[span],
                None => "",
            },
            None => "",
        };

        let check_name = diagnostic
            .category()
            .map(|category| category.name())
            .unwrap_or_default();

        calculate_hash(&Fingerprint {
            check_name,
            path,
            code,
        })
    }
}

impl Display for GitLabDiagnostics<'_> {
    fn fmt(&self, fmt: &mut Formatter) -> std::io::Result<()> {
        let Self {
            verbose,
            lock,
            payload,
            path: _,
        } = self;
        let mut hasher = lock.write().unwrap();
        let gitlab_diagnostics: Vec<_> = payload
            .diagnostics
            .iter()
            .filter(|d| d.severity() >= payload.diagnostic_level)
            .filter(|d| {
                if *verbose {
                    d.tags().is_verbose()
                } else {
                    true
                }
            })
            .filter_map(|biome_diagnostic| {
                let absolute_path = match biome_diagnostic.location().resource {
                    Some(Resource::File(file)) => Some(file),
                    _ => None,
                }
                .unwrap_or_default();
                let path_buf = self.attempt_to_relativize(absolute_path);
                let path = match path_buf {
                    Some(buf) => buf.as_str().to_string(),
                    None => absolute_path.to_string(),
                };

                let initial_fingerprint = self.compute_initial_fingerprint(biome_diagnostic, &path);
                let fingerprint = hasher.rehash_until_unique(initial_fingerprint);

                GitLabDiagnostic::try_from_diagnostic(biome_diagnostic, path.clone(), fingerprint)
            })
            .collect();
        let serialized = serde_json::to_string_pretty(&gitlab_diagnostics)?;
        fmt.write_str(serialized.as_str())?;
        Ok(())
    }
}

/// An entry in the GitLab Code Quality report.
/// See https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool
#[derive(Serialize)]
pub struct GitLabDiagnostic<'a> {
    /// A description of the code quality violation.
    description: String,
    /// A unique name representing the static analysis check that emitted this issue.
    check_name: &'a str,
    /// A unique fingerprint to identify the code quality violation. For example, an MD5 hash.
    fingerprint: String,
    /// A severity string (can be info, minor, major, critical, or blocker).
    severity: &'a str,
    /// The location where the code quality violation occurred.
    location: Location,
}

impl<'a> GitLabDiagnostic<'a> {
    pub fn try_from_diagnostic(
        diagnostic: &'a Error,
        path: String,
        fingerprint: u64,
    ) -> Option<Self> {
        let location = diagnostic.location();
        let span = location
            .span
            .unwrap_or(TextRange::new(TextSize::from(1), TextSize::from(1)));
        let source_code = location.source_code?;
        let description = PrintDescription(diagnostic).to_string();
        let begin = match SourceFile::new(source_code).location(span.start()) {
            Ok(start) => start.line_number.get(),
            Err(_) => return None,
        };
        let check_name = diagnostic
            .category()
            .map(|category| category.name())
            .unwrap_or_default();

        Some(GitLabDiagnostic {
            severity: match diagnostic.severity() {
                Severity::Hint => "info",
                Severity::Information => "minor",
                Severity::Warning => "major",
                Severity::Error => "critical",
                Severity::Fatal => "blocker",
            },
            description,
            check_name,
            // A u64 does not fit into a JSON number, so we serialize this as a
            // string
            fingerprint: fingerprint.to_string(),
            location: Location {
                path,
                lines: Lines { begin },
            },
        })
    }
}

#[derive(Serialize)]
struct Location {
    /// The relative path to the file containing the code quality violation.
    path: String,
    lines: Lines,
}

#[derive(Serialize)]
struct Lines {
    /// The line on which the code quality violation occurred.
    begin: usize,
}

#[derive(Hash)]
struct Fingerprint<'a> {
    // Including the source code in our hash leads to more stable
    // fingerprints. If you instead rely on e.g. the line number and change
    // the first line of a file, all of its fingerprint would change.
    code: &'a str,
    check_name: &'a str,
    path: &'a str,
}

fn calculate_hash<T: Hash>(t: &T) -> u64 {
    let mut s = DefaultHasher::new();
    t.hash(&mut s);
    s.finish()
}
