/*
 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
 *
 * Permission to use, copy, modify, and distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

/*!
 * Implementation of the `bob publish` command.
 *
 * Package publishing has two knobs in `publish.packages`:
 *
 * - `tmppath` (optional): if unset, rsync writes straight to `path`.
 *   If set, rsync writes to `tmppath` with `--link-dest=path` so that
 *   unchanged files become hardlinks against the live tree.
 * - `swapcmd` (optional, requires `tmppath`): a shell script run on
 *   the remote host after rsync completes.  Either a literal string,
 *   or a Lua function returning a `scriptenv(run, env)` bundle so the
 *   env values can reference other config sections.  The script is
 *   piped to `ssh host sh -eu` on stdin; `set -eu` is mandatory so
 *   failures and unset variable references abort immediately rather
 *   than silently proceeding.  No default -- if `swapcmd` is unset,
 *   the staged data is left in `tmppath` and the caller is
 *   responsible for whatever happens next.
 *
 * Restricted packages (those with `NO_BIN_ON_FTP` set) are excluded
 * from the upload filter list.
 *
 * Report publishing generates HTML, text, and machine-readable reports
 * then rsyncs the log directory to a remote server.  The build ID is
 * appended to the remote path so each run gets its own directory.
 */

use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;

use anyhow::{Context, Result, bail};
use glob::Pattern;
use strum::VariantArray;
use tracing::{debug, info};

use bob::build::{BuildResult, BuildSummary};
use bob::config::{Config, Pkgsrc, Publish, PublishPackages, ScriptValue};
use bob::db::Database;
use bob::{PackageCounts, PackageState};

struct PublishResult {
    uploaded: usize,
    restricted: usize,
}

#[allow(clippy::too_many_arguments)]
pub fn run(
    config: &Config,
    pkgsrc: &Pkgsrc,
    db: &Database,
    packages: bool,
    report: bool,
    email: bool,
    dry_run: bool,
    baseline: Option<&str>,
) -> Result<()> {
    let build_id = db
        .build_id()
        .context("No build recorded.  Perform a build first.")?;

    if report || email {
        generate_reports(config, pkgsrc, db, &build_id, baseline)?;
    }

    if report {
        publish_report(config, &build_id, dry_run)?;
    }

    if email {
        send_email(config, db, &build_id, dry_run)?;
    }

    if packages {
        let result = publish_packages(config, db, dry_run)?;
        println!(
            "Published {} packages ({} restricted excluded)",
            result.uploaded, result.restricted
        );
    }

    Ok(())
}

fn publish_packages(config: &Config, db: &Database, dry_run: bool) -> Result<PublishResult> {
    let publish = config
        .publish()
        .ok_or_else(|| anyhow::anyhow!("No publish section in configuration"))?;
    let packages = publish
        .packages
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("No publish.packages section in configuration"))?;

    let pkgsrc_env = db.load_pkgsrc_env()?;
    let successful = db.get_successful_packages()?;

    validate_pre_publish(packages, &successful)?;

    if dry_run {
        println!("Publishing packages (dry-run)...");
    } else {
        println!("Publishing packages...");
    }

    let restricted = db.get_restricted_packages()?;
    if !restricted.is_empty() {
        info!(count = restricted.len(), "Excluding restricted packages");
    }

    let uploadable: Vec<&String> = successful
        .iter()
        .filter(|p| !restricted.contains_key(p.as_str()))
        .collect();

    info!(
        total = successful.len(),
        uploadable = uploadable.len(),
        restricted = restricted.len(),
        "Package counts"
    );

    let filter_path = config.dbdir().join("rsync-filter");
    write_rsync_filter(&uploadable, &filter_path)?;

    let result = run_rsync(
        publish,
        packages,
        &filter_path,
        &pkgsrc_env.packages,
        dry_run,
    );

    let _ = std::fs::remove_file(&filter_path);
    result?;

    if let Some(swapcmd) = &packages.swapcmd {
        let remote = format_remote(&packages.host, packages.user.as_deref());
        let script = build_remote_script(swapcmd)?;

        if dry_run {
            info!("Dry run: would pipe to ssh {} sh -eu:", remote);
            for line in script.lines() {
                println!("  {}", line);
            }
        } else {
            info!(remote = %remote, "Running swapcmd via ssh");
            run_remote_script(&remote, &script)?;
        }
    }

    Ok(PublishResult {
        uploaded: uploadable.len(),
        restricted: restricted.len(),
    })
}

fn generate_reports(
    config: &Config,
    pkgsrc: &Pkgsrc,
    db: &Database,
    build_id: &str,
    baseline: Option<&str>,
) -> Result<()> {
    let publish = config
        .publish()
        .ok_or_else(|| anyhow::anyhow!("No publish section in configuration"))?;
    let report_cfg = publish
        .report
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("No publish.report section in configuration"))?;

    let pkgsrc_env = db.load_pkgsrc_env()?;
    let vcs_info = db.load_vcs_info().unwrap_or_default();
    let logdir = config.logdir();

    let mut states = PackageCounts::default();
    for r in &db.get_all_build_results()? {
        states.add(r.state);
    }
    for r in &db.get_scan_outcomes()? {
        states.add(r.state);
    }

    let duration = db.get_build_duration()?;
    let report_url = report_cfg
        .url
        .as_ref()
        .map(|u| format!("{}/{}", u, build_id));

    let diff = resolve_baseline(db, build_id, baseline)?
        .and_then(|b| db.compute_build_diff(&b, build_id).ok());

    let commits = diff
        .as_ref()
        .and_then(|d| compute_diff_commits(db, pkgsrc, &vcs_info, d));

    std::fs::create_dir_all(logdir)
        .with_context(|| format!("Failed to create {}", logdir.display()))?;

    write_variables_json(
        &pkgsrc_env,
        &vcs_info,
        &states,
        build_id,
        report_url.as_deref(),
        duration,
        logdir,
        diff.as_ref(),
    )?;

    let report_path = logdir.join("report.html");
    let report_meta = ReportMeta {
        build_id,
        pkgsrc_env: &pkgsrc_env,
        vcs_info: &vcs_info,
    };

    println!("Generating report...");
    write_html_report(
        db,
        logdir,
        &report_path,
        &report_meta,
        diff.as_ref(),
        commits.as_ref(),
    )?;
    write_machine_report(db, logdir)?;
    write_text_report(
        db,
        logdir,
        &report_meta,
        report_url.as_deref(),
        diff.as_ref(),
    )?;

    Ok(())
}

fn resolve_baseline(
    db: &Database,
    build_id: &str,
    baseline: Option<&str>,
) -> Result<Option<String>> {
    let builds = db.list_history_builds()?;
    match baseline {
        Some(b) => {
            if b == build_id {
                bail!("--baseline must differ from the current build ({build_id})");
            }
            if !builds.iter().any(|h| h.build_id == b) {
                bail!("Build ID '{b}' not found in history");
            }
            Ok(Some(b.to_string()))
        }
        None => Ok(builds
            .iter()
            .find(|h| h.build_id != build_id)
            .map(|h| h.build_id.clone())),
    }
}

fn publish_report(config: &Config, build_id: &str, dry_run: bool) -> Result<()> {
    let publish = config
        .publish()
        .ok_or_else(|| anyhow::anyhow!("No publish section in configuration"))?;
    let report_cfg = publish
        .report
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("No publish.report section in configuration"))?;

    let logdir = config.logdir();
    let rsync_args = &report_cfg.rsync_args;

    let target = format!(
        "{}:{}/{}",
        format_remote(&report_cfg.host, report_cfg.user.as_deref()),
        report_cfg.path,
        build_id
    );

    info!(
        target = %target,
        source = %logdir.display(),
        build_id = %build_id,
        "Publishing report"
    );

    let mut cmd = Command::new(&publish.rsync);
    for arg in rsync_args.split_whitespace() {
        cmd.arg(arg);
    }
    if dry_run {
        cmd.arg("--dry-run");
    }
    cmd.arg(".").arg(&target);
    cmd.current_dir(logdir);

    debug!(cmd = ?cmd, "Running rsync for report");
    let status = cmd.status().context("Failed to execute rsync")?;
    if !status.success() {
        bail!(
            "rsync failed with exit code {}",
            status.code().unwrap_or(-1)
        );
    }

    if !dry_run {
        let report_url = report_cfg
            .url
            .as_ref()
            .map(|u| format!("{}/{}", u, build_id));
        if let Some(url) = &report_url {
            println!("Report available at: {}/report.html", url);
        }
    }

    Ok(())
}

// ========================================================================
// PRE-PUBLISH VALIDATION
// ========================================================================

fn send_email(config: &Config, db: &Database, build_id: &str, dry_run: bool) -> Result<()> {
    let publish = config
        .publish()
        .ok_or_else(|| anyhow::anyhow!("No publish section in configuration"))?;
    let report_cfg = publish
        .report
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("No publish.report section in configuration"))?;

    let from_str = report_cfg
        .from
        .as_deref()
        .ok_or_else(|| anyhow::anyhow!("publish.report.from is required for email"))?;
    if report_cfg.to.is_empty() {
        bail!("publish.report.to is required for email");
    }

    let pkgsrc_env = db.load_pkgsrc_env()?;
    let vcs_info = db.load_vcs_info().unwrap_or_default();
    let platform = pkgsrc_env.platform().unwrap_or_default();
    let branch = report_cfg
        .branch
        .as_deref()
        .or(vcs_info.remote_branch.as_deref())
        .or(vcs_info.local_branch.as_deref())
        .ok_or_else(|| {
            anyhow::anyhow!(
                "Cannot determine branch for email subject. \
                 Set publish.report.branch in config."
            )
        })?;
    let date = bob::parse_build_id(build_id)
        .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
        .context("Failed to parse build ID as timestamp")?;
    let subject = format!("{} - {} - {}", branch, platform, date);

    let logdir = config.logdir();
    let body = std::fs::read_to_string(logdir.join("report.txt"))
        .context("Failed to read report.txt -- was the report generated?")?;

    use lettre::message::Mailbox;
    use lettre::message::header::{HeaderName, HeaderValue};
    use lettre::{Message, SendmailTransport, Transport};

    let from: Mailbox = from_str
        .parse()
        .with_context(|| format!("Invalid from address: {}", from_str))?;

    let mut builder = Message::builder().from(from.clone()).subject(&subject);

    for addr in &report_cfg.to {
        let to: Mailbox = addr
            .parse()
            .with_context(|| format!("Invalid to address: {}", addr))?;
        builder = builder.to(to);
    }

    if let Some(url) = &report_cfg.url {
        let base = format!("{}/{}", url, build_id);
        builder = builder.raw_header(HeaderValue::new(
            HeaderName::new_from_ascii_str("X-Bob-Report-URL"),
            format!("{}/report.html", base),
        ));
        builder = builder.raw_header(HeaderValue::new(
            HeaderName::new_from_ascii_str("X-Bob-Report-Raw"),
            format!("{}/report.zst", base),
        ));
    }

    let message = builder
        .body(body)
        .context("Failed to build email message")?;

    if dry_run {
        info!("Dry run: would send email");
        println!("  From:    {}", from);
        println!("  To:      {}", report_cfg.to.join(", "));
        println!("  Subject: {}", subject);
        return Ok(());
    }

    let recipients = report_cfg.to.join(", ");
    println!("Sending report email to {}...", recipients);
    info!(from = %from, to = %recipients, subject = %subject, "Sending email");

    let mailer = SendmailTransport::new();
    mailer
        .send(&message)
        .context("Failed to send email via sendmail")?;
    Ok(())
}

fn validate_pre_publish(packages: &PublishPackages, successful: &[String]) -> Result<()> {
    if let Some(minimum) = packages.minimum
        && successful.len() < minimum
    {
        bail!(
            "Only {} successful packages, minimum required is {}",
            successful.len(),
            minimum
        );
    }

    for pattern_str in &packages.required {
        let pattern = Pattern::new(pattern_str).with_context(|| {
            format!(
                "Invalid glob pattern in publish.packages.required: {}",
                pattern_str
            )
        })?;
        let matched = successful.iter().any(|p| pattern.matches(p));
        if !matched {
            bail!(
                "Required pattern '{}' did not match any successful package",
                pattern_str
            );
        }
    }

    Ok(())
}

// ========================================================================
// RSYNC AND SSH
// ========================================================================

fn write_rsync_filter(packages: &[&String], path: &Path) -> Result<()> {
    let mut file = std::fs::File::create(path)
        .with_context(|| format!("Failed to create {}", path.display()))?;

    let mut lines: Vec<String> = vec![
        "+ All/".to_string(),
        "+ All/pkg_summary.bz2".to_string(),
        "+ All/pkg_summary.gz".to_string(),
        "+ All/pkg_summary.xz".to_string(),
        "+ All/pkg_summary.zst".to_string(),
    ];

    for pkgname in packages {
        lines.push(format!("+ All/{}.tgz", pkgname));
    }

    lines.sort();
    lines.push("- *".to_string());

    for line in &lines {
        writeln!(file, "{}", line)?;
    }
    file.flush()?;

    debug!(
        path = %path.display(),
        entries = lines.len(),
        "Wrote rsync filter"
    );

    Ok(())
}

fn run_rsync(
    publish: &Publish,
    packages: &PublishPackages,
    filter_path: &Path,
    packages_dir: &Path,
    dry_run: bool,
) -> Result<()> {
    let rsync_args = &packages.rsync_args;
    let remote = format_remote(&packages.host, packages.user.as_deref());
    let dest = packages.tmppath.as_ref().unwrap_or(&packages.path);

    let mut cmd = Command::new(&publish.rsync);
    cmd.arg("--exclude-from").arg(filter_path);
    for arg in rsync_args.split_whitespace() {
        cmd.arg(arg);
    }
    cmd.arg("--partial-dir=.rsync-partial");
    if packages.tmppath.is_some() {
        cmd.arg(format!("--link-dest={}", packages.path));
    }
    if dry_run {
        cmd.arg("--dry-run");
    }
    cmd.arg(".");
    cmd.arg(format!("{}:{}", remote, dest));
    cmd.current_dir(packages_dir);

    info!(
        remote = %remote,
        dest = %dest,
        path = %packages.path,
        mode = if packages.tmppath.is_some() { "staged" } else { "direct" },
        "Running rsync"
    );
    debug!(cmd = ?cmd, "rsync command");

    let status = cmd.status().context("Failed to execute rsync")?;
    if !status.success() {
        bail!(
            "rsync failed with exit code {}",
            status.code().unwrap_or(-1)
        );
    }

    Ok(())
}

/**
 * Build a shell script to pipe over ssh.  Each env var becomes a
 * `name=value` assignment on its own line (shell-quoted by shlex if
 * the value needs it), followed by the user's script body.
 */
fn build_remote_script(sv: &ScriptValue) -> Result<String> {
    let mut out = String::new();
    for (k, v) in &sv.env {
        let quoted = shlex::try_quote(v)
            .with_context(|| format!("env value for '{}' cannot be shell-quoted", k))?;
        out.push_str(k);
        out.push('=');
        out.push_str(&quoted);
        out.push('\n');
    }
    out.push_str(&sv.run);
    Ok(out)
}

/**
 * Run a script on a remote host via ssh.  The script body is sent on
 * stdin and executed by `sh -eu`, so the script never appears as a
 * shell-quoted argument.  `set -eu` is mandatory: any failed command
 * or unset variable reference aborts immediately rather than silently
 * proceeding.
 */
fn run_remote_script(remote: &str, script: &str) -> Result<()> {
    let mut child = Command::new("ssh")
        .arg(remote)
        .arg("sh")
        .arg("-eu")
        .stdin(Stdio::piped())
        .spawn()
        .context("Failed to spawn ssh")?;
    {
        let stdin = child
            .stdin
            .as_mut()
            .ok_or_else(|| anyhow::anyhow!("Failed to open ssh stdin"))?;
        stdin
            .write_all(script.as_bytes())
            .context("Failed to write script to ssh stdin")?;
    }
    let status = child.wait().context("Failed to wait for ssh")?;
    if !status.success() {
        bail!(
            "swapcmd failed with exit code {}",
            status.code().unwrap_or(-1)
        );
    }
    Ok(())
}

fn format_remote(host: &str, user: Option<&str>) -> String {
    match user {
        Some(user) => format!("{}@{}", user, host),
        None => host.to_string(),
    }
}

// ========================================================================
// HTML REPORT GENERATION
// ========================================================================

const BUILD_PHASES: &[(&str, &str)] = &[
    ("pre-clean", "pre-clean.log"),
    ("depends", "depends.log"),
    ("checksum", "checksum.log"),
    ("configure", "configure.log"),
    ("build", "build.log"),
    ("install", "install.log"),
    ("package", "package.log"),
    ("deinstall", "deinstall.log"),
    ("clean", "clean.log"),
];

struct FailedPackageInfo<'a> {
    result: &'a BuildResult,
    breaks_count: usize,
    failed_log: Option<String>,
    blockers: Vec<String>,
}

fn escape_html(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

struct ReportMeta<'a> {
    build_id: &'a str,
    pkgsrc_env: &'a bob::config::PkgsrcEnv,
    vcs_info: &'a bob::vcs::VcsInfo,
}

/*
 * Build summary and per-package break counts shared by the text and
 * HTML reports.
 */
fn report_summary(db: &Database) -> Result<(BuildSummary, HashMap<String, usize>)> {
    let mut results = db.get_all_build_results()?;
    let duration = db.get_build_duration()?;
    let breaks_counts = db.dep_counts()?;
    let scanfail: Vec<(pkgsrc::PkgPath, String)> = db
        .get_scan_failures()?
        .into_iter()
        .filter_map(|(p, e)| pkgsrc::PkgPath::new(&p).ok().map(|pp| (pp, e)))
        .chain(db.get_unresolved_reasons()?)
        .collect();
    results.extend(db.get_scan_outcomes()?);
    let summary = BuildSummary {
        duration,
        results,
        scanfail,
    };
    Ok((summary, breaks_counts))
}

fn write_text_report(
    db: &Database,
    logdir: &Path,
    meta: &ReportMeta,
    report_url: Option<&str>,
    diff: Option<&bob::db::BuildDiff>,
) -> Result<()> {
    let path = logdir.join("report.txt");
    let mut file = std::fs::File::create(&path)
        .with_context(|| format!("Failed to create {}", path.display()))?;

    let m = &meta.pkgsrc_env.metadata;
    let platform = meta
        .pkgsrc_env
        .platform()
        .unwrap_or_else(|| "unknown".to_string());

    let (summary, breaks_counts) = report_summary(db)?;

    let c = summary.counts();

    let duration_str = bob::format_duration(summary.duration);

    if let Some(url) = report_url {
        writeln!(file, "URL: {}/report.html", url)?;
        writeln!(file)?;
    }

    let mut right: Vec<(&str, String)> = vec![("Platform", platform)];
    if let Some(cc) = m.get("CC_VERSION") {
        right.push(("Compiler", cc.clone()));
    }
    if let Some(url) = meta
        .vcs_info
        .web_url()
        .or_else(|| meta.vcs_info.remote_url.clone())
    {
        right.push(("Repository", url));
    }
    if let Some(branch) = &meta.vcs_info.remote_branch {
        match &meta.vcs_info.revision {
            Some(rev) => right.push(("Branch", format!("{} (revision: {})", branch, rev))),
            None => right.push(("Branch", branch.clone())),
        }
    }
    right.push(("Duration", duration_str));

    let successful = c.states.successful();
    let failed = c.states.failed();
    let masked = c.states.masked();
    let left = [
        ("Total", c.states.total()),
        ("Successful", successful),
        ("Failed", failed),
        ("UpToDate", c.states[PackageState::UpToDate]),
        ("Masked", masked),
    ];

    /* Width = longest "Label:" + 1 trailing space. */
    let left_label_w = left.iter().map(|(k, _)| k.len()).fold(0, usize::max) + 2;
    let right_label_w = right.iter().map(|(k, _)| k.len()).fold(0, usize::max) + 2;

    for (i, (lk, lv)) in left.iter().enumerate() {
        let right_part = if i < right.len() {
            format!(
                "  {:<w$}{}",
                format!("{}:", right[i].0),
                right[i].1,
                w = right_label_w
            )
        } else {
            String::new()
        };
        writeln!(
            file,
            "{:<w$}{:>5}{}",
            format!("{}:", lk),
            lv,
            right_part,
            w = left_label_w
        )?;
    }

    let mut maintainers: HashMap<String, String> = HashMap::new();
    for (pkgname, idx, _) in db.get_report_data()? {
        if let Some(m) = idx.maintainer {
            maintainers.insert(pkgname, m);
        }
    }

    if let Some(d) = diff
        && !d.new_failures.is_empty()
    {
        let mut sorted: Vec<_> = d.new_failures.iter().collect();
        let get_breaks = |e: &bob::db::DiffEntry| -> usize {
            e.build2_pkgname
                .as_deref()
                .and_then(|n| breaks_counts.get(n))
                .copied()
                .unwrap_or(0)
        };
        let was_previously_ok = |e: &bob::db::DiffEntry| -> bool {
            matches!(
                e.build1_outcome,
                None | Some(PackageState::Success) | Some(PackageState::UpToDate)
            )
        };
        sorted.sort_by(|a, b| {
            was_previously_ok(b)
                .cmp(&was_previously_ok(a))
                .then_with(|| get_breaks(b).cmp(&get_breaks(a)))
        });
        writeln!(file)?;
        writeln!(
            file,
            "{:<44} {:>6}  Previously",
            format!("New Failures Since {}", d.build1_id),
            "Breaks"
        )?;
        writeln!(file, "{}", "-".repeat(76))?;
        for e in &sorted {
            let pkgname = e.build2_pkgname.as_deref().unwrap_or("-");
            let breaks = breaks_counts.get(pkgname).copied().unwrap_or(0);
            let previously: &str = e.build1_outcome.map_or("absent", |o| o.as_str());
            let breaks_str = if breaks > 0 {
                breaks.to_string()
            } else {
                String::new()
            };
            writeln!(file, "{:<44} {:>6}  {}", pkgname, breaks_str, previously)?;
        }
    }

    if c.scanfail > 0 {
        let max_path = summary
            .scanfail
            .iter()
            .map(|(p, _)| p.as_path().display().to_string().len())
            .max()
            .unwrap_or(0);
        writeln!(file)?;
        writeln!(file, "Scan Failures")?;
        writeln!(file, "{}", "-".repeat(76))?;
        for (pkgpath, error_msg) in &summary.scanfail {
            writeln!(
                file,
                "{:<width$}  {}",
                pkgpath.as_path().display(),
                error_msg,
                width = max_path
            )?;
        }
    }

    let mut failed: Vec<_> = summary
        .results
        .iter()
        .filter(|r| r.state == PackageState::Failed)
        .map(|r| {
            let breaks = breaks_counts.get(r.pkgname.pkgname()).copied().unwrap_or(0);
            (r, breaks)
        })
        .collect();

    failed.sort_by(|a, b| {
        b.1.cmp(&a.1)
            .then_with(|| a.0.pkgname.pkgname().cmp(b.0.pkgname.pkgname()))
    });

    if !failed.is_empty() {
        writeln!(file)?;
        writeln!(file, "{:<44} {:>6}  Maintainer", "Build Failures", "Breaks")?;
        writeln!(file, "{}", "-".repeat(76))?;
        for (result, breaks) in &failed {
            let maintainer = maintainers
                .get(result.pkgname.pkgname())
                .map(|s| s.as_str())
                .unwrap_or_default();
            let breaks_str = if *breaks > 0 {
                breaks.to_string()
            } else {
                String::new()
            };
            writeln!(
                file,
                "{:<44} {:>6}  {}",
                result.pkgname.pkgname(),
                breaks_str,
                maintainer
            )?;
        }
    }

    debug!(path = %path.display(), "Wrote text report");
    Ok(())
}

fn write_machine_report(db: &Database, logdir: &Path) -> Result<()> {
    let path = logdir.join("report.zst");
    let file = std::fs::File::create(&path)
        .with_context(|| format!("Failed to create {}", path.display()))?;
    let mut encoder = zstd::Encoder::new(file, 19)?;

    let dep_counts: HashMap<String, usize> = db.dep_counts()?;

    for (pkgname, idx, outcome_id) in db.get_report_data()? {
        write!(encoder, "{}", idx.report())?;

        // TODO: packages not in the scheduler (e.g. skipped at scan time)
        // won't have a dep_count entry.  These default to 1 (just themselves).
        let pkg_depth = dep_counts.get(&pkgname).copied().unwrap_or(0) + 1;
        writeln!(encoder, "PKG_DEPTH={}", pkg_depth)?;

        let status = match outcome_id {
            Some(id) => match PackageState::try_from(id) {
                Ok(state) => state.as_pbulk_str(),
                Err(_) => "open",
            },
            None => "open",
        };
        writeln!(encoder, "BUILD_STATUS={}", status)?;
    }

    encoder.finish()?;
    debug!(path = %path.display(), "Wrote machine-readable report");
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn write_variables_json(
    pkgsrc_env: &bob::config::PkgsrcEnv,
    vcs_info: &bob::vcs::VcsInfo,
    states: &PackageCounts,
    build_id: &str,
    report_url: Option<&str>,
    duration: Duration,
    logdir: &Path,
    diff: Option<&bob::db::BuildDiff>,
) -> Result<()> {
    let mut pkgsrc = serde_json::Map::new();
    pkgsrc.insert(
        "PREFIX".to_string(),
        pkgsrc_env.prefix.display().to_string().into(),
    );
    let mut sorted: Vec<_> = pkgsrc_env.metadata.iter().collect();
    sorted.sort_by_key(|(k, _)| k.as_str());
    for (key, value) in sorted {
        pkgsrc.insert(key.clone(), value.clone().into());
    }

    let mut counts: serde_json::Map<_, _> = PackageState::VARIANTS
        .iter()
        .map(|kind| (kind.as_str().to_string(), states[*kind].into()))
        .collect();
    let total = states.total();
    counts.insert("total".to_string(), total.into());

    let mut report = serde_json::Map::new();
    report.insert("date".to_string(), build_id.into());
    report.insert("duration".to_string(), duration.as_secs().into());
    if let Some(base) = report_url {
        report.insert("url".to_string(), format!("{}/report.html", base).into());
        report.insert("raw".to_string(), format!("{}/report.zst", base).into());
    }

    let mut root = serde_json::Map::new();
    root.insert("pkgsrc".to_string(), pkgsrc.into());
    root.insert("counts".to_string(), counts.into());
    root.insert("report".to_string(), report.into());

    if vcs_info.is_detected() {
        let vcs = serde_json::to_value(vcs_info)?;
        root.insert("vcs".to_string(), vcs);
    }

    if let Some(d) = diff {
        root.insert(
            "diff".to_string(),
            serde_json::json!({
                "compared_build_id": d.build1_id,
                "new_failures": d.new_failures.len(),
                "fixes": d.fixes.len(),
                "version_changes": d.version_changes.len(),
                "other_changes": d.other_changes.len(),
            }),
        );
    }

    let path = logdir.join("variables.json");
    let file = std::fs::File::create(&path)
        .with_context(|| format!("Failed to create {}", path.display()))?;
    serde_json::to_writer_pretty(file, &root)?;
    debug!(path = %path.display(), "Wrote variables.json");
    Ok(())
}

const GITHUB_SVG: &str = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20' height='20'%3E%3Cpath fill='%23caa080' d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z'/%3E%3C/svg%3E";
const REPORT_SVG: &str = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20' height='20'%3E%3Cpath fill='%23caa080' d='M2 14V2h12v12H2zm2-1h2V7H4v6zm3 0h2V4H7v9zm3 0h2V9h-2v4z'/%3E%3C/svg%3E";
const VARS_SVG: &str = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20' height='20'%3E%3Cpath fill='%23caa080' d='M4 1h8a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V2a1 1 0 011-1zm1 3v1h6V4H5zm0 2.5v1h6v-1H5zm0 2.5v1h4V9H5z'/%3E%3C/svg%3E";

const REPORT_CSS: &str = "\
body { font-family: \"Trebuchet MS\", Verdana, sans-serif; font-size: 0.875rem; color: #444; margin: 1em 4%; background: #fefefe; line-height: 1.45; }\n\
a { color: #8a4500; text-decoration: none; }\n\
a:hover { color: #f37021; text-decoration: underline; }\n\
a:visited { color: #8a4500; }\n\
table { border-collapse: collapse; margin-bottom: 1em; }\n\
th, td { padding: 0.25em 0.625em; text-align: left; }\n\
th { white-space: nowrap; color: #d35400; background: none; border-bottom: 1px solid #f37021; cursor: pointer; user-select: none; }\n\
th:focus-visible { outline: 2px solid #f37021; outline-offset: -2px; }\n\
th[aria-sort=\"ascending\"]::after { content: \" \\25B2\"; font-size: 0.75em; }\n\
th[aria-sort=\"descending\"]::after { content: \" \\25BC\"; font-size: 0.75em; }\n\
.data { font-size: 0.8125rem; white-space: nowrap; margin: 0 auto; }\n\
.data tbody tr:nth-child(even) td { background: #fdfaf7; }\n\
.r { text-align: right; }\n\
.header { margin-bottom: 1em; padding-bottom: 0.625em; border-bottom: 1px solid #e8ddd4; }\n\
.header-table { width: 100%; white-space: nowrap; border: none; margin: 0; }\n\
.header-table td { border: none; padding: 0 0.5em; vertical-align: middle; }\n\
.header-icons { width: 1%; }\n\
.header-icons a { color: #caa080; margin-left: 0.375em; }\n\
.header-icons a:hover { color: #f37021; }\n\
.hdr-label { font-size: 1.5rem; color: #333; }\n\
.hdr-platform { font-size: 1.5rem; color: #333; text-align: center; }\n\
.hdr-date { font-size: 1.5rem; color: #333; text-align: right; }\n\
.var-columns { margin-bottom: 1em; text-align: center; }\n\
.var-columns table { display: inline-table; vertical-align: top; margin-right: 1.5em; text-align: left; }\n\
.vars th, .vars td, .vars .vk { font-family: Consolas, Monaco, \"Courier New\", monospace; }\n\
.vars th { text-align: left; color: #d35400; font-size: 0.75rem; background: none; border-bottom: 1px solid #f37021; padding: 0.1875em 0.625em 0.3125em; font-family: \"Trebuchet MS\", Verdana, sans-serif; cursor: default; }\n\
.vars .vk { color: #8a4500; font-weight: bold; font-size: 0.75rem; }\n\
.vars td { font-size: 0.75rem; }\n\
.stats td:not(.vk) { text-align: right; }\n\
.phase { margin-right: 0.375em; }\n\
.indirect { color: #aaa; }\n\
.reason { color: #888; }\n\
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }\n\
.col-err { width: 75%; }\n\
.col-pkg span, .col-path span { display: inline-block; max-width: 18em; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }\n\
.col-breaks, .col-dur { text-align: right; }\n\
@media (max-width: 100em) { .col-status { display: none; } }\n\
@media (max-width: 93em) { .col-maint { display: none; } }\n\
@media (max-width: 80em) { .col-dur { display: none; } }\n\
@media (max-width: 75em) { .col-path { display: none; } }\n\
@media (max-width: 58em) { .col-breaks { display: none; } }\n";

fn write_html_report(
    db: &Database,
    logdir: &Path,
    path: &Path,
    meta: &ReportMeta,
    diff: Option<&bob::db::BuildDiff>,
    commits: Option<&HashMap<String, Vec<bob::vcs::CommitInfo>>>,
) -> Result<()> {
    let (summary, breaks_counts) = report_summary(db)?;

    let mut file = std::fs::File::create(path)?;
    let m = &meta.pkgsrc_env.metadata;

    let blockers_by_pkg = db.blockers()?;
    let mut failed_info: Vec<FailedPackageInfo> = summary
        .results
        .iter()
        .filter(|r| matches!(r.state, PackageState::Failed | PackageState::IndirectFailed))
        .map(|result| {
            let breaks_count = breaks_counts
                .get(result.pkgname.pkgname())
                .copied()
                .unwrap_or(0);
            let failed_log = result
                .build_stats
                .stage
                .and_then(|stage| {
                    BUILD_PHASES
                        .iter()
                        .find(|(name, _)| *name == stage.into_str())
                        .map(|(_, log)| (*log).to_string())
                })
                .or_else(|| {
                    /*
                     * No build phase reached -- e.g. sandbox or bootstrap
                     * setup failed before bmake.  Fall back to setup.log
                     * if the per-package layer wrote one.
                     */
                    let setup_log = logdir.join(result.pkgname.pkgname()).join("setup.log");
                    setup_log.exists().then(|| "setup.log".to_string())
                });
            let blockers = match blockers_by_pkg.get(result.pkgname.pkgname()) {
                Some(blockers) => blockers.clone(),
                None => Vec::new(),
            };
            FailedPackageInfo {
                result,
                breaks_count,
                failed_log,
                blockers,
            }
        })
        .collect();

    failed_info.sort_by(|a, b| {
        let a_indirect = a.result.state == PackageState::IndirectFailed;
        let b_indirect = b.result.state == PackageState::IndirectFailed;
        a_indirect
            .cmp(&b_indirect)
            .then_with(|| b.breaks_count.cmp(&a.breaks_count))
            .then_with(|| a.result.pkgname.pkgname().cmp(b.result.pkgname.pkgname()))
    });

    let display_date = match bob::parse_build_id(meta.build_id) {
        Some(dt) => dt.format("%Y-%m-%d %H:%M").to_string(),
        None => meta.build_id.to_string(),
    };
    let os = m
        .get("OS_VARIANT")
        .or_else(|| m.get("OPSYS"))
        .context("neither OS_VARIANT nor OPSYS found in metadata")?;
    let ver = m
        .get("LOWER_VARIANT_VERSION")
        .or_else(|| m.get("OS_VERSION"))
        .context("neither LOWER_VARIANT_VERSION nor OS_VERSION found in metadata")?;
    let arch = m
        .get("MACHINE_ARCH")
        .context("MACHINE_ARCH not found in metadata")?;
    let platform = format!("{} {}/{}", os, ver, arch);
    let title = format!("Build Report - {} - {}", platform, display_date);

    writeln!(file, "<!DOCTYPE html>")?;
    writeln!(file, "<html lang=\"en\">")?;
    writeln!(file, "<head>")?;
    writeln!(file, "<meta charset=\"UTF-8\">")?;
    writeln!(
        file,
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
    )?;
    writeln!(file, "<title>{}</title>", escape_html(&title))?;
    writeln!(file, "<style>")?;
    write!(file, "{}", REPORT_CSS)?;
    writeln!(file, "</style>")?;
    write_sort_script(&mut file)?;
    writeln!(file, "</head>")?;
    writeln!(file, "<body>")?;

    write_header(&mut file, &platform, &display_date)?;

    writeln!(file, "<div class=\"var-columns\">")?;
    write_statistics_table(&mut file, &summary)?;
    write_platform_table(&mut file, m)?;
    write_paths_table(&mut file, m, &meta.pkgsrc_env.prefix)?;
    write_misc_table(&mut file, meta.vcs_info, summary.duration, db, diff)?;
    writeln!(file, "</div>")?;

    let pkgpath_base = meta.vcs_info.web_url().and_then(|base| {
        meta.vcs_info
            .revision_full
            .as_ref()
            .map(|rev| format!("{}/tree/{}", base, rev))
    });

    if let Some(d) = diff {
        write_diff_section(
            &mut file,
            d,
            &failed_info,
            pkgpath_base.as_deref(),
            commits,
            meta.vcs_info,
        )?;
    }

    if !summary.scanfail.is_empty() {
        write_scanfail_table(&mut file, &summary.scanfail, pkgpath_base.as_deref())?;
    }

    let mut maintainers: HashMap<String, String> = HashMap::new();
    for (pkgname, idx, _) in db.get_report_data()? {
        if let Some(m) = idx.maintainer {
            maintainers.insert(pkgname, m);
        }
    }
    write_failed_table(
        &mut file,
        "All Failures",
        &failed_info,
        &maintainers,
        logdir,
        pkgpath_base.as_deref(),
    )?;

    writeln!(file, "</body>")?;
    writeln!(file, "</html>")?;

    Ok(())
}

fn write_sort_script(file: &mut std::fs::File) -> Result<()> {
    writeln!(file, "<script>")?;
    writeln!(file, "function cmpVal(cells, col, type) {{")?;
    writeln!(
        file,
        "  var v = cells[col].getAttribute('data-sort') || cells[col].textContent;"
    )?;
    writeln!(file, "  return type === 'num' ? (parseFloat(v) || 0) : v;")?;
    writeln!(file, "}}")?;
    writeln!(file, "function sortTable(id, col, type, col2, type2) {{")?;
    writeln!(file, "  var t = document.getElementById(id);")?;
    writeln!(file, "  var b = t.tBodies[0];")?;
    writeln!(file, "  var rows = Array.prototype.slice.call(b.rows);")?;
    writeln!(file, "  var same = t.getAttribute('data-sort-col') == col;")?;
    writeln!(
        file,
        "  var desc = same ? t.getAttribute('data-sort-desc') != '1' : type === 'num';"
    )?;
    writeln!(file, "  rows.sort(function(a, b) {{")?;
    writeln!(file, "    var av = cmpVal(a.cells, col, type);")?;
    writeln!(file, "    var bv = cmpVal(b.cells, col, type);")?;
    writeln!(
        file,
        "    var r = type === 'num' ? av - bv : av.localeCompare(bv);"
    )?;
    writeln!(file, "    if (r === 0 && col2 !== undefined) {{")?;
    writeln!(file, "      var av2 = cmpVal(a.cells, col2, type2);")?;
    writeln!(file, "      var bv2 = cmpVal(b.cells, col2, type2);")?;
    writeln!(
        file,
        "      r = type2 === 'num' ? bv2 - av2 : av2.localeCompare(bv2);"
    )?;
    writeln!(file, "    }}")?;
    writeln!(file, "    return desc ? -r : r;")?;
    writeln!(file, "  }});")?;
    writeln!(
        file,
        "  for (var i = 0; i < rows.length; i++) b.appendChild(rows[i]);"
    )?;
    writeln!(file, "  t.setAttribute('data-sort-col', col);")?;
    writeln!(
        file,
        "  t.setAttribute('data-sort-desc', desc ? '1' : '0');"
    )?;
    writeln!(file, "  var h = t.tHead.rows[0].cells;")?;
    writeln!(
        file,
        "  for (var i = 0; i < h.length; i++) h[i].setAttribute('aria-sort', i == col ? (desc ? 'descending' : 'ascending') : 'none');"
    )?;
    writeln!(file, "}}")?;
    writeln!(file, "</script>")?;
    Ok(())
}

fn write_header(file: &mut std::fs::File, platform: &str, date: &str) -> Result<()> {
    writeln!(file, "<div class=\"header\">")?;
    writeln!(file, "<table class=\"header-table\"><tr>")?;
    writeln!(
        file,
        "<td class=\"header-icons\"><a href=\"https://pkgsrc.org/\"><img src=\"https://www.pkgsrc.org/img/pkgsrc-square.png\" alt=\"pkgsrc\" height=\"36\"></a></td>"
    )?;
    writeln!(file, "<td class=\"hdr-label\">Build Report</td>")?;
    writeln!(
        file,
        "<td class=\"hdr-platform\">{}</td>",
        escape_html(platform)
    )?;
    writeln!(file, "<td class=\"hdr-date\">{}</td>", escape_html(date))?;
    write!(
        file,
        "<td class=\"header-icons\" style=\"text-align:right\">"
    )?;
    write!(
        file,
        "<a href=\"report.zst\" title=\"Machine-readable report\"><img src=\"{}\" alt=\"Report\"></a>",
        REPORT_SVG
    )?;
    write!(
        file,
        "<a href=\"variables.json\" title=\"Build variables\"><img src=\"{}\" alt=\"Variables\"></a>",
        VARS_SVG
    )?;
    write!(
        file,
        "<a href=\"https://github.com/jperkin/bob\" title=\"bob on GitHub\"><img src=\"{}\" alt=\"GitHub\"></a>",
        GITHUB_SVG
    )?;
    writeln!(file, "</td>")?;
    writeln!(file, "</tr></table>")?;
    writeln!(file, "</div>")?;
    Ok(())
}

#[allow(clippy::too_many_arguments)]
fn write_sortable_th(
    file: &mut std::fs::File,
    table_id: &str,
    col: usize,
    sort_type: &str,
    label: &str,
    class: &str,
    extra_sort: &str,
    aria_sort: &str,
) -> Result<()> {
    let cls = if class.is_empty() {
        String::new()
    } else {
        format!(" class=\"{}\"", class)
    };
    writeln!(
        file,
        "<th{cls} scope=\"col\" role=\"columnheader\" tabindex=\"0\" aria-sort=\"{aria_sort}\" \
         onclick=\"sortTable('{table_id}', {col}, '{sort_type}'{extra_sort})\" \
         onkeydown=\"if(event.key==='Enter')this.click()\">{label}</th>",
    )?;
    Ok(())
}

fn write_var_row(file: &mut std::fs::File, key: &str, value: &str) -> Result<()> {
    writeln!(
        file,
        "<tr><td class=\"vk\">{}</td><td>{}</td></tr>",
        escape_html(key),
        escape_html(value)
    )?;
    Ok(())
}

fn write_statistics_table(file: &mut std::fs::File, summary: &BuildSummary) -> Result<()> {
    let c = summary.counts();

    writeln!(file, "<table class=\"vars stats\">")?;
    writeln!(file, "<tr><th colspan=\"2\">Statistics</th></tr>")?;
    let successful = c.states.successful();
    let failed = c.states.failed();
    let masked = c.states.masked();
    write_var_row(file, "Total", &c.states.total().to_string())?;
    write_var_row(file, "Successful", &successful.to_string())?;
    write_var_row(file, "Failed", &failed.to_string())?;
    write_var_row(
        file,
        "UpToDate",
        &c.states[PackageState::UpToDate].to_string(),
    )?;
    write_var_row(file, "Masked", &masked.to_string())?;
    writeln!(file, "</table>")?;
    Ok(())
}

fn write_platform_table(file: &mut std::fs::File, m: &HashMap<String, String>) -> Result<()> {
    writeln!(file, "<table class=\"vars\">")?;
    writeln!(file, "<tr><th colspan=\"2\">Platform</th></tr>")?;

    let has_variant = m.contains_key("OS_VARIANT");
    let has_variant_version = m.contains_key("LOWER_VARIANT_VERSION");

    if has_variant {
        write_var_row(file, "OS_VARIANT", &m["OS_VARIANT"])?;
    } else if let Some(val) = m.get("OPSYS") {
        write_var_row(file, "OPSYS", val)?;
    }

    if has_variant_version {
        write_var_row(file, "LOWER_VARIANT_VERSION", &m["LOWER_VARIANT_VERSION"])?;
    } else if let Some(val) = m.get("OS_VERSION") {
        write_var_row(file, "OS_VERSION", val)?;
    }

    for key in &["MACHINE_ARCH", "CC_VERSION"] {
        if let Some(val) = m.get(*key) {
            write_var_row(file, key, val)?;
        }
    }

    writeln!(file, "</table>")?;
    Ok(())
}

fn write_paths_table(
    file: &mut std::fs::File,
    m: &HashMap<String, String>,
    prefix: &Path,
) -> Result<()> {
    writeln!(file, "<table class=\"vars\">")?;
    writeln!(file, "<tr><th colspan=\"2\">Paths</th></tr>")?;
    write_var_row(file, "PREFIX", &prefix.display().to_string())?;
    for key in &["SYSCONFBASE", "VARBASE", "PKGMANDIR", "PKGINFODIR"] {
        if let Some(val) = m.get(*key) {
            write_var_row(file, key, val)?;
        }
    }
    writeln!(file, "</table>")?;
    Ok(())
}

fn write_misc_table(
    file: &mut std::fs::File,
    vcs: &bob::vcs::VcsInfo,
    duration: Duration,
    db: &Database,
    diff: Option<&bob::db::BuildDiff>,
) -> Result<()> {
    writeln!(file, "<table class=\"vars\">")?;
    writeln!(file, "<tr><th colspan=\"2\">Miscellaneous</th></tr>")?;

    let web_url = if vcs.is_detected() {
        vcs.web_url()
    } else {
        None
    };
    let web_base = web_url.as_deref();

    if let Some(base) = web_base {
        writeln!(
            file,
            "<tr><td class=\"vk\">Repository</td><td><a href=\"{}\">{}</a></td></tr>",
            escape_html(base),
            escape_html(base)
        )?;
    } else if let Some(url) = &vcs.remote_url {
        write_var_row(file, "Repository", url)?;
    }
    if let Some(branch) = &vcs.remote_branch {
        if let Some(base) = web_base {
            writeln!(
                file,
                "<tr><td class=\"vk\">Branch</td><td><a href=\"{}/tree/{}\">{}</a></td></tr>",
                escape_html(base),
                escape_html(branch),
                escape_html(branch)
            )?;
        } else {
            write_var_row(file, "Branch", branch)?;
        }
    }
    if let Some(rev) = &vcs.revision {
        if let (Some(base), Some(full)) = (web_base, &vcs.revision_full) {
            writeln!(
                file,
                "<tr><td class=\"vk\">Revision</td><td><a href=\"{}/tree/{}\">{}</a></td></tr>",
                escape_html(base),
                escape_html(full),
                escape_html(rev)
            )?;
        } else {
            write_var_row(file, "Revision", rev)?;
        }
    }
    if let Some(d) = diff
        && let Some((url, old_rev, new_rev)) = build_compare_url(db, vcs, &d.build1_id)
    {
        writeln!(
            file,
            "<tr><td class=\"vk\">Compare</td>\
                 <td><a href=\"{}\">{}..{}</a></td></tr>",
            escape_html(&url),
            escape_html(&old_rev),
            escape_html(&new_rev),
        )?;
    }
    write_var_row(file, "Duration", &bob::format_duration(duration))?;
    writeln!(file, "</table>")?;
    Ok(())
}

fn generate_phase_links(pkg_name: &str, log_dir: &Path) -> String {
    if !log_dir.exists() {
        return String::new();
    }
    let mut links = Vec::new();
    if log_dir.join("setup.log").exists() {
        links.push(format!(
            "<a href=\"{pkg_name}/setup.log\" class=\"phase\">setup</a>"
        ));
    }
    for (phase_name, log_file) in BUILD_PHASES {
        let log_path = log_dir.join(log_file);
        if log_path.exists() {
            links.push(format!(
                "<a href=\"{pkg_name}/{log_file}\" class=\"phase\">{phase_name}</a>"
            ));
        }
    }
    links.join(" ")
}

/*
 * Render a pkgname as a link to its failing stage log, or plain text
 * when no log is available.
 */
fn pkg_log_link(pkgname: &str, log: Option<&str>) -> String {
    let e = escape_html(pkgname);
    match log {
        Some(log) => format!("<a href=\"{e}/{log}\">{e}</a>"),
        None => e,
    }
}

/*
 * The same link wrapped in a titled span for table cells.
 */
fn pkg_log_cell(pkgname: &str, log: Option<&str>) -> String {
    format!(
        "<span title=\"{}\">{}</span>",
        escape_html(pkgname),
        pkg_log_link(pkgname, log)
    )
}

fn write_failed_table(
    file: &mut std::fs::File,
    title: &str,
    failed_info: &[FailedPackageInfo],
    maintainers: &HashMap<String, String>,
    logdir: &Path,
    pkgpath_base: Option<&str>,
) -> Result<()> {
    let t = "failed-table";
    writeln!(
        file,
        "<table id=\"{t}\" class=\"data\" data-sort-col=\"6\" data-sort-desc=\"0\">"
    )?;
    writeln!(file, "<caption class=\"sr-only\">Failed packages</caption>")?;
    writeln!(file, "<thead><tr>")?;
    write_sortable_th(file, t, 0, "str", title, "col-pkg", "", "none")?;
    write_sortable_th(file, t, 1, "str", "PkgPath", "col-path", "", "none")?;
    write_sortable_th(file, t, 2, "num", "Breaks", "col-breaks", "", "none")?;
    write_sortable_th(file, t, 3, "num", "Duration", "col-dur", "", "none")?;
    write_sortable_th(file, t, 4, "str", "Maintainer", "col-maint", "", "none")?;
    write_sortable_th(file, t, 5, "str", "Status", "col-status", "", "none")?;
    write_sortable_th(
        file,
        t,
        6,
        "num",
        "Build Logs",
        "col-logs",
        ", 2, 'num'",
        "ascending",
    )?;
    writeln!(file, "</tr></thead>")?;
    writeln!(file, "<tbody>")?;

    let log_by_name: HashMap<&str, &str> = failed_info
        .iter()
        .filter_map(|i| {
            i.failed_log
                .as_deref()
                .map(|log| (i.result.pkgname.pkgname(), log))
        })
        .collect();

    for info in failed_info {
        let pkg_name = info.result.pkgname.pkgname();
        let pkgpath_str = info
            .result
            .pkgpath
            .as_ref()
            .map(|p| p.as_path().display().to_string())
            .unwrap_or_default();
        let escaped_path = escape_html(&pkgpath_str);
        let pkgpath = match pkgpath_base {
            Some(base) if !pkgpath_str.is_empty() => format!(
                "<span title=\"{0}\"><a href=\"{1}/{0}\">{0}</a></span>",
                escaped_path,
                escape_html(base),
            ),
            _ => format!("<span title=\"{0}\">{0}</span>", escaped_path),
        };
        let maintainer = maintainers
            .get(pkg_name)
            .map(|s| s.as_str())
            .unwrap_or_default();

        let breaks_display = if info.breaks_count > 0 {
            info.breaks_count.to_string()
        } else {
            String::new()
        };

        if info.result.state == PackageState::IndirectFailed {
            let links = info
                .blockers
                .iter()
                .map(|b| pkg_log_link(b, log_by_name.get(b.as_str()).copied()))
                .collect::<Vec<_>>()
                .join(", ");
            let reason = format!("Blocked by {links}");
            writeln!(
                file,
                "<tr><td class=\"col-pkg indirect\"><span title=\"{0}\">{0}</span></td><td class=\"col-path indirect\">{1}</td><td class=\"col-breaks r indirect\" data-sort=\"{2}\">{3}</td><td class=\"col-dur r indirect\" data-sort=\"0\"></td><td class=\"col-maint indirect\">{4}</td><td class=\"col-status indirect\">{5}</td><td class=\"col-logs reason\" data-sort=\"1\">{6}</td></tr>",
                escape_html(pkg_name),
                pkgpath,
                info.breaks_count,
                breaks_display,
                escape_html(maintainer),
                info.result.state.as_str(),
                reason
            )?;
            continue;
        }

        let dur_secs = info.result.build_stats.duration.as_secs();
        let duration = bob::format_duration(info.result.build_stats.duration);

        let pkg_link = pkg_log_cell(pkg_name, info.failed_log.as_deref());

        let log_dir = logdir.join(pkg_name);
        let phase_links = generate_phase_links(pkg_name, &log_dir);

        writeln!(
            file,
            "<tr><td class=\"col-pkg\">{}</td><td class=\"col-path\">{}</td><td class=\"col-breaks r\" data-sort=\"{}\">{}</td><td class=\"col-dur r\" data-sort=\"{}\">{}</td><td class=\"col-maint\">{}</td><td class=\"col-status\">{}</td><td class=\"col-logs\" data-sort=\"0\">{}</td></tr>",
            pkg_link,
            pkgpath,
            info.breaks_count,
            breaks_display,
            dur_secs,
            duration,
            escape_html(maintainer),
            info.result.state.as_str(),
            phase_links,
        )?;
    }

    writeln!(file, "</tbody>")?;
    writeln!(file, "</table>")?;
    Ok(())
}

fn write_diff_section(
    file: &mut std::fs::File,
    diff: &bob::db::BuildDiff,
    failed_info: &[FailedPackageInfo],
    pkgpath_base: Option<&str>,
    commits: Option<&HashMap<String, Vec<bob::vcs::CommitInfo>>>,
    vcs_info: &bob::vcs::VcsInfo,
) -> Result<()> {
    if diff.new_failures.is_empty() {
        return Ok(());
    }

    let show_commits = commits.is_some_and(|c| !c.is_empty());

    let info_by_name: HashMap<&str, &FailedPackageInfo> = failed_info
        .iter()
        .map(|i| (i.result.pkgname.pkgname(), i))
        .collect();

    let was_ok = |e: &bob::db::DiffEntry| -> bool {
        matches!(
            e.build1_outcome,
            None | Some(PackageState::Success) | Some(PackageState::UpToDate)
        )
    };

    let mut sorted: Vec<_> = diff.new_failures.iter().collect();
    sorted.sort_by(|a, b| {
        let ai = a
            .build2_pkgname
            .as_deref()
            .and_then(|n| info_by_name.get(n));
        let bi = b
            .build2_pkgname
            .as_deref()
            .and_then(|n| info_by_name.get(n));
        let ab = ai.map(|i| i.breaks_count).unwrap_or(0);
        let bb = bi.map(|i| i.breaks_count).unwrap_or(0);
        was_ok(b).cmp(&was_ok(a)).then_with(|| bb.cmp(&ab))
    });

    let t = "diff-new-failures";
    let title = format!("New Failures Since {}", escape_html(&diff.build1_id));
    writeln!(
        file,
        "<table id=\"{t}\" class=\"data\" data-sort-col=\"2\" data-sort-desc=\"1\">"
    )?;
    writeln!(
        file,
        "<caption class=\"sr-only\">New failures since previous build</caption>"
    )?;
    writeln!(file, "<thead><tr>")?;
    write_sortable_th(file, t, 0, "str", &title, "col-pkg", "", "none")?;
    write_sortable_th(file, t, 1, "str", "PkgPath", "col-path", "", "none")?;
    write_sortable_th(file, t, 2, "num", "Breaks", "col-breaks", "", "none")?;
    write_sortable_th(file, t, 3, "num", "Duration", "col-dur", "", "none")?;
    write_sortable_th(file, t, 4, "str", "Previously", "col-prev", "", "none")?;
    if show_commits {
        write_sortable_th(file, t, 5, "str", "Commits", "col-commits", "", "none")?;
    }
    writeln!(file, "</tr></thead>")?;
    writeln!(file, "<tbody>")?;

    for e in &sorted {
        let pkgname = e
            .build2_pkgname
            .as_deref()
            .or(e.build1_pkgname.as_deref())
            .unwrap_or("-");
        let info = info_by_name.get(pkgname);

        let escaped_path = escape_html(&e.pkgpath);
        let pkgpath_cell = match pkgpath_base {
            Some(base) => format!(
                "<span title=\"{0}\"><a href=\"{1}/{0}\">{0}</a></span>",
                escaped_path,
                escape_html(base),
            ),
            None => format!("<span title=\"{0}\">{0}</span>", escaped_path),
        };

        let breaks = info.map(|i| i.breaks_count).unwrap_or(0);
        let breaks_display = if breaks > 0 {
            breaks.to_string()
        } else {
            String::new()
        };

        let (dur_secs, duration) = match info {
            Some(i) => (
                i.result.build_stats.duration.as_secs(),
                bob::format_duration(i.result.build_stats.duration),
            ),
            None => (0, String::new()),
        };

        let previously: &str = e.build1_outcome.map_or("absent", |o| o.as_str());

        let pkg_link = pkg_log_cell(pkgname, info.and_then(|i| i.failed_log.as_deref()));

        let commits_cell = if show_commits {
            let entries = commits.and_then(|m| m.get(&e.pkgpath));
            let mut parts: Vec<String> = Vec::new();
            if let Some(entries) = entries {
                for c in entries {
                    let label = format!("{}@{}", escape_html(&c.author), escape_html(&c.sha_short));
                    let part = match vcs_info.commit_url(&c.sha_full) {
                        Some(url) => format!("<a href=\"{}\">{}</a>", escape_html(&url), label),
                        None => label,
                    };
                    parts.push(part);
                }
            }
            format!("<td class=\"col-commits\">{}</td>", parts.join(" "))
        } else {
            String::new()
        };

        writeln!(
            file,
            "<tr><td class=\"col-pkg\">{}</td>\
             <td class=\"col-path\">{}</td>\
             <td class=\"col-breaks r\" data-sort=\"{}\">{}</td>\
             <td class=\"col-dur r\" data-sort=\"{}\">{}</td>\
             <td class=\"col-prev\">{}</td>{}</tr>",
            pkg_link,
            pkgpath_cell,
            breaks,
            breaks_display,
            dur_secs,
            duration,
            previously,
            commits_cell,
        )?;
    }

    writeln!(file, "</tbody>")?;
    writeln!(file, "</table>")?;
    Ok(())
}

/**
 * Build a compare URL and short revision pair for two builds.
 *
 * Returns the GitHub-style compare URL (constructed from full SHAs)
 * plus the two short revision strings used for display.  Returns None
 * if any prerequisite is missing.
 */
fn build_compare_url(
    db: &Database,
    vcs_info: &bob::vcs::VcsInfo,
    build1_id: &str,
) -> Option<(String, String, String)> {
    let web_url = vcs_info.web_url()?;
    let old_rev_full = db.get_build_revision(build1_id).ok()??;
    let new_rev_full = vcs_info.revision_full.as_deref()?;
    let new_rev_short = vcs_info.revision.as_deref()?;
    let old_rev_short = &old_rev_full[..12.min(old_rev_full.len())];
    let url = format!("{}/compare/{}..{}", web_url, old_rev_full, new_rev_full);
    Some((url, old_rev_short.to_string(), new_rev_short.to_string()))
}

/**
 * For each pkgpath in `diff.new_failures`, compute the list of commits
 * in the pkgsrc tree that touched that pkgpath since the previous build.
 * Returns None if any prerequisite is missing (no git, missing previous
 * revision, walk failure).  Each early-return path logs at debug level
 * so the reason is visible with `RUST_LOG=bob=debug`.
 */
fn compute_diff_commits(
    db: &Database,
    pkgsrc: &Pkgsrc,
    vcs_info: &bob::vcs::VcsInfo,
    diff: &bob::db::BuildDiff,
) -> Option<HashMap<String, Vec<bob::vcs::CommitInfo>>> {
    let old_rev = match db.get_build_revision(&diff.build1_id) {
        Ok(Some(r)) => r,
        Ok(None) => {
            debug!(
                build1_id = %diff.build1_id,
                "Skipping per-pkgpath commit lookup: no revision stored \
                 for previous build"
            );
            return None;
        }
        Err(e) => {
            debug!(
                build1_id = %diff.build1_id,
                error = format!("{e:#}"),
                "Skipping per-pkgpath commit lookup: failed to query \
                 previous build revision"
            );
            return None;
        }
    };
    let Some(new_rev) = vcs_info.revision_full.as_deref() else {
        debug!(
            "Skipping per-pkgpath commit lookup: no current revision \
             from VcsInfo (not a git tree?)"
        );
        return None;
    };
    let pkgpaths: HashSet<String> = diff
        .new_failures
        .iter()
        .map(|e| e.pkgpath.clone())
        .collect();
    if pkgpaths.is_empty() {
        debug!("Skipping per-pkgpath commit lookup: no new failures in diff");
        return None;
    }
    debug!(
        old_rev = %old_rev,
        new_rev = %new_rev,
        pkgpath_count = pkgpaths.len(),
        repo = %pkgsrc.basedir.display(),
        "Computing per-pkgpath commit list"
    );
    match bob::vcs::commits_for_pkgpaths(&pkgsrc.basedir, &old_rev, new_rev, &pkgpaths) {
        Ok(map) if !map.is_empty() => {
            let total: usize = map.values().map(|v| v.len()).sum();
            debug!(
                pkgpaths_with_commits = map.len(),
                total_commits = total,
                "Per-pkgpath commit list ready"
            );
            Some(map)
        }
        Ok(_) => {
            debug!(
                "Per-pkgpath commit list is empty: walk succeeded but no \
                 commits touched any of the failing pkgpaths"
            );
            None
        }
        Err(e) => {
            debug!(
                error = format!("{e:#}"),
                "Failed to compute per-pkgpath commit list"
            );
            None
        }
    }
}

fn write_scanfail_table(
    file: &mut std::fs::File,
    scanfail: &[(pkgsrc::PkgPath, String)],
    pkgpath_base: Option<&str>,
) -> Result<()> {
    let t = "scanfail-table";
    writeln!(
        file,
        "<table id=\"{t}\" class=\"data\" style=\"white-space:normal\">"
    )?;
    writeln!(file, "<caption class=\"sr-only\">Scan failures</caption>")?;
    writeln!(file, "<thead><tr>")?;
    write_sortable_th(file, t, 0, "str", "PKGPATH", "", "", "none")?;
    write_sortable_th(file, t, 1, "str", "Scan Error", "col-err", "", "none")?;
    writeln!(file, "</tr></thead>")?;
    writeln!(file, "<tbody>")?;

    for (pkgpath, error_msg) in scanfail {
        let path_str = pkgpath.as_path().display().to_string();
        let path_html = match pkgpath_base {
            Some(base) => format!(
                "<a href=\"{}/{}\">{}</a>",
                escape_html(base),
                escape_html(&path_str),
                escape_html(&path_str)
            ),
            None => escape_html(&path_str),
        };
        writeln!(
            file,
            "<tr><td>{}</td><td>{}</td></tr>",
            path_html,
            escape_html(error_msg)
        )?;
    }

    writeln!(file, "</tbody>")?;
    writeln!(file, "</table>")?;
    Ok(())
}
