use crate::model::{Datum, DatumValidationError, ReversalPolicy};
use crate::sync::{DeckVisibility, SyncUuid};
use std::collections::{BTreeMap, HashSet};
use std::fmt;

pub const PROMPT_RESPONSE_SEPARATOR: &str = "---";
pub const DATUM_SEPARATOR: &str = "===";
pub const METADATA_SEPARATOR: &str = ":::";
pub const CURRENT_DECK_VERSION: u64 = 2;
pub const AUDIO_GENERATION_METADATA_KEY: &str = "audio_generation";
pub const AUDIO_GENERATION_DISABLED_VALUE: &str = "disabled";
pub const DECK_UUID_METADATA_KEY: &str = "deck_uuid";
pub const NEXT_CARD_ID_METADATA_KEY: &str = "next_card_id";
pub const VISIBILITY_METADATA_KEY: &str = "visibility";

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeckMetadata {
    has_header: bool,
    fields: BTreeMap<String, String>,
}

impl DeckMetadata {
    pub fn empty() -> Self {
        Self::default()
    }

    pub fn v2() -> Self {
        Self {
            has_header: true,
            fields: BTreeMap::new(),
        }
    }

    pub fn has_header(&self) -> bool {
        self.has_header
    }

    pub fn ensure_v2_header(&mut self) -> bool {
        if self.has_header {
            false
        } else {
            self.has_header = true;
            true
        }
    }

    pub fn get(&self, key: &str) -> Option<&str> {
        self.fields.get(key).map(String::as_str)
    }

    pub fn deck_uuid(&self) -> Option<SyncUuid> {
        self.get(DECK_UUID_METADATA_KEY)
            .and_then(|value| value.parse().ok())
    }

    pub fn set_deck_uuid(&mut self, uuid: &SyncUuid) {
        self.set(DECK_UUID_METADATA_KEY, uuid.as_str());
    }

    pub fn deck_visibility(&self) -> DeckVisibility {
        self.get(VISIBILITY_METADATA_KEY)
            .and_then(DeckVisibility::from_code)
            .unwrap_or_default()
    }

    pub fn set_deck_visibility(&mut self, visibility: DeckVisibility) {
        self.set(VISIBILITY_METADATA_KEY, visibility.code());
    }

    pub fn audio_generation_is_disabled(&self) -> bool {
        self.get(AUDIO_GENERATION_METADATA_KEY)
            .map(str::trim)
            .is_some_and(|value| value.eq_ignore_ascii_case(AUDIO_GENERATION_DISABLED_VALUE))
    }

    pub fn next_card_id(&self) -> Option<u64> {
        self.get(NEXT_CARD_ID_METADATA_KEY)
            .and_then(|value| value.trim().parse::<u64>().ok())
            .filter(|card_id| *card_id > 0)
    }

    pub fn ensure_next_card_id_at_least(&mut self, next_card_id: u64) -> bool {
        if self
            .next_card_id()
            .is_some_and(|current| current >= next_card_id)
        {
            false
        } else {
            self.set(NEXT_CARD_ID_METADATA_KEY, next_card_id.to_string());
            true
        }
    }

    pub fn set_audio_generation_disabled(&mut self, disabled: bool) {
        if disabled {
            self.set(
                AUDIO_GENERATION_METADATA_KEY,
                AUDIO_GENERATION_DISABLED_VALUE,
            );
        } else {
            self.remove(AUDIO_GENERATION_METADATA_KEY);
        }
    }

    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
        self.has_header = true;
        self.fields.insert(key.into(), value.into());
    }

    pub fn remove(&mut self, key: &str) {
        self.has_header = true;
        self.fields.remove(key);
    }

    fn fields(&self) -> impl Iterator<Item = (&str, &str)> {
        self.fields
            .iter()
            .map(|(key, value)| (key.as_str(), value.as_str()))
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeckFile {
    metadata: DeckMetadata,
    data: Vec<Datum>,
}

impl DeckFile {
    pub fn new(data: Vec<Datum>) -> Self {
        Self {
            metadata: DeckMetadata::empty(),
            data,
        }
    }

    pub fn with_metadata(metadata: DeckMetadata, data: Vec<Datum>) -> Self {
        Self { metadata, data }
    }

    pub fn metadata(&self) -> &DeckMetadata {
        &self.metadata
    }

    pub fn data(&self) -> &[Datum] {
        &self.data
    }

    pub fn into_data(self) -> Vec<Datum> {
        self.data
    }
}

/// Parse Cosimo's human-editable deck format.
///
/// Each datum is written as prompt text, a line containing only `---`, response
/// text, and optionally a terminator line before the next datum. Legacy decks
/// use `===`; current decks use `=number=` where the number is the stable card
/// ID.
/// Review history and user ratings are intentionally not part of this format.
pub fn parse_deck(input: &str) -> Result<Vec<Datum>, DeckParseError> {
    parse_deck_file(input).map(DeckFile::into_data)
}

pub fn parse_deck_file(input: &str) -> Result<DeckFile, DeckParseError> {
    let lines: Vec<_> = input
        .lines()
        .enumerate()
        .map(|(n, line)| (n + 1, line))
        .collect();
    let mut index = 0;
    let metadata = parse_metadata_header(&lines, &mut index)?;
    let data = parse_deck_entries(&lines, index)?;

    Ok(DeckFile::with_metadata(metadata, data))
}

fn parse_deck_entries(
    lines: &[(usize, &str)],
    mut index: usize,
) -> Result<Vec<Datum>, DeckParseError> {
    let mut data = Vec::new();
    let mut seen_card_ids = HashSet::new();

    while let Some((entry_start_line, _)) = peek_next_content_line(lines, &mut index) {
        let mut prompt_lines = Vec::new();

        while let Some((_, line)) = lines.get(index) {
            if *line == PROMPT_RESPONSE_SEPARATOR || is_datum_separator_candidate(line) {
                break;
            }

            prompt_lines.push(*line);
            index += 1;
        }

        match lines.get(index) {
            Some((_, line)) if *line == PROMPT_RESPONSE_SEPARATOR => index += 1,
            _ => {
                return Err(DeckParseError::MissingPromptResponseSeparator { entry_start_line });
            }
        }

        let mut response_lines = Vec::new();

        while let Some((line_number, line)) = lines.get(index) {
            if is_datum_separator_candidate(line) {
                break;
            }

            if *line == PROMPT_RESPONSE_SEPARATOR {
                return Err(DeckParseError::UnexpectedPromptResponseSeparator {
                    line: *line_number,
                });
            }

            response_lines.push(*line);
            index += 1;
        }

        let prompt = join_trimmed_outer_blank_lines(&prompt_lines);
        let response = join_trimmed_outer_blank_lines(&response_lines);
        let mut datum =
            Datum::new(prompt, response).map_err(|source| DeckParseError::InvalidDatum {
                entry_start_line,
                source,
            })?;

        if let Some((line_number, line)) = lines.get(index)
            && let Some(separator) = parse_datum_separator(line, *line_number)?
        {
            if let Some(card_id) = separator.card_id {
                if !seen_card_ids.insert(card_id) {
                    return Err(DeckParseError::DuplicateCardId {
                        line: *line_number,
                        card_id,
                    });
                }

                datum = datum.with_card_id(card_id);
            }
            datum = datum.with_reversal_policy(separator.reversal_policy);
        }

        data.push(datum);

        if matches!(lines.get(index), Some((_, line)) if is_datum_separator_candidate(line)) {
            index += 1;
        }
    }

    Ok(data)
}

pub fn format_deck(data: &[Datum]) -> Result<String, DeckFormatError> {
    format_deck_entries(data)
}

pub fn format_deck_file(deck: &DeckFile) -> Result<String, DeckFormatError> {
    format_deck_with_metadata(deck.metadata(), deck.data())
}

pub fn format_deck_with_metadata(
    metadata: &DeckMetadata,
    data: &[Datum],
) -> Result<String, DeckFormatError> {
    let mut output = String::new();
    if metadata.has_header() || data.iter().any(Datum::is_reversal_prohibited) {
        output.push_str(&format!("deck_version={CURRENT_DECK_VERSION}\n"));
        for (key, value) in metadata.fields() {
            output.push_str(key);
            output.push('=');
            output.push_str(value);
            output.push('\n');
        }
        output.push_str(METADATA_SEPARATOR);
        output.push('\n');
        if !data.is_empty() {
            output.push('\n');
        }
    }
    output.push_str(&format_deck_entries(data)?);
    Ok(output)
}

fn format_deck_entries(data: &[Datum]) -> Result<String, DeckFormatError> {
    let mut output = String::new();

    for (index, datum) in data.iter().enumerate() {
        reject_reserved_separator_lines(index, DeckField::Prompt, datum.prompt())?;
        reject_reserved_separator_lines(index, DeckField::Response, datum.response())?;

        if index > 0 {
            output.push('\n');
        }

        output.push_str(datum.prompt());
        output.push('\n');
        output.push_str(PROMPT_RESPONSE_SEPARATOR);
        output.push('\n');
        output.push_str(datum.response());
        output.push('\n');
        if let Some(card_id) = datum.card_id() {
            match datum.reversal_policy() {
                ReversalPolicy::Unspecified => output.push_str(&format!("={card_id}=")),
                ReversalPolicy::Prohibited => output.push_str(&format!("={card_id}i=")),
            }
        } else {
            match datum.reversal_policy() {
                ReversalPolicy::Unspecified => output.push_str(DATUM_SEPARATOR),
                ReversalPolicy::Prohibited => output.push_str("=i="),
            }
        }
        output.push('\n');
    }

    Ok(output)
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeckParseError {
    UnsupportedDeckVersion {
        line: usize,
        version: String,
    },
    MissingMetadataSeparator {
        line: usize,
    },
    InvalidMetadataLine {
        line: usize,
    },
    DuplicateMetadataKey {
        line: usize,
        key: String,
    },
    InvalidDatumSeparator {
        line: usize,
        separator: String,
    },
    MissingPromptResponseSeparator {
        entry_start_line: usize,
    },
    UnexpectedPromptResponseSeparator {
        line: usize,
    },
    InvalidDatum {
        entry_start_line: usize,
        source: DatumValidationError,
    },
    DuplicateCardId {
        line: usize,
        card_id: u64,
    },
}

impl fmt::Display for DeckParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::UnsupportedDeckVersion { line, version } => write!(
                f,
                "unsupported deck version `{version}` on line {line}; expected `{CURRENT_DECK_VERSION}`"
            ),
            Self::MissingMetadataSeparator { line } => {
                write!(
                    f,
                    "expected `{METADATA_SEPARATOR}` after metadata starting on line {line}"
                )
            }
            Self::InvalidMetadataLine { line } => {
                write!(f, "invalid metadata line {line}; expected `key=value`")
            }
            Self::DuplicateMetadataKey { line, key } => {
                write!(f, "duplicate metadata key `{key}` on line {line}")
            }
            Self::InvalidDatumSeparator { line, separator } => write!(
                f,
                "invalid datum separator `{separator}` on line {line}; expected `=number=`, `=numberi=`, or `=i=`"
            ),
            Self::MissingPromptResponseSeparator { entry_start_line } => write!(
                f,
                "expected `{PROMPT_RESPONSE_SEPARATOR}` after prompt starting on line {entry_start_line}"
            ),
            Self::UnexpectedPromptResponseSeparator { line } => write!(
                f,
                "unexpected `{PROMPT_RESPONSE_SEPARATOR}` on line {line}; expected response text or `{DATUM_SEPARATOR}`"
            ),
            Self::InvalidDatum {
                entry_start_line,
                source,
            } => write!(
                f,
                "invalid datum starting on line {entry_start_line}: {source}"
            ),
            Self::DuplicateCardId { line, card_id } => {
                write!(f, "duplicate card id {card_id} on line {line}")
            }
        }
    }
}

impl std::error::Error for DeckParseError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::InvalidDatum { source, .. } => Some(source),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeckFormatError {
    datum_index: usize,
    field: DeckField,
    separator: &'static str,
}

impl DeckFormatError {
    pub fn new(datum_index: usize, field: DeckField, separator: &'static str) -> Self {
        Self {
            datum_index,
            field,
            separator,
        }
    }

    pub fn datum_index(&self) -> usize {
        self.datum_index
    }

    pub fn field(&self) -> DeckField {
        self.field
    }

    pub fn separator(&self) -> &'static str {
        self.separator
    }
}

impl fmt::Display for DeckFormatError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "datum {} {} contains reserved separator line `{}`",
            self.datum_index,
            self.field.as_str(),
            self.separator
        )
    }
}

impl std::error::Error for DeckFormatError {}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeckField {
    Prompt,
    Response,
}

impl DeckField {
    fn as_str(self) -> &'static str {
        match self {
            Self::Prompt => "prompt",
            Self::Response => "response",
        }
    }
}

fn peek_next_content_line<'a>(
    lines: &'a [(usize, &'a str)],
    index: &mut usize,
) -> Option<(usize, &'a str)> {
    while let Some((line_number, line)) = lines.get(*index) {
        if !line.trim().is_empty() {
            return Some((*line_number, *line));
        }

        *index += 1;
    }

    None
}

fn parse_metadata_header(
    lines: &[(usize, &str)],
    index: &mut usize,
) -> Result<DeckMetadata, DeckParseError> {
    let mut probe = *index;
    let Some((first_line_number, first_line)) = peek_next_content_line(lines, &mut probe) else {
        *index = probe;
        return Ok(DeckMetadata::empty());
    };
    let Some((key, value)) = parse_metadata_assignment(first_line) else {
        return Ok(DeckMetadata::empty());
    };
    if key != "deck_version" {
        return Ok(DeckMetadata::empty());
    }
    if value != CURRENT_DECK_VERSION.to_string() {
        return Err(DeckParseError::UnsupportedDeckVersion {
            line: first_line_number,
            version: value,
        });
    }

    let mut metadata = DeckMetadata::v2();
    *index = probe + 1;
    let metadata_start_line = first_line_number;
    while let Some((line_number, line)) = lines.get(*index) {
        let trimmed = line.trim();
        if trimmed == METADATA_SEPARATOR {
            *index += 1;
            return Ok(metadata);
        }
        if trimmed.is_empty() {
            *index += 1;
            continue;
        }
        let Some((key, value)) = parse_metadata_assignment(line) else {
            return Err(DeckParseError::InvalidMetadataLine { line: *line_number });
        };
        if key == "deck_version" || metadata.fields.contains_key(&key) {
            return Err(DeckParseError::DuplicateMetadataKey {
                line: *line_number,
                key,
            });
        }
        metadata.set(key, value);
        *index += 1;
    }

    Err(DeckParseError::MissingMetadataSeparator {
        line: metadata_start_line,
    })
}

fn parse_metadata_assignment(line: &str) -> Option<(String, String)> {
    let (key, value) = line.split_once('=')?;
    let key = key.trim();
    if key.is_empty()
        || !key
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
    {
        return None;
    }
    Some((key.to_string(), value.trim().to_string()))
}

fn join_trimmed_outer_blank_lines(lines: &[&str]) -> String {
    let mut start = 0;
    let mut end = lines.len();

    while start < end && lines[start].trim().is_empty() {
        start += 1;
    }

    while end > start && lines[end - 1].trim().is_empty() {
        end -= 1;
    }

    lines[start..end].join("\n")
}

fn reject_reserved_separator_lines(
    datum_index: usize,
    field: DeckField,
    value: &str,
) -> Result<(), DeckFormatError> {
    for line in value.lines() {
        if line == PROMPT_RESPONSE_SEPARATOR {
            return Err(DeckFormatError::new(
                datum_index,
                field,
                PROMPT_RESPONSE_SEPARATOR,
            ));
        }

        if is_datum_separator_candidate(line) {
            return Err(DeckFormatError::new(
                datum_index,
                field,
                line_separator_name(line),
            ));
        }
    }

    Ok(())
}

pub fn numbered_datum_separator_card_id(line: &str) -> Option<u64> {
    parse_datum_separator(line, 0)
        .ok()
        .flatten()
        .and_then(|separator| separator.card_id)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DatumSeparator {
    card_id: Option<u64>,
    reversal_policy: ReversalPolicy,
}

fn parse_datum_separator(
    line: &str,
    line_number: usize,
) -> Result<Option<DatumSeparator>, DeckParseError> {
    if line == DATUM_SEPARATOR {
        return Ok(Some(DatumSeparator {
            card_id: None,
            reversal_policy: ReversalPolicy::Unspecified,
        }));
    }
    if line == "=i=" {
        return Ok(Some(DatumSeparator {
            card_id: None,
            reversal_policy: ReversalPolicy::Prohibited,
        }));
    }

    let Some(inner) = line
        .strip_prefix('=')
        .and_then(|value| value.strip_suffix('='))
    else {
        return Ok(None);
    };
    if inner.is_empty() || !inner.as_bytes()[0].is_ascii_digit() {
        return Ok(None);
    }

    let (digits, reversal_policy) = match inner.strip_suffix('i') {
        Some(digits) => (digits, ReversalPolicy::Prohibited),
        None => (inner, ReversalPolicy::Unspecified),
    };
    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
        return Err(DeckParseError::InvalidDatumSeparator {
            line: line_number,
            separator: line.to_string(),
        });
    }
    let card_id = digits
        .parse::<u64>()
        .ok()
        .filter(|card_id| *card_id > 0)
        .ok_or_else(|| DeckParseError::InvalidDatumSeparator {
            line: line_number,
            separator: line.to_string(),
        })?;
    Ok(Some(DatumSeparator {
        card_id: Some(card_id),
        reversal_policy,
    }))
}

fn is_datum_separator_candidate(line: &str) -> bool {
    line == DATUM_SEPARATOR
        || line == "=i="
        || line
            .strip_prefix('=')
            .and_then(|value| value.strip_suffix('='))
            .is_some_and(|inner| !inner.is_empty() && inner.as_bytes()[0].is_ascii_digit())
}

fn line_separator_name(line: &str) -> &'static str {
    if line == DATUM_SEPARATOR {
        DATUM_SEPARATOR
    } else if line == "=i=" {
        "=i="
    } else {
        "=number="
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    #[test]
    fn parses_prompt_response_pairs() {
        let data = parse_deck(
            "\
Capital of Spain
---
Madrid
===

Opposite of opaque
---
Transparent
",
        )
        .unwrap();

        assert_eq!(data.len(), 2);
        assert_eq!(data[0].prompt(), "Capital of Spain");
        assert_eq!(data[0].response(), "Madrid");
        assert_eq!(data[1].prompt(), "Opposite of opaque");
        assert_eq!(data[1].response(), "Transparent");
    }

    #[test]
    fn parses_final_entry_without_trailing_datum_separator_and_formats_canonically() {
        let data = parse_deck("Question\n---\nAnswer").unwrap();

        assert_eq!(data, vec![Datum::new("Question", "Answer").unwrap()]);
        assert_eq!(format_deck(&data).unwrap(), "Question\n---\nAnswer\n===\n");
    }

    #[test]
    fn parses_numbered_card_terminators_as_card_ids() {
        let data = parse_deck("Capital of Spain\n---\nMadrid\n=42=\n").unwrap();

        assert_eq!(
            data,
            vec![
                Datum::new("Capital of Spain", "Madrid")
                    .unwrap()
                    .with_card_id(42)
            ]
        );
    }

    #[test]
    fn parses_v2_metadata_header() {
        let deck = parse_deck_file(
            "\
deck_version=2
title=Indonesian
author=modulus
:::

one
---
uno
=1=
",
        )
        .unwrap();

        assert!(deck.metadata().has_header());
        assert_eq!(deck.metadata().get("title"), Some("Indonesian"));
        assert_eq!(deck.metadata().get("author"), Some("modulus"));
        assert_eq!(deck.data()[0].card_id(), Some(1));
    }

    #[test]
    fn audio_generation_disabled_metadata_round_trips() {
        let mut metadata = DeckMetadata::v2();
        assert!(!metadata.audio_generation_is_disabled());

        metadata.set_audio_generation_disabled(true);
        assert!(metadata.audio_generation_is_disabled());

        let deck = DeckFile::with_metadata(
            metadata,
            vec![Datum::new("one", "uno").unwrap().with_card_id(1)],
        );
        let text = format_deck_file(&deck).unwrap();
        assert!(text.contains("audio_generation=disabled\n"));

        let parsed = parse_deck_file(&text).unwrap();
        assert!(parsed.metadata().audio_generation_is_disabled());
    }

    #[test]
    fn deck_uuid_metadata_round_trips_as_typed_uuid() {
        let uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let mut metadata = DeckMetadata::v2();
        metadata.set_deck_uuid(&uuid);

        let deck = DeckFile::with_metadata(
            metadata,
            vec![Datum::new("one", "uno").unwrap().with_card_id(1)],
        );
        let text = format_deck_file(&deck).unwrap();
        assert!(text.contains("deck_uuid=550e8400-e29b-41d4-a716-446655440000\n"));

        let parsed = parse_deck_file(&text).unwrap();
        assert_eq!(parsed.metadata().deck_uuid(), Some(uuid));
    }

    #[test]
    fn invalid_deck_uuid_metadata_is_ignored_by_typed_reader() {
        let mut metadata = DeckMetadata::v2();
        metadata.set("deck_uuid", "not-a-uuid");

        assert_eq!(metadata.deck_uuid(), None);
        assert_eq!(metadata.get("deck_uuid"), Some("not-a-uuid"));
    }

    #[test]
    fn deck_visibility_metadata_defaults_to_private_and_round_trips() {
        let mut metadata = DeckMetadata::v2();
        assert_eq!(metadata.deck_visibility(), DeckVisibility::Private);

        metadata.set_deck_visibility(DeckVisibility::Unlisted);
        assert_eq!(metadata.deck_visibility(), DeckVisibility::Unlisted);

        let deck = DeckFile::with_metadata(
            metadata,
            vec![Datum::new("one", "uno").unwrap().with_card_id(1)],
        );
        let text = format_deck_file(&deck).unwrap();
        assert!(text.contains("visibility=unlisted\n"));

        let parsed = parse_deck_file(&text).unwrap();
        assert_eq!(
            parsed.metadata().deck_visibility(),
            DeckVisibility::Unlisted
        );
    }

    #[test]
    fn unknown_deck_visibility_metadata_is_treated_as_private() {
        let mut metadata = DeckMetadata::v2();
        metadata.set("visibility", "friends");

        assert_eq!(metadata.deck_visibility(), DeckVisibility::Private);
        assert_eq!(metadata.get("visibility"), Some("friends"));
    }

    #[test]
    fn non_disabled_audio_generation_metadata_is_not_treated_as_disabled() {
        let mut metadata = DeckMetadata::v2();
        metadata.set("audio_generation", "enabled");
        assert!(!metadata.audio_generation_is_disabled());

        metadata.set_audio_generation_disabled(false);
        assert_eq!(metadata.get("audio_generation"), None);
        assert!(!metadata.audio_generation_is_disabled());
    }

    #[test]
    fn parses_v2_deck_with_legacy_separator_as_missing_card_id() {
        let deck = parse_deck_file(
            "\
deck_version=2
:::

one
---
uno
===
",
        )
        .unwrap();

        assert!(deck.metadata().has_header());
        assert_eq!(deck.data()[0].card_id(), None);
    }

    #[test]
    fn parses_numbered_non_reversible_marker() {
        let data = parse_deck("one\n---\nuno\n=7i=\n").unwrap();

        assert_eq!(data[0].card_id(), Some(7));
        assert!(data[0].is_reversal_prohibited());
    }

    #[test]
    fn parses_idless_non_reversible_marker() {
        let data = parse_deck("one\n---\nuno\n=i=\n").unwrap();

        assert_eq!(data[0].card_id(), None);
        assert!(data[0].is_reversal_prohibited());
    }

    #[test]
    fn rejects_unknown_numbered_marker() {
        assert_eq!(
            parse_deck("one\n---\nuno\n=7r=\n").unwrap_err(),
            DeckParseError::InvalidDatumSeparator {
                line: 4,
                separator: "=7r=".to_string(),
            }
        );
    }

    #[test]
    fn rejects_duplicate_card_ids() {
        let error = parse_deck(
            "Capital of Spain\n---\nMadrid\n=1=\n\nCapital of France\n---\nParis\n=1=\n",
        )
        .unwrap_err();

        assert_eq!(
            error,
            DeckParseError::DuplicateCardId {
                line: 9,
                card_id: 1
            }
        );
    }

    #[test]
    fn parses_empty_input_as_an_empty_deck() {
        assert_eq!(parse_deck(""), Ok(Vec::new()));
        assert_eq!(parse_deck(" \n\n\t\n"), Ok(Vec::new()));
    }

    #[test]
    fn parses_multiline_prompts_and_responses() {
        let data = parse_deck(
            "\
Translate this sentence:
I opened the window.
---
Abri la ventana.

Also acceptable:
He abierto la ventana.
===
",
        )
        .unwrap();

        assert_eq!(
            data,
            vec![
                Datum::new(
                    "Translate this sentence:\nI opened the window.",
                    "Abri la ventana.\n\nAlso acceptable:\nHe abierto la ventana."
                )
                .unwrap()
            ]
        );
    }

    #[test]
    fn trims_padding_blank_lines_around_fields() {
        let data = parse_deck("\nQuestion\n\n---\n\nAnswer\n\n===\n").unwrap();

        assert_eq!(data[0].prompt(), "Question");
        assert_eq!(data[0].response(), "Answer");
    }

    #[test]
    fn preserves_blank_lines_inside_prompt_and_response() {
        let data =
            parse_deck("Question\n\nWith detail\n---\nAnswer\n\nWith detail\n===\n").unwrap();

        assert_eq!(data[0].prompt(), "Question\n\nWith detail");
        assert_eq!(data[0].response(), "Answer\n\nWith detail");
    }

    #[test]
    fn formats_deck_canonically() {
        let data = vec![
            Datum::new("Capital of Spain", "Madrid")
                .unwrap()
                .with_card_id(1),
            Datum::new("Opposite of opaque", "Transparent")
                .unwrap()
                .with_card_id(2),
        ];

        assert_eq!(
            format_deck(&data).unwrap(),
            "\
Capital of Spain
---
Madrid
=1=

Opposite of opaque
---
Transparent
=2=
"
        );
    }

    #[test]
    fn formats_v2_deck_when_card_is_non_reversible() {
        let data = vec![
            Datum::new("one", "uno")
                .unwrap()
                .with_card_id(1)
                .with_reversal_prohibited(true),
        ];
        let deck = DeckFile::new(data);

        assert_eq!(
            format_deck_file(&deck).unwrap(),
            "\
deck_version=2
:::

one
---
uno
=1i=
"
        );
    }

    #[test]
    fn formats_idless_non_reversible_marker() {
        let data = vec![
            Datum::new("one", "uno")
                .unwrap()
                .with_reversal_prohibited(true),
        ];
        let deck = DeckFile::new(data);

        assert_eq!(
            format_deck_file(&deck).unwrap(),
            "\
deck_version=2
:::

one
---
uno
=i=
"
        );
    }

    #[test]
    fn formats_legacy_data_with_legacy_separator_when_no_id_is_present() {
        let data = vec![Datum::new("Question", "Answer").unwrap()];

        assert_eq!(format_deck(&data).unwrap(), "Question\n---\nAnswer\n===\n");
    }

    #[test]
    fn formatted_deck_round_trips() {
        let data = vec![
            Datum::new("Capital of Spain", "Madrid").unwrap(),
            Datum::new(
                "Translate:\nI opened the window.",
                "Abri la ventana.\n\nHe abierto la ventana.",
            )
            .unwrap(),
        ];

        assert_eq!(parse_deck(&format_deck(&data).unwrap()), Ok(data));
    }

    #[test]
    fn formatting_rejects_reserved_separator_lines_in_prompts_and_responses() {
        for (line, expected_separator) in [
            (PROMPT_RESPONSE_SEPARATOR, PROMPT_RESPONSE_SEPARATOR),
            (DATUM_SEPARATOR, DATUM_SEPARATOR),
            ("=i=", "=i="),
            ("=1=", "=number="),
            ("=1i=", "=number="),
        ] {
            let data = vec![Datum::new(format!("Line one\n{line}\nLine two"), "Answer").unwrap()];
            assert_eq!(
                format_deck(&data).unwrap_err(),
                DeckFormatError::new(0, DeckField::Prompt, expected_separator),
                "prompt line `{line}` should be reserved"
            );

            let data = vec![Datum::new("Question", format!("Line one\n{line}\nLine two")).unwrap()];
            assert_eq!(
                format_deck(&data).unwrap_err(),
                DeckFormatError::new(0, DeckField::Response, expected_separator),
                "response line `{line}` should be reserved"
            );
        }
    }

    #[test]
    fn metadata_separator_lines_round_trip_inside_card_text_after_header() {
        let data = vec![
            Datum::new("Line one\n:::\nLine two", "Answer\n:::\nMore answer")
                .unwrap()
                .with_card_id(1),
        ];
        let deck = DeckFile::with_metadata(DeckMetadata::v2(), data.clone());
        let formatted = format_deck_file(&deck).unwrap();

        assert_eq!(parse_deck_file(&formatted).unwrap().data(), data);
    }

    #[test]
    fn rejects_missing_prompt_response_separator() {
        assert_eq!(
            parse_deck("Question\nAnswer").unwrap_err(),
            DeckParseError::MissingPromptResponseSeparator {
                entry_start_line: 1
            }
        );
    }

    #[test]
    fn rejects_unexpected_prompt_response_separator_inside_response() {
        assert_eq!(
            parse_deck("Question\n---\nLine one\n---\nLine two\n===\n").unwrap_err(),
            DeckParseError::UnexpectedPromptResponseSeparator { line: 4 }
        );
    }

    #[test]
    fn rejects_empty_response() {
        assert_eq!(
            parse_deck("Question\n---\n===\n").unwrap_err(),
            DeckParseError::InvalidDatum {
                entry_start_line: 1,
                source: DatumValidationError::EmptyResponse,
            }
        );
    }

    #[test]
    fn rejects_empty_prompt() {
        assert_eq!(
            parse_deck("---\nAnswer\n===\n").unwrap_err(),
            DeckParseError::InvalidDatum {
                entry_start_line: 1,
                source: DatumValidationError::EmptyPrompt,
            }
        );
    }

    fn safe_deck_field() -> impl Strategy<Value = String> {
        prop::collection::vec("[A-Za-z0-9][A-Za-z0-9 .,;:!?()/+'-]{0,24}", 1..4)
            .prop_map(|lines| lines.join("\n"))
    }

    fn generated_deck_data() -> impl Strategy<Value = Vec<Datum>> {
        prop::collection::vec(
            (
                safe_deck_field(),
                safe_deck_field(),
                any::<bool>(),
                any::<bool>(),
            ),
            0..20,
        )
        .prop_map(|items| {
            items
                .into_iter()
                .enumerate()
                .map(
                    |(index, (prompt, response, has_card_id, reversal_prohibited))| {
                        let mut datum = Datum::new(prompt, response)
                            .expect("generated deck fields should be valid");
                        if has_card_id {
                            datum = datum.with_card_id(index as u64 + 1);
                        }
                        datum.with_reversal_prohibited(reversal_prohibited)
                    },
                )
                .collect()
        })
    }

    fn generated_metadata() -> impl Strategy<Value = DeckMetadata> {
        prop::collection::vec(("k[a-z0-9_]{0,8}", "[A-Za-z0-9_.-]{0,20}"), 0..6).prop_map(
            |fields| {
                let mut metadata = DeckMetadata::v2();
                for (key, value) in fields {
                    metadata.set(key, value);
                }
                metadata
            },
        )
    }

    proptest! {
        #![proptest_config(ProptestConfig {
            cases: 64,
            failure_persistence: None,
            .. ProptestConfig::default()
        })]

        #[test]
        fn formatted_v2_decks_round_trip_canonically(
            metadata in generated_metadata(),
            data in generated_deck_data(),
        ) {
            let deck = DeckFile::with_metadata(metadata, data);
            let formatted = format_deck_file(&deck).expect("generated deck should format");
            let parsed = parse_deck_file(&formatted).expect("formatted deck should parse");

            prop_assert_eq!(&parsed, &deck);
            prop_assert_eq!(format_deck_file(&parsed).unwrap(), formatted);
        }

        #[test]
        fn duplicate_generated_card_ids_are_rejected(
            prompt_a in safe_deck_field(),
            response_a in safe_deck_field(),
            prompt_b in safe_deck_field(),
            response_b in safe_deck_field(),
            card_id in 1_u64..1_000_000,
        ) {
            let deck_text = format!(
                "{prompt_a}\n---\n{response_a}\n={card_id}=\n\n{prompt_b}\n---\n{response_b}\n={card_id}=\n"
            );

            let error = parse_deck(&deck_text).expect_err("duplicate card id should be rejected");
            match error {
                DeckParseError::DuplicateCardId { card_id: duplicate, .. } => {
                    prop_assert_eq!(duplicate, card_id);
                }
                other => {
                    prop_assert!(false, "expected duplicate card id error, got {other:?}");
                }
            }
        }

        #[test]
        fn idless_non_reversible_cards_parse_and_format_canonically(
            prompt in safe_deck_field(),
            response in safe_deck_field(),
        ) {
            let deck_text = format!("{prompt}\n---\n{response}\n=i=\n");
            let data = parse_deck(&deck_text).expect("idless irreversible card should parse");

            prop_assert_eq!(data.len(), 1);
            prop_assert_eq!(data[0].card_id(), None);
            prop_assert!(data[0].is_reversal_prohibited());

            let formatted = format_deck_file(&DeckFile::new(data.clone()))
                .expect("idless irreversible card should format");
            prop_assert_eq!(parse_deck(&formatted).unwrap(), data);
        }

        #[test]
        fn reserved_separator_lines_are_rejected_in_generated_fields(
            prefix in safe_deck_field(),
            suffix in safe_deck_field(),
            separator in prop_oneof![
                Just(PROMPT_RESPONSE_SEPARATOR),
                Just(DATUM_SEPARATOR),
                Just("=i="),
                Just("=1="),
                Just("=1i="),
            ],
        ) {
            let prompt = format!("{prefix}\n{separator}\n{suffix}");
            let prompt_deck = vec![Datum::new(prompt, "Answer").unwrap()];
            prop_assert!(format_deck(&prompt_deck).is_err());

            let response = format!("{prefix}\n{separator}\n{suffix}");
            let response_deck = vec![Datum::new("Prompt", response).unwrap()];
            prop_assert!(format_deck(&response_deck).is_err());
        }
    }
}
