use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};

use super::axis::Axis;

fn default_prune() -> bool {
    true
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct View {
    pub name: String,
    /// Axis assignment for each category
    pub category_axes: IndexMap<String, Axis>,
    /// For page axis: selected item per category
    pub page_selections: HashMap<String, String>,
    /// Hidden items per category
    pub hidden_items: HashMap<String, HashSet<String>>,
    /// Collapsed groups per category
    pub collapsed_groups: HashMap<String, HashSet<String>>,
    /// Number format string (e.g. ",.0f" for comma-separated integer)
    pub number_format: String,
    /// When true, empty rows/columns are pruned from the display.
    #[serde(default = "default_prune")]
    pub prune_empty: bool,
    /// Scroll offset for grid
    pub row_offset: usize,
    pub col_offset: usize,
    /// Selected cell (row_idx, col_idx)
    pub selected: (usize, usize),
}

impl View {
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            category_axes: IndexMap::new(),
            page_selections: HashMap::new(),
            hidden_items: HashMap::new(),
            collapsed_groups: HashMap::new(),
            number_format: ",.0".to_string(),
            prune_empty: false,
            row_offset: 0,
            col_offset: 0,
            selected: (0, 0),
        }
    }

    pub fn on_category_added(&mut self, cat_name: &str) {
        if !self.category_axes.contains_key(cat_name) {
            // Virtual/underscore categories default to Axis::None.
            // Regular categories auto-assign: first → Row, second → Column, rest → Page.
            // If a virtual currently holds Row or Column and a regular category needs
            // the slot, bump the virtual to None.
            let axis = if cat_name.starts_with('_') {
                Axis::None
            } else {
                let regular_rows: Vec<String> = self
                    .categories_on(Axis::Row)
                    .into_iter()
                    .filter(|c| !c.starts_with('_'))
                    .map(String::from)
                    .collect();
                let regular_cols: Vec<String> = self
                    .categories_on(Axis::Column)
                    .into_iter()
                    .filter(|c| !c.starts_with('_'))
                    .map(String::from)
                    .collect();
                if regular_rows.is_empty() {
                    // Bump any virtual on Row to None
                    let bump: Vec<String> = self
                        .categories_on(Axis::Row)
                        .into_iter()
                        .filter(|c| c.starts_with('_'))
                        .map(String::from)
                        .collect();
                    for c in bump {
                        self.category_axes.insert(c, Axis::None);
                    }
                    Axis::Row
                } else if regular_cols.is_empty() {
                    let bump: Vec<String> = self
                        .categories_on(Axis::Column)
                        .into_iter()
                        .filter(|c| c.starts_with('_'))
                        .map(String::from)
                        .collect();
                    for c in bump {
                        self.category_axes.insert(c, Axis::None);
                    }
                    Axis::Column
                } else {
                    Axis::Page
                }
            };
            self.category_axes.insert(cat_name.to_string(), axis);
        }
    }

    pub fn on_category_removed(&mut self, cat_name: &str) {
        self.category_axes.shift_remove(cat_name);
        self.page_selections.remove(cat_name);
        self.hidden_items.remove(cat_name);
    }

    pub fn set_axis(&mut self, cat_name: &str, axis: Axis) {
        if let Some(a) = self.category_axes.get_mut(cat_name) {
            *a = axis;
        }
    }

    pub fn axis_of(&self, cat_name: &str) -> Axis {
        *self
            .category_axes
            .get(cat_name)
            .expect("axis_of called for category not registered with this view")
    }

    pub fn categories_on(&self, axis: Axis) -> Vec<&str> {
        self.category_axes
            .iter()
            .filter(|&(_, &a)| a == axis)
            .map(|(n, _)| n.as_str())
            .collect()
    }

    /// Owned-string variant of `categories_on(Axis::None)`. Used by callers
    /// that need to pass the None-axis set to formula recomputation, which
    /// takes `&[String]` so it can be stored without tying lifetimes to `View`.
    pub fn none_cats(&self) -> Vec<String> {
        self.categories_on(Axis::None)
            .into_iter()
            .map(String::from)
            .collect()
    }

    pub fn set_page_selection(&mut self, cat_name: &str, item: &str) {
        self.page_selections
            .insert(cat_name.to_string(), item.to_string());
    }

    pub fn page_selection(&self, cat_name: &str) -> Option<&str> {
        self.page_selections.get(cat_name).map(|s| s.as_str())
    }

    pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) {
        let set = self
            .collapsed_groups
            .entry(cat_name.to_string())
            .or_default();
        if set.contains(group_name) {
            set.remove(group_name);
        } else {
            set.insert(group_name.to_string());
        }
    }

    pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool {
        self.collapsed_groups
            .get(cat_name)
            .map(|s| s.contains(group_name))
            .unwrap_or(false)
    }

    pub fn hide_item(&mut self, cat_name: &str, item_name: &str) {
        self.hidden_items
            .entry(cat_name.to_string())
            .or_default()
            .insert(item_name.to_string());
    }

    pub fn show_item(&mut self, cat_name: &str, item_name: &str) {
        if let Some(set) = self.hidden_items.get_mut(cat_name) {
            set.remove(item_name);
        }
    }

    pub fn is_hidden(&self, cat_name: &str, item_name: &str) -> bool {
        self.hidden_items
            .get(cat_name)
            .map(|s| s.contains(item_name))
            .unwrap_or(false)
    }

    /// Swap all Row categories to Column and all Column categories to Row.
    /// Page categories are unaffected.
    pub fn transpose_axes(&mut self) {
        let rows: Vec<String> = self
            .categories_on(Axis::Row)
            .iter()
            .map(|s| s.to_string())
            .collect();
        let cols: Vec<String> = self
            .categories_on(Axis::Column)
            .iter()
            .map(|s| s.to_string())
            .collect();
        for cat in &rows {
            self.set_axis(cat, Axis::Column);
        }
        for cat in &cols {
            self.set_axis(cat, Axis::Row);
        }
        self.selected = (0, 0);
        self.row_offset = 0;
        self.col_offset = 0;
    }

    /// Cycle axis for a category: Row → Column → Page → None → Row
    pub fn cycle_axis(&mut self, cat_name: &str) {
        let next = match self.axis_of(cat_name) {
            Axis::Row => Axis::Column,
            Axis::Column => Axis::Page,
            Axis::Page => Axis::None,
            Axis::None => Axis::Row,
        };
        self.set_axis(cat_name, next);
        self.selected = (0, 0);
        self.row_offset = 0;
        self.col_offset = 0;
    }
}

#[cfg(test)]
mod tests {
    use super::View;
    use crate::view::Axis;

    fn view_with_cats(cats: &[&str]) -> View {
        let mut v = View::new("Test");
        for &c in cats {
            v.on_category_added(c);
        }
        v
    }

    #[test]
    fn first_category_assigned_to_row() {
        let v = view_with_cats(&["Region"]);
        assert_eq!(v.axis_of("Region"), Axis::Row);
    }

    #[test]
    fn second_category_assigned_to_column() {
        let v = view_with_cats(&["Region", "Product"]);
        assert_eq!(v.axis_of("Product"), Axis::Column);
    }

    #[test]
    fn third_and_later_categories_assigned_to_page() {
        let v = view_with_cats(&["Region", "Product", "Time", "Scenario"]);
        assert_eq!(v.axis_of("Time"), Axis::Page);
        assert_eq!(v.axis_of("Scenario"), Axis::Page);
    }

    #[test]
    fn set_axis_changes_assignment() {
        let mut v = view_with_cats(&["Region", "Product"]);
        v.set_axis("Region", Axis::Column);
        assert_eq!(v.axis_of("Region"), Axis::Column);
    }

    #[test]
    fn categories_on_returns_correct_list() {
        let v = view_with_cats(&["Region", "Product", "Time"]);
        assert_eq!(v.categories_on(Axis::Row), vec!["Region"]);
        assert_eq!(v.categories_on(Axis::Column), vec!["Product"]);
        assert_eq!(v.categories_on(Axis::Page), vec!["Time"]);
    }

    #[test]
    fn transpose_axes_swaps_row_and_column() {
        let mut v = view_with_cats(&["Region", "Product"]);
        // Default: Region=Row, Product=Column
        assert_eq!(v.axis_of("Region"), Axis::Row);
        assert_eq!(v.axis_of("Product"), Axis::Column);
        v.transpose_axes();
        assert_eq!(v.axis_of("Region"), Axis::Column);
        assert_eq!(v.axis_of("Product"), Axis::Row);
    }

    #[test]
    fn transpose_axes_leaves_page_categories_unchanged() {
        let mut v = view_with_cats(&["Region", "Product", "Time"]);
        // Default: Region=Row, Product=Column, Time=Page
        v.transpose_axes();
        assert_eq!(v.axis_of("Time"), Axis::Page);
    }

    #[test]
    fn transpose_axes_is_its_own_inverse() {
        let mut v = view_with_cats(&["Region", "Product"]);
        v.transpose_axes();
        v.transpose_axes();
        assert_eq!(v.axis_of("Region"), Axis::Row);
        assert_eq!(v.axis_of("Product"), Axis::Column);
    }

    #[test]
    #[should_panic(expected = "axis_of called for category not registered")]
    fn axis_of_unknown_category_panics() {
        let v = View::new("Test");
        v.axis_of("Ghost");
    }

    #[test]
    fn page_selection_set_and_get() {
        let mut v = view_with_cats(&["Region", "Product", "Time"]);
        v.set_page_selection("Time", "Q1");
        assert_eq!(v.page_selection("Time"), Some("Q1"));
        assert_eq!(v.page_selection("Region"), None);
    }

    #[test]
    fn toggle_group_collapse_toggles_twice() {
        let mut v = View::new("Test");
        assert!(!v.is_group_collapsed("Time", "Q1"));
        v.toggle_group_collapse("Time", "Q1");
        assert!(v.is_group_collapsed("Time", "Q1"));
        v.toggle_group_collapse("Time", "Q1");
        assert!(!v.is_group_collapsed("Time", "Q1"));
    }

    #[test]
    fn is_group_collapsed_isolated_across_categories() {
        let mut v = View::new("Test");
        v.toggle_group_collapse("Cat1", "G1");
        assert!(!v.is_group_collapsed("Cat2", "G1"));
    }

    #[test]
    fn is_group_collapsed_isolated_across_groups() {
        let mut v = View::new("Test");
        v.toggle_group_collapse("Cat1", "G1");
        assert!(!v.is_group_collapsed("Cat1", "G2"));
    }

    #[test]
    fn hide_and_show_item() {
        let mut v = View::new("Test");
        assert!(!v.is_hidden("Region", "East"));
        v.hide_item("Region", "East");
        assert!(v.is_hidden("Region", "East"));
        v.show_item("Region", "East");
        assert!(!v.is_hidden("Region", "East"));
    }

    #[test]
    fn cycle_axis_row_to_column() {
        let mut v = view_with_cats(&["Region", "Product"]);
        v.cycle_axis("Region");
        assert_eq!(v.axis_of("Region"), Axis::Column);
    }

    #[test]
    fn cycle_axis_column_to_page() {
        let mut v = view_with_cats(&["Region", "Product"]);
        v.set_axis("Product", Axis::Column);
        v.cycle_axis("Product");
        assert_eq!(v.axis_of("Product"), Axis::Page);
    }

    #[test]
    fn cycle_axis_page_to_none() {
        let mut v = view_with_cats(&["Region", "Product", "Time"]);
        v.cycle_axis("Time");
        assert_eq!(v.axis_of("Time"), Axis::None);
    }

    #[test]
    fn cycle_axis_none_to_row() {
        let mut v = view_with_cats(&["Region", "Product", "Time"]);
        v.set_axis("Time", Axis::None);
        v.cycle_axis("Time");
        assert_eq!(v.axis_of("Time"), Axis::Row);
    }

    #[test]
    fn cycle_axis_resets_scroll_and_selection() {
        let mut v = view_with_cats(&["Region"]);
        v.row_offset = 5;
        v.col_offset = 3;
        v.selected = (2, 2);
        v.cycle_axis("Region");
        assert_eq!(v.row_offset, 0);
        assert_eq!(v.col_offset, 0);
        assert_eq!(v.selected, (0, 0));
    }
}

#[cfg(test)]
mod prop_tests {
    use super::View;
    use crate::view::Axis;
    use proptest::prelude::*;

    fn unique_cat_names() -> impl Strategy<Value = Vec<String>> {
        prop::collection::hash_set("[A-Za-z][a-z]{1,7}", 1usize..=8)
            .prop_map(|s| s.into_iter().collect::<Vec<_>>())
    }

    proptest! {
        /// axis_of and categories_on are consistent: cat is in categories_on(axis_of(cat))
        #[test]
        fn axis_of_and_categories_on_consistent(cats in unique_cat_names()) {
            let mut v = View::new("T");
            for c in &cats { v.on_category_added(c); }
            for c in &cats {
                let axis = v.axis_of(c);
                let on_axis = v.categories_on(axis);
                prop_assert!(on_axis.contains(&c.as_str()),
                    "categories_on({:?}) should contain '{}'", axis, c);
            }
        }

        /// Each known category appears on exactly one axis
        #[test]
        fn each_category_on_exactly_one_axis(cats in unique_cat_names()) {
            let mut v = View::new("T");
            for c in &cats { v.on_category_added(c); }
            let all_axes = [Axis::Row, Axis::Column, Axis::Page, Axis::None];
            for c in &cats {
                let count = all_axes.iter()
                    .filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
                    .count();
                prop_assert_eq!(count, 1,
                    "category '{}' should be on exactly one axis, found {}", c, count);
            }
        }

        /// on_category_added is idempotent: adding same cat twice keeps original axis
        #[test]
        fn on_category_added_idempotent(cats in unique_cat_names()) {
            let mut v = View::new("T");
            for c in &cats { v.on_category_added(c); }
            let axes_before: Vec<Axis> = cats.iter().map(|c| v.axis_of(c)).collect();
            for c in &cats { v.on_category_added(c); }
            let axes_after: Vec<Axis> = cats.iter().map(|c| v.axis_of(c)).collect();
            prop_assert_eq!(axes_before, axes_after);
        }

        /// set_axis updates axis_of for the target category
        #[test]
        fn set_axis_updates_axis_of(
            cats in unique_cat_names(),
            target_idx in 0usize..8,
            axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
        ) {
            let mut v = View::new("T");
            for c in &cats { v.on_category_added(c); }
            let idx = target_idx % cats.len();
            let cat = &cats[idx];
            v.set_axis(cat, axis);
            prop_assert_eq!(v.axis_of(cat), axis);
        }

        /// After set_axis(cat, X), cat is NOT in categories_on(Y) for Y ≠ X
        #[test]
        fn set_axis_exclusive(
            cats in unique_cat_names(),
            target_idx in 0usize..8,
            axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
        ) {
            let mut v = View::new("T");
            for c in &cats { v.on_category_added(c); }
            let idx = target_idx % cats.len();
            let cat = &cats[idx];
            v.set_axis(cat, axis);
            let other_axes = [Axis::Row, Axis::Column, Axis::Page]
                .into_iter()
                .filter(|&a| a != axis);
            for other in other_axes {
                prop_assert!(!v.categories_on(other).contains(&cat.as_str()),
                    "after set_axis({:?}), '{}' should not be in categories_on({:?})",
                    axis, cat, other);
            }
        }

        /// No two categories share the same axis entry (map guarantees uniqueness by key)
        /// — equivalently, total count across all axes equals number of known categories
        #[test]
        fn total_category_count_consistent(cats in unique_cat_names()) {
            let mut v = View::new("T");
            for c in &cats { v.on_category_added(c); }
            let total: usize = [Axis::Row, Axis::Column, Axis::Page]
                .iter()
                .map(|&ax| v.categories_on(ax).len())
                .sum();
            prop_assert_eq!(total, cats.len());
        }

        /// page_selection round-trips: set then get returns the same value
        #[test]
        fn page_selection_roundtrip(
            cat in "[A-Za-z][a-z]{1,7}",
            item in "[A-Za-z][a-z]{1,7}",
        ) {
            let mut v = View::new("T");
            v.set_page_selection(&cat, &item);
            prop_assert_eq!(v.page_selection(&cat), Some(item.as_str()));
        }

        /// hide/show round-trip: hiding then showing leaves item visible
        #[test]
        fn hide_show_roundtrip(
            cat in "[A-Za-z][a-z]{1,7}",
            item in "[A-Za-z][a-z]{1,7}",
        ) {
            let mut v = View::new("T");
            v.hide_item(&cat, &item);
            prop_assert!(v.is_hidden(&cat, &item));
            v.show_item(&cat, &item);
            prop_assert!(!v.is_hidden(&cat, &item));
        }

        /// toggle_group_collapse is its own inverse
        #[test]
        fn toggle_group_collapse_involutive(
            cat in "[A-Za-z][a-z]{1,7}",
            group in "[A-Za-z][a-z]{1,7}",
        ) {
            let mut v = View::new("T");
            let initial = v.is_group_collapsed(&cat, &group);
            v.toggle_group_collapse(&cat, &group);
            v.toggle_group_collapse(&cat, &group);
            prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial);
        }
    }
}
