import { computed, effect, Injectable, inject, signal } from '@angular/core';
import { Observable, animationFrameScheduler, combineLatest, of } from 'rxjs';
import { map, observeOn, switchMap, take } from 'rxjs/operators';
import { TaskWithSubTasks } from '../tasks/task.model';
import { selectAllProjects } from '../project/store/project.selectors';
import { Store } from '@ngrx/store';
import { Project } from '../project/project.model';
import { Tag } from '../tag/tag.model';
import { TODAY_TAG } from '../tag/tag.const';
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
import { getDbDateStr } from '../../util/get-db-date-str';
import { getWeekRange } from '../../util/get-week-range';
import { WorkContextService } from '../work-context/work-context.service';
import { WorkContextType } from '../work-context/work-context.model';
import { ProjectService } from '../project/project.service';
import { TagService } from '../tag/tag.service';
import { MenuTreeService } from '../menu-tree/menu-tree.service';
import {
  SortOption,
  CustomizerContextState,
  DEFAULT_OPTIONS,
  FilterOption,
  GroupOption,
  SORT_OPTION_TYPE,
  GROUP_OPTION_TYPE,
  FILTER_OPTION_TYPE,
  FILTER_SCHEDULE,
  SORT_ORDER,
  FILTER_COMMON,
  OPTIONS,
  NO_TAG_GROUP_ID,
} from './types';
import { DateAdapter } from '@angular/material/core';
import { lsGetJSON, lsSetJSON } from '../../util/ls-util';
import { LS } from '../../core/persistence/storage-keys.const';
import { LanguageService } from 'src/app/core/language/language.service';
import { TranslateService } from '@ngx-translate/core';
import { T } from '../../t.const';

const GROUP_OPTIONS_NO_PROJECT = OPTIONS.group.list.filter(
  (opt) => opt.type !== GROUP_OPTION_TYPE.project,
);

// Display keys for the two virtual tag-group buckets (kept as constants so the
// grouping and the drag-to-retag id-map agree on the exact strings).
const NO_TAG_GROUP_KEY = 'No tag';
const UNKNOWN_TAG_GROUP_KEY = 'Unknown tag';

/** Result of {@link TaskViewCustomizerService.customizeUndoneTasks}. */
export interface CustomizedUndoneTasks {
  list: TaskWithSubTasks[];
  grouped?: Record<string, TaskWithSubTasks[]>;
  /**
   * Tag grouping only: group-header title → its tagId, or `null` for buckets
   * with no single tag ('No tag', 'Unknown tag', or a title shared by several
   * tags). Drives drag-to-retag in the work view (dragging a task into another
   * tag group reassigns its tags). Absent for non-tag groupings.
   */
  groupTagIdByKey?: Record<string, string | null>;
}

@Injectable({ providedIn: 'root' })
export class TaskViewCustomizerService {
  private store = inject(Store);
  private _workContextService = inject(WorkContextService);
  private _dateAdapter = inject(DateAdapter);
  private _projectService = inject(ProjectService);
  private _tagService = inject(TagService);
  private _menuTreeService = inject(MenuTreeService);
  private _languageService = inject(LanguageService);
  private _translateService = inject(TranslateService);
  private _collator: Intl.Collator | null = null;
  private _collatorLocale: string | null = null;

  public selectedSort = signal<SortOption>(DEFAULT_OPTIONS.sort);
  public selectedGroup = signal<GroupOption>(DEFAULT_OPTIONS.group);
  public selectedFilter = signal<FilterOption>(DEFAULT_OPTIONS.filter);
  public collapsedGroupIds = signal<string[]>([]);

  isCustomized = computed(() => {
    return [
      this.selectedSort().type,
      this.selectedGroup().type,
      this.selectedFilter().type,
    ].some((x) => x !== null);
  });

  private _isInProject = toSignal(this._workContextService.isActiveWorkContextProject$, {
    initialValue: false,
  });

  // "Group By: Project" would collapse into a single group inside a single-project
  // view, so offer it only for tag contexts. See issue #7279.
  availableGroupOptions = computed(() =>
    this._isInProject() ? GROUP_OPTIONS_NO_PROJECT : OPTIONS.group.list,
  );

  private _stateByContext: Record<string, CustomizerContextState> = lsGetJSON<
    Record<string, CustomizerContextState>
  >(LS.TASK_VIEW_CUSTOMIZER_BY_CONTEXT, {});
  private _currentContextKey: string | null = null;

  constructor() {
    this._initProjects();
    this._initTags();

    // Load stored customizations for the active work context, and reset to
    // defaults when switching contexts so filters don't leak across them.
    this._workContextService.activeWorkContextTypeAndId$
      .pipe(takeUntilDestroyed())
      .subscribe(({ activeId, activeType }) => {
        this._currentContextKey = `${activeType}:${activeId}`;
        const stored = this._stateByContext[this._currentContextKey];
        this.selectedSort.set(stored?.sort ?? DEFAULT_OPTIONS.sort);
        this.selectedGroup.set(this._sanitizeGroupForContext(stored?.group, activeType));
        this.selectedFilter.set(this._sanitizeFilter(stored?.filter));
        this.collapsedGroupIds.set(stored?.collapsedGroupIds ?? []);
      });

    effect(() => {
      const sort = this.selectedSort();
      const group = this.selectedGroup();
      const filter = this.selectedFilter();
      const collapsedGroupIds = this.collapsedGroupIds();
      if (!this._currentContextKey) return;
      this._stateByContext = {
        ...this._stateByContext,
        [this._currentContextKey]: { sort, group, filter, collapsedGroupIds },
      };
      lsSetJSON(LS.TASK_VIEW_CUSTOMIZER_BY_CONTEXT, this._stateByContext);
    });
  }

  toggleGroupExpansion(groupId: string): void {
    const current = this.collapsedGroupIds();
    if (current.includes(groupId)) {
      this.collapsedGroupIds.set(current.filter((id) => id !== groupId));
    } else {
      this.collapsedGroupIds.set([...current, groupId]);
    }
  }

  private _allProjects: Project[] = [];
  private _projectsLoaded = false;
  private _allTags: Tag[] = [];
  private _tagsLoaded = false;

  private _initProjects(): void {
    if (!this._projectsLoaded) {
      this.store
        .select(selectAllProjects)
        .pipe(takeUntilDestroyed())
        .subscribe((projects) => {
          this._allProjects = projects;
        });
      this._projectsLoaded = true;
    }
  }

  private _initTags(): void {
    if (!this._tagsLoaded) {
      toObservable(this._tagService.tagsInTreeOrder)
        .pipe(takeUntilDestroyed())
        .subscribe((tags) => {
          this._allTags = tags;
        });
      this._tagsLoaded = true;
    }
  }

  private _sanitizeGroupForContext(
    stored: GroupOption | undefined,
    activeType: WorkContextType,
  ): GroupOption {
    if (!stored) return DEFAULT_OPTIONS.group;
    if (
      activeType === WorkContextType.PROJECT &&
      stored.type === GROUP_OPTION_TYPE.project
    ) {
      return DEFAULT_OPTIONS.group;
    }
    return stored;
  }

  // Unlike _sanitizeGroupForContext (which passes the stored value through),
  // re-resolve the option from the current OPTIONS.filter.list and keep only the
  // user's `preset`. The persisted `label` can be stale after a translation-key
  // change (the panel renders selectedFilter().label directly), so we always
  // adopt the current label; an unknown stored `type` falls back to the default.
  private _sanitizeFilter(stored: FilterOption | undefined): FilterOption {
    if (!stored) return DEFAULT_OPTIONS.filter;

    const currentFilter = OPTIONS.filter.list.find(
      (option) => option.type === stored.type,
    );
    return currentFilter
      ? { ...currentFilter, preset: stored.preset ?? null }
      : DEFAULT_OPTIONS.filter;
  }

  customizeUndoneTasks(
    undoneTasks$: Observable<TaskWithSubTasks[]>,
  ): Observable<CustomizedUndoneTasks> {
    return combineLatest([
      undoneTasks$,
      toObservable(this.selectedSort),
      toObservable(this.selectedGroup),
      toObservable(this.selectedFilter),
    ]).pipe(
      map(([tasks, sort, group, filter]) => {
        const normalizedFilterVal = filter.preset?.trim();
        const filterValueToUse = normalizedFilterVal ?? '';

        const isDefaultFilter = !filter.type || !filterValueToUse;
        const isDefaultSort = !sort.type;
        const isDefaultGroup = !group.type;

        if (isDefaultFilter && isDefaultSort && isDefaultGroup) {
          return { result: { list: tasks }, isDefault: true };
        }

        const filtered = isDefaultFilter
          ? tasks
          : this.applyFilter(tasks, filter.type, filterValueToUse);
        const sorted = isDefaultSort
          ? filtered
          : this.applySort(filtered, sort.type, sort.order);
        const grouped = !isDefaultGroup
          ? this.applyGrouping(sorted, group.type)
          : undefined;
        const groupTagIdByKey =
          grouped && group.type === GROUP_OPTION_TYPE.tag
            ? this._buildGroupTagIdByKey(grouped)
            : undefined;

        return { result: { list: sorted, grouped, groupTagIdByKey }, isDefault: false };
      }),
      // Emit the default (uncustomized) list synchronously, but keep the
      // customized path on the animation-frame scheduler. The customized branch
      // does heavier sort/group/filter work and is driven by the customizer
      // signals (`toObservable(selectedSort/Group/Filter)`); deferring it
      // batches the rapid emissions that fire when switching work context (the
      // original reason this frame-defer was added, commit fddedf3fa6). The
      // default branch is store-driven only — emitting it on the same tick drops
      // the extra frame between a drag-drop dispatch and the list re-render that
      // otherwise surfaces as a snap-back flicker on drop.
      switchMap(({ result, isDefault }) =>
        isDefault ? of(result) : of(result).pipe(observeOn(animationFrameScheduler)),
      ),
    );
  }

  private applyFilter(
    tasks: TaskWithSubTasks[],
    type: FILTER_OPTION_TYPE | FILTER_COMMON | null,
    value: string,
  ): TaskWithSubTasks[] {
    if (!type || !value) return tasks;

    switch (type) {
      case FILTER_OPTION_TYPE.tag:
        if (value === FILTER_COMMON.NOT_SPECIFIED) {
          return tasks.filter((t) => !t.tagIds.length);
        }

        const tag = this._allTags.find((t) =>
          t.title.toLowerCase().includes(value.toLowerCase().trim()),
        );
        if (!tag) return [];
        return tasks.filter((task) => task.tagIds?.includes(tag.id));
      case FILTER_OPTION_TYPE.project:
        const project = this._allProjects.find((p) =>
          p.title.toLowerCase().includes(value.toLowerCase().trim()),
        );
        if (!project) return [];
        return tasks.filter((task) => task.projectId === project.id);
      case FILTER_OPTION_TYPE.scheduledDate:
        return this._filterByDateFields(tasks, value, (t) => [t.dueDay, t.dueWithTime]);
      case FILTER_OPTION_TYPE.deadline:
        return this._filterByDateFields(tasks, value, (t) => [
          t.deadlineDay,
          t.deadlineWithTime,
        ]);
      case FILTER_OPTION_TYPE.estimatedTime:
        if (value === FILTER_COMMON.NOT_SPECIFIED) {
          return tasks.filter((task) => task.timeEstimate === 0);
        }

        return tasks.filter((task) => task.timeEstimate >= +value);
      case FILTER_OPTION_TYPE.timeSpent:
        if (value === FILTER_COMMON.NOT_SPECIFIED) {
          return tasks.filter((task) => task.timeSpent === 0);
        }

        return tasks.filter((task) => {
          const spent = task.timeSpentOnDay
            ? Object.values(task.timeSpentOnDay).reduce((a, b) => a + b, 0)
            : 0;
          return spent >= +value;
        });
      default:
        return tasks;
    }
  }

  private applySort(
    tasks: TaskWithSubTasks[],
    sortType: SORT_OPTION_TYPE | null,
    order?: SORT_ORDER,
  ): TaskWithSubTasks[] {
    const tasksCopy = [...tasks];

    // Factor for bidirectional for sorting
    const factor = order === SORT_ORDER.DESC ? -1 : 1;

    const locale = this._languageService.detect();
    if (!this._collator || this._collatorLocale !== locale) {
      this._collator = new Intl.Collator(locale, {
        sensitivity: 'base',
        numeric: true,
      });
      this._collatorLocale = locale;
    }
    const collator = this._collator;
    const sortByTitle = (a: string, b: string, multiplier = 1): number => {
      return collator.compare(a, b) * multiplier;
    };

    const tagsInSidebarOrder = this._tagsInSidebarOrder();
    const tagOrderById = new Map(tagsInSidebarOrder.map((tag, index) => [tag.id, index]));
    const unknownTagRank = tagsInSidebarOrder.length;
    const noTagRank = unknownTagRank + 1;

    // A task is placed by its highest-priority tag = the one with the lowest
    // sidebar (menu-tree) index. Unknown tag ids rank after all known tags,
    // untagged tasks last. (#8400)
    const getPrimaryTagRank = (task: TaskWithSubTasks): number => {
      if (!task.tagIds?.length) {
        return noTagRank;
      }

      let min = unknownTagRank;
      for (const tagId of task.tagIds) {
        const rank = tagOrderById.get(tagId) ?? unknownTagRank;
        if (rank < min) min = rank;
      }
      return min;
    };

    // Order by the primary tag's sidebar position. Equal ranks (same tag group)
    // return 0, so the stable sort keeps the user's manual ordering within a tag
    // instead of re-sorting it by task title (#8486). Note this makes DESC flip
    // only the group order, not the order within a group - that asymmetry is
    // intentional; re-sorting within a group would bring the bug back.
    const sortByTagRank = (a: TaskWithSubTasks, b: TaskWithSubTasks): number =>
      (getPrimaryTagRank(a) - getPrimaryTagRank(b)) * factor;

    switch (sortType) {
      case SORT_OPTION_TYPE.name:
        return tasksCopy.sort((a, b) => sortByTitle(a.title, b.title, factor));

      case SORT_OPTION_TYPE.tag: {
        return tasksCopy.sort(sortByTagRank);
      }

      case SORT_OPTION_TYPE.creationDate:
        return tasksCopy.sort((a, b) => (a.created - b.created) * factor);

      case SORT_OPTION_TYPE.scheduledDate:
        return this._sortByDateFields(tasksCopy, factor, (t) => [
          t.dueDay,
          t.dueWithTime,
        ]);

      case SORT_OPTION_TYPE.deadline:
        return this._sortByDateFields(tasksCopy, factor, (t) => [
          t.deadlineDay,
          t.deadlineWithTime,
        ]);

      case SORT_OPTION_TYPE.estimatedTime:
        return tasksCopy.sort(
          (a, b) => ((a.timeEstimate || 0) - (b.timeEstimate || 0)) * factor,
        );

      case SORT_OPTION_TYPE.timeSpent:
        return tasksCopy.sort((a, b) => {
          const aSpent = a.timeSpentOnDay
            ? Object.values(a.timeSpentOnDay).reduce((x, y) => x + y, 0)
            : 0;
          const bSpent = b.timeSpentOnDay
            ? Object.values(b.timeSpentOnDay).reduce((x, y) => x + y, 0)
            : 0;
          return (aSpent - bSpent) * factor;
        });

      default:
        return tasksCopy;
    }
  }

  private applyGrouping(
    tasks: TaskWithSubTasks[],
    groupType: GROUP_OPTION_TYPE | null,
  ): Record<string, TaskWithSubTasks[]> {
    if (groupType === GROUP_OPTION_TYPE.tag) {
      return this._groupByTag(tasks);
    }

    return tasks.reduce(
      (acc, task) => {
        if (groupType === GROUP_OPTION_TYPE.project) {
          const project = this._allProjects.find((p) => p.id === task.projectId);
          const key = project ? project.title : 'No project';
          acc[key] = acc[key] || [];
          acc[key].push(task);
        } else if (groupType === GROUP_OPTION_TYPE.scheduledDate) {
          const key =
            task.dueDay ||
            (task.dueWithTime ? getDbDateStr(task.dueWithTime) : 'No date');
          acc[key] = acc[key] || [];
          acc[key].push(task);
        } else if (groupType === GROUP_OPTION_TYPE.deadline) {
          const key =
            task.deadlineDay ||
            (task.deadlineWithTime
              ? getDbDateStr(task.deadlineWithTime)
              : this._translateService.instant(
                  T.F.TASK_VIEW.CUSTOMIZER.GROUP_DEADLINE_NONE,
                ));
          acc[key] = acc[key] || [];
          acc[key].push(task);
        }
        return acc;
      },
      {} as Record<string, TaskWithSubTasks[]>,
    );
  }

  /**
   * Order group headers for display. Tag groups follow the sidebar (menu-tree)
   * order so they match the tag-toggle menu (#8400); other group types keep the
   * natural ascending order the keyvalue pipe used previously. Special tag
   * buckets ('No tag', 'Unknown tag') sort after the real tags.
   */
  getOrderedGroupKeys(grouped: Record<string, TaskWithSubTasks[]>): string[] {
    const keys = Object.keys(grouped);
    const ascending = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);

    if (this.selectedGroup().type !== GROUP_OPTION_TYPE.tag) {
      return keys.sort(ascending);
    }

    const titleOrder = this._getTagTitleOrderMap();
    return keys.sort((a, b) => {
      const ai = titleOrder.get(a);
      const bi = titleOrder.get(b);
      if (ai !== undefined && bi !== undefined) return ai - bi;
      if (ai !== undefined) return -1;
      if (bi !== undefined) return 1;
      return ascending(a, b);
    });
  }

  /**
   * Tags in sidebar (menu-tree) order. The virtual TODAY tag is excluded so this
   * matches the tag-toggle menus, which are fed the my-day-excluded list, and so
   * its trailing index can never leak into ordering (it's never a real task tag).
   */
  private _tagsInSidebarOrder(): Tag[] {
    return this._menuTreeService.buildTagListInTreeOrder(
      this._allTags.filter((t) => t.id !== TODAY_TAG.id),
    );
  }

  /**
   * Per-title metadata in sidebar (menu-tree) order — the single source for both
   * group ordering and the drag-to-retag id-map, so the two can't drift:
   * - `index`: lowest sidebar index among tags with that title (for ordering).
   * - `id`: the tagId, or `null` when several tags share the title (ambiguous,
   *   so retag can't pick one).
   * Duplicate-titled tags collapse to one slot, matching {@link _groupByTag}
   * which also keys its buckets by title. TODAY is excluded by
   * {@link _tagsInSidebarOrder} (virtual membership, never a real tagId), so it
   * can never appear — see ARCHITECTURE-DECISIONS #2 / sync rule #5.
   */
  private _tagMetaByTitle(): Map<string, { id: string | null; index: number }> {
    const byTitle = new Map<string, { id: string | null; index: number }>();
    this._tagsInSidebarOrder().forEach((tag, index) => {
      const existing = byTitle.get(tag.title);
      if (existing) {
        existing.id = null; // duplicate title → ambiguous
      } else {
        byTitle.set(tag.title, { id: tag.id, index });
      }
    });
    return byTitle;
  }

  private _getTagTitleOrderMap(): Map<string, number> {
    const order = new Map<string, number>();
    this._tagMetaByTitle().forEach((meta, title) => order.set(title, meta.index));
    return order;
  }

  /**
   * Map each tag-group header title to its drag-to-retag target:
   * - a real tagId for a single-tag group,
   * - {@link NO_TAG_GROUP_ID} for the 'No tag' bucket (a drop there clears tags),
   * - `null` for the 'Unknown tag' bucket or a title shared by several tags
   *   (ambiguous — can't be retagged).
   */
  private _buildGroupTagIdByKey(
    grouped: Record<string, TaskWithSubTasks[]>,
  ): Record<string, string | null> {
    const meta = this._tagMetaByTitle();
    const out: Record<string, string | null> = {};
    Object.keys(grouped).forEach((key) => {
      const realTag = meta.get(key);
      // A real tag owning this title wins (its id, or null when several tags
      // share it). This guards the case of a user tag literally titled "No tag":
      // dropping there adds that tag instead of hitting the clear-tags sentinel,
      // which is reserved for the genuine virtual untagged bucket.
      if (realTag) {
        out[key] = realTag.id;
      } else if (key === NO_TAG_GROUP_KEY) {
        out[key] = NO_TAG_GROUP_ID;
      } else {
        out[key] = null;
      }
    });
    return out;
  }

  private _groupByTag(tasks: TaskWithSubTasks[]): Record<string, TaskWithSubTasks[]> {
    const tagById = new Map(this._allTags.map((tag) => [tag.id, tag]));
    const groupedByTagId = new Map<string, TaskWithSubTasks[]>();
    const unknownTagTasks: TaskWithSubTasks[] = [];
    const noTagTasks: TaskWithSubTasks[] = [];

    tasks.forEach((task) => {
      if (!task.tagIds?.length) {
        noTagTasks.push(task);
        return;
      }

      task.tagIds.forEach((tagId) => {
        if (tagById.has(tagId)) {
          const tagTasks = groupedByTagId.get(tagId) ?? [];
          tagTasks.push(task);
          groupedByTagId.set(tagId, tagTasks);
        } else {
          unknownTagTasks.push(task);
        }
      });
    });

    const grouped: Record<string, TaskWithSubTasks[]> = {};
    this._allTags.forEach((tag) => {
      const tagTasks = groupedByTagId.get(tag.id);
      if (tagTasks?.length) {
        // Distinct tags can share a title; merge their tasks into the same
        // title-keyed bucket instead of overwriting, matching the previous
        // grouping behavior and _getTagTitleOrderMap's single-slot contract.
        grouped[tag.title] = (grouped[tag.title] ?? []).concat(tagTasks);
      }
    });
    if (unknownTagTasks.length) {
      grouped[UNKNOWN_TAG_GROUP_KEY] = unknownTagTasks;
    }
    if (noTagTasks.length) {
      grouped[NO_TAG_GROUP_KEY] = noTagTasks;
    }

    return grouped;
  }

  private _filterByDateFields(
    tasks: TaskWithSubTasks[],
    value: string,
    getFields: (
      t: TaskWithSubTasks,
    ) => [string | undefined | null, number | undefined | null],
  ): TaskWithSubTasks[] {
    if (value === FILTER_COMMON.NOT_SPECIFIED) {
      return tasks.filter((t) => {
        const [day, withTime] = getFields(t);
        return !day && !withTime;
      });
    }

    const today = new Date();
    const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
    const todayStr = getDbDateStr(today);
    const tomorrowStr = getDbDateStr(new Date(new Date().setDate(today.getDate() + 1)));

    return tasks.filter((task) => {
      const [day, withTime] = getFields(task);
      const dateStr = day ? day : withTime ? getDbDateStr(withTime) : null;
      if (!dateStr) return false;

      switch (value) {
        case FILTER_SCHEDULE.today:
          return dateStr.startsWith(todayStr);
        case FILTER_SCHEDULE.tomorrow:
          return dateStr.startsWith(tomorrowStr);
        case FILTER_SCHEDULE.thisWeek: {
          const weekRange = getWeekRange(today, firstDayOfWeek);
          return (
            dateStr >= getDbDateStr(weekRange.start) &&
            dateStr <= getDbDateStr(weekRange.end)
          );
        }
        case FILTER_SCHEDULE.nextWeek: {
          const nextWeekStart = new Date(new Date().setDate(today.getDate() + 7));
          const weekRange = getWeekRange(nextWeekStart, firstDayOfWeek);
          return (
            dateStr >= getDbDateStr(weekRange.start) &&
            dateStr <= getDbDateStr(weekRange.end)
          );
        }
        case FILTER_SCHEDULE.thisMonth: {
          const yearMonth = getDbDateStr(today).substring(0, 7);
          return dateStr.startsWith(yearMonth);
        }
        case FILTER_SCHEDULE.nextMonth: {
          const nextMonth = new Date(new Date().setDate(1));
          nextMonth.setMonth(nextMonth.getMonth() + 1);
          const yearMonth = getDbDateStr(nextMonth).substring(0, 7);
          return dateStr.startsWith(yearMonth);
        }
        default:
          return true;
      }
    });
  }

  private _sortByDateFields(
    tasks: TaskWithSubTasks[],
    factor: number,
    getFields: (
      t: TaskWithSubTasks,
    ) => [string | undefined | null, number | undefined | null],
  ): TaskWithSubTasks[] {
    return tasks.sort((a, b) => {
      const [dayA, withTimeA] = getFields(a);
      const [dayB, withTimeB] = getFields(b);
      const dateA = dayA ? new Date(dayA) : withTimeA ? new Date(withTimeA) : null;
      const dateB = dayB ? new Date(dayB) : withTimeB ? new Date(withTimeB) : null;

      if (dateA === null && dateB === null) return 0;
      if (dateA === null) return 1 * factor;
      if (dateB === null) return -1 * factor;

      return (dateA.getTime() - dateB.getTime()) * factor;
    });
  }

  setSort(val: SortOption): void {
    const isSame = val.type === this.selectedSort().type;
    if (isSame) {
      // reverse sorting
      val.order = val.order === SORT_ORDER.ASC ? SORT_ORDER.DESC : SORT_ORDER.ASC;
    }
    this.selectedSort.set({ ...val });
  }

  setGroup(val: GroupOption): void {
    this.selectedGroup.set(val);
  }

  setFilter(val: FilterOption): void {
    this.selectedFilter.set(val);
  }

  resetAll(): void {
    this.setSort(DEFAULT_OPTIONS.sort);
    this.setGroup(DEFAULT_OPTIONS.group);
    this.setFilter(DEFAULT_OPTIONS.filter);
  }

  /**
   * Instantly save sort changes by reordering tasks in the current work context
   * ! Saved sorting will be default
   */
  async saveSort(): Promise<void> {
    const selectedSort = { ...this.selectedSort() };

    // Saved sorting will be default
    this.setSort(DEFAULT_OPTIONS.sort);

    const workContextId = this._workContextService.activeWorkContextId;
    const workContextType = this._workContextService.activeWorkContextType;
    if (!workContextId || !workContextType) return;

    // Tasks in the current work context
    const [allTasks, undoneTasks] = await Promise.all([
      this._workContextService.mainListTasks$.pipe(take(1)).toPromise(),
      this._workContextService.undoneTasks$.pipe(take(1)).toPromise(),
    ]);

    if (!allTasks?.length || !undoneTasks?.length) return;

    const sortedTasks = this.applySort(
      undoneTasks,
      selectedSort.type,
      selectedSort.order,
    );
    if (!sortedTasks.length) return;

    const sortedIdQueue = sortedTasks.map((task) => task.id);
    const sortedIdSet = new Set(sortedIdQueue);
    const newOrderedIds = allTasks.map((task) => {
      if (!sortedIdSet.has(task.id)) return task.id;

      const nextId = sortedIdQueue.shift();
      return nextId ?? task.id;
    });

    const isOrderChanged = allTasks.some((task, idx) => task.id !== newOrderedIds[idx]);
    if (!isOrderChanged) return;

    if (workContextType === WorkContextType.PROJECT) {
      this._projectService.update(workContextId, { taskIds: newOrderedIds });
    } else if (workContextType === WorkContextType.TAG) {
      this._tagService.updateTag(workContextId, { taskIds: newOrderedIds });
    }
  }
}
