// SPDX-License-Identifier: GPL-3.0-or-later
/*
File: cfait/src/store.rs

Optimized in-memory store for tasks.

Overview & Rationale:
- The TaskStore is the single-process in-memory representation of all task data
  grouped by calendar (calendar_href -> HashMap<uid, Task>).
- Performance goals:
  * O(1) lookup by UID (index: uid -> calendar_href)
  * Minimal cloning: filter pipeline operates over &Task references and only clones
    the final, visible tasks returned to UI layers.
  * Relation indices (related_from_index, blocking_index) are maintained to allow
    efficient reverse-lookup for "related to" and "blocked by" queries without
    scanning the whole dataset repeatedly.

Behavioral notes (important):
- Blocking semantics:
  * `is_blocked` on a Task represents an explicit block (e.g. tag `blocked` or
    an unresolved dependency).
  * `is_implicitly_blocked` is a transient flag that indicates inherited block
    status coming from an ancestor in the parent hierarchy. Both are used to
    decide "is:ready" semantics and ranking, but UI badges generally use only
    the explicit `is_blocked`.
- Hierarchy and virtual rows:
  * The model supports injecting small "virtual" tasks (Expand/Collapse rows)
    used by UI layers to truncate large groups of completed subtasks while still
    providing a way to expand them. These virtual tasks are created during the
    `organize_hierarchy` stage and are not persisted.
- Storage interactions:
  * The store keeps an index for fast lookups; persistent save/load is handled
    by higher-level modules (LocalStorage / Cache). Store provides convenient
    methods that ensure relation indices are rebuilt when data mutates.

API guarantees:
- Methods that mutate the internal maps update the relation indices so callers
  can rely on the reverse-maps (get_tasks_related_to / get_tasks_blocking).
- get_task_ref / get_task_mut provide direct map-backed access and will auto-fix
  inconsistent index state by removing dangling index entries.

Implementation notes:
- The file favors clarity of the logic in filter/propagation/hierarchy routines.
- Filtering is done in multiple passes:
  1. Build a reference iterator of candidate tasks from allowed calendars.
  2. Apply predicate filters on &Task references to avoid cloning.
  3. Optionally expand the set to include parent/child context when search term
     requires it.
  4. Clone only the final set of tasks to produce the FilterResult (where
     transient attributes are computed).
*/

use crate::config::Config;
use crate::context::AppContext;
use crate::journal::Action as JournalAction;
use crate::model::{AppIntent, DateType, Task, TaskStatus};
use chrono::{DateTime, Utc};
use fastrand;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;

pub const UNCATEGORIZED_ID: &str = ":::uncategorized:::";

/// Enum representing items in the task list - either real tasks or UI control elements
#[derive(Debug, Clone)]
pub enum TaskListItem {
    Task(Box<crate::model::Task>),
    ExpandGroup(String, usize),   // parent_uid, depth
    CollapseGroup(String, usize), // parent_uid, depth
}

#[derive(Debug, Clone, PartialEq)]
pub struct AggregateItem {
    pub full_key: String,
    pub display_name: String,
    pub count: u32,
    pub depth: u32,
    pub has_children: bool,
    pub is_expanded: bool,
}

/// Result container returned by the `filter` pipeline.
pub struct FilterResult {
    pub items: Vec<TaskListItem>,
    pub categories: Vec<AggregateItem>,
    pub locations: Vec<AggregateItem>,
}

/// Context structure used during hierarchy organization
struct HierarchyContext<'a> {
    children_map: &'a HashMap<String, Vec<Task>>,
    result: &'a mut Vec<TaskListItem>,
    visited_keys: &'a mut HashSet<(String, String)>,
    expanded_groups: &'a HashSet<String>,
    search_active: bool,
    max_done_subtasks: usize,
}

/// Organize tasks into a hierarchy with proper parent/child relationships and inject
/// expand/collapse control items for large groups of completed tasks.
/// This function handles:
/// - Building parent->children mapping
/// - Sorting tasks by canonical comparator
/// - Managing indentation depth
/// - Injecting ExpandGroup/CollapseGroup items for truncated completed task groups
pub fn organize_hierarchy(
    mut tasks: Vec<Task>,
    default_priority: u8,
    sort_standard_by_priority: bool,
    expanded_groups: &HashSet<String>,
    max_done_roots: usize,
    max_done_subtasks: usize,
    search_active: bool,
) -> Vec<TaskListItem> {
    let present_uids: HashSet<String> = tasks.iter().map(|t| t.uid.clone()).collect();
    let mut children_map: HashMap<String, Vec<Task>> = HashMap::new();
    let mut roots: Vec<Task> = Vec::new();

    // Sort by the canonical comparator before building hierarchy
    tasks.sort_by(|a, b| a.compare_for_sort(b, default_priority, sort_standard_by_priority));

    for mut task in tasks {
        let is_orphan = match &task.parent_uid {
            Some(p_uid) => !present_uids.contains(p_uid),
            None => true,
        };

        if is_orphan {
            if task.parent_uid.is_some() {
                task.depth = 0; // orphaned child promoted to root
            }
            roots.push(task);
        } else {
            let p_uid = task.parent_uid.as_ref().unwrap().clone();
            children_map.entry(p_uid).or_default().push(task);
        }
    }

    let mut result = Vec::new();
    let mut visited_keys = HashSet::new();

    let mut context = HierarchyContext {
        children_map: &children_map,
        result: &mut result,
        visited_keys: &mut visited_keys,
        expanded_groups,
        search_active,
        max_done_subtasks,
    };

    fn mark_tree_as_visited(task: &Task, context: &mut HierarchyContext) {
        let visit_key = (task.uid.clone(), task.calendar_href.clone());
        if !context.visited_keys.insert(visit_key) {
            return;
        }
        let mut stack = vec![task.uid.clone()];
        while let Some(current) = stack.pop() {
            if let Some(children) = context.children_map.get(&current) {
                for child in children {
                    let v_key = (child.uid.clone(), child.calendar_href.clone());
                    if context.visited_keys.insert(v_key) {
                        stack.push(child.uid.clone());
                    }
                }
            }
        }
    }

    /// Process a group of tasks (either root level or children of a parent)
    /// and handle truncation of completed tasks with expand/collapse controls
    fn process_group(
        raw_group: Vec<Task>,
        parent_uid: String,
        limit: usize,
        is_root: bool,
        context: &mut HierarchyContext,
        depth: usize,
    ) {
        // Partition into active and done to allow truncation of the done-group
        let (active, done): (Vec<Task>, Vec<Task>) =
            raw_group.into_iter().partition(|t| !t.status.is_done());

        // Append active tasks always
        for task in active {
            append_task_and_children(&task, context, depth);
        }

        if done.is_empty() {
            return;
        }

        let effective_key = if is_root {
            "".to_string()
        } else {
            parent_uid.clone()
        };
        let is_expanded = context.expanded_groups.contains(&effective_key);

        if is_expanded {
            for task in done {
                append_task_and_children(&task, context, depth);
            }
            context
                .result
                .push(TaskListItem::CollapseGroup(effective_key, depth));
        } else if done.len() > limit {
            let count_to_show = limit.saturating_sub(1);
            let mut iter = done.into_iter();

            for _ in 0..count_to_show {
                if let Some(task) = iter.next() {
                    append_task_and_children(&task, context, depth);
                }
            }
            for task in iter {
                mark_tree_as_visited(&task, context);
            }
            context
                .result
                .push(TaskListItem::ExpandGroup(effective_key, depth));
        } else {
            for task in done {
                append_task_and_children(&task, context, depth);
            }
        }
    }

    /// Recursively append a task and its children, handling depth and visited tracking
    fn append_task_and_children(task: &Task, context: &mut HierarchyContext, depth: usize) {
        let visit_key = (task.uid.clone(), task.calendar_href.clone());
        if context.visited_keys.contains(&visit_key) {
            return;
        }
        context.visited_keys.insert(visit_key.clone());

        let mut t = task.clone();
        t.depth = depth;

        // --- THE FIX ---
        // Only show the tree icon if children survived the current filters
        t.has_visible_subtasks = context.children_map.contains_key(&task.uid);
        // ---------------

        context.result.push(TaskListItem::Task(Box::new(t)));

        // If the user manually folded this tree, truncate children iteration (unless searching)
        if task.collapsed && !context.search_active {
            if let Some(children) = context.children_map.get(&task.uid) {
                for child in children {
                    mark_tree_as_visited(child, context);
                }
            }
            return;
        }

        if let Some(children) = context.children_map.get(&task.uid) {
            let (active, done): (Vec<Task>, Vec<Task>) =
                children.iter().cloned().partition(|t| !t.status.is_done());

            for child in active {
                append_task_and_children(&child, context, depth + 1);
            }

            if !done.is_empty() {
                let is_expanded = context.expanded_groups.contains(&task.uid);
                if is_expanded {
                    for child in done {
                        append_task_and_children(&child, context, depth + 1);
                    }
                    context
                        .result
                        .push(TaskListItem::CollapseGroup(task.uid.clone(), depth + 1));
                } else if done.len() > context.max_done_subtasks {
                    let show = context.max_done_subtasks.saturating_sub(1);
                    let mut iter = done.into_iter();
                    for _ in 0..show {
                        if let Some(c) = iter.next() {
                            append_task_and_children(&c, context, depth + 1);
                        }
                    }
                    for c in iter {
                        mark_tree_as_visited(&c, context);
                    }
                    context
                        .result
                        .push(TaskListItem::ExpandGroup(task.uid.clone(), depth + 1));
                } else {
                    for child in done {
                        append_task_and_children(&child, context, depth + 1);
                    }
                }
            }
        }
    }

    process_group(roots, "".to_string(), max_done_roots, true, &mut context, 0);

    // Catch any tasks that were missed due to cyclical parent_uid relationships
    let mut unvisited = Vec::new();
    for children in context.children_map.values() {
        for child in children {
            let visit_key = (child.uid.clone(), child.calendar_href.clone());
            if !context.visited_keys.contains(&visit_key) {
                unvisited.push(child.clone());
            }
        }
    }

    if !unvisited.is_empty() {
        unvisited
            .sort_by(|a, b| a.compare_for_sort(b, default_priority, sort_standard_by_priority));
        process_group(
            unvisited,
            "".to_string(),
            max_done_roots,
            true,
            &mut context,
            0,
        );
    }

    result
}

/// Select an index from `tasks` at random weighted by priority.
/// - Tasks with priority 0 use the provided `default_priority`.
/// - Lower numeric priority indicates higher importance; we invert to produce
///   weights where priority 1 -> weight 9, priority 9 -> weight 1.
/// - Filters for "is:ready" criteria:
///   * Must not be done (Completed/Cancelled)
///   * Must not be explicitly OR implicitly blocked
///   * Must not have a future start date
///
/// Returns `None` for an empty slice or if no tasks qualify.
pub fn select_weighted_random_index(tasks: &[Task], default_priority: u8) -> Option<usize> {
    if tasks.is_empty() {
        return None;
    }

    let weights: Vec<u32> = tasks
        .iter()
        .map(|t| {
            // 1. Must be active (not done/cancelled)
            if t.status.is_done() {
                return 0;
            }

            // 2. Must not be blocked (explicit or inherited)
            if t.is_blocked || t.is_implicitly_blocked {
                return 0;
            }

            // 3. Must not start in the future (is:ready logic)
            if t.is_future_start || t.is_implicitly_future {
                return 0;
            }

            // Invert priority so 1 is high weight (range 1..=9 -> weight 9..=1)
            let p = if t.priority == 0 {
                default_priority
            } else {
                t.priority
            };
            (10u32).saturating_sub(p as u32)
        })
        .collect();

    let total_weight: u32 = weights.iter().sum();
    if total_weight == 0 {
        return None;
    }

    let mut rng = fastrand::Rng::new();
    let mut choice = rng.u32(0..total_weight);

    for (i, w) in weights.iter().enumerate() {
        if *w == 0 {
            continue;
        }
        if choice < *w {
            return Some(i);
        }
        choice -= *w;
    }

    None
}

#[derive(Debug, Clone)]
pub struct TaskStore {
    /// calendars: calendar_href -> (uid -> Task)
    pub calendars: HashMap<String, HashMap<String, Task>>,
    /// index: uid -> calendar_href (fast lookup for operations that only know a UID)
    pub index: HashMap<String, String>,
    /// Reverse lookup for related_to: target_uid -> Vec<source_uid>
    pub related_from_index: HashMap<String, Vec<String>>,
    /// Reverse lookup for dependencies: dep_uid -> Vec<task_uid_that_depend_on_dep>
    pub blocking_index: HashMap<String, Vec<String>>,
    /// AppContext used for persistence operations (if store needs to save).
    pub ctx: Arc<dyn AppContext>,
}

/// Options to parameterize a filter operation. Using a struct keeps the signature
/// manageable as the filter logic supports many toggles.
pub struct FilterOptions<'a> {
    pub active_cal_href: Option<&'a str>,
    pub hidden_calendars: &'a HashSet<String>,
    pub selected_categories: &'a HashSet<String>,
    pub selected_locations: &'a HashSet<String>,
    pub match_all_categories: bool,
    pub search_term: &'a str,
    pub hide_completed_global: bool,
    pub hide_fully_completed_tags: bool,
    pub hide_aliases_in_sidebar: bool,
    pub cutoff_date: Option<DateTime<Utc>>,
    pub min_duration: Option<u32>,
    pub max_duration: Option<u32>,
    pub include_unset_duration: bool,
    pub urgent_days: u32,
    pub urgent_prio: u8,
    pub default_priority: u8,
    pub start_grace_period_days: u32,
    pub sort_standard_by_priority: bool,
    pub expanded_done_groups: &'a HashSet<String>,
    pub expanded_tags: &'a HashSet<String>,
    pub expanded_locations: &'a HashSet<String>,
    pub max_done_roots: usize,
    pub max_done_subtasks: usize,
    pub tag_aliases: &'a HashMap<String, Vec<String>>,
}

impl TaskStore {
    /// Construct a new TaskStore with an AppContext reference for persistence.
    pub fn new(ctx: Arc<dyn AppContext>) -> Self {
        Self {
            calendars: HashMap::new(),
            index: HashMap::new(),
            related_from_index: HashMap::new(),
            blocking_index: HashMap::new(),
            ctx,
        }
    }

    /// Whether the store contains any tasks (fast O(1) via index map).
    /// This is used to differentiate between a truly-empty app vs filters hiding items.
    pub fn has_any_tasks(&self) -> bool {
        !self.index.is_empty()
    }

    pub fn has_tasks_blocking(&self, uid: &str) -> bool {
        self.blocking_index
            .get(uid)
            .map(|l| !l.is_empty())
            .unwrap_or(false)
    }

    pub fn has_tasks_related_to(&self, uid: &str) -> bool {
        self.related_from_index
            .get(uid)
            .map(|l| !l.is_empty())
            .unwrap_or(false)
    }

    // --- INCREMENTAL INDEX HELPERS ---
    fn remove_task_from_indices(&mut self, task: &Task) {
        for r in &task.related_to {
            if let Some(sources) = self.related_from_index.get_mut(r) {
                sources.retain(|u| u != &task.uid);
                if sources.is_empty() {
                    self.related_from_index.remove(r);
                }
            }
        }
        for dep in &task.dependencies {
            if let Some(list) = self.blocking_index.get_mut(dep) {
                list.retain(|u| u != &task.uid);
                if list.is_empty() {
                    self.blocking_index.remove(dep);
                }
            }
        }
    }

    fn add_task_to_indices(&mut self, task: &Task) {
        for r in &task.related_to {
            self.related_from_index
                .entry(r.clone())
                .or_default()
                .push(task.uid.clone());
        }
        for dep in &task.dependencies {
            self.blocking_index
                .entry(dep.clone())
                .or_default()
                .push(task.uid.clone());
        }
    }
    // --------------------------------

    /// Replace or insert an entire calendar's tasks.
    /// This sets up the internal uid index and rebuilds relation indices for correctness.
    pub fn insert(&mut self, calendar_href: String, tasks: Vec<Task>) {
        let mut new_map = HashMap::new();
        let mut uids_to_add = Vec::new();

        for mut task in tasks {
            let uid = task.uid.clone();

            // Protect against stale reads wiping out recent local mutations
            if let Some(existing_map) = self.calendars.get(&calendar_href)
                && let Some(existing_task) = existing_map.get(&uid)
                && existing_task.sequence > task.sequence
            {
                task = existing_task.clone();
            }

            if let Some(existing_href) = self.index.get(&uid)
                && existing_href != &calendar_href
            {
                // Duplicate UID across calendars found!
                // Determine which one wins deterministically.
                if let Some(existing_task) =
                    self.calendars.get(existing_href).and_then(|m| m.get(&uid))
                {
                    let keep_existing = if existing_task.sequence != task.sequence {
                        existing_task.sequence > task.sequence
                    } else {
                        // Tie-break with href string comparison to ensure consistency across platforms
                        existing_href > &calendar_href
                    };

                    if keep_existing {
                        // Skip inserting this task into the new map
                        continue;
                    } else {
                        // The new task wins. Remove from old calendar.
                        let old_href_clone = existing_href.clone();
                        if let Some(old_map) = self.calendars.get_mut(&old_href_clone)
                            && let Some(old_task) = old_map.remove(&uid)
                        {
                            self.remove_task_from_indices(&old_task);
                        }
                    }
                }
            }

            new_map.insert(uid.clone(), task);
            uids_to_add.push(uid);
        }

        // --- ADDED: Automatic cleanup for legacy history tasks mistakenly parsed as children ---
        let mut fixes = Vec::new();
        for task in new_map.values() {
            // A task is a history snapshot if:
            // 1. It is completed and not recurring itself.
            // 2. Its parent is a recurring task.
            // 3. EITHER its CREATED timestamp OR its summary matches the parent's.
            if task.status.is_done()
                && task.rrule.is_none()
                && let Some(p_uid) = &task.parent_uid
                && let Some(parent) = new_map.get(p_uid)
                && parent.rrule.is_some()
            {
                let mut is_history_snapshot = task
                    .unmapped_properties
                    .iter()
                    .any(|p| p.key == "X-CFAIT-HISTORY-OF");

                if !is_history_snapshot {
                    // Heuristic 1 (most reliable): Check for matching CREATED timestamp.
                    let task_created = task.unmapped_properties.iter().find(|p| p.key == "CREATED");
                    let parent_created = parent
                        .unmapped_properties
                        .iter()
                        .find(|p| p.key == "CREATED");

                    if let (Some(tc), Some(pc)) = (task_created, parent_created)
                        && tc.value == pc.value
                    {
                        is_history_snapshot = true;
                    }

                    // Heuristic 2 (fallback): Check for matching summary.
                    if !is_history_snapshot && parent.summary == task.summary {
                        is_history_snapshot = true;
                    }
                }

                if is_history_snapshot {
                    fixes.push(task.uid.clone());
                }
            }
        }

        for uid in fixes {
            if let Some(task) = new_map.get_mut(&uid) {
                // Take the parent_uid...
                let p_uid = task.parent_uid.take().unwrap();
                // ...and correctly move it to the related_to list.
                if !task.related_to.contains(&p_uid) {
                    task.related_to.push(p_uid);
                }
            }
        }
        // --- END ADDED ---

        // Cleanup index for tasks that were in this calendar but are now gone
        if let Some(old_map) = self.calendars.get(&calendar_href) {
            for uid in old_map.keys() {
                if !new_map.contains_key(uid) && self.index.get(uid) == Some(&calendar_href) {
                    self.index.remove(uid);
                }
            }
        }

        // Insert new indices
        for uid in uids_to_add {
            self.index.insert(uid, calendar_href.clone());
        }

        self.calendars.insert(calendar_href, new_map);
        self.rebuild_relation_index();
    }

    /// Add a single task into the store. If it already exists, it will be overwritten
    /// in the calendar map and indices are rebuilt to reflect the new relationships.
    pub fn add_task(&mut self, task: Task) {
        let href = task.calendar_href.clone();
        self.index.insert(task.uid.clone(), href.clone());

        if let Some(old) = self
            .calendars
            .entry(href)
            .or_default()
            .insert(task.uid.clone(), task.clone())
        {
            self.remove_task_from_indices(&old);
        }

        self.add_task_to_indices(&task);
    }

    /// Update an existing task or insert it if missing. This method attempts to handle
    /// moves between calendars by checking the uid index and adjusting maps accordingly.
    pub fn update_or_add_task(&mut self, task: Task) {
        let href = task.calendar_href.clone();
        let uid = task.uid.clone();
        if let Some(existing_href) = self.index.get(&uid) {
            if existing_href == &href {
                if let Some(map) = self.calendars.get_mut(&href) {
                    if let Some(old) = map.insert(uid.clone(), task.clone()) {
                        self.remove_task_from_indices(&old);
                    }
                } else {
                    self.calendars
                        .entry(href.clone())
                        .or_default()
                        .insert(uid.clone(), task.clone());
                }
            } else {
                // Task was moved between calendars: remove from old map and insert in new
                let existing_href_clone = existing_href.clone();
                if let Some(map) = self.calendars.get_mut(&existing_href_clone)
                    && let Some(old) = map.remove(&uid)
                {
                    self.remove_task_from_indices(&old);
                }
                self.index.insert(uid.clone(), href.clone());
                self.calendars
                    .entry(href.clone())
                    .or_default()
                    .insert(uid.clone(), task.clone());
            }
        } else {
            // New task
            self.index.insert(uid.clone(), href.clone());
            self.calendars
                .entry(href.clone())
                .or_default()
                .insert(uid.clone(), task.clone());
        }
        self.add_task_to_indices(&task);
    }

    /// Remove all tasks and indices from the store.
    pub fn clear(&mut self) {
        self.calendars.clear();
        self.index.clear();
        self.related_from_index.clear();
        self.blocking_index.clear();
    }

    /// Remove an entire calendar from the store and drop related index entries.
    pub fn remove(&mut self, calendar_href: &str) {
        if let Some(tasks_map) = self.calendars.remove(calendar_href) {
            for uid in tasks_map.keys() {
                self.index.remove(uid);
            }
        }
        self.rebuild_relation_index();
    }

    /// Get a mutable reference to a task together with its calendar href.
    /// Returns None if the uid is not present or index is inconsistent (auto-fix).
    pub fn get_task_mut(&mut self, uid: &str) -> Option<(&mut Task, String)> {
        let href = self.index.get(uid)?.clone();
        if let Some(map) = self.calendars.get_mut(&href)
            && let Some(task) = map.get_mut(uid)
        {
            return Some((task, href));
        }
        // Index pointed to a non-existent entry; clean it up.
        self.index.remove(uid);
        None
    }

    /// Immutable reference by uid (O(1)).
    pub fn get_task_ref(&self, uid: &str) -> Option<&Task> {
        let href = self.index.get(uid)?;
        self.calendars.get(href).and_then(|map| map.get(uid))
    }

    /// O(1) delete a task and return (task, calendar_href) on success.
    /// Relation indices are rebuilt afterwards.
    pub fn delete_task(&mut self, uid: &str) -> Option<(Task, String)> {
        let href = self.index.get(uid)?.clone();
        if let Some(map) = self.calendars.get_mut(&href)
            && let Some(task) = map.remove(uid)
        {
            self.index.remove(uid);
            self.remove_task_from_indices(&task);
            return Some((task, href));
        }
        None
    }

    /// Evaluates retention settings and safely moves a task to the local trash.
    /// Returns (OriginalDeletedTask, Option<NewTrashedTask>).
    pub fn soft_delete_task(
        &mut self,
        uid: &str,
        retention_days: u32,
    ) -> Option<(Task, Option<Task>)> {
        let is_already_trash = self
            .get_task_ref(uid)
            .map(|t| t.calendar_href == crate::storage::LOCAL_TRASH_HREF)
            .unwrap_or(false);
        if retention_days == 0 || is_already_trash {
            let (deleted, _) = self.delete_task(uid)?;
            Some((deleted, None))
        } else {
            self.calendars
                .entry(crate::storage::LOCAL_TRASH_HREF.to_string())
                .or_default();
            let (orig, mut updated) =
                self.move_task(uid, crate::storage::LOCAL_TRASH_HREF.to_string())?;
            let now_str = chrono::Utc::now().to_rfc3339();
            updated
                .unmapped_properties
                .retain(|p| p.key != "X-TRASHED-DATE");
            updated.unmapped_properties.push(crate::model::RawProperty {
                key: "X-TRASHED-DATE".to_string(),
                value: now_str,
                params: vec![],
            });
            self.update_or_add_task(updated.clone());
            Some((orig, Some(updated)))
        }
    }

    /// Extends soft_delete_task recursively to the entire sub-tree.
    pub fn soft_delete_task_tree(
        &mut self,
        root_uid: &str,
        retention_days: u32,
    ) -> Vec<(Task, Option<Task>)> {
        let mut uids = self.get_descendant_uids(root_uid);
        uids.push(root_uid.to_string());
        let mut results = Vec::new();
        for uid in uids {
            if let Some(res) = self.soft_delete_task(&uid, retention_days) {
                results.push(res);
            }
        }
        results
    }

    /// Toggle convenience: Completed <-> NeedsAction (returns primary, optional secondary, reset children)
    pub fn toggle_task(&mut self, uid: &str) -> Option<(Task, Option<Task>, Vec<Task>)> {
        let current_status = self.get_task_ref(uid)?.status;
        let next_status = if current_status.is_done() {
            TaskStatus::NeedsAction
        } else {
            TaskStatus::Completed
        };
        self.set_status(uid, next_status, false)
    }

    /// Shift-Toggle convenience: Forces a relative completion shift
    pub fn toggle_task_shift(&mut self, uid: &str) -> Option<(Task, Option<Task>, Vec<Task>)> {
        let current_status = self.get_task_ref(uid)?.status;
        let next_status = if current_status.is_done() {
            TaskStatus::NeedsAction
        } else {
            TaskStatus::Completed
        };
        self.set_status(uid, next_status, true)
    }

    /// Set the status for a task. Special handling:
    /// - If the task is recurring and is being completed, `recycle` is invoked which may
    ///   return (history_snapshot, Some(next_instance)). Both are persisted to the store.
    /// - When recurring completion advances, descendants that were completed will be
    ///   reset to NeedsAction so the recurrence semantics remain coherent. The list of
    ///   reset children is returned for callers to persist/journal.
    pub fn set_status(
        &mut self,
        uid: &str,
        status: TaskStatus,
        shift_schedule: bool,
    ) -> Option<(Task, Option<Task>, Vec<Task>)> {
        let mut task_copy = self.get_task_ref(uid)?.clone();
        task_copy.sequence += 1;
        // If the task is recurring and we are completing it we may need to reset children
        let should_reset_children = task_copy.rrule.is_some() && status.is_done();

        // Model-level helper computes the primary (history or updated) and optional secondary
        let (primary, secondary) = task_copy.recycle(status, shift_schedule);

        // Save primary and secondary into the store (optimistic/instant UI update)
        self.update_or_add_task(primary.clone());

        if let Some(sec) = &secondary {
            self.update_or_add_task(sec.clone());
        }

        // Reset descendants if appropriate (recurrence completion path)
        let mut reset_children: Vec<Task> = Vec::new();

        if should_reset_children && secondary.is_some() {
            // Build adjacency (Parent -> [Children]) across all calendars
            let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
            for map in self.calendars.values() {
                for t in map.values() {
                    if let Some(p) = &t.parent_uid {
                        adjacency.entry(p.clone()).or_default().push(t.uid.clone());
                    }
                }
            }

            // BFS to discover all descendant UIDs
            let mut queue = vec![uid.to_string()];
            let mut descendants = HashSet::new();

            while let Some(parent) = queue.pop() {
                if let Some(children) = adjacency.get(&parent) {
                    for child_uid in children {
                        if descendants.insert(child_uid.clone()) {
                            queue.push(child_uid.clone());
                        }
                    }
                }
            }

            // Reset each done descendant to NeedsAction and persist
            for child_uid in descendants {
                if let Some((child, _)) = self.get_task_mut(&child_uid)
                    && child.status.is_done()
                {
                    child.status = TaskStatus::NeedsAction;
                    child.percent_complete = None;
                    child
                        .unmapped_properties
                        .retain(|p| p.key.to_uppercase() != "COMPLETED");

                    let child_copy = child.clone();
                    self.update_or_add_task(child_copy.clone());
                    reset_children.push(child_copy);
                }
            }
        }

        Some((primary, secondary, reset_children))
    }

    /// Mark a task InProcess and begin timing; bubble to parent tasks so the timer
    /// context is preserved. Returns the set of tasks that were updated.
    pub fn set_status_in_process(&mut self, uid: &str) -> Vec<Task> {
        let mut updated = Vec::new();
        let mut current_uid = uid.to_string();
        let now = Utc::now().timestamp();
        let mut visited = HashSet::new();

        loop {
            if !visited.insert(current_uid.clone()) {
                break;
            }
            if let Some((task, _)) = self.get_task_mut(&current_uid) {
                let mut changed = false;
                if task.status != TaskStatus::InProcess {
                    task.status = TaskStatus::InProcess;
                    changed = true;
                }
                if task.last_started_at.is_none() {
                    task.last_started_at = Some(now);
                    changed = true;
                }
                if changed {
                    task.sequence += 1;
                    updated.push(task.clone());
                }
                if let Some(p) = task.parent_uid.clone() {
                    current_uid = p;
                    continue;
                }
            }
            break;
        }
        updated
    }

    /// Pause a task and all descendants (stop timing and record a session as appropriate).
    pub fn pause_task(&mut self, uid: &str) -> Vec<Task> {
        let mut updated = Vec::new();
        let now = Utc::now().timestamp();
        let mut queue = vec![uid.to_string()];
        let mut visited = HashSet::new();

        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
        for map in self.calendars.values() {
            for t in map.values() {
                if let Some(p) = &t.parent_uid {
                    adjacency.entry(p.clone()).or_default().push(t.uid.clone());
                }
            }
        }

        while let Some(current_uid) = queue.pop() {
            if !visited.insert(current_uid.clone()) {
                continue;
            }

            if let Some((task, _)) = self.get_task_mut(&current_uid) {
                let mut changed = false;
                if task.status == TaskStatus::InProcess {
                    task.status = TaskStatus::NeedsAction;
                    changed = true;
                }

                if let Some(start) = task.last_started_at {
                    if now > start {
                        let duration = (now - start) as u64;
                        task.time_spent_seconds = task.time_spent_seconds.saturating_add(duration);
                        if duration > 60 {
                            task.sessions
                                .push(crate::model::item::WorkSession { start, end: now });
                        }
                    }
                    task.last_started_at = None;
                    changed = true;
                }

                if changed {
                    task.sequence += 1;
                    updated.push(task.clone());
                }

                if let Some(children) = adjacency.get(&current_uid) {
                    queue.extend(children.clone());
                }
            }
        }
        updated
    }

    /// Stop a running or paused task and its subtree; reset all time tracking data.
    pub fn stop_task(&mut self, uid: &str) -> Vec<Task> {
        let mut updated = Vec::new();
        let mut queue = vec![uid.to_string()];
        let mut visited = HashSet::new();

        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
        for map in self.calendars.values() {
            for t in map.values() {
                if let Some(p) = &t.parent_uid {
                    adjacency.entry(p.clone()).or_default().push(t.uid.clone());
                }
            }
        }

        while let Some(current_uid) = queue.pop() {
            if !visited.insert(current_uid.clone()) {
                continue;
            }

            if let Some((task, _)) = self.get_task_mut(&current_uid) {
                let mut changed = false;
                if task.status != TaskStatus::NeedsAction {
                    task.status = TaskStatus::NeedsAction;
                    changed = true;
                }
                if task.last_started_at.is_some() {
                    task.last_started_at = None;
                    changed = true;
                }
                if task.time_spent_seconds > 0 {
                    task.time_spent_seconds = 0;
                    changed = true;
                }
                if !task.sessions.is_empty() {
                    task.sessions.clear();
                    changed = true;
                }

                if changed {
                    task.sequence += 1;
                    updated.push(task.clone());
                }

                if let Some(children) = adjacency.get(&current_uid) {
                    queue.extend(children.clone());
                }
            }
        }
        updated
    }

    /// Adjust priority by `delta` with sensible clamping. Delta semantics:
    /// positive -> increase importance (lower numeric), negative -> decrease importance.
    pub fn change_priority(&mut self, uid: &str, delta: i8, default_priority: u8) -> Option<Task> {
        if let Some((task, _)) = self.get_task_mut(uid) {
            // If priority is unset, use the default and then apply the delta in one atomic step.
            let mut p = task.priority as i16;
            if p == 0 {
                p = default_priority as i16;
            }

            if delta > 0 {
                p = (p - delta as i16).max(1);
            } else if delta < 0 {
                p = (p - delta as i16).min(9);
            }

            task.priority = p as u8;
            task.sequence += 1;
            return Some(task.clone());
        }
        None
    }

    /// Deep-duplicate a task and all its descendants.
    /// Returns the list of cloned tasks (with new UIDs and remapped relations) for persistence.
    /// Returns a list of all descendant UIDs for a given root.
    pub fn get_descendant_uids(&self, root_uid: &str) -> Vec<String> {
        let mut descendants = Vec::new();
        let mut queue = vec![root_uid.to_string()];

        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
        for map in self.calendars.values() {
            for t in map.values() {
                if let Some(p) = &t.parent_uid {
                    adjacency.entry(p.clone()).or_default().push(t.uid.clone());
                }
            }
        }

        let mut visited = HashSet::new();
        while let Some(curr) = queue.pop() {
            if !visited.insert(curr.clone()) {
                continue;
            }
            if curr != root_uid {
                descendants.push(curr.clone());
            }
            if let Some(children) = adjacency.get(&curr) {
                queue.extend(children.clone());
            }
        }
        descendants
    }

    pub fn duplicate_task_tree(&mut self, root_uid: &str) -> Vec<Task> {
        let mut new_tasks = Vec::new();
        let mut uid_map = HashMap::new(); // old -> new
        let mut queue = vec![root_uid.to_string()];
        let mut descendants = Vec::new();

        // Build adjacency to find descendants quickly
        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
        for map in self.calendars.values() {
            for t in map.values() {
                if let Some(p) = &t.parent_uid {
                    adjacency.entry(p.clone()).or_default().push(t.uid.clone());
                }
            }
        }

        let mut visited = HashSet::new();
        while let Some(curr) = queue.pop() {
            if !visited.insert(curr.clone()) {
                continue;
            }
            descendants.push(curr.clone());
            if let Some(children) = adjacency.get(&curr) {
                queue.extend(children.clone());
            }
        }

        for old_uid in &descendants {
            if let Some(t) = self.get_task_ref(old_uid) {
                let mut clone = t.clone();
                let new_uid = uuid::Uuid::new_v4().to_string();
                uid_map.insert(old_uid.clone(), new_uid.clone());

                clone.uid = new_uid;
                clone.href = String::new();
                clone.etag = String::new();
                clone.status = crate::model::TaskStatus::NeedsAction;
                clone.percent_complete = None;
                clone.time_spent_seconds = 0;
                clone.last_started_at = None;
                clone.sessions.clear();
                clone
                    .unmapped_properties
                    .retain(|p| p.key != "COMPLETED" && p.key != "CREATED");
                clone
                    .alarms
                    .retain(|a| !a.is_snooze() && a.acknowledged.is_none());

                // Visually differentiate the root clone
                if old_uid == root_uid {
                    clone.summary = format!("{} (Copy)", clone.summary);
                }

                new_tasks.push(clone);
            }
        }

        // Second pass: remap intra-tree relations
        for t in &mut new_tasks {
            if let Some(p) = &t.parent_uid
                && let Some(new_p) = uid_map.get(p)
            {
                t.parent_uid = Some(new_p.clone());
            }
            let old_deps = std::mem::take(&mut t.dependencies);
            for d in old_deps {
                if let Some(new_d) = uid_map.get(&d) {
                    t.dependencies.push(new_d.clone());
                } else {
                    t.dependencies.push(d); // Maintain external blockers
                }
            }
            let old_rels = std::mem::take(&mut t.related_to);
            for r in old_rels {
                if let Some(new_r) = uid_map.get(&r) {
                    t.related_to.push(new_r.clone());
                } else {
                    t.related_to.push(r);
                }
            }
        }

        for t in &new_tasks {
            self.update_or_add_task(t.clone());
        }

        new_tasks
    }

    /// Set or unset a parent relationship for a task.
    pub fn set_parent(
        &mut self,
        child_uid: &str,
        parent_uid: Option<String>,
    ) -> Result<Task, &'static str> {
        if let Some(p_uid) = &parent_uid {
            if p_uid == child_uid {
                return Err(Box::leak(
                    rust_i18n::t!("error_cannot_be_child_of_self")
                        .into_owned()
                        .into_boxed_str(),
                ));
            }
            if self.get_descendant_uids(child_uid).contains(p_uid) {
                return Err("Cycle detected: Cannot set a task as a child of its own descendant");
            }
        }
        if let Some((task, _)) = self.get_task_mut(child_uid) {
            task.parent_uid = parent_uid;
            task.sequence += 1;
            return Ok(task.clone());
        }
        Err(Box::leak(
            rust_i18n::t!("error_task_not_found")
                .into_owned()
                .into_boxed_str(),
        ))
    }

    /// Add a dependency (task_uid depends on dep_uid). Maintain reverse blocking index.
    pub fn add_dependency(&mut self, task_uid: &str, dep_uid: String) -> Option<Task> {
        if let Some((task, _)) = self.get_task_mut(task_uid)
            && !task.dependencies.contains(&dep_uid)
        {
            task.dependencies.push(dep_uid.clone());
            task.sequence += 1;
            let task_clone = task.clone();
            self.blocking_index
                .entry(dep_uid)
                .or_default()
                .push(task_uid.to_string());
            return Some(task_clone);
        }
        None
    }

    /// Remove a dependency and update reverse index.
    pub fn remove_dependency(&mut self, task_uid: &str, dep_uid: &str) -> Option<Task> {
        if let Some((task, _)) = self.get_task_mut(task_uid)
            && let Some(pos) = task.dependencies.iter().position(|d| d == dep_uid)
        {
            task.dependencies.remove(pos);
            task.sequence += 1;
            let task_clone = task.clone();
            if let Some(list) = self.blocking_index.get_mut(dep_uid) {
                list.retain(|u| u != task_uid);
                if list.is_empty() {
                    self.blocking_index.remove(dep_uid);
                }
            }
            return Some(task_clone);
        }
        None
    }

    /// Add a related_to relationship and update reverse index for fast lookups.
    pub fn add_related_to(&mut self, task_uid: &str, related_uid: String) -> Option<Task> {
        let result = if let Some((task, _)) = self.get_task_mut(task_uid)
            && !task.related_to.contains(&related_uid)
        {
            task.related_to.push(related_uid.clone());
            task.sequence += 1;
            Some(task.clone())
        } else {
            None
        };
        if result.is_some() {
            self.related_from_index
                .entry(related_uid)
                .or_default()
                .push(task_uid.to_string());
        }
        result
    }

    /// Remove a related_to relationship and update reverse index.
    pub fn remove_related_to(&mut self, task_uid: &str, related_uid: &str) -> Option<Task> {
        let result = if let Some((task, _)) = self.get_task_mut(task_uid)
            && let Some(pos) = task.related_to.iter().position(|r| r == related_uid)
        {
            task.related_to.remove(pos);
            task.sequence += 1;
            Some(task.clone())
        } else {
            None
        };
        if result.is_some()
            && let Some(sources) = self.related_from_index.get_mut(related_uid)
        {
            sources.retain(|uid| uid != task_uid);
            if sources.is_empty() {
                self.related_from_index.remove(related_uid);
            }
        }
        result
    }

    /// Move a task between calendars in an atomic fashion:
    /// - delete from old calendar, adjust index, insert into target calendar
    /// - returns (original, updated) pair when a move occurred
    pub fn move_task(&mut self, uid: &str, target_href: String) -> Option<(Task, Task)> {
        if let Some((mut task, old_href)) = self.delete_task(uid) {
            if old_href == target_href {
                // No-op move; re-add to same calendar
                self.add_task(task);
                return None;
            }
            let original = task.clone();
            task.calendar_href = target_href.clone();
            task.sequence += 1;
            self.add_task(task.clone());
            return Some((original, task));
        }
        None
    }

    /// Apply an alias retroactively: examine tasks that match the alias key (or its children)
    /// and mutate categories/location/priority as specified by `raw_values`. Returns the set
    /// of modified tasks for callers to persist/save.
    pub fn apply_alias_retroactively(
        &mut self,
        alias_key: &str,
        raw_values: &[String],
    ) -> Vec<Task> {
        let mut uids_to_update: Vec<String> = Vec::new();
        let is_location_alias = alias_key.starts_with("@@");
        let (clean_key, alias_prefix) = if is_location_alias {
            let clean = alias_key.trim_start_matches("@@");
            (clean, format!("{}:", clean))
        } else {
            (alias_key, format!("{}:", alias_key))
        };

        for map in self.calendars.values() {
            for task in map.values() {
                let has_alias_or_child = if is_location_alias {
                    if let Some(loc) = &task.location {
                        loc == clean_key || loc.starts_with(&alias_prefix)
                    } else {
                        false
                    }
                } else {
                    task.categories
                        .iter()
                        .any(|cat| cat == clean_key || cat.starts_with(&alias_prefix))
                };

                if has_alias_or_child {
                    let mut needs_update = false;
                    for val in raw_values {
                        if let Some(tag) = val.strip_prefix('#') {
                            let clean = crate::model::parser::strip_quotes(tag);
                            if !task.categories.contains(&clean) {
                                needs_update = true;
                                break;
                            }
                        } else if let Some(loc) = val.strip_prefix("@@") {
                            let clean = crate::model::parser::strip_quotes(loc);
                            if task.location.as_ref() != Some(&clean) {
                                needs_update = true;
                                break;
                            }
                        } else if let Some(prio) = val.strip_prefix('!')
                            && let Ok(p) = prio.parse::<u8>()
                            && task.priority != p
                        {
                            needs_update = true;
                            break;
                        }
                    }
                    if needs_update {
                        uids_to_update.push(task.uid.clone());
                    }
                }
            }
        }

        if uids_to_update.is_empty() {
            return Vec::new();
        }

        let mut modified_tasks = Vec::new();
        for uid in uids_to_update {
            if let Some((task, _)) = self.get_task_mut(&uid) {
                for val in raw_values {
                    if let Some(tag) = val.strip_prefix('#') {
                        let clean = crate::model::parser::strip_quotes(tag);
                        if !task.categories.contains(&clean) {
                            task.categories.push(clean);
                        }
                    } else if let Some(loc) = val.strip_prefix("@@") {
                        task.location = Some(crate::model::parser::strip_quotes(loc));
                    } else if let Some(prio) = val.strip_prefix('!')
                        && let Ok(p) = prio.parse::<u8>()
                    {
                        task.priority = p.min(9);
                    }
                }
                task.categories.sort();
                task.categories.dedup();
                task.sequence += 1;
                modified_tasks.push(task.clone());
            }
        }
        modified_tasks
    }

    /// Convenience: is task done by uid.
    pub fn is_task_done(&self, uid: &str) -> Option<bool> {
        self.get_task_ref(uid).map(|t| t.status.is_done())
    }

    /// Determine explicit blocking for a task by checking 'blocked' category and dependencies.
    /// This uses the store's index for O(1) existence checks of dependency UIDs.
    pub fn is_blocked(&self, task: &Task) -> bool {
        if task.categories.contains(&"blocked".to_string()) {
            return true;
        }
        if task.dependencies.is_empty() {
            return false;
        }
        for dep_uid in &task.dependencies {
            if let Some(is_done) = self.is_task_done(dep_uid)
                && !is_done
            {
                return true;
            }
        }
        false
    }

    /// Get list of tasks that DECLARE they are related to the provided uid (i.e. sources).
    pub fn get_tasks_related_to(&self, uid: &str) -> Vec<(String, String)> {
        if let Some(source_uids) = self.related_from_index.get(uid) {
            source_uids
                .iter()
                .filter_map(|source_uid| {
                    self.get_summary(source_uid)
                        .map(|summary| (source_uid.clone(), summary))
                })
                .collect()
        } else {
            Vec::new()
        }
    }

    /// Returns tasks that are blocked BY the given uid (i.e. successors).
    pub fn get_tasks_blocking(&self, uid: &str) -> Vec<(String, String)> {
        if let Some(blocked_uids) = self.blocking_index.get(uid) {
            blocked_uids
                .iter()
                .filter_map(|blocked_uid| {
                    self.get_summary(blocked_uid)
                        .map(|summary| (blocked_uid.clone(), summary))
                })
                .collect()
        } else {
            Vec::new()
        }
    }

    /// Rebuild both reverse indices (related_from_index and blocking_index).
    /// This is called after bulk mutations and ensures the indices are consistent.
    pub fn rebuild_relation_index(&mut self) {
        self.related_from_index.clear();
        self.blocking_index.clear();

        let mut relationships = Vec::new();
        let mut blocking_rels = Vec::new();

        // Iterate all calendars and tasks only once to collect relations
        for map in self.calendars.values() {
            for (uid, task) in map {
                for r in &task.related_to {
                    relationships.push((r.clone(), uid.clone()));
                }
                for dep in &task.dependencies {
                    blocking_rels.push((dep.clone(), uid.clone()));
                }
            }
        }

        // Populate reverse maps
        for (from, to) in relationships {
            self.related_from_index.entry(from).or_default().push(to);
        }
        for (from, to) in blocking_rels {
            self.blocking_index.entry(from).or_default().push(to);
        }
    }

    /// Fast summary retrieval (O(1) via index + map lookup)
    pub fn get_summary(&self, uid: &str) -> Option<String> {
        self.get_task_ref(uid).map(|t| t.summary.clone())
    }

    /// Main filter pipeline that performs multi-stage filtering and returns
    /// prepared results (cloned tasks and aggregated category/location lists).
    pub fn filter(&self, options: FilterOptions) -> FilterResult {
        // Build set of completed UIDs for quick membership checks (used by blocking checks)
        let mut completed_uids: HashSet<String> = HashSet::new();
        for map in self.calendars.values() {
            for t in map.values() {
                if t.status.is_done() {
                    completed_uids.insert(t.uid.clone());
                }
            }
        }

        // Helper: explicit blocked state (ignores inherited parent blocking)
        let check_is_blocked_explicit = |t: &Task, done_set: &HashSet<String>| -> bool {
            if t.categories.contains(&"blocked".to_string()) {
                return true;
            }
            if t.dependencies.is_empty() {
                return false;
            }
            for dep in &t.dependencies {
                if self.index.contains_key(dep) && !done_set.contains(dep) {
                    return true;
                }
            }
            false
        };

        // Helper: determine whether a task is effectively blocked by checking ancestors
        let check_is_effectively_blocked = |t: &Task, done_set: &HashSet<String>| -> bool {
            let mut current = t;
            let mut visited = HashSet::new();

            loop {
                if check_is_blocked_explicit(current, done_set) {
                    return true;
                }

                if let Some(p_uid) = &current.parent_uid {
                    if !visited.insert(p_uid.clone()) {
                        // cycle / already visited: stop
                        break;
                    }
                    if let Some(p_task) = self.get_task_ref(p_uid) {
                        current = p_task;
                        continue;
                    }
                }
                break;
            }
            false
        };

        let search_lower = options.search_term.to_lowercase();
        let is_ready_mode = search_lower.contains("is:ready");
        let is_blocked_mode = search_lower.contains("is:blocked");
        let now = Utc::now();

        // Helper: determine whether a task is effectively in the future by checking ancestors
        let check_is_effectively_future = |t: &Task| -> bool {
            let mut current = t;
            let mut visited = HashSet::new();

            loop {
                if let Some(start) = &current.dtstart
                    && start.to_start_comparison_time() > now
                {
                    return true;
                }

                if let Some(p_uid) = &current.parent_uid {
                    if !visited.insert(p_uid.clone()) {
                        break;
                    }
                    if let Some(p_task) = self.get_task_ref(p_uid) {
                        current = p_task;
                        continue;
                    }
                }
                break;
            }
            false
        };

        // 1) Build iterator over allowed calendars (respecting active/hidden calendar restrictions).
        let all_allowed_refs: Vec<&Task> = self
            .calendars
            .iter()
            .filter(|(href, _)| {
                if let Some(active) = options.active_cal_href {
                    *href == active && !options.hidden_calendars.contains(*href)
                } else {
                    !options.hidden_calendars.contains(*href)
                }
            })
            .flat_map(|(_, map)| map.values())
            .collect();

        // 2) Define the filtering pipeline as a reusable closure.
        // This allows us to calculate the final tasks, and recalculate aggregates ignoring specific filters for OR modes.
        let run_pipeline = |ignore_categories: bool, ignore_locations: bool| -> Vec<&Task> {
            let visible_refs: Vec<&Task> = all_allowed_refs
                .iter()
                .copied()
                .filter(|t| {
                    if t.uid == "cfait-global-settings-v1"
                        || t.summary.starts_with("⚙ Cfait Settings")
                    {
                        return false;
                    }

                    // Status-based filtering
                    let has_status_filter = search_lower.contains("is:done")
                        || search_lower.contains("is:active")
                        || search_lower.contains("is:started")
                        || search_lower.contains("is:ongoing");

                    if !has_status_filter && t.status.is_done() && options.hide_completed_global {
                        return false;
                    }

                    if is_ready_mode {
                        if t.status.is_done() {
                            return false;
                        }
                        // InProcess (ongoing) tasks should be considered actionable/ready
                        // even if they would otherwise be treated as blocked or start in the future.
                        if t.status != TaskStatus::InProcess {
                            if check_is_effectively_future(t) {
                                return false;
                            }
                            if check_is_effectively_blocked(t, &completed_uids) {
                                return false;
                            }
                        }
                    }

                    if is_blocked_mode && !check_is_effectively_blocked(t, &completed_uids) {
                        return false;
                    }

                    // Duration filters
                    if let Some(mins) = t.estimated_duration {
                        if let Some(min) = options.min_duration
                            && mins < min
                        {
                            return false;
                        }
                        if let Some(max) = options.max_duration
                            && mins > max
                        {
                            return false;
                        }
                    } else if !options.include_unset_duration {
                        return false;
                    }

                    // Category matching
                    if !ignore_categories && !options.selected_categories.is_empty() {
                        let filter_uncategorized =
                            options.selected_categories.contains(UNCATEGORIZED_ID);
                        let check_match = |task_cat: &str, selected: &str| -> bool {
                            let tc_lower = task_cat.to_lowercase();
                            let sel_lower = selected.to_lowercase();
                            if tc_lower == sel_lower {
                                return true;
                            }
                            if let Some(stripped) = tc_lower.strip_prefix(&sel_lower) {
                                return stripped.starts_with(':');
                            }
                            false
                        };

                        if options.match_all_categories {
                            for sel in options.selected_categories {
                                if sel == UNCATEGORIZED_ID {
                                    if !t.categories.is_empty() {
                                        return false;
                                    }
                                } else {
                                    let mut has = false;
                                    for c in &t.categories {
                                        if check_match(c, sel) {
                                            has = true;
                                            break;
                                        }
                                    }
                                    if !has {
                                        return false;
                                    }
                                }
                            }
                        } else {
                            let mut hit = false;
                            if filter_uncategorized && t.categories.is_empty() {
                                hit = true;
                            } else {
                                for sel in options.selected_categories {
                                    if sel != UNCATEGORIZED_ID {
                                        for c in &t.categories {
                                            if check_match(c, sel) {
                                                hit = true;
                                                break;
                                            }
                                        }
                                    }
                                    if hit {
                                        break;
                                    }
                                }
                            }
                            if !hit {
                                return false;
                            }
                        }
                    }

                    // Location matching
                    if !ignore_locations && !options.selected_locations.is_empty() {
                        if let Some(loc) = &t.location {
                            let mut hit = false;
                            for sel in options.selected_locations {
                                if loc == sel
                                    || (loc.starts_with(sel)
                                        && loc.chars().nth(sel.len()) == Some(':'))
                                {
                                    hit = true;
                                    break;
                                }
                            }
                            if !hit {
                                return false;
                            }
                        } else {
                            return false;
                        }
                    }

                    true
                })
                .collect();

            // 3) Search term expansion
            if options.search_term.is_empty() {
                visible_refs
            } else {
                let mut children_map = HashMap::new();
                for t in &visible_refs {
                    if let Some(p) = &t.parent_uid {
                        children_map
                            .entry(p.clone())
                            .or_insert_with(Vec::new)
                            .push(t.uid.clone());
                    }
                }

                let mut matched_uids = HashSet::new();
                let mut queue = Vec::new();

                for t in &visible_refs {
                    if t.matches_search_term(options.search_term)
                        && matched_uids.insert(t.uid.clone())
                    {
                        queue.push(t.uid.clone());
                    }
                }

                let mut idx = 0;
                while idx < queue.len() {
                    let curr = queue[idx].clone();
                    idx += 1;
                    if let Some(children) = children_map.get(&curr) {
                        for child in children {
                            if matched_uids.insert(child.clone()) {
                                queue.push(child.clone());
                            }
                        }
                    }
                }

                visible_refs
                    .into_iter()
                    .filter(|t| matched_uids.contains(&t.uid))
                    .collect()
            }
        };

        // Execution of pipelines:
        // The final task list applies ALL filters
        let final_refs = run_pipeline(false, false);

        // For tags: If OR mode, ignore current tag selection so user can pick multiple parallel tags
        let tag_refs = if options.match_all_categories {
            final_refs.clone()
        } else {
            run_pipeline(true, false)
        };

        // For locations: A task only has 1 location, so multiple locations is ALWAYS an OR operation.
        // We always ignore the location filter when computing location aggregates so other locations stay visible.
        let loc_refs = run_pipeline(false, true);

        // 4) Build category and location aggregates
        let mut cat_active_counts: HashMap<String, u32> = HashMap::new();
        let mut cat_display_names: HashMap<String, String> = HashMap::new();
        let mut cat_present_lower: HashSet<String> = HashSet::new();
        let mut uncat_active_count: u32 = 0;
        let mut uncat_any = false;

        let mut loc_active_counts: HashMap<String, u32> = HashMap::new();
        let mut loc_present: HashSet<String> = HashSet::new();

        // Process tag refs
        for t in &tag_refs {
            let is_active = !t.status.is_done();

            if t.categories.is_empty() {
                uncat_any = true;
                if is_active {
                    uncat_active_count += 1;
                }
            } else {
                let mut seen_for_task = HashSet::new();
                for cat in &t.categories {
                    let parts: Vec<&str> = cat.split(':').collect();
                    let mut current_hierarchy = String::with_capacity(cat.len());

                    for (i, part) in parts.iter().enumerate() {
                        if i > 0 {
                            current_hierarchy.push(':');
                        }
                        current_hierarchy.push_str(part);

                        let lower_key = current_hierarchy.to_lowercase();
                        if !seen_for_task.insert(lower_key.clone()) {
                            continue;
                        }

                        cat_present_lower.insert(lower_key.clone());
                        cat_display_names
                            .entry(lower_key.clone())
                            .or_insert_with(|| current_hierarchy.clone());

                        if is_active {
                            *cat_active_counts.entry(lower_key.clone()).or_insert(0) += 1;
                        }
                    }
                }
            }
        }

        // Process location refs
        for t in &loc_refs {
            let is_active = !t.status.is_done();

            if let Some(loc) = &t.location {
                let parts: Vec<&str> = loc.split(':').collect();
                let mut current_hierarchy = String::with_capacity(loc.len());
                for (i, part) in parts.iter().enumerate() {
                    if i > 0 {
                        current_hierarchy.push(':');
                    }
                    current_hierarchy.push_str(part);

                    if is_active {
                        *loc_active_counts
                            .entry(current_hierarchy.clone())
                            .or_insert(0) += 1;
                    }
                    loc_present.insert(current_hierarchy.clone());
                }
            }
        }

        let build_aggregates = |counts: HashMap<String, u32>,
                                display_names: HashMap<String, String>,
                                expanded_set: &HashSet<String>,
                                is_location: bool|
         -> Vec<AggregateItem> {
            let mut sorted_keys: Vec<String> = counts.keys().cloned().collect();
            sorted_keys.sort();

            let mut results = Vec::new();
            for key in &sorted_keys {
                if key == UNCATEGORIZED_ID {
                    results.push(AggregateItem {
                        full_key: key.clone(),
                        display_name: rust_i18n::t!("uncategorized").to_string(),
                        count: counts[key],
                        depth: 0,
                        has_children: false,
                        is_expanded: false,
                    });
                    continue;
                }

                let count = counts[key];
                let parts: Vec<&str> = key.split(':').collect();
                let depth_val = parts.len() - 1;
                let depth = depth_val as u32;
                let original_full = display_names
                    .get(key)
                    .map(|s| s.as_str())
                    .unwrap_or(key.as_str());
                let original_parts: Vec<&str> = original_full.split(':').collect();
                let display_name = original_parts
                    .last()
                    .unwrap_or(parts.last().unwrap())
                    .to_string();

                let prefix = format!("{}:", key);
                let has_children = sorted_keys.iter().any(|k| k.starts_with(&prefix));

                let alias_key_to_check = if is_location {
                    format!("@@{}", key)
                } else {
                    key.clone()
                };

                // Hide leaf aliases if requested
                if options.hide_aliases_in_sidebar
                    && options.tag_aliases.contains_key(&alias_key_to_check)
                    && !has_children
                {
                    continue;
                }

                let mut visible = true;
                let mut ancestor = String::new();
                for (i, part) in parts.iter().enumerate().take(depth_val) {
                    if i > 0 {
                        ancestor.push(':');
                    }
                    ancestor.push_str(part);
                    if !expanded_set.contains(&ancestor) {
                        visible = false;
                        break;
                    }
                }

                if visible {
                    results.push(AggregateItem {
                        full_key: key.clone(),
                        display_name,
                        count,
                        depth,
                        has_children,
                        is_expanded: expanded_set.contains(key),
                    });
                }
            }
            results
        };

        // Convert category maps into sorted vectors for UI
        let categories = build_aggregates(
            cat_active_counts,
            cat_display_names,
            options.expanded_tags,
            false,
        );

        let empty_names = HashMap::new(); // Locations use self-display names naturally
        let locations = build_aggregates(
            loc_active_counts,
            empty_names,
            options.expanded_locations,
            true,
        );

        // Add uncategorized if needed
        let mut final_categories = categories;
        if uncat_any {
            final_categories.insert(
                0,
                AggregateItem {
                    full_key: UNCATEGORIZED_ID.to_string(),
                    display_name: rust_i18n::t!("uncategorized").to_string(),
                    count: uncat_active_count,
                    depth: 0,
                    has_children: false,
                    is_expanded: false,
                },
            );
        }

        // duplicate aggregates removed (handled above)

        // 5) Clone final results into owned Task structs and compute transient fields.
        let mut final_tasks_processed: Vec<Task> = final_refs
            .into_iter()
            .map(|t_ref| {
                let mut t = t_ref.clone();
                // Compute blocked flags: explicit vs implicit
                t.is_blocked = check_is_blocked_explicit(&t, &completed_uids);
                t.is_implicitly_blocked =
                    !t.is_blocked && check_is_effectively_blocked(&t, &completed_uids);
                // Penalize blocked tasks by lowering their effective priority so they sort after non-blocked
                // tasks within the same rank. Numeric priority: lower is better, so increase the numeric
                // value for blocked tasks (worse priority). Clamp to 9.
                t.effective_priority = if t.is_blocked || t.is_implicitly_blocked {
                    if t.priority == 0 {
                        9
                    } else {
                        (t.priority.saturating_add(3)).min(9)
                    }
                } else {
                    t.priority
                };
                t.effective_due = t.due.clone();
                t.effective_dtstart = t.dtstart.clone();
                t
            })
            .collect();

        // Dependency due-date propagation
        // If task A depends on task B, B is the blocking task.
        // B should inherit A's effective_due if it is sooner.
        let mut blocking_to_blocked: HashMap<String, Vec<usize>> = HashMap::new();
        for (i, t) in final_tasks_processed.iter().enumerate() {
            for dep in &t.dependencies {
                blocking_to_blocked.entry(dep.clone()).or_default().push(i);
            }
        }

        let mut dep_cache: HashMap<usize, Option<DateType>> = HashMap::new();
        let mut dep_visiting: HashSet<usize> = HashSet::new();

        fn resolve_deps_due(
            idx: usize,
            tasks: &[Task],
            blocking_to_blocked: &HashMap<String, Vec<usize>>,
            cache: &mut HashMap<usize, Option<DateType>>,
            visiting: &mut HashSet<usize>,
        ) -> Option<DateType> {
            if let Some(cached) = cache.get(&idx) {
                return cached.clone();
            }
            if visiting.contains(&idx) {
                return tasks[idx].effective_due.clone();
            }

            visiting.insert(idx);
            let mut min_d = tasks[idx].effective_due.clone();

            let uid = &tasks[idx].uid;
            if let Some(blocked_indices) = blocking_to_blocked.get(uid) {
                for &b_idx in blocked_indices {
                    let d = resolve_deps_due(b_idx, tasks, blocking_to_blocked, cache, visiting);
                    min_d = match (&min_d, &d) {
                        (Some(da), Some(db)) => {
                            if da.to_comparison_time() < db.to_comparison_time() {
                                Some(da.clone())
                            } else {
                                Some(db.clone())
                            }
                        }
                        (Some(da), None) => Some(da.clone()),
                        (None, Some(db)) => Some(db.clone()),
                        (None, None) => None,
                    };
                }
            }

            visiting.remove(&idx);
            cache.insert(idx, min_d.clone());
            min_d
        }

        for i in 0..final_tasks_processed.len() {
            if !dep_cache.contains_key(&i) {
                resolve_deps_due(
                    i,
                    &final_tasks_processed,
                    &blocking_to_blocked,
                    &mut dep_cache,
                    &mut dep_visiting,
                );
            }
        }

        // Apply propagated dependency due dates back to tasks
        for (i, t) in final_tasks_processed.iter_mut().enumerate() {
            if let Some(min_due) = dep_cache.get(&i) {
                t.effective_due = min_due.clone();
            }
        }

        // 6) Compute rank, sort order, and transient UI fields for the final tasks.
        for t in final_tasks_processed.iter_mut() {
            let eff_blocked = t.is_blocked || t.is_implicitly_blocked;
            t.sort_rank = t.calculate_base_rank(
                options.cutoff_date,
                options.urgent_days,
                options.urgent_prio,
                options.start_grace_period_days,
                eff_blocked,
            );

            t.has_blocking_tasks = self.has_tasks_blocking(&t.uid);
            t.has_related_tasks = self.has_tasks_related_to(&t.uid);

            t.is_future_start = t
                .dtstart
                .as_ref()
                .map(|start| start.to_start_comparison_time() > now)
                .unwrap_or(false);
            t.is_implicitly_future = !t.is_future_start && check_is_effectively_future(t);
            t.is_overdue = t
                .due
                .as_ref()
                .map(|d| !t.status.is_done() && d.to_comparison_time() < now)
                .unwrap_or(false);

            let (p_tags, p_loc) = if let Some(p_uid) = &t.parent_uid {
                if let Some(p) = self.get_task_ref(p_uid) {
                    (p.categories.iter().cloned().collect(), p.location.clone())
                } else {
                    (HashSet::new(), None)
                }
            } else {
                (HashSet::new(), None)
            };

            let (visible_tags, visible_location) =
                t.resolve_visual_attributes(&p_tags, &p_loc, options.tag_aliases);
            t.visible_categories = visible_tags;
            t.visible_location = visible_location;
        }

        // Propagation: certain UI operations look up best child/parent contributions.
        // Build helper maps for O(1) access by index into the vector.
        let mut uid_to_index = HashMap::new();
        for (i, t) in final_tasks_processed.iter().enumerate() {
            uid_to_index.insert(t.uid.clone(), i);
        }

        // Children map (index-based) used by propagation resolution
        let mut map: HashMap<String, Vec<usize>> = HashMap::new();
        for (i, t) in final_tasks_processed.iter().enumerate() {
            if let Some(p) = &t.parent_uid {
                map.entry(p.clone()).or_default().push(i);
            }
        }

        // Resolve function used to compute the 'best' child result used in some UI heuristics.
        fn resolve(
            idx: usize,
            tasks: &Vec<Task>,
            map: &HashMap<String, Vec<usize>>,
            cache: &mut HashMap<usize, Task>,
            visiting: &mut HashSet<usize>,
            default_prio: u8,
        ) -> Task {
            if let Some(cached) = cache.get(&idx) {
                return cached.clone();
            }
            if visiting.contains(&idx) {
                return tasks[idx].clone();
            }

            visiting.insert(idx);
            let t = &tasks[idx];
            let mut best = t.clone();

            let is_suppressed = t.status.is_done() || t.is_blocked || t.is_implicitly_blocked;

            if !is_suppressed && let Some(children) = map.get(&t.uid) {
                for &child_idx in children {
                    let child_eff = resolve(child_idx, tasks, map, cache, visiting, default_prio);
                    let a = crate::model::item::SortKey {
                        rank: child_eff.sort_rank,
                        prio: child_eff.effective_priority,
                        due: child_eff.effective_due.clone(),
                        start: child_eff.effective_dtstart.clone(),
                    };
                    let b = crate::model::item::SortKey {
                        rank: best.sort_rank,
                        prio: best.effective_priority,
                        due: best.effective_due.clone(),
                        start: best.effective_dtstart.clone(),
                    };
                    let ordering =
                        crate::model::item::compare_sortkeys(&a, &b, default_prio, false);
                    if ordering == std::cmp::Ordering::Less {
                        best = child_eff;
                    }
                }
            }

            visiting.remove(&idx);
            cache.insert(idx, best.clone());
            best
        }

        // Run propagation resolver for every node to produce any necessary transient values.
        let mut cache: HashMap<usize, Task> = HashMap::new();
        let mut visiting: HashSet<usize> = HashSet::new();
        for i in 0..final_tasks_processed.len() {
            if !cache.contains_key(&i) {
                let _ = resolve(
                    i,
                    &final_tasks_processed,
                    &map,
                    &mut cache,
                    &mut visiting,
                    options.default_priority,
                );
            }
        }

        let parent_uids = self.get_all_parent_uids();

        // Apply propagated sort values back to the tasks so parents sort according to their highest actionable child
        for (i, t) in final_tasks_processed.iter_mut().enumerate() {
            t.has_subtasks = parent_uids.contains(&t.uid);
            if let Some(best) = cache.get(&i) {
                t.sort_rank = best.sort_rank;
                t.effective_priority = best.effective_priority;
                t.effective_due = best.effective_due.clone();
                t.effective_dtstart = best.effective_dtstart.clone();
            }
        }

        // Delegate to the hierarchy organizer which handles parent/child relationships,
        // indentation depth, and injecting expand/collapse control items for completed groups.
        // Note: organize_hierarchy applies `compare_for_sort` internally before building the tree.
        let organized_items = organize_hierarchy(
            final_tasks_processed,
            options.default_priority,
            options.sort_standard_by_priority,
            options.expanded_done_groups,
            options.max_done_roots,
            options.max_done_subtasks,
            !options.search_term.is_empty(),
        );

        FilterResult {
            items: organized_items,
            categories: final_categories,
            locations,
        }
    }

    /// Returns list of (Summary, GeoString) for the root and all descendants
    pub fn get_tree_waypoints(&self, root_uid: &str) -> Vec<(String, String)> {
        let mut uids = self.get_descendant_uids(root_uid);
        uids.push(root_uid.to_string());

        uids.iter()
            .filter_map(|id| self.get_task_ref(id))
            .filter_map(|t| t.geo.as_ref().map(|g| (t.summary.clone(), g.clone())))
            .collect()
    }

    /// Returns the total number of locations in a task tree without allocating strings
    pub fn count_tree_locations(&self, root_uid: &str) -> usize {
        let mut uids = self.get_descendant_uids(root_uid);
        uids.push(root_uid.to_string());

        uids.iter()
            .filter_map(|id| self.get_task_ref(id))
            .filter(|t| t.geo.is_some())
            .count()
    }

    /// Fast lookup to see which tasks are parents
    pub fn get_all_parent_uids(&self) -> HashSet<String> {
        let mut parents = HashSet::new();
        for map in self.calendars.values() {
            for t in map.values() {
                if let Some(p) = &t.parent_uid {
                    parents.insert(p.clone());
                }
            }
        }
        parents
    }

    /// Applies a Task-related AppIntent to the in-memory store and returns the list of
    /// persistence Actions that should be written to the journal/server.
    /// This method ignores Session-related intents (like SetSearchTerm).
    pub fn apply_task_intent(&mut self, intent: &AppIntent, config: &Config) -> Vec<JournalAction> {
        let mut actions = Vec::new();
        match intent {
            AppIntent::ToggleTask { uid } => {
                if let Some((primary, secondary, children)) = self.toggle_task(uid) {
                    if let Some(sec) = secondary {
                        actions.push(JournalAction::Create(primary));
                        actions.push(JournalAction::Update(sec));
                    } else {
                        actions.push(JournalAction::Update(primary));
                    }
                    for c in children {
                        actions.push(JournalAction::Update(c));
                    }
                }
            }
            AppIntent::ToggleTaskShift { uid } => {
                if let Some((primary, secondary, children)) = self.toggle_task_shift(uid) {
                    if let Some(sec) = secondary {
                        actions.push(JournalAction::Create(primary));
                        actions.push(JournalAction::Update(sec));
                    } else {
                        actions.push(JournalAction::Update(primary));
                    }
                    for c in children {
                        actions.push(JournalAction::Update(c));
                    }
                }
            }
            AppIntent::DeleteTask { uid } => {
                if let Some((deleted, trashed_opt)) =
                    self.soft_delete_task(uid, config.trash_retention_days)
                {
                    actions.push(JournalAction::Delete(deleted));
                    if let Some(trashed) = trashed_opt {
                        actions.push(JournalAction::Create(trashed));
                    }
                }
            }
            AppIntent::DeleteTaskTree { uid } => {
                let pairs = self.soft_delete_task_tree(uid, config.trash_retention_days);
                for (deleted, trashed_opt) in pairs {
                    actions.push(JournalAction::Delete(deleted));
                    if let Some(trashed) = trashed_opt {
                        actions.push(JournalAction::Create(trashed));
                    }
                }
            }
            AppIntent::CancelTask { uid } => {
                if let Some((primary, secondary, children)) =
                    self.set_status(uid, crate::model::TaskStatus::Cancelled, false)
                {
                    if let Some(sec) = secondary {
                        actions.push(JournalAction::Create(primary));
                        actions.push(JournalAction::Update(sec));
                    } else {
                        actions.push(JournalAction::Update(primary));
                    }
                    for c in children {
                        actions.push(JournalAction::Update(c));
                    }
                }
            }
            AppIntent::ChangePriority { uid, delta } => {
                if let Some(updated) = self.change_priority(uid, *delta, config.default_priority) {
                    actions.push(JournalAction::Update(updated));
                }
            }
            AppIntent::StartTask { uid } => {
                let updated = self.set_status_in_process(uid);
                actions.extend(updated.into_iter().map(JournalAction::Update));
            }
            AppIntent::PauseTask { uid } => {
                let updated = self.pause_task(uid);
                actions.extend(updated.into_iter().map(JournalAction::Update));
            }
            AppIntent::StopTask { uid } => {
                let updated = self.stop_task(uid);
                actions.extend(updated.into_iter().map(JournalAction::Update));
            }
            AppIntent::MoveTask { uid, target_href } => {
                let safe_target = if target_href == crate::storage::LOCAL_TRASH_HREF
                    || target_href == "local://recovery"
                {
                    crate::storage::LOCAL_CALENDAR_HREF.to_string()
                } else {
                    target_href.clone()
                };
                if let Some((orig, updated)) = self.move_task(uid, safe_target.clone()) {
                    if !orig.calendar_href.starts_with("local://")
                        && safe_target.starts_with("local://")
                    {
                        actions.push(JournalAction::Delete(orig));
                        actions.push(JournalAction::Create(updated));
                    } else if orig.calendar_href.starts_with("local://")
                        && !safe_target.starts_with("local://")
                    {
                        actions.push(JournalAction::Delete(orig));
                        let mut moved = updated.clone();
                        moved.href = String::new();
                        moved.etag = String::new();
                        actions.push(JournalAction::Create(moved));
                    } else {
                        actions.push(JournalAction::Move(orig, safe_target));
                    }
                }
            }
            AppIntent::DuplicateTaskTree { uid } => {
                let new_tasks = self.duplicate_task_tree(uid);
                actions.extend(new_tasks.into_iter().map(JournalAction::Create));
            }
            AppIntent::RemoveParent { uid } => {
                if let Ok(updated) = self.set_parent(uid, None) {
                    actions.push(JournalAction::Update(updated));
                }
            }
            AppIntent::MakeChild { uid, parent_uid } => {
                if let Ok(updated) = self.set_parent(uid, Some(parent_uid.clone())) {
                    actions.push(JournalAction::Update(updated));
                }
            }
            AppIntent::AddDependency { uid, blocker_uid } => {
                if let Some(updated) = self.add_dependency(uid, blocker_uid.clone()) {
                    actions.push(JournalAction::Update(updated));
                }
            }
            AppIntent::RemoveDependency { uid, blocker_uid } => {
                if let Some(updated) = self.remove_dependency(uid, blocker_uid) {
                    actions.push(JournalAction::Update(updated));
                }
            }
            AppIntent::AddRelatedTo { uid, related_uid } => {
                if let Some(updated) = self.add_related_to(uid, related_uid.clone()) {
                    actions.push(JournalAction::Update(updated));
                }
            }
            AppIntent::RemoveRelatedTo { uid, related_uid } => {
                if let Some(updated) = self.remove_related_to(uid, related_uid) {
                    actions.push(JournalAction::Update(updated));
                }
            }
            AppIntent::ToggleTreeCollapse { uid } => {
                if let Some((task, _)) = self.get_task_mut(uid) {
                    task.collapsed = !task.collapsed;
                    task.sequence += 1;
                    let updated = task.clone();
                    actions.push(JournalAction::Update(updated));
                }
            }
            _ => {} // Ignore session intents
        }
        actions
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Task, TaskStatus};

    fn make_task(uid: &str, parent_uid: Option<&str>, status: TaskStatus, collapsed: bool) -> Task {
        Task {
            uid: uid.to_string(),
            summary: uid.to_string(),
            description: String::new(),
            status,
            estimated_duration: None,
            estimated_duration_max: None,
            due: None,
            dtstart: None,
            alarms: vec![],
            exdates: vec![],
            priority: 5,
            percent_complete: None,
            parent_uid: parent_uid.map(|s| s.to_string()),
            dependencies: vec![],
            related_to: vec![],
            etag: String::new(),
            href: String::new(),
            calendar_href: "local://default".to_string(),
            categories: vec![],
            depth: 0,
            rrule: None,
            location: None,
            url: None,
            geo: None,
            collapsed,
            time_spent_seconds: 0,
            last_started_at: None,
            sessions: vec![],
            unmapped_properties: vec![],
            sequence: 0,
            raw_alarms: vec![],
            raw_components: vec![],
            create_event: None,
            // Transient fields
            is_blocked: false,
            is_implicitly_blocked: false,
            is_implicitly_future: false,
            has_subtasks: false,
            has_visible_subtasks: false,
            sort_rank: 0,
            effective_priority: 5,
            effective_due: None,
            effective_dtstart: None,
            visible_categories: vec![],
            visible_location: None,
            has_blocking_tasks: false,
            has_related_tasks: false,
            is_future_start: false,
            is_overdue: false,
        }
    }

    #[test]
    fn test_collapsed_tree_children_not_orphaned() {
        // Create a parent task with collapsed flag and two children
        let parent = make_task("parent", None, TaskStatus::NeedsAction, true);
        let child1 = make_task("child1", Some("parent"), TaskStatus::NeedsAction, false);
        let child2 = make_task("child2", Some("parent"), TaskStatus::NeedsAction, false);

        let tasks = vec![parent, child1, child2];
        let expanded_groups = HashSet::new();

        let result = organize_hierarchy(
            tasks,
            5,     // default_priority
            false, // sort_standard_by_priority
            &expanded_groups,
            10,    // max_done_roots
            10,    // max_done_subtasks
            false, // search_active
        );

        // Extract task UIDs from the result
        let result_uids: Vec<String> = result
            .into_iter()
            .filter_map(|item| match item {
                TaskListItem::Task(t) => Some(t.uid),
                _ => None,
            })
            .collect();

        // The parent should be present, but children should NOT appear as orphans at root level
        assert!(
            result_uids.contains(&"parent".to_string()),
            "Parent should be present"
        );
        // Children should NOT be in the result since parent is collapsed and we're not searching
        assert!(
            !result_uids.contains(&"child1".to_string()),
            "Child1 should not appear when parent is collapsed"
        );
        assert!(
            !result_uids.contains(&"child2".to_string()),
            "Child2 should not appear when parent is collapsed"
        );
    }

    #[test]
    fn test_collapsed_tree_with_done_children_not_orphaned() {
        // Create a parent task with collapsed flag and two done children
        let parent = make_task("parent", None, TaskStatus::NeedsAction, true);
        let child1 = make_task("child1", Some("parent"), TaskStatus::Completed, false);
        let child2 = make_task("child2", Some("parent"), TaskStatus::Completed, false);

        let tasks = vec![parent, child1, child2];
        let expanded_groups = HashSet::new();

        let result = organize_hierarchy(
            tasks,
            5,
            false, // sort_standard_by_priority
            &expanded_groups,
            10,
            1, // max_done_subtasks = 1, so only 1 done child shown, 1 hidden
            false,
        );

        let result_uids: Vec<String> = result
            .into_iter()
            .filter_map(|item| match item {
                TaskListItem::Task(t) => Some(t.uid),
                _ => None,
            })
            .collect();

        // Parent should be present
        assert!(
            result_uids.contains(&"parent".to_string()),
            "Parent should be present"
        );
        // At most 1 child should appear (due to max_done_subtasks=1)
        let child_count = result_uids
            .iter()
            .filter(|u| u.starts_with("child"))
            .count();
        assert!(
            child_count <= 1,
            "At most 1 child should appear due to truncation"
        );
        // The hidden child should NOT appear as orphan
        // If child1 is shown, child2 should not be orphaned; if child2 is shown, child1 should not be orphaned
    }

    #[test]
    fn test_truncated_done_tasks_not_orphaned() {
        // Create 5 done tasks at root level with max_done_roots=3
        // Note: the logic uses saturating_sub(1) so with limit=3, it shows 2 and hides the rest
        let tasks: Vec<Task> = (0..5)
            .map(|i| make_task(&format!("done{}", i), None, TaskStatus::Completed, false))
            .collect();

        let expanded_groups = HashSet::new();

        let result = organize_hierarchy(
            tasks,
            5,
            false, // sort_standard_by_priority
            &expanded_groups,
            3, // max_done_roots = 3, so 2 shown (3-1), 3 hidden
            10,
            false,
        );

        let result_uids: Vec<String> = result
            .clone()
            .into_iter()
            .filter_map(|item| match item {
                TaskListItem::Task(t) => Some(t.uid),
                _ => None,
            })
            .collect();

        // Only 2 done tasks should appear (due to truncation: limit.saturating_sub(1) = 3-1 = 2)
        assert_eq!(
            result_uids.len(),
            2,
            "Only 2 done tasks should appear at root level (3-1=2)"
        );
        // Check that there's an ExpandGroup item
        let has_expand = result
            .iter()
            .any(|item| matches!(item, TaskListItem::ExpandGroup(_, _)));
        assert!(
            has_expand,
            "Should have an ExpandGroup item for truncated tasks"
        );
    }
}
