use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use bone_ui::a11y::widget_node_id;
use bone_ui::gallery::{
    GALLERY_CANVAS, GalleryState, STORIES, declared_ids, path_id, render_with, story_id,
};
use bone_ui::raster::{decode_png, encode_png, rasterize};
use bone_ui::theme::ThemeMode;
use bone_ui::widgets::WidgetPaint;
use bone_ui::{AccessTreeBuilder, FocusManager, StringTable, Theme, WidgetId};

const SNAPSHOT_CHANNEL_TOLERANCE: u8 = 1;
const SNAPSHOT_DRIFT_LIMIT: f64 = 0.0;

fn render_once() -> (FocusManager, AccessTreeBuilder) {
    let mut state = GalleryState::new();
    let mut focus = FocusManager::new();
    let mut a11y = AccessTreeBuilder::new();
    let _ = render_with(
        Arc::new(Theme::light()),
        &mut state,
        &mut focus,
        &mut a11y,
        StringTable::empty(),
    );
    (focus, a11y)
}

fn name_lookup() -> BTreeMap<WidgetId, String> {
    let primary = STORIES.iter().copied().map(|story| {
        let path: Vec<&str> = story.parent.into_iter().chain([story.key]).collect();
        (story_id(story), path.join("/"))
    });
    let extras = STORIES.iter().copied().flat_map(|story| {
        story
            .extras
            .iter()
            .map(|path| (path_id(path), path.join("/")))
    });
    primary.chain(extras).collect()
}

fn label(id: WidgetId, lookup: &BTreeMap<WidgetId, String>) -> String {
    match lookup.get(&id) {
        Some(name) => name.clone(),
        None => format!("{id:?}"),
    }
}

fn snapshot_path(theme: ThemeMode) -> PathBuf {
    let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    path.push("tests");
    path.push("snapshots");
    path.push(format!("gallery_{theme}.png"));
    path
}

fn render_theme(theme: &Theme) -> Vec<u8> {
    let mut state = GalleryState::new();
    let mut focus = FocusManager::new();
    let mut a11y = AccessTreeBuilder::new();
    let paint = render_with(
        Arc::new(theme.clone()),
        &mut state,
        &mut focus,
        &mut a11y,
        StringTable::empty(),
    );
    rasterize(theme, &paint, GALLERY_CANVAS, StringTable::empty())
}

struct Drift {
    max_channel_diff: u8,
    drifting_pixels: usize,
    total_pixels: usize,
}

impl Drift {
    fn measure(fresh: &[u8], pinned: &[u8]) -> Self {
        assert_eq!(
            fresh.len(),
            pinned.len(),
            "rgba buffer length mismatch: fresh={}, pinned={}",
            fresh.len(),
            pinned.len(),
        );
        let total = fresh.len() / 4;
        let (max_channel_diff, drifting_pixels) = fresh
            .chunks_exact(4)
            .zip(pinned.chunks_exact(4))
            .fold((0_u8, 0_usize), |(mx, count), (a, b)| {
                let pixel_max = a
                    .iter()
                    .zip(b.iter())
                    .map(|(x, y)| x.abs_diff(*y))
                    .max()
                    .unwrap_or(0);
                let counted = if pixel_max > SNAPSHOT_CHANNEL_TOLERANCE {
                    count + 1
                } else {
                    count
                };
                (mx.max(pixel_max), counted)
            });
        Self {
            max_channel_diff,
            drifting_pixels,
            total_pixels: total,
        }
    }

    fn drift_ratio(&self) -> f64 {
        #[allow(
            clippy::cast_precision_loss,
            reason = "pixel counts bounded by canvas dimensions, far below f64 mantissa"
        )]
        let ratio = self.drifting_pixels as f64 / self.total_pixels.max(1) as f64;
        ratio
    }

    fn within_tolerance(&self) -> bool {
        self.drift_ratio() <= SNAPSHOT_DRIFT_LIMIT
    }
}

fn bless(path: &Path, fresh: &[u8], theme_mode: ThemeMode) {
    let Ok(png) = encode_png(fresh, GALLERY_CANVAS) else {
        panic!("encode_png({theme_mode}) failed");
    };
    if let Some(parent) = path.parent()
        && let Err(e) = fs::create_dir_all(parent)
    {
        panic!("create snapshot dir {}: {e}", parent.display());
    }
    let Ok(()) = fs::write(path, &png) else {
        panic!("failed to write pinned PNG at {}", path.display());
    };
}

fn bless_or_compare(theme_mode: ThemeMode, theme: &Theme) {
    let fresh = render_theme(theme);
    let path = snapshot_path(theme_mode);

    if std::env::var_os("BONE_GALLERY_BLESS").is_some() {
        bless(&path, &fresh, theme_mode);
        return;
    }

    let Ok(pinned_bytes) = fs::read(&path) else {
        panic!(
            "pinned PNG missing at {}. Run with BONE_GALLERY_BLESS=1 to bless.",
            path.display(),
        );
    };
    let Ok(pinned_rgba) = decode_png(&pinned_bytes, GALLERY_CANVAS) else {
        panic!("decode pinned PNG at {} failed", path.display());
    };
    let drift = Drift::measure(&fresh, &pinned_rgba);
    assert!(
        drift.within_tolerance(),
        "{theme_mode} gallery PNG drift: max channel diff = {} (per-pixel tolerance {}), \
         {} of {} pixels exceed tolerance ({:.4}%, limit {:.4}%). \
         Run with BONE_GALLERY_BLESS=1 to refresh {}",
        drift.max_channel_diff,
        SNAPSHOT_CHANNEL_TOLERANCE,
        drift.drifting_pixels,
        drift.total_pixels,
        100.0 * drift.drift_ratio(),
        100.0 * SNAPSHOT_DRIFT_LIMIT,
        path.display(),
    );
}

#[test]
fn gallery_light_matches_pinned_png() {
    bless_or_compare(ThemeMode::Light, &Theme::light());
}

#[test]
fn gallery_dark_matches_pinned_png() {
    bless_or_compare(ThemeMode::Dark, &Theme::dark());
}

#[test]
fn every_declared_id_has_a_focusable_or_a11y_entry() {
    let (focus, a11y) = render_once();
    let focusables: BTreeSet<WidgetId> = focus.focusable_ids().collect();
    let a11y_ids: BTreeSet<WidgetId> = a11y.ids().collect();
    let lookup = name_lookup();
    let missing: Vec<String> = declared_ids()
        .into_iter()
        .filter(|id| !focusables.contains(id) && !a11y_ids.contains(id))
        .map(|id| label(id, &lookup))
        .collect();
    assert!(
        missing.is_empty(),
        "ids declared in STORIES but absent from focus and a11y trees: {missing:?}. \
         Adding a component or state requires a gallery entry that renders it.",
    );
}

#[test]
fn every_rendered_id_is_declared_in_stories() {
    let (focus, a11y) = render_once();
    let mut rendered: BTreeSet<WidgetId> = focus.focusable_ids().collect();
    rendered.extend(a11y.ids());
    let declared = declared_ids();
    let lookup = name_lookup();
    let extras: Vec<String> = rendered
        .difference(&declared)
        .copied()
        .map(|id| label(id, &lookup))
        .collect();
    assert!(
        extras.is_empty(),
        "gallery rendered ids absent from STORIES: {extras:?}. \
         Adding a widget or sub-id requires a STORIES entry or extras key.",
    );
}

#[test]
fn gallery_emits_at_least_one_tooltip_paint() {
    let mut state = GalleryState::new();
    let mut focus = FocusManager::new();
    let mut a11y = AccessTreeBuilder::new();
    let paint = render_with(
        Arc::new(Theme::light()),
        &mut state,
        &mut focus,
        &mut a11y,
        StringTable::empty(),
    );
    let tooltip_paints = paint
        .iter()
        .filter(|p| matches!(p, WidgetPaint::Tooltip { .. }))
        .count();
    assert!(
        tooltip_paints > 0,
        "gallery must paint a tooltip so the snapshot covers tooltip styling; \
         if this fails, the seeded hover or tooltip delay rules drifted",
    );
}

#[test]
fn paint_rects_stay_inside_gallery_canvas() {
    let mut state = GalleryState::new();
    let mut focus = FocusManager::new();
    let mut a11y = AccessTreeBuilder::new();
    let paint = render_with(
        Arc::new(Theme::light()),
        &mut state,
        &mut focus,
        &mut a11y,
        StringTable::empty(),
    );
    #[allow(
        clippy::cast_precision_loss,
        reason = "canvas dimensions far below f32 precision boundary"
    )]
    let max_x = GALLERY_CANVAS.width.value() as f32;
    #[allow(
        clippy::cast_precision_loss,
        reason = "canvas dimensions far below f32 precision boundary"
    )]
    let max_y = GALLERY_CANVAS.height.value() as f32;
    let lookup = name_lookup();
    let oob: Vec<String> = paint
        .iter()
        .filter_map(|p| {
            let (rect, anchor) = match p {
                WidgetPaint::Surface { rect, .. }
                | WidgetPaint::Label { rect, .. }
                | WidgetPaint::AlignedLabel { rect, .. }
                | WidgetPaint::Mark { rect, .. }
                | WidgetPaint::FocusRing { rect, .. }
                | WidgetPaint::SelectionHighlight { rect, .. }
                | WidgetPaint::Caret { rect, .. } => (*rect, None),
                WidgetPaint::Tooltip { rect, anchor, .. } => (*rect, Some(*anchor)),
            };
            let outside = rect.min_x().value() < 0.0
                || rect.min_y().value() < 0.0
                || rect.max_x().value() > max_x
                || rect.max_y().value() > max_y;
            outside.then(|| match anchor {
                Some(id) => format!("{} (anchor {})", paint_kind(p), label(id, &lookup)),
                None => paint_kind(p).to_string(),
            })
        })
        .collect();
    assert!(
        oob.is_empty(),
        "paint rects exceed canvas {max_x}x{max_y}: {oob:?}",
    );
}

fn paint_kind(p: &WidgetPaint) -> &'static str {
    match p {
        WidgetPaint::Surface { .. } => "Surface",
        WidgetPaint::Label { .. } => "Label",
        WidgetPaint::AlignedLabel { .. } => "AlignedLabel",
        WidgetPaint::Mark { .. } => "Mark",
        WidgetPaint::FocusRing { .. } => "FocusRing",
        WidgetPaint::SelectionHighlight { .. } => "SelectionHighlight",
        WidgetPaint::Caret { .. } => "Caret",
        WidgetPaint::Tooltip { .. } => "Tooltip",
    }
}

#[test]
fn every_story_primary_id_has_layout_bounds_in_a11y_tree() {
    let (focus, a11y) = render_once();
    let strings = StringTable::empty();
    let update = a11y.build(strings, focus.focused());
    let nodes: BTreeMap<accesskit::NodeId, &accesskit::Node> =
        update.nodes.iter().map(|(id, node)| (*id, node)).collect();
    let missing: Vec<&'static str> = STORIES
        .iter()
        .copied()
        .filter(|story| {
            let node_id = widget_node_id(story_id(*story));
            !nodes.contains_key(&node_id)
        })
        .map(|s| s.key)
        .collect();
    assert!(
        missing.is_empty(),
        "stories absent from accesskit tree: {missing:?}",
    );
}
