//! Sample-driven preview rendering for the install-time gallery picker.
//!
//! Builds a `Payload` per widget using each fetcher's `sample_body` (or the shape-level
//! canonical sample as a fallback), then runs the real layout engine with those samples.
//! Same technique the docs-site snapshot generator uses — zero network / filesystem
//! access, deterministic output, matches what the user will actually see after install.

use std::collections::HashMap;

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Block;

use crate::config::{Config, DashboardConfig, SettingsConfig, WidgetConfig};
use crate::fetcher::{RegisteredFetcher, Registry as FetcherRegistry};
use crate::layout as layout_engine;
use crate::layout::WidgetId;
use crate::payload::{Body, ImageData, Payload, TextBlockData, TextData};
use crate::render::{
    Registry as RenderRegistry, RenderOptions, RenderSpec, Shape, default_renderer_for,
};
use crate::samples;
use crate::theme::Theme;

pub(crate) struct TemplatePreview {
    config: Config,
    payloads: HashMap<WidgetId, Payload>,
    specs: HashMap<WidgetId, RenderSpec>,
    theme: Theme,
    renderers: RenderRegistry,
}

impl TemplatePreview {
    /// Parses the template body and pre-builds every widget payload. Returns `None` for
    /// templates that fail to parse — callers render a fallback "preview unavailable"
    /// string rather than crashing the picker.
    pub(crate) fn from_body(body: &str) -> Option<Self> {
        Self::from_body_with_theme(body, Theme::default())
    }

    /// Same as [`from_body`] but paints the preview under an arbitrary theme — used by
    /// the theme picker so each candidate shows what the dashboard would look like.
    pub(crate) fn from_body_with_theme(body: &str, theme: Theme) -> Option<Self> {
        let dashboard = DashboardConfig::parse(body).ok()?;
        let config = Config::from_parts(SettingsConfig::default(), dashboard);
        let fetchers = FetcherRegistry::with_builtins();
        let renderers = RenderRegistry::with_builtins();
        let payloads = build_payloads(&config.widgets, &fetchers, &renderers);
        let specs = build_specs(&config.widgets);
        Some(Self {
            config,
            payloads,
            specs,
            theme,
            renderers,
        })
    }

    pub(crate) fn draw(&self, frame: &mut Frame, area: Rect) {
        if area.width == 0 || area.height == 0 {
            return;
        }
        frame.render_widget(
            Block::default().style(Style::default().bg(self.theme.bg)),
            area,
        );
        let root = self.config.to_layout();
        let loading: HashMap<WidgetId, Shape> = HashMap::new();
        layout_engine::draw(
            frame,
            area,
            &root,
            &self.payloads,
            &self.specs,
            &self.renderers,
            &self.theme,
            &self.config.general,
            &loading,
        );
    }
}

fn build_payloads(
    widgets: &[WidgetConfig],
    fetchers: &FetcherRegistry,
    renderers: &RenderRegistry,
) -> HashMap<WidgetId, Payload> {
    widgets
        .iter()
        .filter_map(|w| {
            let fetcher = fetchers.get(&w.fetcher)?;
            let shape = resolve_shape(w, &fetcher, renderers);
            let body = override_from_format(w, shape)
                .or_else(|| fetcher.sample_body(shape))
                .or_else(|| samples::canonical_sample(shape))
                .unwrap_or_else(|| {
                    Body::Image(ImageData {
                        path: "/dev/null".into(),
                    })
                });
            Some((
                w.id.clone(),
                Payload {
                    icon: None,
                    status: None,
                    format: w.format.clone(),
                    body,
                },
            ))
        })
        .collect()
}

fn resolve_shape(
    w: &WidgetConfig,
    fetcher: &RegisteredFetcher,
    renderers: &RenderRegistry,
) -> Shape {
    let shapes = fetcher.shapes();
    let accepted = w
        .render
        .as_ref()
        .and_then(|spec| renderers.get(spec.renderer_name()))
        .map(|r| r.accepts().to_vec());
    if let Some(accepted) = accepted {
        if let Some(shape) = shapes.iter().find(|s| accepted.contains(s)) {
            return *shape;
        }
        if let Some(&first) = accepted.first() {
            return first;
        }
    }
    fetcher.default_shape()
}

/// `basic_static` carries its literal through `format = "..."`; honour it so the preview
/// reflects what the user would see. Other fetchers don't piggy-back on `format` for
/// sample output — this is a no-op for them.
fn override_from_format(w: &WidgetConfig, shape: Shape) -> Option<Body> {
    if w.fetcher != "basic_static" {
        return None;
    }
    let fmt = w.format.as_ref()?;
    match shape {
        Shape::Text => Some(Body::Text(TextData { value: fmt.clone() })),
        Shape::TextBlock => Some(Body::TextBlock(TextBlockData {
            lines: fmt.lines().map(String::from).collect(),
        })),
        _ => None,
    }
}

fn build_specs(widgets: &[WidgetConfig]) -> HashMap<WidgetId, RenderSpec> {
    widgets
        .iter()
        .map(|w| {
            let spec = w.render.clone().unwrap_or_else(|| {
                RenderSpec::Short(default_renderer_for(Shape::Text).to_string())
            });
            (w.id.clone(), deanimate(spec))
        })
        .collect()
}

/// Animations look frozen at `t=0` (fade_in at 0% opacity, sweep_in at 0 progress) so the
/// preview would show a near-blank panel for anything wrapped in `animated_postfx` /
/// `animated_typewriter`. Swap those out for their underlying renderer's resting state so
/// the picker shows the final look of the widget.
fn deanimate(spec: RenderSpec) -> RenderSpec {
    match spec {
        RenderSpec::Short(ref name) if name == "animated_typewriter" => {
            RenderSpec::Short("text_plain".into())
        }
        RenderSpec::Full {
            ref type_name,
            ref options,
        } if type_name == "animated_typewriter" => RenderSpec::Full {
            type_name: "text_plain".into(),
            options: options.clone(),
        },
        RenderSpec::Short(ref name) if name == "animated_postfx" => {
            RenderSpec::Short("text_plain".into())
        }
        RenderSpec::Full {
            ref type_name,
            ref options,
        } if type_name == "animated_postfx" => RenderSpec::Full {
            type_name: inner_renderer_name(options),
            options: options.clone(),
        },
        RenderSpec::Short(ref name) if name == "animated_boot" => {
            RenderSpec::Short("text_plain".into())
        }
        RenderSpec::Full {
            ref type_name,
            ref options,
        } if type_name == "animated_boot" => RenderSpec::Full {
            type_name: inner_renderer_name(options),
            options: options.clone(),
        },
        RenderSpec::Short(ref name) if name == "animated_scanlines" => {
            RenderSpec::Short("text_plain".into())
        }
        RenderSpec::Full {
            ref type_name,
            ref options,
        } if type_name == "animated_scanlines" => RenderSpec::Full {
            type_name: inner_renderer_name(options),
            options: options.clone(),
        },
        RenderSpec::Short(ref name) if name == "animated_splitflap" => {
            RenderSpec::Short("text_plain".into())
        }
        RenderSpec::Full {
            ref type_name,
            ref options,
        } if type_name == "animated_splitflap" => RenderSpec::Full {
            type_name: inner_renderer_name(options),
            options: options.clone(),
        },
        RenderSpec::Short(ref name) if name == "animated_wave" => {
            RenderSpec::Short("text_plain".into())
        }
        RenderSpec::Full {
            ref type_name,
            ref options,
        } if type_name == "animated_wave" => RenderSpec::Full {
            type_name: inner_renderer_name(options),
            options: options.clone(),
        },
        // animated_figlet_morph's resting frame = text_ascii with the sequence's last font.
        // No inner knob here — the renderer always delegates to text_ascii — so swap in a
        // matching text_ascii spec for the preview.
        RenderSpec::Short(ref name) if name == "animated_figlet_morph" => RenderSpec::Full {
            type_name: "text_ascii".into(),
            options: RenderOptions {
                style: Some("figlet".into()),
                ..RenderOptions::default()
            }
            .with_extra("font", "ansi_shadow"),
        },
        RenderSpec::Full {
            ref type_name,
            ref options,
        } if type_name == "animated_figlet_morph" => {
            let resting_font = font_sequence(options)
                .and_then(|seq| seq.last().cloned())
                .unwrap_or_else(|| "ansi_shadow".into());
            RenderSpec::Full {
                type_name: "text_ascii".into(),
                options: RenderOptions {
                    style: Some("figlet".into()),
                    color: options.color.clone(),
                    align: options.align.clone(),
                    ..RenderOptions::default()
                }
                .with_extra("font", resting_font),
            }
        }
        other => other,
    }
}

/// Pull the wrapper renderer's `inner` field out of the raw extras blob, falling back to
/// `text_plain` so previews always have a concrete renderer to delegate to.
fn inner_renderer_name(opts: &RenderOptions) -> String {
    #[derive(serde::Deserialize, Default)]
    struct Wrapper {
        #[serde(default)]
        inner: Option<String>,
    }
    let parsed: Wrapper = opts.parse_specific();
    parsed.inner.unwrap_or_else(|| "text_plain".into())
}

fn font_sequence(opts: &RenderOptions) -> Option<Vec<String>> {
    #[derive(serde::Deserialize, Default)]
    struct Wrapper {
        #[serde(default)]
        font_sequence: Option<Vec<String>>,
    }
    let parsed: Wrapper = opts.parse_specific();
    parsed.font_sequence
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::templates;
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    fn widget(id: &str, fetcher: &str) -> WidgetConfig {
        WidgetConfig {
            id: id.into(),
            fetcher: fetcher.into(),
            ..Default::default()
        }
    }

    #[test]
    fn every_template_builds_a_preview() {
        for t in templates::TEMPLATES {
            assert!(
                TemplatePreview::from_body(t.body).is_some(),
                "{} failed to build preview",
                t.name
            );
        }
    }

    #[test]
    fn draw_does_not_panic_on_small_area() {
        let preview = TemplatePreview::from_body(templates::TEMPLATES[0].body).unwrap();
        let backend = TestBackend::new(40, 16);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| preview.draw(frame, frame.area()))
            .unwrap();
    }

    /// Theme picker renders the same template under each built-in preset. Guards against
    /// a preset-specific panic sneaking into the render path.
    #[test]
    fn every_theme_preset_renders_home_splash() {
        let body = templates::find("home_splash").unwrap().body;
        for name in crate::theme::presets::KNOWN.iter().copied() {
            let theme = crate::theme::presets::by_name(name).unwrap();
            let preview =
                TemplatePreview::from_body_with_theme(body, theme).expect("preview builds");
            let backend = TestBackend::new(100, 32);
            let mut terminal = Terminal::new(backend).unwrap();
            terminal
                .draw(|frame| preview.draw(frame, frame.area()))
                .unwrap_or_else(|e| panic!("{name} failed to render: {e}"));
        }
    }

    #[test]
    fn invalid_template_returns_none() {
        assert!(TemplatePreview::from_body("not [valid = toml").is_none());
    }

    #[test]
    fn draw_returns_early_for_zero_area() {
        let preview = TemplatePreview::from_body(templates::TEMPLATES[0].body).unwrap();
        let backend = TestBackend::new(40, 16);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| preview.draw(frame, Rect::new(0, 0, 0, 0)))
            .unwrap();
    }

    #[test]
    fn build_payloads_uses_static_format_and_sample_bodies() {
        let fetchers = FetcherRegistry::with_builtins();
        let renderers = RenderRegistry::with_builtins();

        let mut static_widget = widget("static", "basic_static");
        static_widget.format = Some("alpha\nbeta".into());
        static_widget.render = Some(RenderSpec::Short("list_plain".into()));

        let mut clock_widget = widget("clock", "clock");
        clock_widget.render = Some(RenderSpec::Short("grid_table".into()));

        let payloads = build_payloads(
            &[
                static_widget,
                clock_widget,
                widget("missing", "no_such_fetcher"),
            ],
            &fetchers,
            &renderers,
        );

        assert_eq!(payloads.len(), 2);
        assert!(matches!(
            &payloads.get("static").unwrap().body,
            Body::TextBlock(TextBlockData { lines })
                if lines == &vec![String::from("alpha"), String::from("beta")]
        ));
        assert!(matches!(
            payloads.get("clock").map(|payload| &payload.body),
            Some(Body::Entries(_))
        ));
        assert!(!payloads.contains_key("missing"));
    }

    #[test]
    fn resolve_shape_prefers_shared_shape_and_falls_back_to_renderer_or_default() {
        let fetchers = FetcherRegistry::with_builtins();
        let renderers = RenderRegistry::with_builtins();

        let mut shared = widget("shared", "basic_static");
        shared.render = Some(RenderSpec::Short("list_plain".into()));
        assert_eq!(
            resolve_shape(&shared, &fetchers.get("basic_static").unwrap(), &renderers),
            Shape::TextBlock
        );

        let mut mismatch = widget("mismatch", "git_repo_name");
        mismatch.render = Some(RenderSpec::Short("status_badge".into()));
        assert_eq!(
            resolve_shape(
                &mismatch,
                &fetchers.get("git_repo_name").unwrap(),
                &renderers
            ),
            Shape::Badge
        );

        assert_eq!(
            resolve_shape(
                &widget("defaulted", "clock"),
                &fetchers.get("clock").unwrap(),
                &renderers
            ),
            Shape::Text
        );
    }

    #[test]
    fn resolve_shape_uses_renderer_first_shape_when_no_shapes_overlap() {
        let fetchers = FetcherRegistry::with_builtins();
        let renderers = RenderRegistry::with_builtins();

        let mut mismatch = widget("mismatch", "clock");
        mismatch.render = Some(RenderSpec::Short("status_badge".into()));

        assert_eq!(
            resolve_shape(&mismatch, &fetchers.get("clock").unwrap(), &renderers),
            Shape::Badge
        );
    }

    #[test]
    fn build_specs_defaults_and_deanimates_wrapped_renderers() {
        let plain = widget("plain", "basic_static");

        let mut typewriter = widget("typewriter", "basic_static");
        typewriter.render = Some(RenderSpec::Short("animated_typewriter".into()));

        let mut postfx = widget("postfx", "basic_static");
        postfx.render = Some(RenderSpec::Full {
            type_name: "animated_postfx".into(),
            options: RenderOptions {
                align: Some("center".into()),
                ..RenderOptions::default()
            }
            .with_extra("inner", "list_plain"),
        });

        let specs = build_specs(&[plain, typewriter, postfx]);

        assert!(matches!(
            specs.get("plain"),
            Some(RenderSpec::Short(name)) if name == "text_plain"
        ));
        assert!(matches!(
            specs.get("typewriter"),
            Some(RenderSpec::Short(name)) if name == "text_plain"
        ));
        assert!(matches!(
            specs.get("postfx"),
            Some(RenderSpec::Full { type_name, options })
                if type_name == "list_plain" && options.align.as_deref() == Some("center")
        ));
    }

    #[test]
    fn override_from_format_returns_none_for_non_text_shapes() {
        let mut static_widget = widget("static", "basic_static");
        static_widget.format = Some("alpha".into());
        assert!(override_from_format(&static_widget, Shape::Badge).is_none());
    }

    #[test]
    fn deanimate_unwraps_wrappers_and_figlet_morph() {
        for name in [
            "animated_typewriter",
            "animated_postfx",
            "animated_boot",
            "animated_scanlines",
            "animated_splitflap",
            "animated_wave",
        ] {
            match deanimate(RenderSpec::Short(name.into())) {
                RenderSpec::Short(inner) => assert_eq!(inner, "text_plain"),
                other => panic!("expected short text_plain for {name}, got {other:?}"),
            }
        }

        for type_name in [
            "animated_postfx",
            "animated_boot",
            "animated_scanlines",
            "animated_splitflap",
            "animated_wave",
        ] {
            let spec = RenderSpec::Full {
                type_name: type_name.into(),
                options: RenderOptions::default().with_extra("inner", "status_badge"),
            };
            assert!(matches!(
                deanimate(spec),
                RenderSpec::Full { type_name, .. } if type_name == "status_badge"
            ));
        }

        assert!(matches!(
            deanimate(RenderSpec::Full {
                type_name: "animated_boot".into(),
                options: RenderOptions::default(),
            }),
            RenderSpec::Full { type_name, .. } if type_name == "text_plain"
        ));

        assert!(matches!(
            deanimate(RenderSpec::Short("animated_figlet_morph".into())),
            RenderSpec::Full { type_name, options }
                if type_name == "text_ascii"
                    && options.style.as_deref() == Some("figlet")
                    && options.extra_str("font") == Some("ansi_shadow")
        ));

        let spec = RenderSpec::Full {
            type_name: "animated_figlet_morph".into(),
            options: RenderOptions {
                align: Some("right".into()),
                color: Some("panel_title".into()),
                ..RenderOptions::default()
            }
            .with_extra("font_sequence", vec!["small", "doom"]),
        };
        assert!(matches!(
            deanimate(spec),
            RenderSpec::Full { type_name, options }
                if type_name == "text_ascii"
                    && options.style.as_deref() == Some("figlet")
                    && options.align.as_deref() == Some("right")
                    && options.color.as_deref() == Some("panel_title")
                    && options.extra_str("font") == Some("doom")
        ));

        assert!(matches!(
            deanimate(RenderSpec::Full {
                type_name: "animated_figlet_morph".into(),
                options: RenderOptions::default(),
            }),
            RenderSpec::Full { type_name, options }
                if type_name == "text_ascii"
                    && options.extra_str("font") == Some("ansi_shadow")
        ));
    }

    #[test]
    fn deanimate_full_typewriter_preserves_render_options() {
        let spec = RenderSpec::Full {
            type_name: "animated_typewriter".into(),
            options: RenderOptions {
                align: Some("right".into()),
                color: Some("panel_title".into()),
                ..RenderOptions::default()
            }
            .with_extra("speed", "2"),
        };

        assert!(matches!(
            deanimate(spec),
            RenderSpec::Full { type_name, options }
                if type_name == "text_plain"
                    && options.align.as_deref() == Some("right")
                    && options.color.as_deref() == Some("panel_title")
                    && options.extra_str("speed") == Some("2")
        ));
    }
}
