//! [`TemplateEngine`] — runtime Minijinja environment with optional autoreload.
//!
//! Two operating modes:
//!
//! - [`TemplateMode::Disk`] reads templates from `templates_root` on every
//!   request via `minijinja::path_loader`. When the `template-autoreload`
//!   feature is enabled, the [`minijinja_autoreload::AutoReloader`] watches the
//!   directory and rebuilds the environment on filesystem change. This is the
//!   dev-loop mode.
//! - [`TemplateMode::Embedded`] embeds templates into the binary via
//!   [`minijinja::Environment::add_template`]. The caller supplies a static map
//!   of `(name, source)` pairs — typically populated from `include_str!` macros
//!   in build.rs output.
//!
//! Mode selection is config-driven; the recommended environment switch is
//! `RUSTELO_TEMPLATES_MODE=disk|embedded` resolved by the caller before
//! constructing the engine.

use std::path::PathBuf;
use std::sync::RwLock;

use minijinja::{Environment, Value};

/// Operating mode for the template engine.
pub enum TemplateMode {
    /// Read templates from the given directory on every render. Watched for
    /// changes when the `template-autoreload` feature is enabled.
    Disk { templates_root: PathBuf },
    /// Templates baked into the binary at compile time.
    Embedded {
        templates: Vec<(&'static str, &'static str)>,
    },
}

/// Errors raised by the template engine.
#[derive(Debug, thiserror::Error)]
pub enum TemplateError {
    /// The named template was not found by the loader.
    #[error("template not found: {0}")]
    NotFound(String),
    /// The named block was not found inside the requested template.
    #[error("template block not found: {template}::{block}")]
    BlockNotFound { template: String, block: String },
    /// Minijinja raised a render-time error.
    #[error("template render error: {0}")]
    Render(String),
    /// Filesystem or autoreload error.
    #[error("template io error: {0}")]
    Io(String),
}

/// Public template engine with stable render APIs.
pub struct TemplateEngine {
    inner: EngineInner,
}

enum EngineInner {
    /// One-shot embedded environment (cheap clone of `&Environment`).
    Static(Environment<'static>),
    /// Disk-backed environment rebuilt under a RwLock for hot reload.
    Disk {
        /// Retained for diagnostics and a future manual-reload entry point even
        /// though the field is currently unread on the main render path.
        #[allow(dead_code)]
        templates_root: PathBuf,
        env: RwLock<Environment<'static>>,
    },
    /// Auto-reloading disk-backed environment (feature-gated).
    #[cfg(feature = "template-autoreload")]
    AutoReload {
        reloader: minijinja_autoreload::AutoReloader,
    },
}

impl TemplateEngine {
    /// Build a new engine from the supplied mode.
    pub fn new(mode: TemplateMode) -> Result<Self, TemplateError> {
        match mode {
            TemplateMode::Embedded { templates } => {
                let mut env = Environment::new();
                for (name, source) in templates {
                    env.add_template(name, source)
                        .map_err(|e| TemplateError::Render(e.to_string()))?;
                }
                Ok(Self {
                    inner: EngineInner::Static(env),
                })
            }
            TemplateMode::Disk { templates_root } => Self::new_disk(templates_root),
        }
    }

    #[cfg(not(feature = "template-autoreload"))]
    fn new_disk(templates_root: PathBuf) -> Result<Self, TemplateError> {
        let env = build_disk_environment(&templates_root)?;
        Ok(Self {
            inner: EngineInner::Disk {
                templates_root,
                env: RwLock::new(env),
            },
        })
    }

    #[cfg(feature = "template-autoreload")]
    fn new_disk(templates_root: PathBuf) -> Result<Self, TemplateError> {
        let watched = templates_root.clone();
        let reloader = minijinja_autoreload::AutoReloader::new(move |notifier| {
            let env = build_disk_environment(&watched).map_err(|e| {
                minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
            })?;
            notifier.watch_path(&watched, true);
            Ok(env)
        });
        Ok(Self {
            inner: EngineInner::AutoReload { reloader },
        })
    }

    /// Render the full template — top-level extends/blocks are resolved.
    pub fn render_page(&self, name: &str, ctx: Value) -> Result<String, TemplateError> {
        self.with_env(|env| {
            let tmpl = env
                .get_template(name)
                .map_err(|_| TemplateError::NotFound(name.to_owned()))?;
            tmpl.render(ctx)
                .map_err(|e| TemplateError::Render(e.to_string()))
        })
    }

    /// Render a single named block from the template — used for htmx fragments.
    ///
    /// The block must exist directly inside the template (not inside an
    /// extended parent that wasn't overridden) and is rendered with the
    /// same context.
    pub fn render_block(
        &self,
        name: &str,
        block: &str,
        ctx: Value,
    ) -> Result<String, TemplateError> {
        self.with_env(|env| {
            let tmpl = env
                .get_template(name)
                .map_err(|_| TemplateError::NotFound(name.to_owned()))?;
            let mut captured = tmpl
                .render_captured(ctx)
                .map_err(|e| TemplateError::Render(e.to_string()))?;
            captured
                .with_state_mut(|state| state.render_block(block))
                .map_err(|e| match e.kind() {
                    minijinja::ErrorKind::UnknownBlock => TemplateError::BlockNotFound {
                        template: name.to_owned(),
                        block: block.to_owned(),
                    },
                    _ => TemplateError::Render(e.to_string()),
                })
        })
    }

    /// Returns the set of templates currently known to the engine.
    pub fn known_templates(&self) -> Vec<String> {
        self.with_env(|env| {
            Ok::<_, TemplateError>(env.templates().map(|(name, _)| name.to_owned()).collect())
        })
        .unwrap_or_default()
    }

    /// Build an engine and apply `setup` to the underlying [`Environment`]
    /// before any templates are rendered. The setup closure runs once for
    /// embedded mode and once per rebuild for the autoreload mode.
    pub fn with_setup<F>(mode: TemplateMode, setup: F) -> Result<Self, TemplateError>
    where
        F: Fn(&mut Environment<'static>) -> Result<(), TemplateError> + Send + Sync + 'static,
    {
        match mode {
            TemplateMode::Embedded { templates } => {
                let mut env = Environment::new();
                for (name, source) in templates {
                    env.add_template(name, source)
                        .map_err(|e| TemplateError::Render(e.to_string()))?;
                }
                setup(&mut env)?;
                Ok(Self {
                    inner: EngineInner::Static(env),
                })
            }
            TemplateMode::Disk { templates_root } => {
                Self::new_disk_with_setup(templates_root, setup)
            }
        }
    }

    #[cfg(not(feature = "template-autoreload"))]
    fn new_disk_with_setup<F>(templates_root: PathBuf, setup: F) -> Result<Self, TemplateError>
    where
        F: Fn(&mut Environment<'static>) -> Result<(), TemplateError> + Send + Sync + 'static,
    {
        let mut env = build_disk_environment(&templates_root)?;
        setup(&mut env)?;
        Ok(Self {
            inner: EngineInner::Disk {
                templates_root,
                env: RwLock::new(env),
            },
        })
    }

    #[cfg(feature = "template-autoreload")]
    fn new_disk_with_setup<F>(templates_root: PathBuf, setup: F) -> Result<Self, TemplateError>
    where
        F: Fn(&mut Environment<'static>) -> Result<(), TemplateError> + Send + Sync + 'static,
    {
        let watched = templates_root.clone();
        let reloader = minijinja_autoreload::AutoReloader::new(move |notifier| {
            let mut env = build_disk_environment(&watched).map_err(|e| {
                minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
            })?;
            setup(&mut env).map_err(|e| {
                minijinja::Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
            })?;
            notifier.watch_path(&watched, true);
            Ok(env)
        });
        Ok(Self {
            inner: EngineInner::AutoReload { reloader },
        })
    }

    fn with_env<F, T>(&self, f: F) -> Result<T, TemplateError>
    where
        F: FnOnce(&Environment<'static>) -> Result<T, TemplateError>,
    {
        match &self.inner {
            EngineInner::Static(env) => f(env),
            EngineInner::Disk { env, .. } => {
                let guard = env.read().map_err(|e| TemplateError::Io(e.to_string()))?;
                f(&guard)
            }
            #[cfg(feature = "template-autoreload")]
            EngineInner::AutoReload { reloader } => {
                let env = reloader
                    .acquire_env()
                    .map_err(|e| TemplateError::Io(e.to_string()))?;
                f(&env)
            }
        }
    }
}

fn build_disk_environment(
    templates_root: &std::path::Path,
) -> Result<Environment<'static>, TemplateError> {
    if !templates_root.exists() {
        return Err(TemplateError::Io(format!(
            "templates_root does not exist: {}",
            templates_root.display()
        )));
    }
    let mut env = Environment::new();
    env.set_loader(minijinja::path_loader(templates_root));
    Ok(env)
}

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

    #[test]
    fn renders_embedded_page() {
        let engine = TemplateEngine::new(TemplateMode::Embedded {
            templates: vec![("hello.html", "Hi, {{ name }}!")],
        })
        .expect("engine");
        let ctx = minijinja::context! { name => "world" };
        let html = engine.render_page("hello.html", ctx).expect("render");
        assert_eq!(html, "Hi, world!");
    }

    #[test]
    fn renders_named_block() {
        let source = r#"{% block content %}<p>{{ msg }}</p>{% endblock %}<footer>x</footer>"#;
        let engine = TemplateEngine::new(TemplateMode::Embedded {
            templates: vec![("page.html", source)],
        })
        .expect("engine");
        let ctx = minijinja::context! { msg => "ok" };
        let fragment = engine
            .render_block("page.html", "content", ctx)
            .expect("block");
        assert_eq!(fragment, "<p>ok</p>");
    }

    #[test]
    fn block_not_found_is_typed() {
        let engine = TemplateEngine::new(TemplateMode::Embedded {
            templates: vec![("page.html", "{% block content %}a{% endblock %}")],
        })
        .expect("engine");
        let err = engine
            .render_block("page.html", "missing", minijinja::Value::UNDEFINED)
            .unwrap_err();
        match err {
            TemplateError::BlockNotFound { template, block } => {
                assert_eq!(template, "page.html");
                assert_eq!(block, "missing");
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn missing_template_is_typed() {
        let engine =
            TemplateEngine::new(TemplateMode::Embedded { templates: vec![] }).expect("engine");
        let err = engine
            .render_page("absent.html", minijinja::Value::UNDEFINED)
            .unwrap_err();
        assert!(matches!(err, TemplateError::NotFound(name) if name == "absent.html"));
    }

    #[test]
    fn lists_known_templates() {
        let engine = TemplateEngine::new(TemplateMode::Embedded {
            templates: vec![("a.html", "A"), ("b.html", "B")],
        })
        .expect("engine");
        let mut names = engine.known_templates();
        names.sort();
        assert_eq!(names, vec!["a.html".to_string(), "b.html".to_string()]);
    }
}
