use rmp::decode::{self, Bytes};
use rmp::encode;
use std::env;

use atuin_common::filter::OrFilter;
use atuin_common::time::OffsetDateTimeExt;
use atuin_common::utils::{normalize_optional_string, uuid_v7};
use atuin_domain::record::DecryptedData;
use std::sync::LazyLock;

use eyre::{Result, bail};

use crate::secrets::SECRET_PATTERNS_RE;
use crate::settings::Settings;
use crate::utils::get_host_user;
use crate::utils::rmp::{DecodeError, EncodeError, read_optional, read_string, write_optional};
use time::OffsetDateTime;

pub(crate) mod builder;
pub mod store;

/// Known AI agent author values. Used when matching against [`AuthorPattern::AllAgent`] and
/// [`AuthorPattern::AllUser`].
pub const KNOWN_AGENTS: &[&str] = &["claude-code", "codex", "copilot", "opencode", "pi"];

/// The spelling of [`AuthorPattern::AllUser`] on the command line and in the MCP tool schema.
pub const AUTHOR_FILTER_ALL_USER: &str = "$all-user";

/// The spelling of [`AuthorPattern::AllAgent`] on the command line and in the MCP tool schema.
pub const AUTHOR_FILTER_ALL_AGENT: &str = "$all-agent";

pub fn is_known_agent(author: &str) -> bool {
    KNOWN_AGENTS.contains(&author)
}

/// An element of an author filter.
///
/// In addition to a plain string, this type can also be the special pattern `AllUser` or
/// `AllAgent`, which matches known agents or all authors than are not a known agent.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum AuthorPattern {
    /// Matches every author that is not a known agent.
    AllUser,
    /// Matches every author that is a known agent (see [`KNOWN_AGENTS`]).
    AllAgent,
    /// Matches exactly one author name.
    Name(String),
}

impl From<String> for AuthorPattern {
    fn from(value: String) -> Self {
        match value.as_str() {
            AUTHOR_FILTER_ALL_USER => Self::AllUser,
            AUTHOR_FILTER_ALL_AGENT => Self::AllAgent,
            _ => Self::Name(value),
        }
    }
}

impl From<&str> for AuthorPattern {
    fn from(value: &str) -> Self {
        match value {
            AUTHOR_FILTER_ALL_USER => Self::AllUser,
            AUTHOR_FILTER_ALL_AGENT => Self::AllAgent,
            _ => Self::Name(value.to_owned()),
        }
    }
}

/// An author filter that only allows non-agent commands (i.e., [`AuthorPattern::AllUser`]).
///
/// This function uses a [`LazyLock`] to avoid building the filter every time.
pub fn all_user_author_filter() -> OrFilter<&'static [AuthorPattern]> {
    static FILTER: LazyLock<OrFilter<Vec<AuthorPattern>>> = LazyLock::new(|| {
        OrFilter::from_list(vec![AuthorPattern::AllUser]).expect("the vector is not empty")
    });
    FILTER.as_slice_filter()
}

pub const HISTORY_TAG: &str = "history";
const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR";
const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT";

#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, derive_more::Display)]
#[display("{}", self.name())]
#[repr(u16)]
pub enum Version {
    Zero = 0,
    One = 1,
    Two = 2,
}

impl Version {
    pub const VARIANTS: [Self; 3] = [Self::Zero, Self::One, Self::Two];
    pub const LATEST: Self = Version::Two;

    pub fn from_name(s: &str) -> Option<Self> {
        match s {
            "v0" => Some(Self::Zero),
            "v1" => Some(Self::One),
            "v2" => Some(Self::Two),
            _ => None,
        }
    }

    pub const fn name(&self) -> &'static str {
        match self {
            Self::Zero => "v0",
            Self::One => "v1",
            Self::Two => "v2",
        }
    }

    pub const fn as_int(&self) -> u16 {
        *self as u16
    }

    pub fn min_fields(&self) -> u32 {
        match self {
            Self::Zero => 9,
            Self::One => 10,
            Self::Two => 12,
        }
    }

    pub fn max_fields(&self) -> Option<u32> {
        match self {
            Self::Zero => Some(9),
            Self::One => Some(11),
            Self::Two => None,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Hash, derive_more::Display, derive_more::From)]
#[display("{_0}")]
pub struct HistoryId(pub String);

/// Client-side history entry.
///
/// Client stores data unencrypted, and only encrypts it before sending to the server.
///
/// To create a new history entry, use one of the builders:
/// - [`History::import()`] to import an entry from the shell history file
/// - [`History::capture()`] to capture an entry via hook
/// - [`History::from_db()`] to create an instance from the database entry
//
// ## Implementation Notes
//
// New fields must be added to `History::{serialize,deserialize}` in a backwards
// compatible way (sensible defaults and careful `nfields` handling).
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct History {
    /// A client-generated ID, used to identify the entry when syncing.
    ///
    /// Stored as `client_id` in the database.
    pub id: HistoryId,
    /// When the command was run.
    pub timestamp: OffsetDateTime,
    /// How long the command took to run.
    pub duration: i64,
    /// The exit code of the command.
    pub exit: i64,
    /// The command that was run.
    pub command: String,
    /// The current working directory when the command was run.
    pub cwd: String,
    /// The session ID, associated with a terminal session.
    pub session: String,
    /// The hostname of the machine the command was run on.
    pub hostname: String,
    /// Who wrote this command (human user or automation/agent identity).
    pub author: String,
    /// Optional rationale for why the command was executed.
    pub intent: Option<String>,
    /// Timestamp, which is set when the entry is deleted, allowing a soft delete.
    pub deleted_at: Option<OffsetDateTime>,
    /// The shell used to run the command.
    pub shell: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct HistoryStats {
    /// The command that was ran after this one in the session
    pub next: Option<History>,
    ///
    /// The command that was ran before this one in the session
    pub previous: Option<History>,

    /// How many times has this command been ran?
    pub total: u64,

    pub average_duration: u64,

    pub exits: Vec<(i64, i64)>,

    pub day_of_week: Vec<(String, i64)>,

    pub duration_over_time: Vec<(String, i64)>,
}

impl History {
    pub(crate) fn author_from_hostname(hostname: &str) -> String {
        hostname
            .split_once(':')
            .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned())
    }

    #[allow(clippy::too_many_arguments)]
    fn new(
        timestamp: OffsetDateTime,
        command: String,
        cwd: String,
        exit: i64,
        duration: i64,
        session: Option<String>,
        hostname: Option<String>,
        author: Option<String>,
        intent: Option<String>,
        deleted_at: Option<OffsetDateTime>,
        shell: Option<String>,
    ) -> Self {
        let session = session
            .or_else(|| env::var("ATUIN_SESSION").ok())
            .unwrap_or_else(|| uuid_v7().as_simple().to_string());
        let hostname = hostname.unwrap_or_else(get_host_user);
        let author = normalize_optional_string(author)
            .or_else(|| normalize_optional_string(env::var(HISTORY_AUTHOR_ENV).ok()))
            .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str()));
        let intent = normalize_optional_string(intent)
            .or_else(|| normalize_optional_string(env::var(HISTORY_INTENT_ENV).ok()));
        let shell = normalize_optional_string(shell);

        Self {
            id: uuid_v7().as_simple().to_string().into(),
            timestamp,
            command,
            cwd,
            exit,
            duration,
            session,
            hostname,
            author,
            intent,
            deleted_at,
            shell,
        }
    }

    /// Serializes a history entry in the V2 format.
    ///
    /// Differences from V1:
    ///
    /// * `intent` is always written; if `None`, nil is written to the output.
    /// * Added new field `shell`.
    ///
    /// V2 is designed to allow new fields to be added without incrementing the version. V1 cannot
    /// accommodate this because its deserialization routine errors if more than 11 fields are
    /// provided.
    pub fn serialize(&self) -> Result<DecryptedData, EncodeError> {
        let mut output = vec![];

        // write the version
        encode::write_u16(&mut output, Version::LATEST.as_int())?;
        encode::write_array_len(&mut output, Version::LATEST.min_fields())?;

        encode::write_str(&mut output, &self.id.0)?;
        encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?;
        encode::write_sint(&mut output, self.duration)?;
        encode::write_sint(&mut output, self.exit)?;
        encode::write_str(&mut output, &self.command)?;
        encode::write_str(&mut output, &self.cwd)?;
        encode::write_str(&mut output, &self.session)?;
        encode::write_str(&mut output, &self.hostname)?;

        write_optional(
            &mut output,
            self.deleted_at.map(|d| d.unix_timestamp_nanos() as u64),
            encode::write_u64,
        )?;
        encode::write_str(&mut output, self.author.as_str())?;
        write_optional(&mut output, self.intent.as_deref(), encode::write_str)?;
        write_optional(&mut output, self.shell.as_deref(), encode::write_str)?;
        Ok(DecryptedData(output))
    }

    pub fn deserialize(bytes: &[u8], version: &str) -> Result<History> {
        let Some(version) = Version::from_name(version) else {
            bail!("unknown version {version:?}");
        };

        let mut bytes = Bytes::new(bytes);

        let real_version = decode::read_u16(&mut bytes).map_err(DecodeError::from)?;
        if real_version != version.as_int() {
            bail!("expected to decode {version} record, found v{real_version}");
        }

        let nfields = decode::read_array_len(&mut bytes).map_err(DecodeError::from)?;
        let min_fields = version.min_fields();
        if nfields < min_fields || version.max_fields().is_some_and(|max| nfields > max) {
            bail!("unexpected number of fields ({nfields}) for history version {version}");
        }

        let id = read_string(&mut bytes)?;
        let timestamp = decode::read_u64(&mut bytes).map_err(DecodeError::from)?;
        let duration = decode::read_int(&mut bytes).map_err(DecodeError::from)?;
        let exit = decode::read_int(&mut bytes).map_err(DecodeError::from)?;

        let command = read_string(&mut bytes)?;
        let cwd = read_string(&mut bytes)?;
        let session = read_string(&mut bytes)?;
        let hostname = read_string(&mut bytes)?;
        let deleted_at = read_optional(&mut bytes, decode::read_u64)?;

        let author = if version >= Version::One {
            read_optional(&mut bytes, read_string)?
        } else {
            None
        };

        let intent = if match version {
            Version::Zero => false,
            Version::One => nfields > min_fields,
            Version::Two => true,
        } {
            read_optional(&mut bytes, read_string)?
        } else {
            None
        };

        let shell = if version >= Version::Two {
            read_optional(&mut bytes, read_string)?
        } else {
            None
        };

        if version < Version::Two && !bytes.remaining_slice().is_empty() {
            bail!("trailing bytes in encoded history. malformed");
        }

        Ok(History {
            id: id.into(),
            timestamp: OffsetDateTime::from_unix_nanos_u64(timestamp),
            duration,
            exit,
            command,
            cwd,
            session,
            author: author.unwrap_or_else(|| Self::author_from_hostname(&hostname)),
            hostname,
            intent,
            deleted_at: deleted_at.map(OffsetDateTime::from_unix_nanos_u64),
            shell,
        })
    }

    /// Builder for a history entry that is imported from shell history.
    ///
    /// The only two required fields are `timestamp` and `command`.
    ///
    /// ## Examples
    /// ```
    /// use atuin_client::history::History;
    ///
    /// let history: History = History::import()
    ///     .timestamp(time::OffsetDateTime::now_utc())
    ///     .command("ls -la")
    ///     .build()
    ///     .into();
    /// ```
    ///
    /// If shell history contains more information, it can be added to the builder:
    /// ```
    /// use atuin_client::history::History;
    ///
    /// let history: History = History::import()
    ///     .timestamp(time::OffsetDateTime::now_utc())
    ///     .command("ls -la")
    ///     .cwd("/home/user")
    ///     .exit(0)
    ///     .duration(100)
    ///     .build()
    ///     .into();
    /// ```
    ///
    /// Unknown command or command without timestamp cannot be imported, which
    /// is forced at compile time:
    ///
    /// ```compile_fail
    /// use atuin_client::history::History;
    ///
    /// // this will not compile because timestamp is missing
    /// let history: History = History::import()
    ///     .command("ls -la")
    ///     .build()
    ///     .into();
    /// ```
    pub fn import() -> builder::HistoryImportedBuilder {
        builder::HistoryImported::builder()
    }

    /// Builder for a history entry that is captured via hook.
    ///
    /// This builder is used only at the `start` step of the hook,
    /// so it doesn't have any fields which are known only after
    /// the command is finished, such as `exit` or `duration`.
    ///
    /// ## Examples
    /// ```rust
    /// use atuin_client::history::History;
    ///
    /// let history: History = History::capture()
    ///     .timestamp(time::OffsetDateTime::now_utc())
    ///     .command("ls -la")
    ///     .cwd("/home/user")
    ///     .build()
    ///     .into();
    /// ```
    ///
    /// Command without any required info cannot be captured, which is forced at compile time:
    ///
    /// ```compile_fail
    /// use atuin_client::history::History;
    ///
    /// // this will not compile because `cwd` is missing
    /// let history: History = History::capture()
    ///     .timestamp(time::OffsetDateTime::now_utc())
    ///     .command("ls -la")
    ///     .build()
    ///     .into();
    /// ```
    pub fn capture() -> builder::HistoryCapturedBuilder {
        builder::HistoryCaptured::builder()
    }

    /// Builder for a history entry that is captured via hook, and sent to the daemon.
    ///
    /// This builder is used only at the `start` step of the hook,
    /// so it doesn't have any fields which are known only after
    /// the command is finished, such as `exit` or `duration`.
    ///
    /// It does, however, include information that can usually be inferred.
    ///
    /// This is because the daemon we are sending a request to lacks the context of the command
    ///
    /// ## Examples
    /// ```rust
    /// use atuin_client::history::History;
    ///
    /// let history: History = History::daemon()
    ///     .timestamp(time::OffsetDateTime::now_utc())
    ///     .command("ls -la")
    ///     .cwd("/home/user")
    ///     .session("018deb6e8287781f9973ef40e0fde76b")
    ///     .hostname("computer:ellie")
    ///     .build()
    ///     .into();
    /// ```
    ///
    /// Command without any required info cannot be captured, which is forced at compile time:
    ///
    /// ```compile_fail
    /// use atuin_client::history::History;
    ///
    /// // this will not compile because `hostname` is missing
    /// let history: History = History::daemon()
    ///     .timestamp(time::OffsetDateTime::now_utc())
    ///     .command("ls -la")
    ///     .cwd("/home/user")
    ///     .session("018deb6e8287781f9973ef40e0fde76b")
    ///     .build()
    ///     .into();
    /// ```
    pub fn daemon() -> builder::HistoryDaemonCaptureBuilder {
        builder::HistoryDaemonCapture::builder()
    }

    /// Builder for a history entry that is imported from the database.
    ///
    /// All fields are required, as they are all present in the database.
    ///
    /// ```compile_fail
    /// use atuin_client::history::History;
    ///
    /// // this will not compile because `id` field is missing
    /// let history: History = History::from_db()
    ///     .timestamp(time::OffsetDateTime::now_utc())
    ///     .command("ls -la".to_string())
    ///     .cwd("/home/user".to_string())
    ///     .exit(0)
    ///     .duration(100)
    ///     .session("somesession".to_string())
    ///     .hostname("localhost".to_string())
    ///     .author("user".to_string())
    ///     .intent(None)
    ///     .deleted_at(None)
    ///     .shell(None)
    ///     .build()
    ///     .into();
    /// ```
    pub fn from_db() -> builder::HistoryFromDbBuilder {
        builder::HistoryFromDb::builder()
    }

    pub fn success(&self) -> bool {
        self.exit == 0 || self.duration == -1
    }

    pub fn should_save(&self, settings: &Settings) -> bool {
        !(self.command.starts_with(' ')
            || self.command.is_empty()
            || settings.history_filter.is_match(&self.command)
            || settings.cwd_filter.is_match(&self.cwd)
            || (settings.secrets_filter && SECRET_PATTERNS_RE.is_match(&self.command)))
    }
}

#[cfg(test)]
mod tests {
    use regex::RegexSet;
    use rstest::*;
    use time::macros::datetime;

    use crate::{history::Version, settings::Settings};

    use super::{AuthorPattern, History, all_user_author_filter, is_known_agent};
    use atuin_common::filter::OrFilter;

    /// Whether an author filter permits `author`, mirroring the SQL that
    /// [`apply_author_filter`](crate::database::OptFilters::authors) builds.
    ///
    /// There are only three ways a filter can admit an author, so each is one binary search rather
    /// than a scan that reinterprets every element in turn. No guard against an author *named*
    /// `$all-agent` is needed: such an author is an [`AuthorPattern::Name`], a different value from
    /// [`AuthorPattern::AllAgent`].
    fn author_matches_filters(author: &str, filters: OrFilter<&[AuthorPattern]>) -> bool {
        // `contains` is true for an "all" filter, so that case needs no separate check.
        filters.contains(&AuthorPattern::Name(author.to_owned()))
            || (filters.contains(&AuthorPattern::AllUser) && !is_known_agent(author))
            || (filters.contains(&AuthorPattern::AllAgent) && is_known_agent(author))
    }

    #[fixture]
    fn privacy_settings() -> Settings {
        Settings {
            cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(),
            history_filter: RegexSet::new(["^psql"]).unwrap(),
            ..Settings::utc()
        }
    }

    // Test that we don't save history where necessary
    #[rstest]
    #[case::normal("echo foo", "/", true)]
    #[case::leading_space(" echo bar", "/", false)]
    #[case::empty("", "/", false)]
    #[case::stripe_key("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop", "/", false)]
    #[case::secret_dir("echo ohno", "/supasecret", false)]
    #[case::psql("psql", "/supasecret", false)]
    fn should_save_respects_privacy(
        #[from(privacy_settings)] settings: Settings,
        #[case] command: &str,
        #[case] cwd: &str,
        #[case] expected: bool,
    ) {
        let history: History = History::capture()
            .timestamp(time::OffsetDateTime::now_utc())
            .command(command)
            .cwd(cwd)
            .build()
            .into();
        assert_eq!(history.should_save(&settings), expected);
    }

    #[rstest]
    fn known_agents_include_pi() {
        let agents = OrFilter::from_list(vec![AuthorPattern::AllAgent]).unwrap();
        let users = OrFilter::from_list(vec![AuthorPattern::AllUser]).unwrap();

        assert!(is_known_agent("pi"));
        assert!(author_matches_filters("pi", agents.as_slice_filter()));
        assert!(!author_matches_filters("pi", users.as_slice_filter()));
        assert!(!author_matches_filters("ellie", agents.as_slice_filter()));
        assert!(author_matches_filters("ellie", users.as_slice_filter()));
    }

    #[test]
    fn an_all_author_filter_matches_everyone() {
        let all = OrFilter::all();
        assert!(author_matches_filters("pi", all));
        assert!(author_matches_filters("ellie", all));
    }

    #[test]
    fn the_all_user_filter_excludes_agents() {
        assert!(!author_matches_filters("pi", all_user_author_filter()));
        assert!(author_matches_filters("ellie", all_user_author_filter()));
    }

    #[rstest]
    fn disable_secrets() {
        let settings = Settings {
            secrets_filter: false,
            ..Settings::utc()
        };

        let stripe_key: History = History::capture()
            .timestamp(time::OffsetDateTime::now_utc())
            .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
            .cwd("/")
            .build()
            .into();

        assert!(stripe_key.should_save(&settings));
    }

    #[rstest]
    #[case::basic(History {
        id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
        timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
        duration: 49206000,
        exit: 0,
        command: "git status".to_owned(),
        cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
        session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
        hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
        author: "conrad.ludgate".to_owned(),
        intent: None,
        deleted_at: None,
        shell: None,
    })]
    #[case::deleted(History {
        id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
        timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
        duration: 49206000,
        exit: 0,
        command: "git status".to_owned(),
        cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
        session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
        hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
        author: "conrad.ludgate".to_owned(),
        intent: None,
        deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)),
        shell: Some("bash".into()),
    })]
    #[case::with_author_and_intent(History {
        id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
        timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
        duration: 49206000,
        exit: 0,
        command: "git status".to_owned(),
        cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
        session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
        hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
        author: "claude".to_owned(),
        intent: Some("check repository status".to_owned()),
        deleted_at: None,
        shell: Some("fish".into()),
    })]
    fn serialize_deserialize_roundtrip(#[case] history: History) {
        let serialized = history.serialize().expect("failed to serialize history");
        assert_eq!(
            &serialized.0[0..3],
            [205, 0, 2],
            "should encode as history v2"
        );

        let deserialized = History::deserialize(&serialized.0, Version::LATEST.name())
            .expect("failed to deserialize history");
        assert_eq!(history, deserialized);
    }

    const BYTES_V0: &[u8] = &[
        205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55, 53,
        51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99, 98,
        117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116, 97,
        116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100, 46,
        108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115, 47, 99,
        111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97, 51, 48, 54,
        102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49, 102, 57, 52, 53,
        55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58, 99, 111, 110, 114, 97,
        100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
    ];

    const BYTES_V1: &[u8] = &[
        205, 0, 1, 155, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55, 53,
        51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99, 98,
        117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116, 97,
        116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100, 46,
        108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115, 47, 99,
        111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97, 51, 48, 54,
        102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49, 102, 57, 52, 53,
        55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58, 99, 111, 110, 114, 97,
        100, 46, 108, 117, 100, 103, 97, 116, 101, 207, 24, 194, 83, 235, 108, 206, 10, 0, 174, 99,
        111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 173, 115, 97, 109, 112, 108,
        101, 32, 105, 110, 116, 101, 110, 116,
    ];

    fn expected_v2() -> History {
        History {
            id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
            duration: 49206000,
            exit: 0,
            command: "git status".to_owned(),
            cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
            session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
            hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
            author: "conrad.ludgate".to_owned(),
            intent: Some("sample intent".to_owned()),
            deleted_at: Some(time::OffsetDateTime::from_unix_timestamp(1784080673).unwrap()),
            shell: Some("zsh".into()),
        }
    }

    fn expected_v1() -> History {
        History {
            shell: None,
            ..expected_v2()
        }
    }

    fn expected_v0() -> History {
        History {
            intent: None,
            deleted_at: None,
            ..expected_v1()
        }
    }

    #[rstest]
    #[case::from_v0(Version::Zero, BYTES_V0, expected_v0())]
    #[case::from_v1(Version::One, BYTES_V1, expected_v1())]
    #[case::from_v2(Version::Two, &expected_v2().serialize().unwrap(), expected_v2())]
    fn deserialize_across_versions(
        #[case] source: Version,
        #[case] bytes: &[u8],
        #[case] expected: History,
        #[values(Version::Zero, Version::One, Version::Two)] decode_as: Version,
    ) {
        let got = History::deserialize(bytes, decode_as.name());
        if decode_as == source {
            assert_eq!(got.unwrap(), expected, "{decode_as}");
        } else {
            assert!(
                got.is_err(),
                "unexpected success deserializing as {decode_as}"
            );
        }
    }
}
