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

use anyhow::Context as _;
use derive_more::Display;
use itertools::{Either, Itertools};

use crate::{
    checks::{ReviewFailure, ReviewResult, ReviewSuccess},
    git::CommitDetails,
};

const PULL_REQUEST_BASE_URL: &str = "https://github.com/zed-industries/zed/pull";

#[derive(Debug)]
pub struct ReportEntry<R> {
    pub commit: CommitDetails,
    reason: R,
}

impl<R> ReportEntry<R> {
    pub fn new(commit: CommitDetails, reason: R) -> Self {
        Self { commit, reason }
    }
}

impl<R: ToString> ReportEntry<R> {
    fn commit_cell(&self) -> String {
        let title = escape_markdown_link_text(self.commit.title());

        match self.commit.pr_number() {
            Some(pr_number) => format!("[{title}]({PULL_REQUEST_BASE_URL}/{pr_number})"),
            None => escape_markdown_table_text(self.commit.title()),
        }
    }

    fn pull_request_cell(&self) -> String {
        self.commit
            .pr_number()
            .map(|pr_number| format!("#{pr_number}"))
            .unwrap_or_else(|| "—".to_owned())
    }

    fn author_cell(&self) -> String {
        escape_markdown_table_text(&self.commit.author().to_string())
    }

    fn reason_cell(&self) -> String {
        escape_markdown_table_text(&self.reason.to_string())
    }
}

impl ReportEntry<ReviewResult> {
    pub fn is_unknown_error(&self) -> bool {
        matches!(self.reason, Err(ReviewFailure::Other(_)))
    }
}

impl ReportEntry<ReviewFailure> {
    fn issue_kind(&self) -> IssueKind {
        match self.reason {
            ReviewFailure::Other(_) => IssueKind::Error,
            _ => IssueKind::NotReviewed,
        }
    }
}

impl ReportEntry<ReviewSuccess> {
    fn reviewers_cell(&self) -> String {
        match &self.reason.reviewers() {
            Ok(reviewers) => escape_markdown_table_text(&reviewers),
            Err(_) => "—".to_owned(),
        }
    }
}

#[derive(Debug, Default)]
pub struct ReportSummary {
    pub pull_requests: usize,
    pub reviewed_prs: usize,
    pub other_checked: usize,
    pub not_reviewed: usize,
    pub errors: usize,
}

pub enum ReportReviewSummary {
    MissingReviews,
    MissingReviewsWithErrors,
    NoIssuesFound,
}

impl ReportSummary {
    fn from_entries(entries: &[ReportEntry<ReviewResult>]) -> Self {
        Self {
            pull_requests: entries
                .iter()
                .filter_map(|entry| entry.commit.pr_number())
                .unique()
                .count(),
            reviewed_prs: entries
                .iter()
                .filter(|entry| entry.reason.is_ok() && entry.commit.pr_number().is_some())
                .count(),
            other_checked: entries
                .iter()
                .filter(|entry| entry.reason.is_ok() && entry.commit.pr_number().is_none())
                .count(),
            not_reviewed: entries
                .iter()
                .filter(|entry| {
                    matches!(
                        entry.reason,
                        Err(ReviewFailure::NoPullRequestFound
                            | ReviewFailure::Unreviewed
                            | ReviewFailure::UnexpectedZippyAction(_))
                    )
                })
                .count(),
            errors: entries
                .iter()
                .filter(|entry| entry.is_unknown_error())
                .count(),
        }
    }

    pub fn review_summary(&self) -> ReportReviewSummary {
        match self.not_reviewed {
            0 if self.errors == 0 => ReportReviewSummary::NoIssuesFound,
            1.. if self.errors == 0 => ReportReviewSummary::MissingReviews,
            _ => ReportReviewSummary::MissingReviewsWithErrors,
        }
    }

    fn has_errors(&self) -> bool {
        self.errors > 0
    }

    pub fn prs_with_errors(&self) -> usize {
        self.pull_requests.saturating_sub(self.reviewed_prs)
    }
}

#[derive(Clone, Copy, Debug, Display, PartialEq, Eq, PartialOrd, Ord)]
enum IssueKind {
    #[display("Error")]
    Error,
    #[display("Not reviewed")]
    NotReviewed,
}

#[derive(Debug, Default)]
pub struct Report {
    entries: Vec<ReportEntry<ReviewResult>>,
}

impl Report {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn from_entries(entries: impl IntoIterator<Item = ReportEntry<ReviewResult>>) -> Self {
        Self {
            entries: entries.into_iter().collect(),
        }
    }

    pub fn add(&mut self, commit: CommitDetails, result: ReviewResult) {
        self.entries.push(ReportEntry {
            commit,
            reason: result,
        });
    }

    pub fn errors(&self) -> impl Iterator<Item = &ReportEntry<ReviewResult>> {
        self.entries.iter().filter(|entry| entry.reason.is_err())
    }

    pub fn summary(&self) -> ReportSummary {
        ReportSummary::from_entries(&self.entries)
    }

    pub fn write_markdown(self, path: impl AsRef<Path>) -> anyhow::Result<()> {
        let path = path.as_ref();

        if let Some(parent) = path
            .parent()
            .filter(|parent| !parent.as_os_str().is_empty())
        {
            fs::create_dir_all(parent).with_context(|| {
                format!(
                    "Failed to create parent directory for markdown report at {}",
                    path.display()
                )
            })?;
        }

        let summary = self.summary();
        let (successes, mut issues): (Vec<_>, Vec<_>) =
            self.entries
                .into_iter()
                .partition_map(|entry| match entry.reason {
                    Ok(success) => Either::Left(ReportEntry {
                        reason: success,
                        commit: entry.commit,
                    }),
                    Err(fail) => Either::Right(ReportEntry {
                        reason: fail,
                        commit: entry.commit,
                    }),
                });

        issues.sort_by_key(|entry| entry.issue_kind());

        let file = File::create(path)
            .with_context(|| format!("Failed to create markdown report at {}", path.display()))?;
        let mut writer = BufWriter::new(file);

        writeln!(writer, "# Compliance report")?;
        writeln!(writer)?;
        writeln!(writer, "## Overview")?;
        writeln!(writer)?;
        writeln!(writer, "- PRs: {}", summary.pull_requests)?;
        writeln!(writer, "- Reviewed: {}", summary.reviewed_prs)?;
        writeln!(writer, "- Not reviewed: {}", summary.not_reviewed)?;
        writeln!(
            writer,
            "- Differently validated commits: {}",
            summary.other_checked
        )?;
        if summary.has_errors() {
            writeln!(writer, "- Errors: {}", summary.errors)?;
        }
        writeln!(writer)?;

        write_issue_table(&mut writer, &issues, &summary)?;
        write_success_table(&mut writer, &successes)?;

        writer
            .flush()
            .with_context(|| format!("Failed to flush markdown report to {}", path.display()))
    }
}

fn write_issue_table(
    writer: &mut impl Write,
    issues: &[ReportEntry<ReviewFailure>],
    summary: &ReportSummary,
) -> std::io::Result<()> {
    if summary.has_errors() {
        writeln!(writer, "## Errors and unreviewed commits")?;
    } else {
        writeln!(writer, "## Unreviewed commits")?;
    }
    writeln!(writer)?;

    if issues.is_empty() {
        if summary.has_errors() {
            writeln!(writer, "No errors or unreviewed commits found.")?;
        } else {
            writeln!(writer, "No unreviewed commits found.")?;
        }
        writeln!(writer)?;
        return Ok(());
    }

    writeln!(writer, "| Commit | PR | Author | Outcome | Reason |")?;
    writeln!(writer, "| --- | --- | --- | --- | --- |")?;

    for entry in issues {
        let issue_kind = entry.issue_kind();
        writeln!(
            writer,
            "| {} | {} | {} | {} | {} |",
            entry.commit_cell(),
            entry.pull_request_cell(),
            entry.author_cell(),
            issue_kind,
            entry.reason_cell(),
        )?;
    }

    writeln!(writer)?;
    Ok(())
}

fn write_success_table(
    writer: &mut impl Write,
    successful_entries: &[ReportEntry<ReviewSuccess>],
) -> std::io::Result<()> {
    writeln!(writer, "## Successful commits")?;
    writeln!(writer)?;

    if successful_entries.is_empty() {
        writeln!(writer, "No successful commits found.")?;
        writeln!(writer)?;
        return Ok(());
    }

    writeln!(writer, "| Commit | PR | Author | Reviewers | Reason |")?;
    writeln!(writer, "| --- | --- | --- | --- | --- |")?;

    for entry in successful_entries {
        writeln!(
            writer,
            "| {} | {} | {} | {} | {} |",
            entry.commit_cell(),
            entry.pull_request_cell(),
            entry.author_cell(),
            entry.reviewers_cell(),
            entry.reason_cell(),
        )?;
    }

    writeln!(writer)?;
    Ok(())
}

fn escape_markdown_link_text(input: &str) -> String {
    escape_markdown_table_text(input)
        .replace('[', r"\[")
        .replace(']', r"\]")
}

fn escape_markdown_table_text(input: &str) -> String {
    input
        .replace('\\', r"\\")
        .replace('|', r"\|")
        .replace('\r', "")
        .replace('\n', "<br>")
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use crate::{
        checks::{ReviewFailure, ReviewSuccess},
        git::{AutomatedChangeKind, CommitDetails, CommitList},
        github::{AuthorAssociation, GithubLogin, GithubUser, PullRequestReview, ReviewState},
    };

    use super::{Report, ReportReviewSummary};

    fn make_commit(
        sha: &str,
        author_name: &str,
        author_email: &str,
        title: &str,
        body: &str,
    ) -> CommitDetails {
        let formatted = format!(
            "{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|{title}|body-delimiter|{body}|commit-delimiter|"
        );
        CommitList::from_str(&formatted)
            .expect("test commit should parse")
            .into_iter()
            .next()
            .expect("should have one commit")
    }

    fn reviewed() -> ReviewSuccess {
        ReviewSuccess::PullRequestReviewed(vec![PullRequestReview {
            user: Some(GithubUser {
                login: "reviewer".to_owned(),
            }),
            state: Some(ReviewState::Approved),
            body: None,
            author_association: Some(AuthorAssociation::Member),
        }])
    }

    #[test]
    fn report_summary_counts_are_accurate() {
        let mut report = Report::new();

        report.add(
            make_commit(
                "aaa",
                "Alice",
                "alice@test.com",
                "Reviewed commit (#100)",
                "",
            ),
            Ok(reviewed()),
        );
        report.add(
            make_commit("bbb", "Bob", "bob@test.com", "Unreviewed commit (#200)", ""),
            Err(ReviewFailure::Unreviewed),
        );
        report.add(
            make_commit("ccc", "Carol", "carol@test.com", "No PR commit", ""),
            Err(ReviewFailure::NoPullRequestFound),
        );
        report.add(
            make_commit("ddd", "Dave", "dave@test.com", "Error commit (#300)", ""),
            Err(ReviewFailure::Other(anyhow::anyhow!("some error"))),
        );
        report.add(
            make_commit("ddd", "Dave", "dave@test.com", "Bump Version", ""),
            Ok(ReviewSuccess::ZedZippyCommit(
                AutomatedChangeKind::VersionBump,
                GithubLogin::new("dave".to_string()),
            )),
        );

        let summary = report.summary();
        assert_eq!(summary.pull_requests, 3);
        assert_eq!(summary.reviewed_prs, 1);
        assert_eq!(summary.other_checked, 1);
        assert_eq!(summary.not_reviewed, 2);
        assert_eq!(summary.errors, 1);
    }

    #[test]
    fn report_summary_all_reviewed_is_no_issues() {
        let mut report = Report::new();

        report.add(
            make_commit("aaa", "Alice", "alice@test.com", "First (#100)", ""),
            Ok(reviewed()),
        );
        report.add(
            make_commit("bbb", "Bob", "bob@test.com", "Second (#200)", ""),
            Ok(reviewed()),
        );

        let summary = report.summary();
        assert!(matches!(
            summary.review_summary(),
            ReportReviewSummary::NoIssuesFound
        ));
    }

    #[test]
    fn report_summary_missing_reviews_only() {
        let mut report = Report::new();

        report.add(
            make_commit("aaa", "Alice", "alice@test.com", "Reviewed (#100)", ""),
            Ok(reviewed()),
        );
        report.add(
            make_commit("bbb", "Bob", "bob@test.com", "Unreviewed (#200)", ""),
            Err(ReviewFailure::Unreviewed),
        );

        let summary = report.summary();
        assert!(matches!(
            summary.review_summary(),
            ReportReviewSummary::MissingReviews
        ));
    }

    #[test]
    fn report_summary_errors_and_missing_reviews() {
        let mut report = Report::new();

        report.add(
            make_commit("aaa", "Alice", "alice@test.com", "Unreviewed (#100)", ""),
            Err(ReviewFailure::Unreviewed),
        );
        report.add(
            make_commit("bbb", "Bob", "bob@test.com", "Errored (#200)", ""),
            Err(ReviewFailure::Other(anyhow::anyhow!("check failed"))),
        );

        let summary = report.summary();
        assert!(matches!(
            summary.review_summary(),
            ReportReviewSummary::MissingReviewsWithErrors
        ));
    }

    #[test]
    fn report_summary_deduplicates_pull_requests() {
        let mut report = Report::new();

        report.add(
            make_commit("aaa", "Alice", "alice@test.com", "First change (#100)", ""),
            Ok(reviewed()),
        );
        report.add(
            make_commit("bbb", "Bob", "bob@test.com", "Second change (#100)", ""),
            Ok(reviewed()),
        );

        let summary = report.summary();
        assert_eq!(summary.pull_requests, 1);
    }
}
