//! This module compiles temporary Typst files and manages output files.
//!
//! The compile module has two primary functions:
//!
//! 1. Compile the temporary Typst file generated by the `generate` module using
//!    the appropriate Typst commands (PNG, SVG, or both based on note formats).
//!
//! 2. Associate output files with corresponding notes and fields. It queries the
//!    Typst file for metadata using the `query` module and builds a vector of
//!    `Note` structs. Output files are systematically renamed using the format:
//!    `<label>@@<field>@@<timestamp>.<format>` and added to the `picture` field
//!    of the `Note` struct. For Plain format, no output file is generated and
//!    the string value is passed directly to the note's fields.

use crate::ankiconnect::{Deck, Field, FieldValue, MediaFile, Model, Note as AnkiNote, Tag};
use crate::error::{Error, Result};
use crate::metadata::CompletedNote;
use crate::query;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use tokio::process::Command as AsyncCommand;

/// Format types for rendering note fields.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Format {
    /// Plain text format - no compilation needed.
    Plain,
    /// SVG vector format.
    Svg,
    /// PNG raster format.
    Png,
}

impl Format {
    /// Get the file extension for this format.
    pub fn extension(&self) -> &'static str {
        match self {
            Format::Plain => "txt",
            Format::Svg => "svg",
            Format::Png => "png",
        }
    }

    /// Parse a format name, rejecting anything that is not a known format.
    pub fn parse(s: &str) -> Result<Self> {
        match s.to_lowercase().as_str() {
            "plain" => Ok(Format::Plain),
            "svg" => Ok(Format::Svg),
            "png" => Ok(Format::Png),
            other => Err(Error::custom(format!(
                "unknown card format '{}' (expected \"svg\", \"png\", or \"plain\")",
                other
            ))),
        }
    }

    /// Get the typst format argument for this format.
    pub fn typst_arg(&self) -> &'static str {
        match self {
            Format::Plain => panic!("Plain format should not be compiled"),
            Format::Svg => "svg",
            Format::Png => "png",
        }
    }
}

/// Configuration for compiling Typst files.
#[derive(Debug, Clone)]
pub struct CompileConfig {
    /// The temporary Typst file to compile.
    pub temp_file: PathBuf,
    /// Output directory for compiled files.
    pub output_dir: PathBuf,
    /// Additional arguments to pass to typst compile.
    pub extra_args: Vec<String>,
    /// Note metadata with all defaults applied, in document order.
    pub completed_notes_metadata: Vec<CompletedNote>,
    /// The image formats that must be compiled for this run.
    pub required_formats: Vec<Format>,
}

impl CompileConfig {
    /// Create a new compile configuration.
    pub fn new(
        temp_file: PathBuf,
        output_dir: PathBuf,
        completed_notes_metadata: Vec<CompletedNote>,
    ) -> Result<Self> {
        Ok(Self {
            temp_file,
            output_dir,
            extra_args: Vec::new(),
            required_formats: query::determine_required_formats(&completed_notes_metadata)?,
            completed_notes_metadata,
        })
    }

    /// Add extra arguments to the typst compile command.
    pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
        self.extra_args = args;
        self
    }
}

/// Result of a compilation operation.
#[derive(Debug)]
pub struct CompileResult {
    /// The notes with associated media files.
    pub notes: Vec<AnkiNote>,
    /// Paths to generated output files.
    pub output_files: HashMap<Format, Vec<PathBuf>>,
}

/// Compile a temporary Typst file and generate Anki notes.
///
/// This function:
/// 1. Queries the temporary file for note metadata
/// 2. Determines which formats need to be compiled
/// 3. Runs the appropriate typst compile commands
/// 4. Associates output files with notes and fields
/// 5. Returns structured note data for AnkiConnect
///
/// # Arguments
///
/// * `config` - Compilation configuration
///
/// # Returns
///
/// Returns a `CompileResult` containing the generated notes and output file paths.
///
/// # Errors
///
/// Returns an error if:
/// - The temporary file cannot be queried for metadata
/// - Typst compilation fails
/// - Output files cannot be read or renamed
pub async fn compile_temp_file(config: &CompileConfig) -> Result<CompileResult> {
    if config.completed_notes_metadata.is_empty() {
        return Ok(CompileResult {
            notes: Vec::new(),
            output_files: HashMap::new(),
        });
    }

    // Compile the files for each required format, in parallel
    let futures: Vec<_> = config
        .required_formats
        .iter()
        .map(|format| {
            let format_clone = format.clone();
            async move {
                let files = compile_format(config, &format_clone).await;
                (format_clone, files)
            }
        })
        .collect();
    let results = futures::future::join_all(futures).await;
    let mut output_files = HashMap::new();
    for (format, files_result) in results {
        let files = files_result?;
        output_files.insert(format, files);
    }

    // Associate output files with notes and fields
    let notes =
        associate_files_with_notes(config, &config.completed_notes_metadata, &output_files).await?;

    Ok(CompileResult {
        notes,
        output_files,
    })
}

/// Compile the temporary file for a specific format.
async fn compile_format(config: &CompileConfig, format: &Format) -> Result<Vec<PathBuf>> {
    // Use {p} pattern to generate one file per page
    let output_pattern = config
        .output_dir
        .join(format!("output-{{p}}.{}", format.extension()));

    // Build the typst compile command. `extra_args` already carries a resolved
    // `--root` (and any `--font-path`s) set up by the sync module, so the
    // compile and the metadata query share one project root.
    let mut cmd = AsyncCommand::new("typst");
    cmd.arg("compile").arg("--format").arg(format.typst_arg());
    for arg in &config.extra_args {
        cmd.arg(arg);
    }
    cmd.arg(&config.temp_file).arg(&output_pattern);

    // Execute the command
    let output = cmd
        .output()
        .await
        .map_err(|e| Error::custom(format!("Failed to execute typst compile: {}", e)))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(Error::custom(format!(
            "Typst compilation failed: {}",
            stderr
        )));
    }

    // Find all generated output files (output-1.ext, output-2.ext, etc.)
    let mut output_files = Vec::new();
    let output_dir = config.output_dir.as_path();

    if output_dir.exists() {
        let entries = fs::read_dir(output_dir).map_err(|e| {
            Error::custom(format!(
                "Failed to read output directory {}: {}",
                output_dir.display(),
                e
            ))
        })?;

        for entry in entries {
            let entry = entry
                .map_err(|e| Error::custom(format!("Failed to read directory entry: {}", e)))?;

            let path = entry.path();
            if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
                // Check for pattern: output-<number>.extension
                if filename.starts_with("output-")
                    && path
                        .extension()
                        .is_some_and(|ext| ext == format.extension())
                {
                    output_files.push(path);
                }
            }
        }
    }

    // Sort output files by page number to ensure consistent ordering
    output_files.sort_by(|a, b| {
        let extract_page_num = |path: &PathBuf| -> u32 {
            path.file_stem()
                .and_then(|stem| stem.to_str())
                .and_then(|s| s.strip_prefix("output-"))
                .and_then(|s| s.parse().ok())
                .unwrap_or(0)
        };
        extract_page_num(a).cmp(&extract_page_num(b))
    });

    Ok(output_files)
}

/// Associate output files with notes and fields.
async fn associate_files_with_notes(
    _config: &CompileConfig,
    metadata_notes: &[CompletedNote],
    output_files: &HashMap<Format, Vec<PathBuf>>,
) -> Result<Vec<AnkiNote>> {
    let mut anki_notes = Vec::new();
    let timestamp = chrono::Utc::now().timestamp();

    // Create a mapping of (note_index, field_name) to output file
    let file_associations = create_file_associations(metadata_notes, output_files)?;

    for (note_index, metadata_note) in metadata_notes.iter().enumerate() {
        let mut fields = HashMap::new();
        let mut picture_files = Vec::new();

        // Sort field names to ensure consistent ordering (as per documentation)
        let mut sorted_fields: Vec<_> = metadata_note.data.keys().collect();
        sorted_fields.sort();

        for field_name in sorted_fields {
            let field_value = &metadata_note.data[field_name];
            let field_format = Format::parse(field_value.format.as_str())?;
            let output_file = file_associations.get(&(note_index, field_name.as_str()));
            match field_format {
                Format::Plain => {
                    // Plain text goes straight into the field.
                    fields.insert(
                        Field::new(field_name.clone()),
                        FieldValue::new(field_value.value.clone()),
                    );
                }
                Format::Svg => {
                    // SVG is inlined directly into the field, recoloured so it
                    // follows the Anki card's (themed) text colour. Being part
                    // of the card's DOM, an inline SVG can use `currentColor`;
                    // an `<img>`-embedded SVG could not. `inline_svg` also gives
                    // it a `<desc>` so Anki does not read the note as empty.
                    match output_file.and_then(|files| files.get(&Format::Svg)) {
                        Some(svg_path) => {
                            let svg = fs::read_to_string(svg_path).map_err(|e| {
                                Error::custom(format!(
                                    "Failed to read SVG '{}': {}",
                                    svg_path.display(),
                                    e
                                ))
                            })?;
                            fields.insert(
                                Field::new(field_name.clone()),
                                FieldValue::new(Some(inline_svg(
                                    &svg,
                                    &metadata_note.label,
                                    field_name,
                                ))),
                            );
                        }
                        None => {
                            fields.insert(
                                Field::new(field_name.clone()),
                                FieldValue::new(field_value.value.clone()),
                            );
                        }
                    }
                }
                Format::Png => {
                    // PNG is stored as an Anki media file, referenced via the
                    // note's `picture` array.
                    if let Some(output_file) = output_file {
                        let media_file = create_media_file(
                            output_file,
                            &metadata_note.label,
                            field_name,
                            timestamp,
                            &field_format,
                        )?;
                        fields.insert(
                            Field::new(field_name.clone()),
                            FieldValue::new(Some(String::new())),
                        );
                        picture_files.push(media_file);
                    } else {
                        fields.insert(
                            Field::new(field_name.clone()),
                            FieldValue::new(field_value.value.clone()),
                        );
                    }
                }
            }
        }

        // Create the Anki note
        let anki_note = AnkiNote {
            deck_name: Deck::new(metadata_note.deck.clone()),
            model_name: Model::new(metadata_note.model.clone()),
            fields,
            tags: Some(
                metadata_note
                    .tags
                    .iter()
                    .map(|tag| Tag::new(tag.clone()))
                    .collect(),
            ),
            options: None,
            audio: None,
            video: None,
            picture: if picture_files.is_empty() {
                None
            } else {
                Some(picture_files)
            },
        };

        anki_notes.push(anki_note);
    }

    Ok(anki_notes)
}

/// Maps `(note index, field name)` to the rendered output file for each format.
type FileAssociations<'a> = HashMap<(usize, &'a str), HashMap<Format, PathBuf>>;

/// Create a mapping of (note_index, field_name) to record { [format]: output_file }.
fn create_file_associations<'a>(
    metadata_notes: &'a [CompletedNote],
    output_files: &'a HashMap<Format, Vec<PathBuf>>,
) -> Result<FileAssociations<'a>> {
    let mut associations = HashMap::new();

    // For each format, get a reference to the Vec<PathBuf>
    let mut format_to_files: HashMap<Format, &Vec<PathBuf>> = HashMap::new();
    for (format, files) in output_files.iter() {
        format_to_files.insert(format.clone(), files);
    }

    // For each field, we need to know which index it is in the global field order
    // The order is: for all notes, for all fields (sorted), in order
    let mut global_field_index = 0;
    for (note_index, metadata_note) in metadata_notes.iter().enumerate() {
        let mut sorted_fields: Vec<_> = metadata_note.data.keys().collect();
        sorted_fields.sort();

        for field_name in sorted_fields {
            // For this (note_index, field_name), build a HashMap<Format, PathBuf>
            let mut field_files = HashMap::new();
            for (format, files) in &format_to_files {
                if global_field_index < files.len() {
                    field_files.insert(format.clone(), files[global_field_index].clone());
                }
            }
            associations.insert((note_index, field_name.as_str()), field_files);
            global_field_index += 1;
        }
    }

    Ok(associations)
}

/// Recolour a Typst-rendered SVG so it adapts to the Anki card's theme.
///
/// The card pages are rendered with no page fill, so the SVG is already
/// transparent and the card's own (light/dark) background shows through. The
/// foreground is rendered black by Typst; rewriting it to `currentColor` makes
/// it follow the card's CSS `color`, which Anki themes for light/dark mode.
/// Non-black colours (e.g. a coloured diagram) are deliberately left untouched.
fn theme_svg(svg: &str) -> String {
    svg.replace("fill=\"#000000\"", "fill=\"currentColor\"")
        .replace("stroke=\"#000000\"", "stroke=\"currentColor\"")
}

/// Escape a string for use as XML text content.
fn escape_xml(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

/// Prepare a Typst-rendered SVG to be inlined into an Anki field: theme it
/// (see [`theme_svg`]), then give it a `<desc>`.
///
/// Typst renders text as vector glyph paths, so an inline `<svg>` carries no
/// text and no media reference. Anki's empty-note check strips HTML and would
/// see nothing — rejecting any all-SVG note as "empty" — while its duplicate
/// check would see every SVG note alike. The `<desc>`, holding the note label
/// and field name, survives the strip: the field reads as non-empty and
/// unique. `<desc>` is SVG metadata, never drawn, so the card is unchanged.
fn inline_svg(svg: &str, label: &str, field: &str) -> String {
    let themed = theme_svg(svg);
    let desc = format!("<desc>{}: {}</desc>", escape_xml(label), escape_xml(field));
    // Insert the <desc> as the first child, just after the opening <svg> tag.
    match themed
        .find("<svg")
        .and_then(|start| themed[start..].find('>').map(|end| start + end + 1))
    {
        Some(at) => {
            let mut out = String::with_capacity(themed.len() + desc.len());
            out.push_str(&themed[..at]);
            out.push_str(&desc);
            out.push_str(&themed[at..]);
            out
        }
        // No recognisable <svg> tag; leave the content as-is.
        None => themed,
    }
}

/// Reduce a label or field name to characters safe for a media filename, so a
/// note label cannot smuggle path separators into Anki's media directory.
fn sanitize_filename_part(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
                c
            } else {
                '_'
            }
        })
        .collect()
}

/// Create a media file from an output file.
pub fn create_media_file(
    output_file: &HashMap<Format, PathBuf>,
    note_label: &str,
    field_name: &str,
    timestamp: i64,
    format: &Format,
) -> Result<MediaFile> {
    // AnkiConnect resolves a media file's `path` relative to Anki's own working
    // directory, not ours — so it must be sent as an absolute path.
    let path = &output_file[format];
    let absolute_path = std::fs::canonicalize(path).map_err(|e| {
        Error::custom(format!(
            "Failed to resolve output file path '{}': {}",
            path.display(),
            e
        ))
    })?;

    Ok(MediaFile {
        filename: format!(
            "{}@@{}@@{}.{}",
            sanitize_filename_part(note_label),
            sanitize_filename_part(field_name),
            timestamp,
            format.extension()
        ),
        data: None,
        path: Some(absolute_path.to_string_lossy().to_string()),
        url: None,
        skip_hash: None,
        fields: Some(vec![Field::new(field_name.to_string())]),
    })
}

/// Clean up output files in the given directory.
///
/// This function removes all files matching the pattern `output-*` in the
/// specified directory.
///
/// # Arguments
///
/// * `dir` - The directory to clean up
///
/// # Errors
///
/// Returns an error if the directory cannot be read or files cannot be removed.
pub fn cleanup_output_files(dir: &Path) -> Result<()> {
    if !dir.exists() {
        return Ok(()); // Nothing to clean up
    }

    let entries = fs::read_dir(dir)
        .map_err(|e| Error::custom(format!("Failed to read directory {}: {}", dir.display(), e)))?;

    for entry in entries {
        let entry = entry.map_err(|e| {
            Error::custom(format!(
                "Failed to read directory entry in {}: {}",
                dir.display(),
                e
            ))
        })?;

        let path = entry.path();
        if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
            // Clean up both pattern: output-<number>.ext and temp render files
            if filename.starts_with("output-") || filename.ends_with("_render.typ") {
                fs::remove_file(&path).map_err(|e| {
                    Error::custom(format!(
                        "Failed to remove output file {}: {}",
                        path.display(),
                        e
                    ))
                })?;
            }
        }
    }

    Ok(())
}

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

    #[test]
    fn format_parse_accepts_known_formats_case_insensitively() {
        assert_eq!(Format::parse("svg").unwrap(), Format::Svg);
        assert_eq!(Format::parse("PNG").unwrap(), Format::Png);
        assert_eq!(Format::parse("Plain").unwrap(), Format::Plain);
    }

    #[test]
    fn format_parse_rejects_unknown_formats() {
        assert!(Format::parse("jpeg").is_err());
        assert!(Format::parse("").is_err());
    }

    #[test]
    fn format_extension_matches_the_format() {
        assert_eq!(Format::Svg.extension(), "svg");
        assert_eq!(Format::Png.extension(), "png");
        assert_eq!(Format::Plain.extension(), "txt");
    }

    #[test]
    fn theme_svg_recolours_black_to_currentcolor() {
        let themed = theme_svg(r##"<path fill="#000000"/><path stroke="#000000"/>"##);
        assert!(themed.contains(r#"fill="currentColor""#));
        assert!(themed.contains(r#"stroke="currentColor""#));
        assert!(!themed.contains("#000000"));
    }

    #[test]
    fn theme_svg_leaves_non_black_colours_untouched() {
        let svg = r##"<path fill="#0074d9"/>"##;
        assert_eq!(theme_svg(svg), svg);
    }

    #[test]
    fn inline_svg_injects_a_desc_as_the_first_child() {
        let svg = r#"<svg class="typst-doc" width="10pt"><path d="M0 0"/></svg>"#;
        let out = inline_svg(svg, "def-limit", "Front");
        // The <desc> follows the opening <svg> tag and carries label + field,
        // so the field survives Anki's empty-note check and is unique per note.
        assert!(
            out.starts_with(r#"<svg class="typst-doc" width="10pt"><desc>def-limit: Front</desc>"#)
        );
        // The original graphic is preserved.
        assert!(out.contains(r#"<path d="M0 0"/>"#));
    }

    #[test]
    fn inline_svg_escapes_xml_in_the_desc() {
        let out = inline_svg(r#"<svg width="1pt"><path/></svg>"#, "a & b", "F<x>");
        assert!(out.contains("<desc>a &amp; b: F&lt;x&gt;</desc>"));
    }
}
