// Copyright (c) The nextest Contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Store command implementation for managing the record store.

use crate::{
    ExpectedError, Result, cargo_cli::CargoCli, dispatch::EarlyArgs, output::OutputContext,
};
use camino::{Utf8Path, Utf8PathBuf};
use chrono::Utc;
use clap::{Args, Subcommand};
use nextest_runner::{
    helpers::ThemeCharacters,
    pager::PagedOutput,
    record::{
        DisplayRunList, PortableRecording, PortableRecordingWriter, PruneKind,
        RecordRetentionPolicy, RecordedRunStatus, RunIdIndex, RunIdOrRecordingSelector,
        RunIdSelector, RunStore, SnapshotWithReplayability, Styles as RecordStyles,
        has_zip_extension, records_state_dir,
    },
    redact::Redactor,
    user_config::{UserConfig, elements::RecordConfig},
    write_str::WriteStr,
};
use owo_colors::OwoColorize;
use tracing::{info, warn};

/// Subcommands for managing the record store.
#[derive(Debug, Subcommand)]
pub(crate) enum StoreCommand {
    /// List all recorded runs.
    List {},
    /// Show detailed information about a recorded run.
    Info(InfoOpts),
    /// Prune old recorded runs according to retention policy.
    Prune(PruneOpts),
    /// Export a recorded run as a portable recording.
    Export(ExportOpts),
}

/// Options for the `cargo nextest store info` command.
#[derive(Debug, Args)]
pub(crate) struct InfoOpts {
    /// Run ID, `latest`, or recording path to show info for [aliases: -R].
    ///
    /// Accepts "latest" for the most recent completed run, a full UUID or
    /// unambiguous prefix, or a file path (ending in `.zip`, or, on Unix,
    /// `<(curl url)`).
    #[arg(
        value_name = "RUN_ID_OR_RECORDING",
        required_unless_present = "run_id_opt"
    )]
    run_id: Option<RunIdOrRecordingSelector>,

    /// Run ID, `latest`, or recording path to show info for (alternative to
    /// positional argument).
    #[arg(
        short = 'R',
        long = "run-id",
        hide = true,
        value_name = "RUN_ID_OR_RECORDING",
        conflicts_with = "run_id"
    )]
    run_id_opt: Option<RunIdOrRecordingSelector>,
}

impl InfoOpts {
    fn resolved_selector(&self) -> &RunIdOrRecordingSelector {
        // One of these must be Some due to clap's required_unless_present.
        self.run_id
            .as_ref()
            .or(self.run_id_opt.as_ref())
            .expect("run_id or run_id_opt is present due to clap validation")
    }

    fn exec_from_store(
        &self,
        run_id_selector: &RunIdSelector,
        state_dir: &Utf8Path,
        styles: &RecordStyles,
        theme_characters: &ThemeCharacters,
        paged_output: &mut PagedOutput,
        redactor: &Redactor,
    ) -> Result<i32> {
        let store =
            RunStore::new(state_dir).map_err(|err| ExpectedError::RecordSetupError { err })?;

        let snapshot = store
            .lock_shared()
            .map_err(|err| ExpectedError::RecordSetupError { err })?
            .into_snapshot();

        let resolved = snapshot
            .resolve_run_id(run_id_selector)
            .map_err(|err| ExpectedError::RunIdResolutionError { err })?;
        let run_id = resolved.run_id;

        // This should never fail since we just resolved the run ID.
        let run = snapshot
            .get_run(run_id)
            .expect("run ID was just resolved, so the run should exist");

        let replayability = run.check_replayability(&snapshot.runs_dir().run_files(run_id));
        let display = run.display_detailed(
            snapshot.run_id_index(),
            &replayability,
            Utc::now(),
            styles,
            theme_characters,
            redactor,
        );

        write!(paged_output, "{}", display).map_err(|err| ExpectedError::WriteError { err })?;

        Ok(0)
    }

    fn exec_from_archive(
        &self,
        archive_path: &Utf8Path,
        styles: &RecordStyles,
        theme_characters: &ThemeCharacters,
        paged_output: &mut PagedOutput,
        redactor: &Redactor,
    ) -> Result<i32> {
        let archive = PortableRecording::open(archive_path)
            .map_err(|err| ExpectedError::PortableRecordingReadError { err })?;

        let run_info = archive.run_info();

        // Create a single-entry index for display purposes.
        let run_id_index = RunIdIndex::new(std::slice::from_ref(&run_info));

        // Check replayability using the archive for file existence checks.
        let replayability = run_info.check_replayability(&archive);

        let display = run_info.display_detailed(
            &run_id_index,
            &replayability,
            Utc::now(),
            styles,
            theme_characters,
            redactor,
        );

        write!(paged_output, "{}", display).map_err(|err| ExpectedError::WriteError { err })?;

        Ok(0)
    }
}

/// Options for the `cargo nextest store prune` command.
#[derive(Debug, Args)]
pub(crate) struct PruneOpts {
    /// Show what would be deleted without actually deleting.
    #[arg(long)]
    dry_run: bool,
}

/// Options for the `cargo nextest store export` command.
#[derive(Debug, Args)]
pub(crate) struct ExportOpts {
    /// Run ID to export, or `latest` [aliases: -R].
    ///
    /// Accepts "latest" for the most recent completed run, or a full UUID or
    /// unambiguous prefix.
    #[arg(value_name = "RUN_ID", required_unless_present = "run_id_opt")]
    run_id: Option<RunIdSelector>,

    /// Run ID to export (alternative to positional argument).
    #[arg(
        short = 'R',
        long = "run-id",
        hide = true,
        value_name = "RUN_ID",
        conflicts_with = "run_id"
    )]
    run_id_opt: Option<RunIdSelector>,

    /// Destination for the archive file.
    ///
    /// Defaults to `nextest-run-<run-id>.zip` in the current directory.
    #[arg(long, value_name = "PATH", value_parser = zip_extension_path)]
    archive_file: Option<Utf8PathBuf>,
}

fn zip_extension_path(input: &str) -> Result<Utf8PathBuf, &'static str> {
    let path = Utf8PathBuf::from(input);
    if has_zip_extension(&path) {
        Ok(path)
    } else {
        Err("must end in .zip")
    }
}

impl ExportOpts {
    fn resolved_run_id(&self) -> &RunIdSelector {
        self.run_id
            .as_ref()
            .or(self.run_id_opt.as_ref())
            .expect("run_id or run_id_opt is present due to clap validation")
    }

    fn exec(&self, state_dir: &Utf8Path, styles: &RecordStyles) -> Result<i32> {
        let store =
            RunStore::new(state_dir).map_err(|err| ExpectedError::RecordSetupError { err })?;

        let snapshot = store
            .lock_shared()
            .map_err(|err| ExpectedError::RecordSetupError { err })?
            .into_snapshot();

        let resolved = snapshot
            .resolve_run_id(self.resolved_run_id())
            .map_err(|err| ExpectedError::RunIdResolutionError { err })?;
        let run_id = resolved.run_id;

        let run = snapshot
            .get_run(run_id)
            .expect("run ID was just resolved, so the run should exist");

        // Warn if the run is incomplete.
        if matches!(
            run.status,
            RecordedRunStatus::Incomplete | RecordedRunStatus::Unknown
        ) {
            warn!(
                "run {} is {}: the exported archive may be incomplete or corrupted",
                run_id.style(styles.label),
                run.status.short_status_str(),
            );
        }

        let writer = PortableRecordingWriter::new(run, snapshot.runs_dir())
            .map_err(|err| ExpectedError::PortableRecordingError { err })?;

        let output_path = self
            .archive_file
            .clone()
            .unwrap_or_else(|| Utf8PathBuf::from(writer.default_filename()));

        let result = writer
            .write_to_path(&output_path)
            .map_err(|err| ExpectedError::PortableRecordingError { err })?;

        info!(
            "exported run {} to {} ({} bytes)",
            run_id.style(styles.label),
            result.path.style(styles.label),
            result.size,
        );

        Ok(0)
    }
}

impl PruneOpts {
    fn exec(
        &self,
        state_dir: &Utf8Path,
        record_config: &RecordConfig,
        styles: &RecordStyles,
        paged_output: &mut PagedOutput,
        redactor: &Redactor,
    ) -> Result<i32> {
        let store =
            RunStore::new(state_dir).map_err(|err| ExpectedError::RecordSetupError { err })?;
        let policy = RecordRetentionPolicy::from(record_config);

        if self.dry_run {
            // Dry run: show what would be deleted via paged output.
            let snapshot = store
                .lock_shared()
                .map_err(|err| ExpectedError::RecordSetupError { err })?
                .into_snapshot();

            let plan = snapshot.compute_prune_plan(&policy);

            write!(
                paged_output,
                "{}",
                plan.display(snapshot.run_id_index(), styles, redactor)
            )
            .map_err(|err| ExpectedError::WriteError { err })?;
            Ok(0)
        } else {
            // Actual prune: output via tracing.
            let mut locked = store
                .lock_exclusive()
                .map_err(|err| ExpectedError::RecordSetupError { err })?;
            let result = locked
                .prune(&policy, PruneKind::Explicit)
                .map_err(|err| ExpectedError::RecordSetupError { err })?;

            info!("{}", result.display(styles));
            Ok(0)
        }
    }
}

impl StoreCommand {
    pub(crate) fn exec(
        self,
        early_args: &EarlyArgs,
        manifest_path: Option<Utf8PathBuf>,
        user_config: &UserConfig,
        output: OutputContext,
    ) -> Result<i32> {
        let mut styles = RecordStyles::default();
        let mut theme_characters = ThemeCharacters::default();
        if output.color.should_colorize(supports_color::Stream::Stdout) {
            styles.colorize();
        }
        if supports_unicode::on(supports_unicode::Stream::Stdout) {
            theme_characters.use_unicode();
        }

        let (pager_setting, paginate) = early_args.resolve_pager(&user_config.ui);
        let mut paged_output =
            PagedOutput::request_pager(&pager_setting, paginate, &user_config.ui.streampager);

        // Create redactor for snapshot testing if __NEXTEST_REDACT=1.
        let redactor = if crate::output::should_redact() {
            Redactor::for_snapshot_testing()
        } else {
            Redactor::noop()
        };

        // Check if this is an archive-based info command first (no workspace needed).
        if let Self::Info(ref opts) = self
            && let RunIdOrRecordingSelector::RecordingPath(path) = opts.resolved_selector()
        {
            return opts.exec_from_archive(
                path,
                &styles,
                &theme_characters,
                &mut paged_output,
                &redactor,
            );
        }

        // All other commands require a workspace.
        let mut cargo_cli = CargoCli::new("locate-project", manifest_path.as_deref(), output);
        cargo_cli.add_args(["--workspace", "--message-format=plain"]);
        let locate_project_output = cargo_cli
            .to_expression()
            .stdout_capture()
            .unchecked()
            .run()
            .map_err(|error| {
                ExpectedError::cargo_locate_project_exec_failed(cargo_cli.all_args(), error)
            })?;
        if !locate_project_output.status.success() {
            return Err(ExpectedError::cargo_locate_project_failed(
                cargo_cli.all_args(),
                locate_project_output.status,
            ));
        }
        let workspace_root = String::from_utf8(locate_project_output.stdout)
            .map_err(|err| ExpectedError::WorkspaceRootInvalidUtf8 { err })?;
        let workspace_root = Utf8Path::new(workspace_root.trim_end());
        let workspace_root =
            workspace_root
                .parent()
                .ok_or_else(|| ExpectedError::WorkspaceRootInvalid {
                    workspace_root: workspace_root.to_owned(),
                })?;

        let state_dir = records_state_dir(workspace_root)
            .map_err(|err| ExpectedError::RecordStateDirNotFound { err })?;

        match self {
            Self::List {} => {
                let store = RunStore::new(&state_dir)
                    .map_err(|err| ExpectedError::RecordSetupError { err })?;

                let snapshot = store
                    .lock_shared()
                    .map_err(|err| ExpectedError::RecordSetupError { err })?
                    .into_snapshot();

                let store_path = if output.verbose {
                    Some(state_dir.as_path())
                } else {
                    None
                };
                let snapshot_with_replayability = SnapshotWithReplayability::new(&snapshot);
                let display = DisplayRunList::new(
                    &snapshot_with_replayability,
                    store_path,
                    &styles,
                    &theme_characters,
                    &redactor,
                );
                write!(paged_output, "{}", display)
                    .map_err(|err| ExpectedError::WriteError { err })?;

                if snapshot.run_count() == 0 {
                    info!("no recorded runs");
                }

                Ok(0)
            }
            Self::Info(opts) => {
                // Archive path was already handled above, so this must be a run ID.
                match opts.resolved_selector() {
                    RunIdOrRecordingSelector::RunId(run_id_selector) => opts.exec_from_store(
                        run_id_selector,
                        &state_dir,
                        &styles,
                        &theme_characters,
                        &mut paged_output,
                        &redactor,
                    ),
                    RunIdOrRecordingSelector::RecordingPath(_) => {
                        unreachable!("recording path was handled above")
                    }
                }
            }
            Self::Prune(opts) => opts.exec(
                &state_dir,
                &user_config.record,
                &styles,
                &mut paged_output,
                &redactor,
            ),
            Self::Export(opts) => opts.exec(&state_dir, &styles),
        }
    }
}
