use std::fs;
use std::path::Path;

use crate::db::DbConnection as Connection;
use crate::db::schema::{posts, tags};
use diesel::prelude::*;
use uuid::Uuid;

use crate::db::queries::template as qt;
use crate::engine::{EngineError, EngineResult, domain_events};
use crate::model::{
    DomainEntity, NotificationAction, Post, Tag, Template, TemplateKind, TemplateStatus,
};
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
use crate::util::paths::template_file_path;
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};

const ALLOWED_LIQUID_TAGS: &[&str] = &[
    "if", "elsif", "else", "endif", "for", "endfor", "assign", "render",
];
const ALLOWED_LIQUID_FILTERS: &[&str] = &[
    "escape",
    "url_encode",
    "default",
    "append",
    "i18n",
    "markdown",
    "slugify",
];

/// Create a new draft template. Content stored in DB, no file written.
pub fn create_template(
    conn: &Connection,
    project_id: &str,
    title: &str,
    kind: TemplateKind,
    content: &str,
) -> EngineResult<Template> {
    let slug_source = if title.is_empty() { "untitled" } else { title };
    let base_slug = slugify(slug_source);
    let base_slug = if base_slug.is_empty() {
        "untitled".to_string()
    } else {
        base_slug
    };
    let slug = ensure_unique(&base_slug, |candidate| {
        qt::get_template_by_slug(conn, project_id, candidate).is_ok()
    });

    let now = now_unix_ms();
    let id = Uuid::new_v4().to_string();
    let tpl = Template {
        id,
        project_id: project_id.to_string(),
        slug,
        title: title.to_string(),
        kind,
        enabled: true,
        version: 1,
        file_path: String::new(),
        status: TemplateStatus::Draft,
        content: Some(content.to_string()),
        created_at: now,
        updated_at: now,
    };

    qt::insert_template(conn, &tpl)?;
    emit_template(&tpl, NotificationAction::Created);
    Ok(tpl)
}

/// Update a template's metadata and/or content. Bumps version.
#[expect(
    clippy::too_many_arguments,
    reason = "optional arguments represent independent template field changes"
)]
pub fn update_template(
    conn: &Connection,
    data_dir: &Path,
    template_id: &str,
    project_id: &str,
    title: Option<&str>,
    slug: Option<&str>,
    kind: Option<TemplateKind>,
    enabled: Option<bool>,
    content: Option<&str>,
) -> EngineResult<Template> {
    let mut tpl = qt::get_template_by_id(conn, template_id)?;
    if tpl.project_id != project_id {
        return Err(EngineError::NotFound(format!("template {template_id}")));
    }
    let original_slug = tpl.slug.clone();
    let original_file_path = tpl.file_path.clone();
    let was_published = tpl.status == TemplateStatus::Published;
    let effective_content = tpl.content.clone().unwrap_or_else(|| {
        if original_file_path.is_empty() {
            String::new()
        } else {
            read_published_template_body(data_dir, &original_file_path)
        }
    });
    let content_changed = content.is_some_and(|new_content| new_content != effective_content);

    if let Some(requested_slug) = slug {
        let slug = slugify(requested_slug);
        let slug = if slug.is_empty() {
            "template".to_string()
        } else {
            slug
        };
        tpl.slug = ensure_unique(&slug, |candidate| {
            qt::get_template_by_slug(conn, project_id, candidate)
                .is_ok_and(|existing| existing.id != template_id)
        });
    }

    if let Some(t) = title {
        tpl.title = t.to_string();
    }
    if let Some(k) = kind {
        tpl.kind = k;
    }
    if let Some(e) = enabled {
        tpl.enabled = e;
    }
    if let Some(c) = content {
        tpl.content = Some(c.to_string());
    }

    if was_published && content_changed {
        tpl.status = TemplateStatus::Draft;
    }

    let slug_changed = tpl.slug != original_slug;
    let published_body = if !original_file_path.is_empty()
        && (slug_changed || (was_published && !content_changed))
    {
        Some(effective_content)
    } else {
        None
    };
    if slug_changed && published_body.is_some() {
        tpl.file_path = template_file_path(&tpl.slug);
    }
    tpl.version += 1;
    tpl.updated_at = now_unix_ms();
    conn.begin_savepoint()?;
    let cascaded = (|| {
        qt::update_template(conn, &tpl)?;
        if slug_changed {
            cascade_template_slug_change(
                conn,
                &tpl.project_id,
                &original_slug,
                &tpl.slug,
                tpl.updated_at,
            )
        } else {
            Ok((Vec::new(), Vec::new()))
        }
    })();
    let (affected_posts, affected_tags) = match cascaded {
        Ok(affected) => {
            conn.release_savepoint()?;
            affected
        }
        Err(error) => {
            let _ = conn.rollback_savepoint();
            return Err(error);
        }
    };

    for post in &affected_posts {
        crate::engine::post::rewrite_published_post(conn, data_dir, &post.id)?;
    }
    if slug_changed {
        crate::engine::tag::rewrite_tags_json(conn, data_dir, &tpl.project_id)?;
    }
    if let Some(body) = published_body {
        rewrite_renamed_template_file(data_dir, &original_file_path, &tpl, &body)?;
    }

    emit_template(&tpl, NotificationAction::Updated);
    for post in &affected_posts {
        domain_events::entity_changed(
            &post.project_id,
            DomainEntity::Post,
            &post.id,
            NotificationAction::Updated,
        );
    }
    for tag in &affected_tags {
        domain_events::entity_changed(
            &tag.project_id,
            DomainEntity::Tag,
            &tag.id,
            NotificationAction::Updated,
        );
    }
    Ok(tpl)
}

/// Save template content (editor save). Updates DB, bumps version.
pub fn save_template(
    conn: &Connection,
    data_dir: &Path,
    template_id: &str,
    content: &str,
) -> EngineResult<Template> {
    let mut tpl = qt::get_template_by_id(conn, template_id)?;
    let was_published = tpl.status == TemplateStatus::Published;
    let effective_content = tpl.content.clone().unwrap_or_else(|| {
        if tpl.file_path.is_empty() {
            String::new()
        } else {
            read_published_template_body(data_dir, &tpl.file_path)
        }
    });
    let content_changed = content != effective_content;
    tpl.content = Some(content.to_string());
    tpl.version += 1;
    tpl.updated_at = now_unix_ms();

    if was_published && content_changed {
        tpl.status = TemplateStatus::Draft;
    }

    qt::update_template(conn, &tpl)?;
    if was_published && !content_changed && !tpl.file_path.is_empty() {
        rewrite_renamed_template_file(data_dir, &tpl.file_path, &tpl, &effective_content)?;
    }
    emit_template(&tpl, NotificationAction::Updated);
    Ok(tpl)
}

/// Validate Liquid template syntax. Returns Ok(()) or a parse error message.
pub fn validate_template(content: &str) -> Result<(), String> {
    validate_liquid_subset(content)?;
    crate::render::validate_liquid_template_syntax(content)
}

/// Publish a template: write frontmatter+body to file, clear content in DB.
pub fn publish_template(
    conn: &Connection,
    data_dir: &Path,
    template_id: &str,
) -> EngineResult<Template> {
    let mut tpl = qt::get_template_by_id(conn, template_id)?;

    if tpl.status == TemplateStatus::Published {
        return Err(EngineError::Conflict(
            "template is already published".to_string(),
        ));
    }

    let body = tpl.content.clone().unwrap_or_default();

    // Validate before publishing
    validate_template(&body).map_err(EngineError::Validation)?;

    let now = now_unix_ms();
    let rel_path = format!("templates/{}.liquid", tpl.slug);
    let abs_path = data_dir.join(&rel_path);

    // Ensure templates/ directory exists
    if let Some(parent) = abs_path.parent() {
        fs::create_dir_all(parent)?;
    }

    // Build frontmatter
    let fm = TemplateFrontmatter {
        id: tpl.id.clone(),
        project_id: Some(tpl.project_id.clone()),
        slug: tpl.slug.clone(),
        title: tpl.title.clone(),
        kind: template_kind_to_frontmatter(&tpl.kind),
        enabled: tpl.enabled,
        version: tpl.version,
        created_at: tpl.created_at,
        updated_at: now,
    };
    let file_content = write_template_file(&fm, &body);
    atomic_write_str(&abs_path, &file_content)?;

    // Update DB
    tpl.status = TemplateStatus::Published;
    tpl.file_path = rel_path;
    tpl.content = None;
    tpl.updated_at = now;
    qt::update_template(conn, &tpl)?;

    emit_template(&tpl, NotificationAction::Updated);

    Ok(tpl)
}

/// Unpublish a template: read content from file back into DB, set status to draft.
pub fn unpublish_template(
    conn: &Connection,
    data_dir: &Path,
    template_id: &str,
) -> EngineResult<Template> {
    let mut tpl = qt::get_template_by_id(conn, template_id)?;

    if tpl.status != TemplateStatus::Published {
        return Err(EngineError::Conflict(
            "template is not published".to_string(),
        ));
    }

    // Read body from file
    if !tpl.file_path.is_empty() {
        let abs_path = data_dir.join(&tpl.file_path);
        if abs_path.exists() {
            let file_content = fs::read_to_string(&abs_path)?;
            let (_fm, body) = crate::util::frontmatter::read_template_file(&file_content)
                .map_err(EngineError::Parse)?;
            tpl.content = Some(body);
        }
    }

    tpl.status = TemplateStatus::Draft;
    tpl.updated_at = now_unix_ms();
    qt::update_template(conn, &tpl)?;

    emit_template(&tpl, NotificationAction::Updated);

    Ok(tpl)
}

/// Delete a template. Checks for references from posts and tags.
pub fn delete_template(
    conn: &Connection,
    data_dir: &Path,
    template_id: &str,
    force: bool,
) -> EngineResult<()> {
    let tpl = qt::get_template_by_id(conn, template_id)?;

    // Check references
    let referencing_posts = count_posts_using_template(conn, &tpl.project_id, &tpl.slug)?;
    let referencing_tags = count_tags_using_template(conn, &tpl.project_id, &tpl.slug)?;

    if (referencing_posts > 0 || referencing_tags > 0) && !force {
        return Err(EngineError::Conflict(format!(
            "template is referenced by {} posts and {} tags",
            referencing_posts, referencing_tags
        )));
    }

    let (affected_posts, affected_tags) = if force {
        let affected = clear_template_references(conn, &tpl.project_id, &tpl.slug, now_unix_ms())?;
        for post in &affected.0 {
            crate::engine::post::rewrite_published_post(conn, data_dir, &post.id)?;
        }
        crate::engine::tag::rewrite_tags_json(conn, data_dir, &tpl.project_id)?;
        affected
    } else {
        (Vec::new(), Vec::new())
    };

    // Delete file if exists
    if !tpl.file_path.is_empty() {
        let abs_path = data_dir.join(&tpl.file_path);
        if abs_path.exists() {
            fs::remove_file(&abs_path)?;
        }
    }

    qt::delete_template(conn, template_id)?;
    emit_template(&tpl, NotificationAction::Deleted);
    for post in &affected_posts {
        domain_events::entity_changed(
            &post.project_id,
            DomainEntity::Post,
            &post.id,
            NotificationAction::Updated,
        );
    }
    for tag in &affected_tags {
        domain_events::entity_changed(
            &tag.project_id,
            DomainEntity::Tag,
            &tag.id,
            NotificationAction::Updated,
        );
    }
    Ok(())
}

fn emit_template(template: &Template, action: NotificationAction) {
    domain_events::entity_changed(
        &template.project_id,
        DomainEntity::Template,
        &template.id,
        action,
    );
}

// --- Helper functions ---

fn template_kind_to_frontmatter(kind: &TemplateKind) -> String {
    match kind {
        TemplateKind::Post => "post".to_string(),
        TemplateKind::List => "list".to_string(),
        TemplateKind::NotFound => "not_found".to_string(),
        TemplateKind::Partial => "partial".to_string(),
    }
}

fn read_published_template_body(data_dir: &Path, file_path: &str) -> String {
    fs::read_to_string(data_dir.join(file_path))
        .ok()
        .and_then(|file| crate::util::frontmatter::read_template_file(&file).ok())
        .map(|(_, body)| body)
        .unwrap_or_default()
}

fn rewrite_renamed_template_file(
    data_dir: &Path,
    original_file_path: &str,
    template: &Template,
    body: &str,
) -> EngineResult<()> {
    let frontmatter = TemplateFrontmatter {
        id: template.id.clone(),
        project_id: Some(template.project_id.clone()),
        slug: template.slug.clone(),
        title: template.title.clone(),
        kind: template_kind_to_frontmatter(&template.kind),
        enabled: template.enabled,
        version: template.version,
        created_at: template.created_at,
        updated_at: template.updated_at,
    };
    atomic_write_str(
        &data_dir.join(&template.file_path),
        &write_template_file(&frontmatter, body),
    )?;
    if original_file_path != template.file_path {
        match fs::remove_file(data_dir.join(original_file_path)) {
            Ok(()) => {}
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(error.into()),
        }
    }
    Ok(())
}

fn validate_liquid_subset(content: &str) -> Result<(), String> {
    let mut cursor = 0;
    while let Some((offset, is_tag)) = next_liquid_markup(&content[cursor..]) {
        let markup_start = cursor + offset + 2;
        let close = if is_tag { "%}" } else { "}}" };
        let Some(close_offset) = find_unquoted(&content[markup_start..], close) else {
            break;
        };
        let markup_end = markup_start + close_offset;
        let markup = content[markup_start..markup_end]
            .trim()
            .trim_start_matches('-')
            .trim_end_matches('-')
            .trim();

        let tag = is_tag.then(|| identifier_at_start(markup)).flatten();
        if let Some(tag) = tag
            && !ALLOWED_LIQUID_TAGS.contains(&tag)
        {
            return Err(format!("unsupported tag: {tag}"));
        }

        validate_liquid_filters(markup)?;
        if let Some(tag @ ("if" | "elsif")) = tag {
            validate_liquid_operators(markup[tag.len()..].trim_start())?;
        }
        cursor = markup_end + close.len();
    }
    Ok(())
}

fn next_liquid_markup(content: &str) -> Option<(usize, bool)> {
    match (content.find("{%"), content.find("{{")) {
        (Some(tag), Some(output)) if tag <= output => Some((tag, true)),
        (Some(_), Some(output)) => Some((output, false)),
        (Some(tag), None) => Some((tag, true)),
        (None, Some(output)) => Some((output, false)),
        (None, None) => None,
    }
}

fn find_unquoted(content: &str, needle: &str) -> Option<usize> {
    let bytes = content.as_bytes();
    let needle = needle.as_bytes();
    let mut quote = None;
    let mut escaped = false;
    let mut index = 0;
    while index + needle.len() <= bytes.len() {
        let byte = bytes[index];
        if let Some(active_quote) = quote {
            if escaped {
                escaped = false;
            } else if byte == b'\\' {
                escaped = true;
            } else if byte == active_quote {
                quote = None;
            }
        } else if matches!(byte, b'\'' | b'"') {
            quote = Some(byte);
        } else if &bytes[index..index + needle.len()] == needle {
            return Some(index);
        }
        index += 1;
    }
    None
}

fn identifier_at_start(content: &str) -> Option<&str> {
    let end = content
        .find(|character: char| !is_liquid_identifier(character))
        .unwrap_or(content.len());
    (end > 0).then(|| &content[..end])
}

fn is_liquid_identifier(character: char) -> bool {
    character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
}

fn validate_liquid_filters(markup: &str) -> Result<(), String> {
    for pipe in unquoted_byte_positions(markup, b'|') {
        let after_pipe = markup[pipe + 1..].trim_start();
        if let Some(filter) = identifier_at_start(after_pipe)
            && !ALLOWED_LIQUID_FILTERS.contains(&filter)
        {
            return Err(format!("unsupported filter: {filter}"));
        }
    }
    Ok(())
}

fn validate_liquid_operators(markup: &str) -> Result<(), String> {
    let bytes = markup.as_bytes();
    let mut quote = None;
    let mut escaped = false;
    let mut index = 0;
    while index < bytes.len() {
        let byte = bytes[index];
        if let Some(active_quote) = quote {
            if escaped {
                escaped = false;
            } else if byte == b'\\' {
                escaped = true;
            } else if byte == active_quote {
                quote = None;
            }
            index += 1;
            continue;
        }
        if matches!(byte, b'\'' | b'"') {
            quote = Some(byte);
            index += 1;
            continue;
        }

        let operator = match (byte, bytes.get(index + 1).copied()) {
            (b'!', Some(b'=')) => Some("!="),
            (b'>', Some(b'=')) => Some(">="),
            (b'<', Some(b'=')) => Some("<="),
            (b'<', _) => Some("<"),
            _ => None,
        };
        if let Some(operator) = operator {
            return Err(format!("unsupported operator: {operator}"));
        }

        if bytes[index..].starts_with(b"contains")
            && is_word_boundary(bytes.get(index.wrapping_sub(1)).copied())
            && is_word_boundary(bytes.get(index + "contains".len()).copied())
            && !markup[..index].trim_end().is_empty()
            && !markup[index + "contains".len()..].trim_start().is_empty()
            && !markup[..index].trim_end().ends_with('.')
        {
            return Err("unsupported operator: contains".to_string());
        }
        index += 1;
    }
    Ok(())
}

fn is_word_boundary(byte: Option<u8>) -> bool {
    byte.is_none_or(|byte| !byte.is_ascii_alphanumeric() && !matches!(byte, b'_' | b'-'))
}

fn unquoted_byte_positions(content: &str, target: u8) -> Vec<usize> {
    let mut positions = Vec::new();
    let mut quote = None;
    let mut escaped = false;
    for (index, byte) in content.bytes().enumerate() {
        if let Some(active_quote) = quote {
            if escaped {
                escaped = false;
            } else if byte == b'\\' {
                escaped = true;
            } else if byte == active_quote {
                quote = None;
            }
        } else if matches!(byte, b'\'' | b'"') {
            quote = Some(byte);
        } else if byte == target {
            positions.push(index);
        }
    }
    positions
}

fn count_posts_using_template(
    conn: &Connection,
    project_id: &str,
    slug: &str,
) -> EngineResult<usize> {
    let count: i64 = conn.with(|c| {
        posts::table
            .filter(posts::project_id.eq(project_id))
            .filter(posts::template_slug.eq(slug))
            .count()
            .get_result(c)
    })?;
    Ok(count as usize)
}

fn count_tags_using_template(
    conn: &Connection,
    project_id: &str,
    slug: &str,
) -> EngineResult<usize> {
    let count: i64 = conn.with(|c| {
        tags::table
            .filter(tags::project_id.eq(project_id))
            .filter(tags::post_template_slug.eq(slug))
            .count()
            .get_result(c)
    })?;
    Ok(count as usize)
}

fn cascade_template_slug_change(
    conn: &Connection,
    project_id: &str,
    old_slug: &str,
    new_slug: &str,
    updated_at: i64,
) -> EngineResult<(Vec<Post>, Vec<Tag>)> {
    let mut affected_posts = conn.with(|c| {
        posts::table
            .filter(posts::project_id.eq(project_id))
            .filter(posts::template_slug.eq(old_slug))
            .select(Post::as_select())
            .load(c)
    })?;
    let mut affected_tags = conn.with(|c| {
        tags::table
            .filter(tags::project_id.eq(project_id))
            .filter(tags::post_template_slug.eq(old_slug))
            .select(Tag::as_select())
            .load(c)
    })?;

    conn.with(|c| {
        diesel::update(
            posts::table
                .filter(posts::project_id.eq(project_id))
                .filter(posts::template_slug.eq(old_slug)),
        )
        .set((
            posts::template_slug.eq(new_slug),
            posts::updated_at.eq(updated_at),
        ))
        .execute(c)
    })?;
    conn.with(|c| {
        diesel::update(
            tags::table
                .filter(tags::project_id.eq(project_id))
                .filter(tags::post_template_slug.eq(old_slug)),
        )
        .set((
            tags::post_template_slug.eq(new_slug),
            tags::updated_at.eq(updated_at),
        ))
        .execute(c)
    })?;

    for post in &mut affected_posts {
        post.template_slug = Some(new_slug.to_string());
        post.updated_at = updated_at;
    }
    for tag in &mut affected_tags {
        tag.post_template_slug = Some(new_slug.to_string());
        tag.updated_at = updated_at;
    }
    Ok((affected_posts, affected_tags))
}

fn clear_template_references(
    conn: &Connection,
    project_id: &str,
    slug: &str,
    updated_at: i64,
) -> EngineResult<(Vec<Post>, Vec<Tag>)> {
    let mut affected_posts = conn.with(|c| {
        posts::table
            .filter(posts::project_id.eq(project_id))
            .filter(posts::template_slug.eq(slug))
            .select(Post::as_select())
            .load(c)
    })?;
    let mut affected_tags = conn.with(|c| {
        tags::table
            .filter(tags::project_id.eq(project_id))
            .filter(tags::post_template_slug.eq(slug))
            .select(Tag::as_select())
            .load(c)
    })?;

    conn.with(|c| {
        diesel::update(
            posts::table
                .filter(posts::project_id.eq(project_id))
                .filter(posts::template_slug.eq(slug)),
        )
        .set((
            posts::template_slug.eq(None::<String>),
            posts::updated_at.eq(updated_at),
        ))
        .execute(c)
    })?;
    conn.with(|c| {
        diesel::update(
            tags::table
                .filter(tags::project_id.eq(project_id))
                .filter(tags::post_template_slug.eq(slug)),
        )
        .set((
            tags::post_template_slug.eq(None::<String>),
            tags::updated_at.eq(updated_at),
        ))
        .execute(c)
    })?;

    for post in &mut affected_posts {
        post.template_slug = None;
        post.updated_at = updated_at;
    }
    for tag in &mut affected_tags {
        tag.post_template_slug = None;
        tag.updated_at = updated_at;
    }
    Ok((affected_posts, affected_tags))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::Database;
    use crate::db::queries::project::{insert_project, make_test_project};
    use tempfile::TempDir;

    fn setup() -> (Database, TempDir) {
        let db = Database::open_in_memory().unwrap();
        db.migrate().unwrap();
        insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
        let dir = TempDir::new().unwrap();
        (db, dir)
    }

    #[test]
    fn create_draft_template() {
        let (db, _dir) = setup();
        let tpl = create_template(
            db.conn(),
            "p1",
            "My Template",
            TemplateKind::Post,
            "<p>hello</p>",
        )
        .unwrap();
        assert_eq!(tpl.title, "My Template");
        assert_eq!(tpl.slug, "my-template");
        assert_eq!(tpl.kind, TemplateKind::Post);
        assert_eq!(tpl.status, TemplateStatus::Draft);
        assert_eq!(tpl.content, Some("<p>hello</p>".to_string()));
        assert!(tpl.file_path.is_empty());
        assert!(tpl.enabled);
        assert_eq!(tpl.version, 1);
    }

    #[test]
    fn create_template_deduplicates_slug() {
        let (db, _dir) = setup();
        let t1 = create_template(db.conn(), "p1", "Test", TemplateKind::Post, "").unwrap();
        let t2 = create_template(db.conn(), "p1", "Test", TemplateKind::Post, "").unwrap();
        assert_eq!(t1.slug, "test");
        assert_eq!(t2.slug, "test-2");
    }

    #[test]
    fn update_template_bumps_version() {
        let (db, dir) = setup();
        let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap();
        let updated = update_template(
            db.conn(),
            dir.path(),
            &tpl.id,
            "p1",
            Some("New Title"),
            None,
            None,
            None,
            Some("new content"),
        )
        .unwrap();
        assert_eq!(updated.title, "New Title");
        assert_eq!(updated.content, Some("new content".to_string()));
        assert_eq!(updated.version, 2);
    }

    #[test]
    fn update_template_slug_normalizes_and_uniquifies() {
        let (db, dir) = setup();
        create_template(db.conn(), "p1", "Alpha", TemplateKind::Post, "").unwrap();
        let t2 = create_template(db.conn(), "p1", "Beta", TemplateKind::Post, "").unwrap();
        let updated = update_template(
            db.conn(),
            dir.path(),
            &t2.id,
            "p1",
            None,
            Some(" Alpha! "),
            None,
            None,
            None,
        )
        .unwrap();
        assert_eq!(updated.slug, "alpha-2");
        assert!(updated.file_path.is_empty());

        let unchanged = update_template(
            db.conn(),
            dir.path(),
            &t2.id,
            "p1",
            None,
            Some(" Alpha 2 "),
            None,
            None,
            None,
        )
        .unwrap();
        assert_eq!(unchanged.slug, "alpha-2");
    }

    #[test]
    fn update_published_template_slug_rewrites_and_renames_file() {
        let (db, dir) = setup();
        let template = create_template(
            db.conn(),
            "p1",
            "Published Template",
            TemplateKind::Post,
            "<article>{{ title }}</article>",
        )
        .unwrap();
        let published = publish_template(db.conn(), dir.path(), &template.id).unwrap();
        let old_path = dir.path().join(&published.file_path);

        let updated = update_template(
            db.conn(),
            dir.path(),
            &template.id,
            "p1",
            None,
            Some(" Renamed Template! "),
            None,
            None,
            None,
        )
        .unwrap();

        assert_eq!(updated.slug, "renamed-template");
        assert_eq!(updated.file_path, "templates/renamed-template.liquid");
        assert!(!old_path.exists());
        let new_file = fs::read_to_string(dir.path().join(&updated.file_path)).unwrap();
        let (frontmatter, body) = crate::util::frontmatter::read_template_file(&new_file).unwrap();
        assert_eq!(frontmatter.slug, "renamed-template");
        assert_eq!(body, "<article>{{ title }}</article>");
        assert_eq!(updated.status, TemplateStatus::Published);
    }

    #[test]
    fn published_template_metadata_update_stays_published_and_rewrites_frontmatter() {
        let (db, dir) = setup();
        let template = create_template(
            db.conn(),
            "p1",
            "Published Template",
            TemplateKind::Post,
            "<article>{{ title }}</article>",
        )
        .unwrap();
        let published = publish_template(db.conn(), dir.path(), &template.id).unwrap();

        let updated = update_template(
            db.conn(),
            dir.path(),
            &template.id,
            "p1",
            Some("Renamed title"),
            None,
            Some(TemplateKind::List),
            Some(false),
            None,
        )
        .unwrap();

        assert_eq!(updated.status, TemplateStatus::Published);
        let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
        let (frontmatter, body) = crate::util::frontmatter::read_template_file(&file).unwrap();
        assert_eq!(frontmatter.title, "Renamed title");
        assert_eq!(frontmatter.kind, "list");
        assert!(!frontmatter.enabled);
        assert_eq!(frontmatter.version, updated.version);
        assert_eq!(body, "<article>{{ title }}</article>");
    }

    #[test]
    fn published_template_content_change_reopens_draft_and_preserves_published_file() {
        let (db, dir) = setup();
        let template = create_template(
            db.conn(),
            "p1",
            "Published Template",
            TemplateKind::Post,
            "old body",
        )
        .unwrap();
        let published = publish_template(db.conn(), dir.path(), &template.id).unwrap();

        let updated = update_template(
            db.conn(),
            dir.path(),
            &template.id,
            "p1",
            None,
            None,
            None,
            None,
            Some("new body"),
        )
        .unwrap();

        assert_eq!(updated.status, TemplateStatus::Draft);
        assert_eq!(updated.content.as_deref(), Some("new body"));
        let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
        let (_, body) = crate::util::frontmatter::read_template_file(&file).unwrap();
        assert_eq!(body, "old body");
    }

    #[test]
    fn identical_published_template_save_stays_published() {
        let (db, dir) = setup();
        let template = create_template(
            db.conn(),
            "p1",
            "Published Template",
            TemplateKind::Post,
            "same",
        )
        .unwrap();
        let published = publish_template(db.conn(), dir.path(), &template.id).unwrap();

        let saved = save_template(db.conn(), dir.path(), &template.id, "same").unwrap();

        assert_eq!(saved.status, TemplateStatus::Published);
        let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
        let (frontmatter, body) = crate::util::frontmatter::read_template_file(&file).unwrap();
        assert_eq!(frontmatter.version, saved.version);
        assert_eq!(body, "same");
    }

    #[test]
    fn update_template_slug_cascades_references_files_metadata_and_events() {
        let (db, dir) = setup();
        crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
        let template = create_template(
            db.conn(),
            "p1",
            "Article",
            TemplateKind::Post,
            "{{ title }}",
        )
        .unwrap();
        let post = crate::engine::post::create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Referenced Post",
            Some("body"),
            vec![],
            vec![],
            None,
            None,
            Some(&template.slug),
        )
        .unwrap();
        let mut published =
            crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
        published.updated_at = 1;
        crate::db::queries::post::update_post(db.conn(), &published).unwrap();
        let mut tag =
            crate::engine::tag::create_tag(db.conn(), dir.path(), "p1", "featured", None).unwrap();
        crate::engine::tag::update_tag(
            db.conn(),
            dir.path(),
            &tag.id,
            None,
            None,
            Some(&template.slug),
        )
        .unwrap();
        tag = crate::db::queries::tag::get_tag_by_id(db.conn(), &tag.id).unwrap();
        tag.updated_at = 1;
        crate::db::queries::tag::update_tag(db.conn(), &tag).unwrap();
        let events = domain_events::subscribe();

        update_template(
            db.conn(),
            dir.path(),
            &template.id,
            "p1",
            None,
            Some("renamed-article"),
            None,
            None,
            None,
        )
        .unwrap();

        let cascaded_post = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
        assert_eq!(
            cascaded_post.template_slug.as_deref(),
            Some("renamed-article")
        );
        assert!(cascaded_post.updated_at > 1);
        let cascaded_tag = crate::db::queries::tag::get_tag_by_id(db.conn(), &tag.id).unwrap();
        assert_eq!(
            cascaded_tag.post_template_slug.as_deref(),
            Some("renamed-article")
        );
        assert!(cascaded_tag.updated_at > 1);

        let post_file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
        let (frontmatter, _) = crate::util::frontmatter::read_post_file(&post_file).unwrap();
        assert_eq!(
            frontmatter.template_slug.as_deref(),
            Some("renamed-article")
        );
        let tags = crate::engine::meta::read_tags_json(dir.path()).unwrap();
        assert_eq!(
            tags[0].post_template_slug.as_deref(),
            Some("renamed-article")
        );

        let emitted = events.drain();
        for (entity, id) in [
            (DomainEntity::Template, template.id.as_str()),
            (DomainEntity::Post, post.id.as_str()),
            (DomainEntity::Tag, tag.id.as_str()),
        ] {
            assert!(emitted.iter().any(|event| matches!(
                event,
                crate::model::DomainEvent::EntityChanged {
                    project_id,
                    entity: actual_entity,
                    entity_id,
                    action: NotificationAction::Updated,
                } if project_id == "p1" && actual_entity == &entity && entity_id == id
            )));
        }
    }

    #[test]
    fn update_template_slug_rolls_back_the_whole_reference_cascade() {
        let (db, dir) = setup();
        let template =
            create_template(db.conn(), "p1", "Article", TemplateKind::Post, "body").unwrap();
        let mut post = crate::db::queries::post::make_test_post("post1", "p1", "post");
        post.template_slug = Some(template.slug.clone());
        crate::db::queries::post::insert_post(db.conn(), &post).unwrap();
        let tag =
            crate::engine::tag::create_tag(db.conn(), dir.path(), "p1", "featured", None).unwrap();
        crate::engine::tag::update_tag(
            db.conn(),
            dir.path(),
            &tag.id,
            None,
            None,
            Some(&template.slug),
        )
        .unwrap();
        db.conn().reject_tag_template_updates_for_test().unwrap();

        let result = update_template(
            db.conn(),
            dir.path(),
            &template.id,
            "p1",
            None,
            Some("renamed-article"),
            None,
            None,
            None,
        );

        assert!(result.is_err());
        let unchanged_template = qt::get_template_by_id(db.conn(), &template.id).unwrap();
        assert_eq!(unchanged_template.slug, "article");
        let unchanged_post = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
        assert_eq!(unchanged_post.template_slug.as_deref(), Some("article"));
        let unchanged_tag = crate::db::queries::tag::get_tag_by_id(db.conn(), &tag.id).unwrap();
        assert_eq!(unchanged_tag.post_template_slug.as_deref(), Some("article"));
    }

    #[test]
    fn save_template_updates_content() {
        let (db, dir) = setup();
        let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap();
        let saved = save_template(db.conn(), dir.path(), &tpl.id, "new content").unwrap();
        assert_eq!(saved.content, Some("new content".to_string()));
        assert_eq!(saved.version, 2);
    }

    #[test]
    fn publish_and_unpublish_template() {
        let (db, dir) = setup();
        let tpl = create_template(
            db.conn(),
            "p1",
            "Pub",
            TemplateKind::Post,
            "<div>body</div>",
        )
        .unwrap();

        // Publish
        let published = publish_template(db.conn(), dir.path(), &tpl.id).unwrap();
        assert_eq!(published.status, TemplateStatus::Published);
        assert!(published.content.is_none());
        assert_eq!(published.file_path, "templates/pub.liquid");
        assert!(dir.path().join("templates/pub.liquid").exists());

        // Unpublish
        let unpublished = unpublish_template(db.conn(), dir.path(), &published.id).unwrap();
        assert_eq!(unpublished.status, TemplateStatus::Draft);
        assert_eq!(unpublished.content, Some("<div>body</div>".to_string()));
    }

    #[test]
    fn publish_requires_draft() {
        let (db, dir) = setup();
        let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "body").unwrap();
        publish_template(db.conn(), dir.path(), &tpl.id).unwrap();
        let result = publish_template(db.conn(), dir.path(), &tpl.id);
        assert!(result.is_err());
    }

    #[test]
    fn delete_template_removes_file() {
        let (db, dir) = setup();
        let tpl = create_template(db.conn(), "p1", "Del", TemplateKind::Post, "body").unwrap();
        publish_template(db.conn(), dir.path(), &tpl.id).unwrap();
        assert!(dir.path().join("templates/del.liquid").exists());

        delete_template(db.conn(), dir.path(), &tpl.id, false).unwrap();
        assert!(!dir.path().join("templates/del.liquid").exists());
        assert!(qt::get_template_by_id(db.conn(), &tpl.id).is_err());
    }

    #[test]
    fn force_delete_clears_and_flushes_only_its_projects_references() {
        use crate::db::queries::{post as post_q, tag as tag_q};
        use crate::engine::{post, tag};

        let (db, dir) = setup();
        crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
        insert_project(db.conn(), &make_test_project("p2", "other-blog")).unwrap();
        let p1_dir = dir.path().join("p1");
        let p2_dir = dir.path().join("p2");
        fs::create_dir_all(&p1_dir).unwrap();
        fs::create_dir_all(&p2_dir).unwrap();

        let p1_template = create_template(
            db.conn(),
            "p1",
            "Article View",
            TemplateKind::Post,
            "{{ content }}",
        )
        .unwrap();
        let p2_template = create_template(
            db.conn(),
            "p2",
            "Article View",
            TemplateKind::Post,
            "{{ content }}",
        )
        .unwrap();
        let p1_template = publish_template(db.conn(), &p1_dir, &p1_template.id).unwrap();
        let p2_template = publish_template(db.conn(), &p2_dir, &p2_template.id).unwrap();

        let p1_post = post::create_post(
            db.conn(),
            &p1_dir,
            "p1",
            "P1 Post",
            Some("P1 body"),
            vec![],
            vec![],
            None,
            Some("en"),
            Some(&p1_template.slug),
        )
        .unwrap();
        let p2_post = post::create_post(
            db.conn(),
            &p2_dir,
            "p2",
            "P2 Post",
            Some("P2 body"),
            vec![],
            vec![],
            None,
            Some("en"),
            Some(&p2_template.slug),
        )
        .unwrap();
        let mut p1_post = post::publish_post(db.conn(), &p1_dir, &p1_post.id).unwrap();
        let p2_post = post::publish_post(db.conn(), &p2_dir, &p2_post.id).unwrap();

        let p1_tag = tag::create_tag(db.conn(), &p1_dir, "p1", "Feature", None).unwrap();
        tag::update_tag(
            db.conn(),
            &p1_dir,
            &p1_tag.id,
            None,
            None,
            Some(&p1_template.slug),
        )
        .unwrap();
        let p2_tag = tag::create_tag(db.conn(), &p2_dir, "p2", "Feature", None).unwrap();
        tag::update_tag(
            db.conn(),
            &p2_dir,
            &p2_tag.id,
            None,
            None,
            Some(&p2_template.slug),
        )
        .unwrap();

        p1_post.updated_at = 1;
        post_q::update_post(db.conn(), &p1_post).unwrap();
        let mut p1_tag = tag_q::get_tag_by_id(db.conn(), &p1_tag.id).unwrap();
        p1_tag.updated_at = 1;
        tag_q::update_tag(db.conn(), &p1_tag).unwrap();

        assert!(delete_template(db.conn(), &p1_dir, &p1_template.id, false).is_err());
        delete_template(db.conn(), &p1_dir, &p1_template.id, true).unwrap();

        let reloaded_p1_post = post_q::get_post_by_id(db.conn(), &p1_post.id).unwrap();
        let reloaded_p2_post = post_q::get_post_by_id(db.conn(), &p2_post.id).unwrap();
        let reloaded_p1_tag = tag_q::get_tag_by_id(db.conn(), &p1_tag.id).unwrap();
        let reloaded_p2_tag = tag_q::get_tag_by_id(db.conn(), &p2_tag.id).unwrap();
        assert_eq!(reloaded_p1_post.template_slug, None);
        assert_eq!(reloaded_p1_tag.post_template_slug, None);
        assert_eq!(
            reloaded_p2_post.template_slug.as_deref(),
            Some(p2_template.slug.as_str())
        );
        assert_eq!(
            reloaded_p2_tag.post_template_slug.as_deref(),
            Some(p2_template.slug.as_str())
        );
        assert!(reloaded_p1_post.updated_at > p1_post.updated_at);
        assert!(reloaded_p1_tag.updated_at > p1_tag.updated_at);

        let p1_post_file = fs::read_to_string(p1_dir.join(&p1_post.file_path)).unwrap();
        let p2_post_file = fs::read_to_string(p2_dir.join(&p2_post.file_path)).unwrap();
        let (p1_frontmatter, p1_body) =
            crate::util::frontmatter::read_post_file(&p1_post_file).unwrap();
        let (p2_frontmatter, p2_body) =
            crate::util::frontmatter::read_post_file(&p2_post_file).unwrap();
        assert_eq!(p1_frontmatter.template_slug, None);
        assert_eq!(p1_body.trim_end(), "P1 body");
        assert_eq!(
            p2_frontmatter.template_slug.as_deref(),
            Some(p2_template.slug.as_str())
        );
        assert_eq!(p2_body.trim_end(), "P2 body");

        let p1_tags = crate::engine::meta::read_tags_json(&p1_dir).unwrap();
        let p2_tags = crate::engine::meta::read_tags_json(&p2_dir).unwrap();
        assert_eq!(p1_tags[0].post_template_slug, None);
        assert_eq!(
            p2_tags[0].post_template_slug.as_deref(),
            Some(p2_template.slug.as_str())
        );
        assert!(!p1_dir.join(&p1_template.file_path).exists());
        assert!(p2_dir.join(&p2_template.file_path).exists());
    }

    #[test]
    fn validate_valid_liquid() {
        assert!(validate_template("<div>{{ title }}</div>").is_ok());
        assert!(validate_template("{% if x %}<p>y</p>{% endif %}").is_ok());
        assert!(validate_template("{% for p in posts %}{{ p.title }}{% endfor %}").is_ok());
    }

    #[test]
    fn validate_rejects_unsupported_liquid_tags() {
        for tag in [
            "unless",
            "case",
            "capture",
            "tablerow",
            "cycle",
            "increment",
        ] {
            let content = format!("{{% {tag} foo %}}bar{{% end{tag} %}}");
            assert_eq!(
                validate_template(&content),
                Err(format!("unsupported tag: {tag}"))
            );
        }
    }

    #[test]
    fn validate_rejects_unsupported_liquid_filters() {
        for filter in ["upcase", "downcase", "date", "truncate", "split", "reverse"] {
            let content = format!("{{{{ title | {filter} }}}}");
            assert_eq!(
                validate_template(&content),
                Err(format!("unsupported filter: {filter}"))
            );
        }
    }

    #[test]
    fn validate_rejects_unsupported_liquid_operators() {
        for operator in ["!=", "<", ">=", "<=", "contains"] {
            let content = format!("{{% if title {operator} other %}}yes{{% endif %}}");
            assert_eq!(
                validate_template(&content),
                Err(format!("unsupported operator: {operator}"))
            );
        }
    }

    #[test]
    fn validate_allows_the_complete_liquid_subset() {
        for filter in [
            "escape",
            "url_encode",
            "default: fallback",
            "append: suffix",
            "i18n: language",
            "markdown: post.id, posts, post_paths, media_paths, language, prefix",
            "slugify",
        ] {
            let result = validate_template(&format!("{{{{ title | {filter} }}}}"));
            assert!(
                result.is_ok(),
                "supported filter {filter} was rejected: {result:?}"
            );
        }
        for content in [
            "{% if title == other %}yes{% elsif total > 0 %}more{% else %}no{% endif %}",
            "{% if published %}yes{% endif %}",
            "{% if a == b and c > d %}yes{% endif %}",
            "{% if a == b or c == blank %}yes{% endif %}",
            "{% assign href = '/posts/' | append: post.slug %}",
            "{% render 'partials/card', post: post %}",
            "{%- for post in posts -%}{{- post.title | escape -}}{%- endfor -%}",
            "{% if values.size > 0 and map[key] %}yes{% endif %}",
        ] {
            let result = validate_template(content);
            assert!(
                result.is_ok(),
                "supported syntax was rejected: {content}: {result:?}"
            );
        }
    }

    #[test]
    fn bundled_starter_templates_conform_to_the_published_liquid_subset() {
        for (name, content) in [
            (
                "single-post",
                include_str!("../../../../assets/starter-templates/single-post.liquid"),
            ),
            (
                "post-list",
                include_str!("../../../../assets/starter-templates/post-list.liquid"),
            ),
            (
                "not-found",
                include_str!("../../../../assets/starter-templates/not-found.liquid"),
            ),
            (
                "head",
                include_str!("../../../../assets/starter-templates/partials/head.liquid"),
            ),
            (
                "language-switcher",
                include_str!(
                    "../../../../assets/starter-templates/partials/language-switcher.liquid"
                ),
            ),
            (
                "menu-items",
                include_str!("../../../../assets/starter-templates/partials/menu-items.liquid"),
            ),
            (
                "menu",
                include_str!("../../../../assets/starter-templates/partials/menu.liquid"),
            ),
            (
                "macro-gallery",
                include_str!("../../../../assets/starter-templates/macros/gallery.liquid"),
            ),
            (
                "macro-youtube",
                include_str!("../../../../assets/starter-templates/macros/youtube.liquid"),
            ),
            (
                "macro-vimeo",
                include_str!("../../../../assets/starter-templates/macros/vimeo.liquid"),
            ),
            (
                "macro-photo-archive",
                include_str!("../../../../assets/starter-templates/macros/photo-archive.liquid"),
            ),
            (
                "macro-tag-cloud",
                include_str!("../../../../assets/starter-templates/macros/tag-cloud.liquid"),
            ),
        ] {
            let result = validate_template(content);
            assert!(result.is_ok(), "starter template {name}: {result:?}");
        }
    }

    #[test]
    fn publish_rejects_unsupported_filter_and_leaves_template_draft() {
        let (db, dir) = setup();
        let template = create_template(
            db.conn(),
            "p1",
            "Strict",
            TemplateKind::Post,
            "{{ title | upcase }}",
        )
        .unwrap();

        let error = publish_template(db.conn(), dir.path(), &template.id).unwrap_err();

        assert!(error.to_string().contains("unsupported filter: upcase"));
        let reloaded = qt::get_template_by_id(db.conn(), &template.id).unwrap();
        assert_eq!(reloaded.status, TemplateStatus::Draft);
        assert_eq!(reloaded.content.as_deref(), Some("{{ title | upcase }}"));
        assert!(!dir.path().join("templates/strict.liquid").exists());
    }

    #[test]
    fn validate_unclosed_output() {
        assert!(validate_template("<div>{{ title </div>").is_err());
    }

    #[test]
    fn validate_unclosed_if() {
        assert!(validate_template("{% if x %}<p>y</p>").is_err());
    }

    #[test]
    fn validate_unmatched_endif() {
        assert!(validate_template("<p>y</p>{% endif %}").is_err());
    }

    #[test]
    fn validate_unclosed_for() {
        assert!(validate_template("{% for p in posts %}{{ p.title }}").is_err());
    }
}
