use crate::deck_format::{DeckFile, DeckMetadata};
use crate::model::Datum;
use rusqlite::Connection;
use serde_json::Value;
use std::collections::BTreeMap;
use std::fmt;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use zip::ZipArchive;
use zip::result::ZipError;

const ANKI_FIELD_SEPARATOR: char = '\u{1f}';
const ANKI_PACKAGE_META: &str = "meta";
const COLLECTION_ANKI21B: &str = "collection.anki21b";
const COLLECTION_ANKI21: &str = "collection.anki21";
const COLLECTION_ANKI2: &str = "collection.anki2";
const MEDIA_MANIFEST: &str = "media";
const AUDIO_MANIFEST_FILE_NAME: &str = "audio-manifest.tsv";
const MANUAL_ANKI_AUDIO_MANIFEST_HASH: &str = "manual:anki";
const MANUAL_ANKI_AUDIO_MANIFEST_ENGINE: &str = "onecore";
const MP3_EXTENSION: &str = "mp3";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnkiDeckImportResult {
    pub deck: DeckFile,
    pub summary: AnkiDeckImportSummary,
    pub audio_files: Vec<AnkiImportedAudioFile>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnkiImportedAudioFile {
    pub card_id: u64,
    pub side: AnkiImportedAudioSide,
    pub archive_entry: String,
}

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

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

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnkiDeckImportSummary {
    pub collection_entry: String,
    pub notes_seen: usize,
    pub imported_cards: usize,
    pub skipped_unsupported_notes: usize,
    pub skipped_empty_notes: usize,
    pub skipped_malformed_notes: usize,
    pub removed_sound_references: usize,
    pub imported_audio_files: usize,
    pub skipped_audio_references: usize,
}

impl AnkiDeckImportSummary {
    fn new(collection_entry: impl Into<String>) -> Self {
        Self {
            collection_entry: collection_entry.into(),
            notes_seen: 0,
            imported_cards: 0,
            skipped_unsupported_notes: 0,
            skipped_empty_notes: 0,
            skipped_malformed_notes: 0,
            removed_sound_references: 0,
            imported_audio_files: 0,
            skipped_audio_references: 0,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeckImportError {
    Io(String),
    Zip(String),
    Sqlite(String),
    Json(String),
    MissingCollection,
    MissingModels,
    InvalidPackageMetadata(String),
    InvalidMediaManifest(String),
    InvalidModelsJson(String),
    NoImportableNotes(AnkiDeckImportSummary),
    TooManyImportedCards,
}

impl fmt::Display for DeckImportError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io(error) => write!(f, "I/O error while importing deck: {error}"),
            Self::Zip(error) => write!(f, "could not read Anki package: {error}"),
            Self::Sqlite(error) => write!(f, "could not read Anki collection database: {error}"),
            Self::Json(error) => write!(f, "could not read Anki note model JSON: {error}"),
            Self::MissingCollection => {
                f.write_str(
                    "Anki package does not contain collection.anki21b, collection.anki21, or collection.anki2",
                )
            }
            Self::MissingModels => {
                f.write_str("Anki collection database does not contain note model metadata")
            }
            Self::InvalidPackageMetadata(error) => {
                write!(f, "Anki package metadata is not supported: {error}")
            }
            Self::InvalidMediaManifest(error) => {
                write!(f, "Anki media manifest is not supported: {error}")
            }
            Self::InvalidModelsJson(error) => {
                write!(f, "Anki note model metadata is not supported: {error}")
            }
            Self::NoImportableNotes(summary) => write!(
                f,
                "Anki package did not contain importable prompt/response notes: {} notes seen, {} unsupported, {} empty, {} malformed",
                summary.notes_seen,
                summary.skipped_unsupported_notes,
                summary.skipped_empty_notes,
                summary.skipped_malformed_notes
            ),
            Self::TooManyImportedCards => {
                f.write_str("Anki package contains too many cards to import")
            }
        }
    }
}

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

impl From<io::Error> for DeckImportError {
    fn from(error: io::Error) -> Self {
        Self::Io(error.to_string())
    }
}

impl From<ZipError> for DeckImportError {
    fn from(error: ZipError) -> Self {
        Self::Zip(error.to_string())
    }
}

impl From<rusqlite::Error> for DeckImportError {
    fn from(error: rusqlite::Error) -> Self {
        Self::Sqlite(error.to_string())
    }
}

impl From<serde_json::Error> for DeckImportError {
    fn from(error: serde_json::Error) -> Self {
        Self::Json(error.to_string())
    }
}

pub fn import_anki_apkg(path: impl AsRef<Path>) -> Result<AnkiDeckImportResult, DeckImportError> {
    let file = File::open(path)?;
    import_anki_apkg_reader(file)
}

pub fn import_anki_apkg_reader<R: Read + Seek>(
    reader: R,
) -> Result<AnkiDeckImportResult, DeckImportError> {
    let mut archive = ZipArchive::new(reader)?;
    let package_format = read_package_format(&mut archive)?;
    let (collection_entry, collection_bytes) =
        read_collection_database(&mut archive, package_format)?;
    let media_manifest = read_media_manifest(&mut archive, package_format)?;
    let temp_collection = TempCollectionFile::write(&collection_bytes)?;
    let connection = Connection::open(temp_collection.path())?;
    import_anki_collection(&connection, collection_entry, &media_manifest)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AnkiPackageFormat {
    Legacy1,
    Legacy2,
    Latest,
}

impl AnkiPackageFormat {
    fn collection_entry(self) -> &'static str {
        match self {
            Self::Legacy1 => COLLECTION_ANKI2,
            Self::Legacy2 => COLLECTION_ANKI21,
            Self::Latest => COLLECTION_ANKI21B,
        }
    }

    fn zstd_compressed(self) -> bool {
        matches!(self, Self::Latest)
    }

    fn legacy_media_manifest(self) -> bool {
        matches!(self, Self::Legacy1 | Self::Legacy2)
    }
}

fn read_package_format<R: Read + Seek>(
    archive: &mut ZipArchive<R>,
) -> Result<AnkiPackageFormat, DeckImportError> {
    let contains_collection_anki21 = archive.file_names().any(|name| name == COLLECTION_ANKI21);
    match archive.by_name(ANKI_PACKAGE_META) {
        Ok(mut file) => {
            let mut bytes = Vec::new();
            file.read_to_end(&mut bytes)?;
            parse_package_format_metadata(&bytes)
        }
        Err(ZipError::FileNotFound) => {
            if contains_collection_anki21 {
                Ok(AnkiPackageFormat::Legacy2)
            } else {
                Ok(AnkiPackageFormat::Legacy1)
            }
        }
        Err(error) => Err(error.into()),
    }
}

fn parse_package_format_metadata(bytes: &[u8]) -> Result<AnkiPackageFormat, DeckImportError> {
    let mut index = 0;
    while index < bytes.len() {
        let key = read_proto_varint(bytes, &mut index, "package metadata")?;
        let field_number = key >> 3;
        let wire_type = key & 0x07;
        if field_number == 1 && wire_type == 0 {
            return match read_proto_varint(bytes, &mut index, "package metadata")? {
                1 => Ok(AnkiPackageFormat::Legacy1),
                2 => Ok(AnkiPackageFormat::Legacy2),
                3 => Ok(AnkiPackageFormat::Latest),
                version => Err(DeckImportError::InvalidPackageMetadata(format!(
                    "unsupported package version {version}"
                ))),
            };
        }
        skip_proto_field(bytes, &mut index, wire_type, "package metadata")?;
    }

    Err(DeckImportError::InvalidPackageMetadata(
        "package version is missing".to_string(),
    ))
}

fn read_collection_database<R: Read + Seek>(
    archive: &mut ZipArchive<R>,
    package_format: AnkiPackageFormat,
) -> Result<(String, Vec<u8>), DeckImportError> {
    let name = package_format.collection_entry();
    match archive.by_name(name) {
        Ok(mut file) => {
            let mut bytes = Vec::new();
            file.read_to_end(&mut bytes)?;
            if package_format.zstd_compressed() {
                bytes = decode_zstd_bytes(&bytes)?;
            }
            Ok((name.to_string(), bytes))
        }
        Err(ZipError::FileNotFound) => Err(DeckImportError::MissingCollection),
        Err(error) => Err(error.into()),
    }
}

fn read_media_manifest<R: Read + Seek>(
    archive: &mut ZipArchive<R>,
    package_format: AnkiPackageFormat,
) -> Result<BTreeMap<String, String>, DeckImportError> {
    let mut file = match archive.by_name(MEDIA_MANIFEST) {
        Ok(file) => file,
        Err(ZipError::FileNotFound) => return Ok(BTreeMap::new()),
        Err(error) => return Err(error.into()),
    };
    let mut bytes = Vec::new();
    file.read_to_end(&mut bytes)?;
    if package_format.zstd_compressed() {
        bytes = decode_zstd_bytes(&bytes)?;
    }
    if !package_format.legacy_media_manifest() {
        return decode_modern_media_manifest(&bytes);
    }

    let value = serde_json::from_slice::<Value>(&bytes)?;
    let Some(object) = value.as_object() else {
        return Ok(BTreeMap::new());
    };
    Ok(object
        .iter()
        .filter_map(|(archive_entry, file_name)| {
            Some((file_name.as_str()?.to_string(), archive_entry.to_string()))
        })
        .collect())
}

fn decode_zstd_bytes(bytes: &[u8]) -> Result<Vec<u8>, DeckImportError> {
    zstd::stream::decode_all(bytes).map_err(DeckImportError::from)
}

fn decode_modern_media_manifest(bytes: &[u8]) -> Result<BTreeMap<String, String>, DeckImportError> {
    let mut manifest = BTreeMap::new();
    let mut index = 0;
    let mut archive_entry_index = 0usize;

    while index < bytes.len() {
        let key = read_proto_varint(bytes, &mut index, "media manifest")?;
        let field_number = key >> 3;
        let wire_type = key & 0x07;
        if field_number == 1 && wire_type == 2 {
            let entry = read_proto_length_delimited(bytes, &mut index, "media manifest")?;
            if let Some(name) = decode_modern_media_entry_name(entry)? {
                manifest.insert(name, archive_entry_index.to_string());
            }
            archive_entry_index += 1;
        } else {
            skip_proto_field(bytes, &mut index, wire_type, "media manifest")?;
        }
    }

    Ok(manifest)
}

fn decode_modern_media_entry_name(bytes: &[u8]) -> Result<Option<String>, DeckImportError> {
    let mut index = 0;
    let mut name = None;

    while index < bytes.len() {
        let key = read_proto_varint(bytes, &mut index, "media entry")?;
        let field_number = key >> 3;
        let wire_type = key & 0x07;
        if field_number == 1 && wire_type == 2 {
            let name_bytes = read_proto_length_delimited(bytes, &mut index, "media entry")?;
            let decoded = String::from_utf8(name_bytes.to_vec()).map_err(|error| {
                DeckImportError::InvalidMediaManifest(format!(
                    "media file name is not UTF-8: {error}"
                ))
            })?;
            name = Some(decoded);
        } else {
            skip_proto_field(bytes, &mut index, wire_type, "media entry")?;
        }
    }

    Ok(name)
}

fn read_proto_length_delimited<'a>(
    bytes: &'a [u8],
    index: &mut usize,
    context: &str,
) -> Result<&'a [u8], DeckImportError> {
    let length = usize::try_from(read_proto_varint(bytes, index, context)?).map_err(|_| {
        invalid_proto_error(context, "length-delimited field is too large".to_string())
    })?;
    let end = index
        .checked_add(length)
        .ok_or_else(|| invalid_proto_error(context, "length overflow".to_string()))?;
    if end > bytes.len() {
        return Err(invalid_proto_error(
            context,
            "length-delimited field extends past the end of the data".to_string(),
        ));
    }
    let value = &bytes[*index..end];
    *index = end;
    Ok(value)
}

fn read_proto_varint(
    bytes: &[u8],
    index: &mut usize,
    context: &str,
) -> Result<u64, DeckImportError> {
    let mut value = 0u64;
    for shift in (0..64).step_by(7) {
        let Some(byte) = bytes.get(*index).copied() else {
            return Err(invalid_proto_error(
                context,
                "unexpected end of varint".to_string(),
            ));
        };
        *index += 1;
        value |= u64::from(byte & 0x7f) << shift;
        if byte & 0x80 == 0 {
            return Ok(value);
        }
    }

    Err(invalid_proto_error(
        context,
        "varint is longer than 64 bits".to_string(),
    ))
}

fn skip_proto_field(
    bytes: &[u8],
    index: &mut usize,
    wire_type: u64,
    context: &str,
) -> Result<(), DeckImportError> {
    match wire_type {
        0 => {
            read_proto_varint(bytes, index, context)?;
            Ok(())
        }
        1 => advance_proto_index(bytes, index, 8, context),
        2 => read_proto_length_delimited(bytes, index, context).map(|_| ()),
        5 => advance_proto_index(bytes, index, 4, context),
        _ => Err(invalid_proto_error(
            context,
            format!("unsupported protobuf wire type {wire_type}"),
        )),
    }
}

fn advance_proto_index(
    bytes: &[u8],
    index: &mut usize,
    count: usize,
    context: &str,
) -> Result<(), DeckImportError> {
    let end = index
        .checked_add(count)
        .ok_or_else(|| invalid_proto_error(context, "field length overflow".to_string()))?;
    if end > bytes.len() {
        return Err(invalid_proto_error(
            context,
            "field extends past the end of the data".to_string(),
        ));
    }
    *index = end;
    Ok(())
}

fn invalid_proto_error(context: &str, message: String) -> DeckImportError {
    match context {
        "package metadata" => DeckImportError::InvalidPackageMetadata(message),
        _ => DeckImportError::InvalidMediaManifest(format!("{context}: {message}")),
    }
}

fn import_anki_collection(
    connection: &Connection,
    collection_entry: String,
    media_manifest: &BTreeMap<String, String>,
) -> Result<AnkiDeckImportResult, DeckImportError> {
    let models = read_model_mappings(connection)?;
    let mut summary = AnkiDeckImportSummary::new(collection_entry);
    let mut data = Vec::new();
    let mut audio_files = Vec::new();
    let mut statement = connection.prepare(
        "SELECT n.id, n.mid, n.flds \
         FROM notes n \
         WHERE EXISTS (SELECT 1 FROM cards c WHERE c.nid = n.id) \
         ORDER BY n.id",
    )?;
    let mut rows = statement.query([])?;

    while let Some(row) = rows.next()? {
        summary.notes_seen += 1;
        let model_id: i64 = row.get(1)?;
        let raw_fields: String = row.get(2)?;
        let Some(mapping) = models.get(&model_id) else {
            summary.skipped_unsupported_notes += 1;
            continue;
        };
        let fields: Vec<_> = raw_fields.split(ANKI_FIELD_SEPARATOR).collect();
        let Some(prompt_raw) = fields.get(mapping.prompt_index) else {
            summary.skipped_malformed_notes += 1;
            continue;
        };
        let Some(response_raw) = fields.get(mapping.response_index) else {
            summary.skipped_malformed_notes += 1;
            continue;
        };
        let prompt = clean_anki_field_text(prompt_raw, &mut summary);
        let response = clean_anki_field_text(response_raw, &mut summary);
        if prompt.trim().is_empty() || response.trim().is_empty() {
            summary.skipped_empty_notes += 1;
            continue;
        }

        let card_id = next_imported_card_id(data.len())?;
        let datum = Datum::new(prompt, response)
            .expect("cleaned non-empty Anki fields should form a datum")
            .with_card_id(card_id);
        append_imported_audio_files(
            &fields,
            mapping,
            card_id,
            media_manifest,
            &mut summary,
            &mut audio_files,
        );
        data.push(datum);
    }

    summary.imported_cards = data.len();
    if data.is_empty() {
        return Err(DeckImportError::NoImportableNotes(summary));
    }

    let mut metadata = DeckMetadata::v2();
    metadata.ensure_next_card_id_at_least(next_imported_card_id(data.len())?);
    Ok(AnkiDeckImportResult {
        deck: DeckFile::with_metadata(metadata, data),
        summary,
        audio_files,
    })
}

fn next_imported_card_id(imported_cards_so_far: usize) -> Result<u64, DeckImportError> {
    u64::try_from(imported_cards_so_far)
        .ok()
        .and_then(|count| count.checked_add(1))
        .ok_or(DeckImportError::TooManyImportedCards)
}

pub fn write_imported_anki_audio_files(
    source_path: impl AsRef<Path>,
    sidecar_dir: impl AsRef<Path>,
    audio_files: &[AnkiImportedAudioFile],
) -> Result<usize, DeckImportError> {
    if audio_files.is_empty() {
        return Ok(0);
    }

    let source_file = File::open(source_path)?;
    let mut archive = ZipArchive::new(source_file)?;
    let package_format = read_package_format(&mut archive)?;
    let sidecar_dir = sidecar_dir.as_ref();
    fs::create_dir_all(sidecar_dir)?;

    for audio_file in audio_files {
        let mut source = archive.by_name(&audio_file.archive_entry)?;
        let destination = sidecar_dir.join(imported_audio_file_name(audio_file));
        if destination.try_exists()? {
            return Err(DeckImportError::Io(format!(
                "audio file already exists: {}",
                destination.display()
            )));
        }
        let temp_destination =
            destination.with_extension(format!("{MP3_EXTENSION}.tmp-{}", std::process::id()));
        let mut output = OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&temp_destination)?;
        let copy_result = if package_format.zstd_compressed() {
            zstd::stream::copy_decode(&mut source, &mut output).map(|_| 0)
        } else {
            io::copy(&mut source, &mut output)
        };
        if let Err(error) = copy_result {
            let _ = fs::remove_file(&temp_destination);
            return Err(error.into());
        }
        if let Err(error) = output.flush() {
            let _ = fs::remove_file(&temp_destination);
            return Err(error.into());
        }
        drop(output);
        if let Err(error) = fs::rename(&temp_destination, &destination) {
            let _ = fs::remove_file(&temp_destination);
            return Err(error.into());
        }
    }

    write_imported_anki_audio_manifest(sidecar_dir, audio_files)?;
    Ok(audio_files.len())
}

fn imported_audio_file_name(audio_file: &AnkiImportedAudioFile) -> String {
    format!(
        "{}.{}.{}",
        audio_file.card_id,
        audio_file.side.file_stem_suffix(),
        MP3_EXTENSION
    )
}

fn write_imported_anki_audio_manifest(
    sidecar_dir: &Path,
    audio_files: &[AnkiImportedAudioFile],
) -> Result<(), DeckImportError> {
    let manifest_path = sidecar_dir.join(AUDIO_MANIFEST_FILE_NAME);
    let mut entries = BTreeMap::new();
    match fs::read_to_string(&manifest_path) {
        Ok(text) => {
            for line in text.lines() {
                let file_name = line.split('\t').next().unwrap_or("");
                if file_name.is_empty() || file_name == "version" {
                    continue;
                }
                entries.insert(file_name.to_string(), line.to_string());
            }
        }
        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
        Err(error) => return Err(error.into()),
    }

    for audio_file in audio_files {
        let file_name = imported_audio_file_name(audio_file);
        entries.insert(
            file_name.clone(),
            format!(
                "{file_name}\t{MANUAL_ANKI_AUDIO_MANIFEST_HASH}\t{MANUAL_ANKI_AUDIO_MANIFEST_ENGINE}\t"
            ),
        );
    }

    let mut text = String::from("version\t2\t\t\n");
    for line in entries.values() {
        text.push_str(line);
        text.push('\n');
    }
    fs::write(manifest_path, text)?;
    Ok(())
}

#[derive(Debug, Clone)]
struct AnkiModelMapping {
    prompt_index: usize,
    response_index: usize,
    prompt_audio_index: Option<usize>,
    response_audio_index: Option<usize>,
}

fn read_model_mappings(
    connection: &Connection,
) -> Result<BTreeMap<i64, AnkiModelMapping>, DeckImportError> {
    if let Some(legacy_mappings) = read_legacy_model_mappings(connection)? {
        return Ok(legacy_mappings);
    }

    read_normalized_model_mappings(connection)
}

fn read_legacy_model_mappings(
    connection: &Connection,
) -> Result<Option<BTreeMap<i64, AnkiModelMapping>>, DeckImportError> {
    let models_json = match connection.query_row("SELECT models FROM col LIMIT 1", [], |row| {
        row.get::<_, String>(0)
    }) {
        Ok(models_json) => models_json,
        Err(rusqlite::Error::QueryReturnedNoRows) => return Err(DeckImportError::MissingModels),
        Err(error) => return Err(error.into()),
    };
    if models_json.trim().is_empty() {
        return Ok(None);
    }

    let models_value = serde_json::from_str::<Value>(&models_json)?;
    let models_object = models_value.as_object().ok_or_else(|| {
        DeckImportError::InvalidModelsJson("top-level models value is not an object".to_string())
    })?;
    let mut mappings = BTreeMap::new();

    for (model_id_key, model) in models_object {
        let Some(model_id) = model_id_key
            .parse::<i64>()
            .ok()
            .or_else(|| model.get("id").and_then(Value::as_i64))
        else {
            continue;
        };
        if model.get("type").and_then(Value::as_i64).unwrap_or(0) != 0 {
            continue;
        }
        let field_names = model_field_names(model)?;
        if let Some(mapping) = choose_field_mapping(&field_names) {
            mappings.insert(model_id, mapping);
        }
    }

    Ok(Some(mappings))
}

fn read_normalized_model_mappings(
    connection: &Connection,
) -> Result<BTreeMap<i64, AnkiModelMapping>, DeckImportError> {
    let mut statement = connection.prepare(
        "SELECT n.id, n.name, f.name \
         FROM notetypes n \
         JOIN fields f ON f.ntid = n.id \
         ORDER BY n.id, f.ord",
    )?;
    let mut rows = statement.query([])?;
    let mut mappings = BTreeMap::new();
    let mut current_model_id = None;
    let mut current_model_name = String::new();
    let mut current_field_names = Vec::new();

    while let Some(row) = rows.next()? {
        let model_id: i64 = row.get(0)?;
        let model_name: String = row.get(1)?;
        let field_name: String = row.get(2)?;
        if current_model_id != Some(model_id) {
            insert_normalized_model_mapping(
                &mut mappings,
                current_model_id,
                &current_model_name,
                &current_field_names,
            );
            current_model_id = Some(model_id);
            current_model_name = model_name;
            current_field_names.clear();
        }
        current_field_names.push(field_name);
    }
    insert_normalized_model_mapping(
        &mut mappings,
        current_model_id,
        &current_model_name,
        &current_field_names,
    );

    Ok(mappings)
}

fn insert_normalized_model_mapping(
    mappings: &mut BTreeMap<i64, AnkiModelMapping>,
    model_id: Option<i64>,
    model_name: &str,
    field_names: &[String],
) {
    let Some(model_id) = model_id else {
        return;
    };
    if normalize_field_name(model_name).contains("cloze") {
        return;
    }
    if let Some(mapping) = choose_field_mapping(field_names) {
        mappings.insert(model_id, mapping);
    }
}

fn model_field_names(model: &Value) -> Result<Vec<String>, DeckImportError> {
    let fields = model.get("flds").and_then(Value::as_array).ok_or_else(|| {
        DeckImportError::InvalidModelsJson("model flds is not an array".to_string())
    })?;
    let mut ordered_fields = Vec::with_capacity(fields.len());
    for (index, field) in fields.iter().enumerate() {
        let name = field
            .get("name")
            .and_then(Value::as_str)
            .ok_or_else(|| {
                DeckImportError::InvalidModelsJson("model field name is missing".to_string())
            })?
            .to_string();
        let order = field
            .get("ord")
            .and_then(Value::as_u64)
            .and_then(|ord| usize::try_from(ord).ok())
            .unwrap_or(index);
        ordered_fields.push((order, name));
    }
    ordered_fields.sort_by_key(|(order, _)| *order);
    Ok(ordered_fields.into_iter().map(|(_, name)| name).collect())
}

fn choose_field_mapping(field_names: &[String]) -> Option<AnkiModelMapping> {
    let normalized: Vec<_> = field_names
        .iter()
        .map(|name| normalize_field_name(name))
        .collect();
    for (prompt_name, response_name) in [
        ("front", "back"),
        ("question", "answer"),
        ("prompt", "response"),
        ("term", "definition"),
        ("word", "worden"),
        ("word", "english"),
        ("word", "translation"),
        ("expression", "meaning"),
        ("source", "target"),
        ("sourcetext", "targettext"),
    ] {
        if let (Some(prompt_index), Some(response_index)) = (
            normalized.iter().position(|name| name == prompt_name),
            normalized.iter().position(|name| name == response_name),
        ) {
            return Some(AnkiModelMapping {
                prompt_index,
                response_index,
                prompt_audio_index: audio_field_index_for(&normalized, prompt_name),
                response_audio_index: audio_field_index_for(&normalized, response_name),
            });
        }
    }

    let non_media_indices: Vec<_> = normalized
        .iter()
        .enumerate()
        .filter_map(|(index, name)| (!is_media_field_name(name)).then_some(index))
        .collect();
    if non_media_indices.len() == 2 {
        let prompt_index = non_media_indices[0];
        let response_index = non_media_indices[1];
        Some(AnkiModelMapping {
            prompt_index,
            response_index,
            prompt_audio_index: audio_field_index_for(&normalized, &normalized[prompt_index]),
            response_audio_index: audio_field_index_for(&normalized, &normalized[response_index]),
        })
    } else {
        None
    }
}

fn audio_field_index_for(normalized_field_names: &[String], base_name: &str) -> Option<usize> {
    let candidates = [
        format!("{base_name}audio"),
        format!("{base_name}sound"),
        format!("audio{base_name}"),
        format!("sound{base_name}"),
    ];
    normalized_field_names
        .iter()
        .position(|name| candidates.iter().any(|candidate| candidate == name))
}

fn normalize_field_name(name: &str) -> String {
    name.chars()
        .filter(|character| character.is_ascii_alphanumeric())
        .flat_map(char::to_lowercase)
        .collect()
}

fn is_media_field_name(name: &str) -> bool {
    name.contains("audio")
        || name.contains("sound")
        || name.contains("media")
        || name.contains("image")
        || name.contains("picture")
}

fn clean_anki_field_text(input: &str, summary: &mut AnkiDeckImportSummary) -> String {
    let without_sound = remove_sound_references(input, summary);
    let with_line_breaks = replace_structural_html_tags(&without_sound);
    let without_tags = strip_html_tags(&with_line_breaks);
    let decoded = decode_html_entities(&without_tags);
    let normalized = normalize_text_whitespace(&decoded);
    escape_reserved_deck_lines(&normalized)
}

fn remove_sound_references(input: &str, summary: &mut AnkiDeckImportSummary) -> String {
    let mut output = String::new();
    let mut remaining = input;
    while let Some(start) = remaining.find("[sound:") {
        output.push_str(&remaining[..start]);
        let after_start = &remaining[start..];
        if let Some(end) = after_start.find(']') {
            summary.removed_sound_references += 1;
            remaining = &after_start[end + 1..];
        } else {
            output.push_str(after_start);
            return output;
        }
    }
    output.push_str(remaining);
    output
}

fn append_imported_audio_files(
    fields: &[&str],
    mapping: &AnkiModelMapping,
    card_id: u64,
    media_manifest: &BTreeMap<String, String>,
    summary: &mut AnkiDeckImportSummary,
    audio_files: &mut Vec<AnkiImportedAudioFile>,
) {
    append_imported_audio_file_for_side(
        fields,
        card_id,
        AnkiImportedAudioSide::Prompt,
        [Some(mapping.prompt_index), mapping.prompt_audio_index],
        media_manifest,
        summary,
        audio_files,
    );
    append_imported_audio_file_for_side(
        fields,
        card_id,
        AnkiImportedAudioSide::Response,
        [Some(mapping.response_index), mapping.response_audio_index],
        media_manifest,
        summary,
        audio_files,
    );
}

fn append_imported_audio_file_for_side(
    fields: &[&str],
    card_id: u64,
    side: AnkiImportedAudioSide,
    field_indices: [Option<usize>; 2],
    media_manifest: &BTreeMap<String, String>,
    summary: &mut AnkiDeckImportSummary,
    audio_files: &mut Vec<AnkiImportedAudioFile>,
) {
    let mut imported_one = false;
    for field_index in field_indices.into_iter().flatten() {
        let Some(field) = fields.get(field_index) else {
            continue;
        };
        for file_name in sound_references(field) {
            if imported_one {
                summary.skipped_audio_references += 1;
                continue;
            }
            if !has_mp3_extension(&file_name) {
                summary.skipped_audio_references += 1;
                continue;
            }
            let Some(archive_entry) = media_manifest.get(&file_name) else {
                summary.skipped_audio_references += 1;
                continue;
            };
            audio_files.push(AnkiImportedAudioFile {
                card_id,
                side,
                archive_entry: archive_entry.clone(),
            });
            summary.imported_audio_files += 1;
            imported_one = true;
        }
    }
}

fn sound_references(input: &str) -> Vec<String> {
    let mut references = Vec::new();
    let mut remaining = input;
    while let Some(start) = remaining.find("[sound:") {
        let after_start = &remaining[start + "[sound:".len()..];
        let Some(end) = after_start.find(']') else {
            break;
        };
        let file_name = after_start[..end].trim();
        if !file_name.is_empty() {
            references.push(file_name.to_string());
        }
        remaining = &after_start[end + 1..];
    }
    references
}

fn has_mp3_extension(file_name: &str) -> bool {
    Path::new(file_name)
        .extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| extension.eq_ignore_ascii_case(MP3_EXTENSION))
}

fn replace_structural_html_tags(input: &str) -> String {
    let mut output = String::new();
    let mut index = 0;
    while let Some(tag_start) = input[index..].find('<') {
        let tag_start = index + tag_start;
        output.push_str(&input[index..tag_start]);
        let Some(tag_end_offset) = input[tag_start..].find('>') else {
            output.push_str(&input[tag_start..]);
            return output;
        };
        let tag_end = tag_start + tag_end_offset;
        let tag = input[tag_start + 1..tag_end]
            .trim_start_matches('/')
            .split_whitespace()
            .next()
            .unwrap_or("")
            .trim_end_matches('/')
            .to_ascii_lowercase();
        if matches!(
            tag.as_str(),
            "br" | "div" | "p" | "li" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
        ) {
            output.push('\n');
        }
        index = tag_end + 1;
    }
    output.push_str(&input[index..]);
    output
}

fn strip_html_tags(input: &str) -> String {
    let mut output = String::new();
    let mut in_tag = false;
    for character in input.chars() {
        match character {
            '<' => in_tag = true,
            '>' => in_tag = false,
            _ if !in_tag => output.push(character),
            _ => {}
        }
    }
    output
}

fn decode_html_entities(input: &str) -> String {
    let mut output = String::new();
    let mut remaining = input;
    while let Some(start) = remaining.find('&') {
        output.push_str(&remaining[..start]);
        let after_start = &remaining[start + 1..];
        let Some(end) = after_start.find(';') else {
            output.push('&');
            output.push_str(after_start);
            return output;
        };
        let entity = &after_start[..end];
        if let Some(decoded) = decode_html_entity(entity) {
            output.push(decoded);
        } else {
            output.push('&');
            output.push_str(entity);
            output.push(';');
        }
        remaining = &after_start[end + 1..];
    }
    output.push_str(remaining);
    output
}

fn decode_html_entity(entity: &str) -> Option<char> {
    match entity {
        "amp" => Some('&'),
        "lt" => Some('<'),
        "gt" => Some('>'),
        "quot" => Some('"'),
        "apos" | "#39" => Some('\''),
        "nbsp" => Some(' '),
        _ => entity
            .strip_prefix("#x")
            .and_then(|hex| u32::from_str_radix(hex, 16).ok())
            .or_else(|| {
                entity
                    .strip_prefix('#')
                    .and_then(|decimal| decimal.parse::<u32>().ok())
            })
            .and_then(char::from_u32),
    }
}

fn normalize_text_whitespace(input: &str) -> String {
    let normalized_line_endings = input.replace("\r\n", "\n").replace('\r', "\n");
    let lines: Vec<_> = normalized_line_endings
        .lines()
        .map(collapse_inline_whitespace)
        .collect();
    let mut first_non_empty = 0;
    while first_non_empty < lines.len() && lines[first_non_empty].is_empty() {
        first_non_empty += 1;
    }
    let mut last_non_empty = lines.len();
    while last_non_empty > first_non_empty && lines[last_non_empty - 1].is_empty() {
        last_non_empty -= 1;
    }

    let mut output = Vec::new();
    let mut previous_empty = false;
    for line in &lines[first_non_empty..last_non_empty] {
        if line.is_empty() {
            if !previous_empty {
                output.push(String::new());
            }
            previous_empty = true;
        } else {
            output.push(line.clone());
            previous_empty = false;
        }
    }
    output.join("\n")
}

fn collapse_inline_whitespace(input: &str) -> String {
    let mut output = String::new();
    let mut previous_space = false;
    for character in input.trim().chars() {
        if character.is_whitespace() && character != '\n' {
            if !previous_space {
                output.push(' ');
            }
            previous_space = true;
        } else {
            output.push(character);
            previous_space = false;
        }
    }
    output
}

fn escape_reserved_deck_lines(input: &str) -> String {
    input
        .lines()
        .map(|line| {
            if is_reserved_deck_line(line) {
                format!(" {line}")
            } else {
                line.to_string()
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn is_reserved_deck_line(line: &str) -> bool {
    if line == "---" || line == "===" {
        return true;
    }
    let Some(inner) = line
        .strip_prefix('=')
        .and_then(|value| value.strip_suffix('='))
    else {
        return false;
    };
    let inner = inner.strip_prefix('!').unwrap_or(inner);
    !inner.is_empty() && inner.chars().all(|character| character.is_ascii_digit())
}

struct TempCollectionFile {
    path: PathBuf,
}

impl TempCollectionFile {
    fn write(bytes: &[u8]) -> Result<Self, DeckImportError> {
        let path = unique_temp_collection_path()?;
        let mut file = OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&path)?;
        file.write_all(bytes)?;
        Ok(Self { path })
    }

    fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TempCollectionFile {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.path);
    }
}

fn unique_temp_collection_path() -> Result<PathBuf, DeckImportError> {
    let since_epoch = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|error| DeckImportError::Io(error.to_string()))?;
    Ok(std::env::temp_dir().join(format!(
        "cosimo-anki-import-{}-{}.sqlite3",
        std::process::id(),
        since_epoch.as_nanos()
    )))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::deck_format::{format_deck_file, parse_deck_file};
    use rusqlite::params;
    use std::io::Cursor;
    use zip::write::SimpleFileOptions;
    use zip::{CompressionMethod, ZipWriter};

    #[test]
    fn imports_basic_front_back_notes_as_reversal_capable_cosimo_cards() {
        let collection = collection_bytes(
            basic_models_json(),
            &[(
                1,
                1000,
                "What<br>now? [sound:front.mp3]",
                "Answer &amp; more [sound:back.mp3]",
            )],
        );
        let result = import_anki_apkg_reader(Cursor::new(apkg_bytes_with_media(
            COLLECTION_ANKI2,
            &collection,
            &[("front.mp3", b"front audio"), ("back.mp3", b"back audio")],
        )))
        .unwrap();

        assert_eq!(result.summary.collection_entry, COLLECTION_ANKI2);
        assert_eq!(result.summary.notes_seen, 1);
        assert_eq!(result.summary.imported_cards, 1);
        assert_eq!(result.summary.removed_sound_references, 2);
        assert_eq!(result.summary.imported_audio_files, 2);
        assert_eq!(result.summary.skipped_audio_references, 0);
        assert_eq!(result.deck.metadata().next_card_id(), Some(2));
        assert_eq!(result.deck.data()[0].card_id(), Some(1));
        assert_eq!(
            result.deck.data()[0].reversal_policy(),
            crate::model::ReversalPolicy::Unspecified
        );
        assert_eq!(result.deck.data()[0].prompt(), "What\nnow?");
        assert_eq!(result.deck.data()[0].response(), "Answer & more");
        assert_eq!(
            result.audio_files,
            vec![
                AnkiImportedAudioFile {
                    card_id: 1,
                    side: AnkiImportedAudioSide::Prompt,
                    archive_entry: "0".to_string(),
                },
                AnkiImportedAudioFile {
                    card_id: 1,
                    side: AnkiImportedAudioSide::Response,
                    archive_entry: "1".to_string(),
                },
            ]
        );

        let formatted = format_deck_file(&result.deck).unwrap();
        let parsed = parse_deck_file(&formatted).unwrap();
        assert_eq!(parsed, result.deck);
    }

    #[test]
    fn imports_word_to_english_models_like_the_indonesian_sample_deck() {
        let collection = collection_bytes(
            ling_models_json(),
            &[(
                1,
                1718730148707,
                "seorang wanita\u{1f}\u{1f}A woman\u{1f}Saya seorang wanita.\u{1f}[sound:word.mp3]\u{1f}[sound:sentence.mp3]",
                "ignored",
            )],
        );
        let result = import_anki_apkg_reader(Cursor::new(apkg_bytes_with_media(
            COLLECTION_ANKI21,
            &collection,
            &[
                ("word.mp3", b"word audio"),
                ("sentence.mp3", b"sentence audio"),
            ],
        )))
        .unwrap();

        assert_eq!(result.summary.collection_entry, COLLECTION_ANKI21);
        assert_eq!(result.summary.imported_cards, 1);
        assert_eq!(result.summary.imported_audio_files, 1);
        assert_eq!(result.summary.skipped_audio_references, 0);
        assert_eq!(result.deck.data()[0].prompt(), "seorang wanita");
        assert_eq!(result.deck.data()[0].response(), "A woman");
        assert_eq!(
            result.audio_files,
            vec![AnkiImportedAudioFile {
                card_id: 1,
                side: AnkiImportedAudioSide::Prompt,
                archive_entry: "0".to_string(),
            }]
        );
    }

    #[test]
    fn write_imported_anki_audio_files_copies_mp3_sidecars() {
        let collection = collection_bytes(
            basic_models_json(),
            &[(1, 1000, "[sound:front.mp3]Front", "Back")],
        );
        let package_path = unique_test_collection_path().with_extension("apkg");
        fs::write(
            &package_path,
            apkg_bytes_with_media(
                COLLECTION_ANKI2,
                &collection,
                &[("front.mp3", b"front audio")],
            ),
        )
        .unwrap();
        let result = import_anki_apkg(&package_path).unwrap();
        let sidecar_dir = unique_test_collection_path().with_extension("audio");

        let copied =
            write_imported_anki_audio_files(&package_path, &sidecar_dir, &result.audio_files)
                .unwrap();

        assert_eq!(copied, 1);
        assert_eq!(
            fs::read(sidecar_dir.join("1.prompt.mp3")).unwrap(),
            b"front audio"
        );
        assert_eq!(
            fs::read_to_string(sidecar_dir.join("audio-manifest.tsv")).unwrap(),
            "version\t2\t\t\n1.prompt.mp3\tmanual:anki\tonecore\t\n"
        );
        fs::remove_file(package_path).unwrap();
        fs::remove_file(sidecar_dir.join("1.prompt.mp3")).unwrap();
        fs::remove_file(sidecar_dir.join("audio-manifest.tsv")).unwrap();
        fs::remove_dir(sidecar_dir).unwrap();
    }

    #[test]
    fn imports_latest_anki_package_with_zstd_collection_and_media() {
        let collection = normalized_collection_bytes(
            1000,
            "Basic",
            &["Front", "Back"],
            &[(1, 1000, "[sound:front.mp3]Front", "[sound:back.mp3]Back")],
        );
        let package_path = unique_test_collection_path().with_extension("apkg");
        fs::write(
            &package_path,
            latest_apkg_bytes_with_media(
                &collection,
                &[("front.mp3", b"front mp3"), ("back.mp3", b"back mp3")],
            ),
        )
        .unwrap();

        let result = import_anki_apkg(&package_path).unwrap();

        assert_eq!(result.summary.collection_entry, COLLECTION_ANKI21B);
        assert_eq!(result.summary.notes_seen, 1);
        assert_eq!(result.summary.imported_cards, 1);
        assert_eq!(result.summary.imported_audio_files, 2);
        assert_eq!(result.deck.data()[0].prompt(), "Front");
        assert_eq!(result.deck.data()[0].response(), "Back");

        let sidecar_dir = unique_test_collection_path().with_extension("audio");
        let copied =
            write_imported_anki_audio_files(&package_path, &sidecar_dir, &result.audio_files)
                .unwrap();

        assert_eq!(copied, 2);
        assert_eq!(
            fs::read(sidecar_dir.join("1.prompt.mp3")).unwrap(),
            b"front mp3"
        );
        assert_eq!(
            fs::read(sidecar_dir.join("1.response.mp3")).unwrap(),
            b"back mp3"
        );
        assert_eq!(
            fs::read_to_string(sidecar_dir.join("audio-manifest.tsv")).unwrap(),
            "version\t2\t\t\n1.prompt.mp3\tmanual:anki\tonecore\t\n1.response.mp3\tmanual:anki\tonecore\t\n"
        );
        fs::remove_file(package_path).unwrap();
        fs::remove_file(sidecar_dir.join("1.prompt.mp3")).unwrap();
        fs::remove_file(sidecar_dir.join("1.response.mp3")).unwrap();
        fs::remove_file(sidecar_dir.join("audio-manifest.tsv")).unwrap();
        fs::remove_dir(sidecar_dir).unwrap();
    }

    #[test]
    fn escapes_cosimo_separator_lines_in_imported_fields() {
        let collection = collection_bytes(basic_models_json(), &[(1, 1000, "---", "=1=")]);
        let result =
            import_anki_apkg_reader(Cursor::new(apkg_bytes(COLLECTION_ANKI2, &collection)))
                .unwrap();

        assert_eq!(result.deck.data()[0].prompt(), " ---");
        assert_eq!(result.deck.data()[0].response(), " =1=");
        format_deck_file(&result.deck).unwrap();
    }

    #[test]
    fn rejects_packages_without_collection_database() {
        let error = import_anki_apkg_reader(Cursor::new(empty_apkg_bytes())).unwrap_err();

        assert_eq!(error, DeckImportError::MissingCollection);
    }

    #[test]
    fn reports_when_no_notes_are_importable() {
        let collection = collection_bytes(
            unsupported_cloze_models_json(),
            &[(1, 1000, "{{c1::front}}", "extra")],
        );
        let error = import_anki_apkg_reader(Cursor::new(apkg_bytes(COLLECTION_ANKI2, &collection)))
            .unwrap_err();

        let DeckImportError::NoImportableNotes(summary) = error else {
            panic!("expected no importable notes error");
        };
        assert_eq!(summary.notes_seen, 1);
        assert_eq!(summary.skipped_unsupported_notes, 1);
    }

    fn basic_models_json() -> String {
        serde_json::json!({
            "1000": {
                "id": 1000,
                "name": "Basic",
                "type": 0,
                "flds": [
                    {"name": "Front", "ord": 0},
                    {"name": "Back", "ord": 1}
                ]
            }
        })
        .to_string()
    }

    fn ling_models_json() -> String {
        serde_json::json!({
            "1718730148707": {
                "id": 1718730148707i64,
                "name": "Ling",
                "type": 0,
                "flds": [
                    {"name": "word", "ord": 0},
                    {"name": "wordrom", "ord": 1},
                    {"name": "worden", "ord": 2},
                    {"name": "sent", "ord": 3},
                    {"name": "wordaudio", "ord": 4},
                    {"name": "sentaudio", "ord": 5}
                ]
            }
        })
        .to_string()
    }

    fn unsupported_cloze_models_json() -> String {
        serde_json::json!({
            "1000": {
                "id": 1000,
                "name": "Cloze",
                "type": 1,
                "flds": [
                    {"name": "Text", "ord": 0},
                    {"name": "Extra", "ord": 1}
                ]
            }
        })
        .to_string()
    }

    fn collection_bytes(models_json: String, notes: &[(i64, i64, &str, &str)]) -> Vec<u8> {
        let path = unique_test_collection_path();
        let connection = Connection::open(&path).unwrap();
        connection
            .execute_batch(
                "CREATE TABLE col (models TEXT NOT NULL); \
                 CREATE TABLE notes (id INTEGER PRIMARY KEY, mid INTEGER NOT NULL, flds TEXT NOT NULL); \
                 CREATE TABLE cards (id INTEGER PRIMARY KEY, nid INTEGER NOT NULL);",
            )
            .unwrap();
        connection
            .execute("INSERT INTO col (models) VALUES (?1)", params![models_json])
            .unwrap();
        for (index, (note_id, model_id, first, second)) in notes.iter().enumerate() {
            let fields = if first.contains(ANKI_FIELD_SEPARATOR) {
                first.to_string()
            } else {
                format!("{first}{ANKI_FIELD_SEPARATOR}{second}")
            };
            connection
                .execute(
                    "INSERT INTO notes (id, mid, flds) VALUES (?1, ?2, ?3)",
                    params![note_id, model_id, fields],
                )
                .unwrap();
            connection
                .execute(
                    "INSERT INTO cards (id, nid) VALUES (?1, ?2)",
                    params![i64::try_from(index).unwrap() + 1, note_id],
                )
                .unwrap();
        }
        drop(connection);
        let bytes = fs::read(&path).unwrap();
        fs::remove_file(path).unwrap();
        bytes
    }

    fn normalized_collection_bytes(
        model_id: i64,
        model_name: &str,
        field_names: &[&str],
        notes: &[(i64, i64, &str, &str)],
    ) -> Vec<u8> {
        let path = unique_test_collection_path();
        let connection = Connection::open(&path).unwrap();
        connection
            .execute_batch(
                "CREATE TABLE col (models TEXT NOT NULL); \
                 CREATE TABLE notetypes (id INTEGER PRIMARY KEY, name TEXT NOT NULL); \
                 CREATE TABLE fields (ntid INTEGER NOT NULL, ord INTEGER NOT NULL, name TEXT NOT NULL); \
                 CREATE TABLE notes (id INTEGER PRIMARY KEY, mid INTEGER NOT NULL, flds TEXT NOT NULL); \
                 CREATE TABLE cards (id INTEGER PRIMARY KEY, nid INTEGER NOT NULL);",
            )
            .unwrap();
        connection
            .execute("INSERT INTO col (models) VALUES ('')", [])
            .unwrap();
        connection
            .execute(
                "INSERT INTO notetypes (id, name) VALUES (?1, ?2)",
                params![model_id, model_name],
            )
            .unwrap();
        for (index, field_name) in field_names.iter().enumerate() {
            connection
                .execute(
                    "INSERT INTO fields (ntid, ord, name) VALUES (?1, ?2, ?3)",
                    params![model_id, i64::try_from(index).unwrap(), field_name],
                )
                .unwrap();
        }
        for (index, (note_id, note_model_id, first, second)) in notes.iter().enumerate() {
            let fields = if first.contains(ANKI_FIELD_SEPARATOR) {
                first.to_string()
            } else {
                format!("{first}{ANKI_FIELD_SEPARATOR}{second}")
            };
            connection
                .execute(
                    "INSERT INTO notes (id, mid, flds) VALUES (?1, ?2, ?3)",
                    params![note_id, note_model_id, fields],
                )
                .unwrap();
            connection
                .execute(
                    "INSERT INTO cards (id, nid) VALUES (?1, ?2)",
                    params![i64::try_from(index).unwrap() + 1, note_id],
                )
                .unwrap();
        }
        drop(connection);
        let bytes = fs::read(&path).unwrap();
        fs::remove_file(path).unwrap();
        bytes
    }

    fn apkg_bytes(collection_name: &str, collection: &[u8]) -> Vec<u8> {
        apkg_bytes_with_media(collection_name, collection, &[])
    }

    fn latest_apkg_bytes_with_media(collection: &[u8], media_files: &[(&str, &[u8])]) -> Vec<u8> {
        let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
        zip.start_file(ANKI_PACKAGE_META, zip_file_options())
            .unwrap();
        zip.write_all(&[0x08, 0x03]).unwrap();
        zip.start_file(COLLECTION_ANKI21B, zip_file_options())
            .unwrap();
        zip.write_all(&zstd::stream::encode_all(Cursor::new(collection), 0).unwrap())
            .unwrap();
        zip.start_file(COLLECTION_ANKI2, zip_file_options())
            .unwrap();
        zip.write_all(b"fallback should not be used").unwrap();
        if !media_files.is_empty() {
            zip.start_file(MEDIA_MANIFEST, zip_file_options()).unwrap();
            zip.write_all(
                &zstd::stream::encode_all(Cursor::new(latest_media_manifest(media_files)), 0)
                    .unwrap(),
            )
            .unwrap();
            for (index, (_, bytes)) in media_files.iter().enumerate() {
                zip.start_file(index.to_string(), zip_file_options())
                    .unwrap();
                zip.write_all(&zstd::stream::encode_all(Cursor::new(bytes), 0).unwrap())
                    .unwrap();
            }
        }
        zip.finish().unwrap().into_inner()
    }

    fn latest_media_manifest(media_files: &[(&str, &[u8])]) -> Vec<u8> {
        let mut manifest = Vec::new();
        for (file_name, bytes) in media_files {
            let mut entry = Vec::new();
            write_proto_string_field(&mut entry, 1, file_name);
            write_proto_varint_field(&mut entry, 2, bytes.len() as u64);
            write_proto_bytes_field(&mut entry, 3, &[0; 20]);
            write_proto_bytes_field(&mut manifest, 1, &entry);
        }
        manifest
    }

    fn write_proto_string_field(output: &mut Vec<u8>, field_number: u64, value: &str) {
        write_proto_bytes_field(output, field_number, value.as_bytes());
    }

    fn write_proto_bytes_field(output: &mut Vec<u8>, field_number: u64, value: &[u8]) {
        write_proto_varint(output, (field_number << 3) | 2);
        write_proto_varint(output, value.len() as u64);
        output.extend_from_slice(value);
    }

    fn write_proto_varint_field(output: &mut Vec<u8>, field_number: u64, value: u64) {
        write_proto_varint(output, field_number << 3);
        write_proto_varint(output, value);
    }

    fn write_proto_varint(output: &mut Vec<u8>, mut value: u64) {
        while value >= 0x80 {
            output.push(((value as u8) & 0x7f) | 0x80);
            value >>= 7;
        }
        output.push(value as u8);
    }

    fn apkg_bytes_with_media(
        collection_name: &str,
        collection: &[u8],
        media_files: &[(&str, &[u8])],
    ) -> Vec<u8> {
        let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
        zip.start_file(collection_name, zip_file_options()).unwrap();
        zip.write_all(collection).unwrap();
        if !media_files.is_empty() {
            let media_manifest = media_files
                .iter()
                .enumerate()
                .map(|(index, (file_name, _))| (index.to_string(), file_name.to_string()))
                .collect::<BTreeMap<_, _>>();
            zip.start_file(MEDIA_MANIFEST, zip_file_options()).unwrap();
            zip.write_all(serde_json::to_string(&media_manifest).unwrap().as_bytes())
                .unwrap();
            for (index, (_, bytes)) in media_files.iter().enumerate() {
                zip.start_file(index.to_string(), zip_file_options())
                    .unwrap();
                zip.write_all(bytes).unwrap();
            }
        }
        zip.finish().unwrap().into_inner()
    }

    fn empty_apkg_bytes() -> Vec<u8> {
        let zip = ZipWriter::new(Cursor::new(Vec::new()));
        zip.finish().unwrap().into_inner()
    }

    fn zip_file_options() -> SimpleFileOptions {
        SimpleFileOptions::default()
            .compression_method(CompressionMethod::Deflated)
            .compression_level(Some(1))
            .unix_permissions(0o644)
    }

    fn unique_test_collection_path() -> PathBuf {
        let since_epoch = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!(
            "cosimo-anki-import-test-{}-{since_epoch}.sqlite3",
            std::process::id()
        ))
    }
}
