import {
  AfterViewInit,
  ChangeDetectionStrategy,
  Component,
  computed,
  DestroyRef,
  effect,
  ElementRef,
  forwardRef,
  HostListener,
  inject,
  input,
  OnDestroy,
  signal,
  untracked,
  viewChild,
} from '@angular/core';
import { TaskService } from '../task.service';
import { EMPTY, forkJoin, Subscription } from 'rxjs';
import {
  HideSubTasksMode,
  SubmitTrigger,
  TaskCopy,
  TaskDetailTargetPanel,
  TaskWithSubTasks,
} from '../task.model';
import { MatDialog } from '@angular/material/dialog';
import { DialogTimeEstimateComponent } from '../dialog-time-estimate/dialog-time-estimate.component';
import {
  expandFadeAnimation,
  expandInOnlyAnimation,
} from '../../../ui/animations/expand.ani';
import {
  ChecklistProgress,
  getChecklistProgress,
} from '../../markdown-checklist/get-checklist-progress';
import { GlobalConfigService } from '../../config/global-config.service';
import { concatMap, first, tap } from 'rxjs/operators';
import { DoneToggleComponent } from '../../../ui/done-toggle/done-toggle.component';
import { SwipeBlockComponent } from '../../../ui/swipe-block/swipe-block.component';
import {
  CollapsibleComponent,
  GROUP_NAV_SELECTOR,
} from '../../../ui/collapsible/collapsible.component';
import { findAdjacentFocusable } from '../../../util/find-adjacent-focusable';
import { TaskAttachmentService } from '../task-attachment/task-attachment.service';
import { DialogEditTaskAttachmentComponent } from '../task-attachment/dialog-edit-attachment/dialog-edit-task-attachment.component';
import { ProjectService } from '../../project/project.service';
import { Project } from '../../project/project.model';
import { _MISSING_PROJECT_, DEFAULT_PROJECT_ICON } from '../../project/project.const';
import { T } from '../../../t.const';
import {
  MatMenu,
  MatMenuContent,
  MatMenuItem,
  MatMenuTrigger,
} from '@angular/material/menu';
import { WorkContextService } from '../../work-context/work-context.service';
import { throttle } from '../../../util/decorators';
import { TaskRepeatCfgService } from '../../task-repeat-cfg/task-repeat-cfg.service';
import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component';
import { openFullscreenMarkdownDialog } from '../../../ui/dialog-fullscreen-markdown/open-fullscreen-markdown-dialog';
import { Location } from '@angular/common';
import { Update } from '@ngrx/entity';
import { DateAdapter } from '@angular/material/core';
import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
import { combineDateAndTime } from '../../../util/combine-date-and-time';
import { getNextWeekDayOffset } from '../../../util/get-next-week-day-offset';
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
import { DateService } from '../../../core/date/date.service';
import { isTouchActive } from '../../../util/input-intent';
import { IS_HYBRID_DEVICE } from '../../../util/is-mouse-primary';
import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants';
import {
  EMPTY_KEYBOARD_CONFIG,
  KeyboardConfig,
  keyboardConfigOrEmpty,
} from '@sp/keyboard-config';
import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component';
import { PlannerActions } from '../../planner/store/planner.actions';
import { PlannerService } from '../../planner/planner.service';
import { DialogDeadlineComponent } from '../dialog-deadline/dialog-deadline.component';
import { isDeadlineOverdue as isDeadlineOverdueFn } from '../util/is-deadline-overdue';
import { isTaskOverdue } from '../util/is-task-overdue';
import { isDeadlineApproaching as isDeadlineApproachingFn } from '../util/is-deadline-approaching';
import { TaskContextMenuComponent } from '../task-context-menu/task-context-menu.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ICAL_TYPE, PLAINSPACE_TYPE } from '../../issue/issue.const';
import { TaskTitleComponent } from '../../../ui/task-title/task-title.component';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton, MatMiniFabButton } from '@angular/material/button';
import { TaskHoverControlsComponent } from './task-hover-controls/task-hover-controls.component';
import { ProgressBarComponent } from '../../../ui/progress-bar/progress-bar.component';
import { TaskListComponent } from '../task-list/task-list.component';
import { MsToStringPipe } from '../../../ui/duration/ms-to-string.pipe';
import { ShortPlannedAtPipe } from '../../../ui/pipes/short-planned-at.pipe';
import { LocalDateStrPipe } from '../../../ui/pipes/local-date-str.pipe';
import { LocaleDatePipe } from '../../../ui/pipes/locale-date.pipe';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { SubTaskTotalTimeSpentPipe } from '../pipes/sub-task-total-time-spent.pipe';
import { TagListComponent } from '../../tag/tag-list/tag-list.component';
import { TagToggleMenuListComponent } from '../../tag/tag-toggle-menu-list/tag-toggle-menu-list.component';
import { Store } from '@ngrx/store';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { environment } from '../../../../environments/environment';
import { TODAY_TAG } from '../../tag/tag.const';
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
import { TaskLog } from '../../../core/log';
import { LayoutService } from '../../../core-ui/layout/layout.service';
import { TaskFocusService } from '../task-focus.service';
import { MatTooltip } from '@angular/material/tooltip';
import { millisecondsDiffToRemindOption } from '../util/remind-option-to-milliseconds';
import { MenuTreeService } from '../../menu-tree/menu-tree.service';
import { SelectOptionRowComponent } from '../../../ui/select-option-row/select-option-row.component';
import { SnackService } from '../../../core/snack/snack.service';
import {
  AddSubtaskInputComponent,
  AddSubtaskInputCloseReason,
} from '../add-subtask-input/add-subtask-input.component';
import { AddSubtaskInputService } from '../add-subtask-input/add-subtask-input.service';

@Component({
  selector: 'task',
  templateUrl: './task.component.html',
  styleUrls: ['./task.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  animations: [expandInOnlyAnimation, expandFadeAnimation],
  /* eslint-disable @typescript-eslint/naming-convention*/
  host: {
    '[id]': 'taskIdWithPrefix()',
    '[attr.data-task-id]': 'task().id',
    '[tabindex]': '0',
    '[class.isDone]': 'task().isDone',
    '[class.isCurrent]': 'isCurrent()',
    '[class.isSelected]': 'isSelected()',
    '[class.hasNoSubTasks]': 'task().subTaskIds.length === 0',
    '[class.isDragReady]': 'isDragReady()',
    '[class.isOverdue]': 'isOverdue()',
    '(contextmenu)': 'onHostContextMenu($event)',
  },
  imports: [
    MatIcon,
    MatMenuTrigger,
    MatIconButton,
    TaskTitleComponent,
    TaskHoverControlsComponent,
    ProgressBarComponent,
    MatMiniFabButton,
    forwardRef(() => TaskListComponent),
    TaskContextMenuComponent,
    MatMenu,
    MatMenuContent,
    MatMenuItem,
    MsToStringPipe,
    LocalDateStrPipe,
    TranslatePipe,
    MatTooltip,
    SubTaskTotalTimeSpentPipe,
    TagListComponent,
    ShortPlannedAtPipe,
    TagToggleMenuListComponent,
    DoneToggleComponent,
    SwipeBlockComponent,
    SelectOptionRowComponent,
    AddSubtaskInputComponent,
  ],
})
export class TaskComponent implements OnDestroy, AfterViewInit {
  private readonly _taskService = inject(TaskService);
  private readonly _taskRepeatCfgService = inject(TaskRepeatCfgService);
  private readonly _matDialog = inject(MatDialog);
  private readonly _location = inject(Location);
  private readonly _configService = inject(GlobalConfigService);
  private readonly _attachmentService = inject(TaskAttachmentService);
  private readonly _elementRef = inject(ElementRef);
  private readonly _dateAdapter = inject(DateAdapter);
  private readonly _store = inject(Store);
  private readonly _projectService = inject(ProjectService);
  private readonly _taskFocusService = inject(TaskFocusService);
  private readonly _dateService = inject(DateService);
  private readonly _destroyRef = inject(DestroyRef);
  private readonly _menuTreeService = inject(MenuTreeService);
  private readonly _snackService = inject(SnackService);
  private readonly _translateService = inject(TranslateService);
  private readonly _datePipe = inject(LocaleDatePipe);
  private readonly _plannerService = inject(PlannerService);
  private readonly _addSubtaskInputService = inject(AddSubtaskInputService);

  readonly workContextService = inject(WorkContextService);
  readonly layoutService = inject(LayoutService);
  readonly globalTrackingIntervalService = inject(GlobalTrackingIntervalService);

  task = input.required<TaskWithSubTasks>();
  isBacklog = input<boolean>(false);
  isInSubTaskList = input<boolean>(false);
  showDoneAnimation = signal(false);
  showUndoneAnimation = signal(false);

  // Use shared signals from services to avoid creating 600+ subscriptions on initial render
  isCurrent = computed(() => this._taskService.currentTaskId() === this.task().id);
  isSelected = computed(() => this._taskService.selectedTaskId() === this.task().id);
  isShowCloseButton = computed(() => {
    // Only show close button when task is selected AND not on mobile (bottom panel)
    return this.isSelected() && !this.layoutService.isXs();
  });

  // Determines if the toggle detail panel button should be visible
  isShowToggleButton = computed(() => {
    const t = this.task();
    // A checklist already shows its own badge (which opens the notes panel), so
    // suppress the redundant plain 'chat' notes indicator. The button is kept for
    // the 'close' (panel open) and 'update' (issue changed) states.
    if (this.checklistProgress() && this.toggleButtonIcon() === 'chat') {
      return false;
    }
    // iCal and Plainspace mirror all their issue data into native task fields, so
    // their detail panel only repeats what's already on the task. Don't surface the
    // button just because they carry an issueId — only for real notes, a remote
    // update (the 'update' icon), or when the panel is open.
    const isMirroredIssueType =
      t.issueType === ICAL_TYPE || t.issueType === PLAINSPACE_TYPE;
    return (
      !!t.notes ||
      (!!t.issueId && !isMirroredIssueType) ||
      !!t.issueWasUpdated ||
      this.isShowCloseButton()
    );
  });

  // Determines which icon to show in the toggle button
  toggleButtonIcon = computed((): 'chat' | 'close' | 'update' => {
    const t = this.task();
    if (t.issueWasUpdated) return 'update';
    if (this.isShowCloseButton()) return 'close';
    return 'chat';
  });

  isTodayListActive = computed(() => this.workContextService.isTodayListSignal());
  taskIdWithPrefix = computed(() => 't-' + this.task().id);
  isRepeatTaskCreatedToday = computed(
    () => !!(this.task().repeatCfgId && this._dateService.isToday(this.task().created)),
  );
  isOverdue = computed(() => {
    const t = this.task();
    const todayStr = this.globalTrackingIntervalService.todayDateStr();
    return (
      !t.isDone &&
      ((t.dueWithTime &&
        !this._dateService.isToday(t.dueWithTime) &&
        t.dueWithTime < Date.now()) ||
        // Note: String comparison works correctly here because dueDay is in YYYY-MM-DD format
        // which is lexicographically sortable. This avoids timezone conversion issues that occur
        // when creating Date objects from date strings.
        // Guard: only compare if dueDay is a valid YYYY-MM-DD string to avoid corrupted data
        // producing false overdue results (see #6908)
        (t.dueDay &&
          isDBDateStr(t.dueDay) &&
          t.dueDay !== todayStr &&
          t.dueDay < todayStr))
    );
  });
  isScheduledToday = computed(() => {
    const t = this.task();
    const todayStr = this.globalTrackingIntervalService.todayDateStr();
    return (
      (t.dueWithTime && this._dateService.isToday(t.dueWithTime)) ||
      (t.dueDay && t.dueDay === todayStr)
    );
  });
  hasTimeConflict = computed(() => {
    const task = this.task();
    return (
      typeof task.dueWithTime === 'number' &&
      this._taskService.timeConflictTaskIds().has(task.id)
    );
  });

  isShowDueDayBtn = computed(() => {
    const dueDay = this.task().dueDay;
    return (
      dueDay &&
      isDBDateStr(dueDay) &&
      (!this.isTodayListActive() ||
        this.isOverdue() ||
        dueDay !== this.globalTrackingIntervalService.todayDateStr())
    );
  });

  progress = computed<number>(() => {
    const t = this.task();
    return (t.timeEstimate && (t.timeSpent / t.timeEstimate) * 100) || 0;
  });

  // Checklist progress derived from markdown checklist in task notes (null = no checklist)
  checklistProgress = computed<ChecklistProgress | null>(() =>
    getChecklistProgress(this.task().notes),
  );

  isShowRemoveFromToday = computed(() => {
    return (
      !this.isTodayListActive() &&
      !this.task().isDone &&
      this.task().dueDay === this.globalTrackingIntervalService.todayDateStr()
    );
  });

  isShowAddToToday = computed(() => {
    const task = this.task();
    const todayStr = this.globalTrackingIntervalService.todayDateStr();
    return this.isTodayListActive()
      ? (task.dueWithTime && !this._dateService.isToday(task.dueWithTime)) ||
          (task.dueDay && task.dueDay !== todayStr)
      : !this.isShowRemoveFromToday() &&
          task.dueDay !== todayStr &&
          (!task.dueWithTime || !this._dateService.isToday(task.dueWithTime));
  });

  isDeadlineOverdue = computed(() =>
    isDeadlineOverdueFn(this.task(), this.globalTrackingIntervalService.todayDateStr()),
  );

  isDeadlineApproaching = computed(() =>
    isDeadlineApproachingFn(
      this.task(),
      this.globalTrackingIntervalService.todayDateStr(),
    ),
  );

  hasDeadline = computed(() => {
    const t = this.task();
    return !!(t.deadlineDay || t.deadlineWithTime);
  });

  T: typeof T = T;
  readonly DEFAULT_PROJECT_ICON = DEFAULT_PROJECT_ICON;
  isTouchActive = isTouchActive;
  isDragOver: boolean = false;
  isDragReady = signal(false);
  private _dragReadyTimeout: number | undefined;
  private _doneAnimationTimeout: number | undefined;
  private _touchListenerCleanups: (() => void)[] = [];
  ShowSubTasksMode: typeof HideSubTasksMode = HideSubTasksMode;
  isFirstLineHover: boolean = false;
  _nextFocusTaskEl?: HTMLElement;

  readonly taskTitleEditEl = viewChild<TaskTitleComponent>('taskTitleEditEl');
  readonly projectMenuTrigger = viewChild('projectMenuTriggerEl', {
    read: MatMenuTrigger,
  });
  readonly tagToggleMenuList = viewChild('tagToggleMenuList', {
    read: TagToggleMenuListComponent,
  });
  readonly taskContextMenu = viewChild('taskContextMenu', {
    read: TaskContextMenuComponent,
  });
  readonly addSubtaskInput = viewChild(AddSubtaskInputComponent);
  readonly isAddSubtaskInputVisible = signal(false);
  // Task the draft was opened from (may be a subtask), captured before focus
  // moves into the input, so Escape can return focus there. See onAddSubtaskInputClosed.
  private _subtaskInputOriginTaskId: string | null = null;

  private readonly _addSubtaskInputRequestEffect = effect(() => {
    const requestedParentId = this._addSubtaskInputService.openRequest();
    if (requestedParentId === null || requestedParentId !== untracked(this.task).id) {
      return;
    }

    window.setTimeout(() => {
      // Consume the request so it isn't replayed (stealing focus) the next time
      // this row is re-created with the same id, e.g. navigating away and back.
      this._addSubtaskInputService.consume();
      // Focus is still on the originating task here (the input isn't shown yet),
      // so capture it now — once the input is focused, the parent row claims it.
      this._subtaskInputOriginTaskId =
        this._taskFocusService.focusedTaskId() ??
        this._taskFocusService.lastFocusedTaskComponent()?.task().id ??
        this.task().id;
      const currentTask = this.task();
      if (currentTask._hideSubTasksMode === HideSubTasksMode.HideAll) {
        this._taskService.showSubTasks(currentTask.id);
      }
      this.isAddSubtaskInputVisible.set(true);
      window.setTimeout(() => this.addSubtaskInput()?.focus());
    });
  });

  // Lazy-loaded project list - only fetched when project menu opens
  moveToProjectList = signal<Project[] | undefined>(undefined);
  projectFolderMap = computed(() => this._menuTreeService.projectFolderMap());
  private _loadedProjectListForProjectId: string | null | undefined;
  private _moveToProjectListSub?: Subscription;

  // Parent title derived directly from task data when available in subtask model.
  // Falls back to a live store subscription only when needed (non-subtask-list with parentId).
  private _parentTaskTitle = signal<string | null>(null);
  parentTitle = computed(() => {
    const t = this.task();
    if (!t.parentId || this.isInSubTaskList()) {
      return null;
    }
    return this._parentTaskTitle();
  });

  isProjectMenuLoaded = signal(false);

  private _parentTitleEffect = effect((onCleanup) => {
    const t = this.task();
    const isInSubTaskList = this.isInSubTaskList();
    if (!t.parentId || isInSubTaskList) {
      this._parentTaskTitle.set(null);
      return;
    }
    // Only subscribe when this task has a parentId and is NOT in a subtask list
    const sub = this._taskService.getByIdLive$(t.parentId).subscribe((parent) => {
      this._parentTaskTitle.set(parent?.title ?? null);
    });
    onCleanup(() => sub.unsubscribe());
  });

  private _dragEnterTarget?: HTMLElement;
  private _doubleClickTimeout?: number;
  private _isTaskDeleteTriggered = false;
  isContextMenuLoaded = signal(false);

  // methods come last

  @HostListener('focusin', ['$event']) onFocus(ev: FocusEvent): void {
    // focusin bubbles, so events from nested <task> elements (subtasks) reach
    // every ancestor task host. Only the innermost task should claim focus.
    if (!this._isInnermostTaskFor(ev.target)) {
      return;
    }
    this._taskFocusService.focusedTaskId.set(this.task().id);
    this._taskFocusService.lastFocusedTaskComponent.set(this);

    // If detail panel is open for another task, update it to show this task (#6578)
    // Skip if this task is inside the detail panel (e.g. subtask list)
    if (this._elementRef.nativeElement.closest('task-detail-panel')) {
      return;
    }
    // Skip when focus came from clicking the toggle-detail-panel button itself —
    // the click handler owns that action; running both inverts the toggle (#7694).
    if (ev.target instanceof Element && ev.target.closest('.show-additional-info-btn')) {
      return;
    }
    const selectedTaskId = this._taskService.selectedTaskId();
    if (selectedTaskId && selectedTaskId !== this.task().id) {
      this._taskService.setSelectedId(this.task().id);
    }
  }

  @HostListener('focusout', ['$event']) onBlur(ev: FocusEvent): void {
    if (!this._isInnermostTaskFor(ev.target)) {
      return;
    }
    if (
      ev.relatedTarget instanceof Node &&
      this._elementRef.nativeElement.contains(ev.relatedTarget)
    ) {
      return;
    }
    this._taskFocusService.focusedTaskId.set(null);
  }

  private _isInnermostTaskFor(target: EventTarget | null): boolean {
    return (
      target instanceof Element &&
      target.closest('task') === this._elementRef.nativeElement
    );
  }

  @HostListener('dragenter', ['$event']) onDragEnter(ev: DragEvent): void {
    this._dragEnterTarget = ev.target as HTMLElement;
    ev.preventDefault();
    ev.stopPropagation();
    this.isDragOver = true;
  }

  @HostListener('dragleave', ['$event']) onDragLeave(ev: DragEvent): void {
    if (this._dragEnterTarget === (ev.target as HTMLElement)) {
      ev.preventDefault();
      ev.stopPropagation();
      this.isDragOver = false;
    }
  }

  @HostListener('drop', ['$event']) onDrop(ev: DragEvent): void {
    ev.preventDefault();
    this.focusSelf();
    this._attachmentService.createFromDrop(ev, this.task().id, true);
    ev.stopPropagation();
    this.isDragOver = false;
  }

  ngAfterViewInit(): void {
    if (isTouchActive() || IS_HYBRID_DEVICE) {
      const el = this._elementRef.nativeElement;
      const onStart = (): void => this.onHostTouchStart();
      const onEnd = (): void => this.onHostTouchEnd();
      el.addEventListener('touchstart', onStart, { passive: true });
      el.addEventListener('touchend', onEnd, { passive: true });
      el.addEventListener('touchmove', onEnd, { passive: true });
      this._touchListenerCleanups = [
        () => el.removeEventListener('touchstart', onStart),
        () => el.removeEventListener('touchend', onEnd),
        () => el.removeEventListener('touchmove', onEnd),
      ];
    }

    // Dev-time sanity check: TODAY_TAG should NEVER be in task.tagIds (virtual tag pattern)
    // Membership is determined by task.dueDay. See: docs/ai/today-tag-architecture.md
    if (!environment.production) {
      if (this.task().tagIds.includes(TODAY_TAG.id)) {
        throw new Error('Task should not have TODAY_TAG in tagIds - it is a virtual tag');
      }
    }

    // hacky but relatively performant
    const t = this.task();
    if (t.parentId && !t.title.length && Date.now() - 200 < t.created) {
      setTimeout(() => {
        // when there are multiple instances with the same task we should focus the last one, since it is the one in the
        // task side panel
        const otherTaskEl = document.querySelectorAll('#t-' + CSS.escape(t.id));
        if (
          otherTaskEl?.length <= 1 ||
          Array.from(otherTaskEl).findIndex(
            (item) => item === this._elementRef.nativeElement,
          ) ===
            otherTaskEl.length - 1
        ) {
          this.focusTitleForEdit();
        }
      });
    }
  }

  ngOnDestroy(): void {
    window.clearTimeout(this._doubleClickTimeout);
    window.clearTimeout(this._dragReadyTimeout);
    window.clearTimeout(this._doneAnimationTimeout);
    this._touchListenerCleanups.forEach((fn) => fn());
    this._moveToProjectListSub?.unsubscribe();
  }

  scheduleTask(): void {
    this._storeNextFocusEl();
    this._matDialog
      .open(DialogScheduleTaskComponent, {
        // we focus inside dialog instead
        autoFocus: false,
        data: { task: this.task() },
      })
      .afterClosed()
      .subscribe((isPlanned) => {
        this.focusSelfOrNextIfNotPossible();
      });
  }

  openDeadlineDialog(): void {
    this._storeNextFocusEl();
    this._matDialog
      .open(DialogDeadlineComponent, {
        autoFocus: false,
        data: { task: this.task() },
      })
      .afterClosed()
      .subscribe(() => {
        this.focusSelfOrNextIfNotPossible();
      });
  }

  async scheduleTaskTomorrow(): Promise<void> {
    const tDate = this._dateService.getLogicalTodayDate();
    tDate.setDate(tDate.getDate() + 1);
    await this._scheduleForDay(tDate);
  }

  async scheduleTaskNextWeek(): Promise<void> {
    const tDate = this._dateService.getLogicalTodayDate();
    const dayOffset = getNextWeekDayOffset(this._dateAdapter, tDate);
    tDate.setDate(tDate.getDate() + dayOffset);
    await this._scheduleForDay(tDate);
  }

  async scheduleTaskNextMonth(): Promise<void> {
    const tDate = this._dateService.getLogicalTodayDate();
    tDate.setDate(1);
    tDate.setMonth(tDate.getMonth() + 1);
    await this._scheduleForDay(tDate);
  }

  private async _scheduleForDay(dayDate: Date): Promise<void> {
    const day = getDbDateStr(dayDate);
    const task = this.task();
    if (task.dueWithTime) {
      const newDate = combineDateAndTime(dayDate, new Date(task.dueWithTime));
      const remindCfg = task.reminderId
        ? millisecondsDiffToRemindOption(task.dueWithTime, task.remindAt)
        : (this._configService.cfg()?.reminder.defaultTaskRemindOption ??
          DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!);

      this._taskService.scheduleTask(task, newDate.getTime(), remindCfg, false);
      this._snackService.open({
        type: 'SUCCESS',
        msg: T.F.PLANNER.S.TASK_PLANNED_FOR,
        ico: 'today',
        translateParams: {
          date: this._dateService.isToday(newDate)
            ? this._translateService.instant(T.G.TODAY_TAG_TITLE)
            : (this._datePipe.transform(day, 'shortDate') as string),
          extra: await this._plannerService.getSnackExtraStr(day),
        },
      });
    } else {
      this._store.dispatch(
        PlannerActions.planTaskForDay({
          task: task as TaskCopy,
          day,
          isShowSnack: true,
        }),
      );
    }
    this.focusSelfOrNextIfNotPossible();
  }

  async editTaskRepeatCfg(): Promise<void> {
    const { DialogEditTaskRepeatCfgComponent } =
      await import('../../task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component');
    this._matDialog
      .open(DialogEditTaskRepeatCfgComponent, {
        data: {
          task: this.task(),
          targetDate: this.task().dueDay || getDbDateStr(new Date(this.task().created)),
        },
      })
      .afterClosed()
      .subscribe(() => this.focusSelf());
  }

  deleteTask(isClick: boolean = false): void {
    // NOTE: prevents attempts to delete the same task multiple times
    if (this._isTaskDeleteTriggered) {
      return;
    }

    const isConfirmBeforeTaskDelete =
      this._configService.cfg()?.tasks?.isConfirmBeforeDelete ?? true;

    if (isConfirmBeforeTaskDelete) {
      this._matDialog
        .open(DialogConfirmComponent, {
          data: {
            okTxt: T.F.TASK.D_CONFIRM_DELETE.OK,
            message: T.F.TASK.D_CONFIRM_DELETE.MSG,
            translateParams: { title: this.task().title },
          },
        })
        .afterClosed()
        .pipe(takeUntilDestroyed(this._destroyRef))
        .subscribe((isConfirm) => {
          if (isConfirm) {
            this._performDelete(isClick);
          }
        });
    } else {
      this._performDelete(isClick);
    }
  }

  private _performDelete(isClick: boolean): void {
    // NOTE: in case we want the focus behaviour on click we could use:
    // this.focusSelf();
    if (!isClick) {
      this.focusNext(true);
    }

    this._isTaskDeleteTriggered = true;
    this._taskService.remove(this.task());
  }

  startTask(): void {
    this._taskService.setCurrentId(this.task().id);
    this.focusSelf();
  }

  pauseTask(): void {
    this._taskService.pauseCurrent();
  }

  togglePlayPause(): void {
    if (this.isCurrent()) {
      this.pauseTask();
    } else {
      this.startTask();
    }
  }

  private _moveAndRefocus(
    move: (id: string, parentId: string | undefined, isBacklog: boolean) => void,
  ): void {
    const t = this.task();
    // Top-level done tasks in the main list are ordered by completion date, so
    // a manual reorder can't take effect — skip it to avoid emitting a spurious
    // taskIds-reorder op that would sync to other devices. Done subtasks and
    // backlog tasks are not auto-sorted, so they stay reorderable.
    if (t.isDone && !t.parentId && !this.isBacklog()) {
      return;
    }
    move(t.id, t.parentId, this.isBacklog());
    // timeout required to let changes take place
    setTimeout(() => this.focusSelf());
    setTimeout(() => this.focusSelf(), 10);
  }

  moveTaskUp(): void {
    this._moveAndRefocus((id, parentId, isBacklog) =>
      this._taskService.moveUp(id, parentId, isBacklog),
    );
  }

  moveTaskDown(): void {
    this._moveAndRefocus((id, parentId, isBacklog) =>
      this._taskService.moveDown(id, parentId, isBacklog),
    );
  }

  moveTaskToTop(): void {
    this._moveAndRefocus((id, parentId, isBacklog) =>
      this._taskService.moveToTop(id, parentId, isBacklog),
    );
  }

  moveTaskToBottom(): void {
    this._moveAndRefocus((id, parentId, isBacklog) =>
      this._taskService.moveToBottom(id, parentId, isBacklog),
    );
  }

  handleArrowUp(): void {
    if (this._focusGroupHeaderIfAtBoundary('prev')) {
      return;
    }
    this.focusPrevious();
  }

  handleArrowDown(): void {
    if (this._focusGroupHeaderIfAtBoundary('next')) {
      return;
    }
    this.focusNext();
  }

  private _focusGroupHeaderIfAtBoundary(direction: 'prev' | 'next'): boolean {
    const adjacent = findAdjacentFocusable(
      this._elementRef.nativeElement,
      direction,
      GROUP_NAV_SELECTOR,
    );
    if (adjacent?.matches('.collapsible-header')) {
      adjacent.focus();
      return true;
    }
    return false;
  }

  handleArrowLeft(): void {
    const t = this.task();
    const hasSubTasks = t.subTasks && t.subTasks.length > 0;

    if (this.isSelected()) {
      this.hideDetailPanel();
      return;
    }
    if (hasSubTasks && t._hideSubTasksMode !== HideSubTasksMode.HideAll) {
      this._taskService.toggleSubTaskMode(t.id, true, false);
      return;
    }
    if (!t.parentId) {
      const group = CollapsibleComponent.findClosestGroup(this._elementRef.nativeElement);
      if (group?.isExpanded) {
        group.focusHeader();
        group.collapseIfExpanded();
        return;
      }
    }
    this.focusPrevious();
  }

  handleArrowRight(): void {
    const t = this.task();
    const hasSubTasks = t.subTasks && t.subTasks.length > 0;

    if (hasSubTasks && t._hideSubTasksMode !== undefined) {
      this._taskService.toggleSubTaskMode(t.id, false, false);
    } else if (!this.isSelected()) {
      this.showDetailPanel();
    } else {
      this.focusNext();
    }
  }

  moveToBacklogWithFocus(): void {
    const t = this.task();
    if (t.projectId && !t.parentId) {
      this.focusPrevious(true);
      this.moveToBacklog();
    }
  }

  moveToTodayWithFocus(): void {
    const t = this.task();
    if (t.projectId) {
      this.focusNext(true, true);
      this.moveToToday();
    }
  }

  openProjectMenu(): void {
    if (this.task().parentId) {
      return;
    }
    this._loadProjectListIfNeeded();
    if (!this.isProjectMenuLoaded()) {
      this.isProjectMenuLoaded.set(true);
      setTimeout(() => this.projectMenuTrigger()?.openMenu());
      return;
    }
    this.projectMenuTrigger()?.openMenu();
  }

  _loadProjectListIfNeeded(): void {
    // Only load if not already loaded
    const currentProjectId = this.task().projectId || null;
    const isLoadedForCurrentProject =
      this._loadedProjectListForProjectId === currentProjectId &&
      this._moveToProjectListSub &&
      !this._moveToProjectListSub.closed;

    if (isLoadedForCurrentProject) {
      return;
    }

    this._moveToProjectListSub?.unsubscribe();
    this._loadedProjectListForProjectId = currentProjectId;

    this._moveToProjectListSub = this._projectService
      .getProjectsWithoutIdInTreeOrder$(currentProjectId)
      .subscribe((projects) => {
        this.moveToProjectList.set(projects);
      });
  }

  updateTaskTitleIfChanged({
    newVal,
    wasChanged,
    blurEvent,
    submitTrigger,
  }: {
    newVal: string;
    wasChanged: boolean;
    blurEvent?: FocusEvent;
    submitTrigger: SubmitTrigger;
  }): void {
    const task = this.task();

    if (wasChanged) {
      this._taskService.update(task.id, { title: newVal });
    }

    if (submitTrigger === 'modEnter') {
      this.addSubTask();
      return;
    }

    // Only focus self if no input/textarea is receiving focus next
    // This prevents stealing focus from any user input that was just clicked for editing
    const nextFocusTarget = blurEvent?.relatedTarget as HTMLElement | null;
    const isNextTargetInput =
      nextFocusTarget &&
      (nextFocusTarget.tagName.toLowerCase() === 'input' ||
        nextFocusTarget.tagName.toLowerCase() === 'textarea' ||
        nextFocusTarget.closest('task') !== null);

    if (!isNextTargetInput) {
      this.focusSelf();
    }
  }

  openNotesFullscreen(): void {
    const task = this.task();
    // Saves-and-closes on a navigation (resize across the mobile breakpoint,
    // Android back) instead of dropping the edit — see openFullscreenMarkdownDialog
    // (#8434).
    const dialogRef = openFullscreenMarkdownDialog(this._matDialog, this._location, {
      content: task.notes || '',
      taskId: task.id,
    });

    dialogRef.afterClosed().subscribe((result) => {
      if (result?.action === 'DELETE') {
        this._taskService.update(task.id, { notes: '' });
      } else if (typeof result === 'string') {
        this._taskService.update(task.id, { notes: result });
      }
      this.focusSelf();
    });
  }

  estimateTime(): void {
    if (this.task().subTaskIds?.length > 0) {
      return;
    }

    this._matDialog
      .open(DialogTimeEstimateComponent, {
        data: { task: this.task() },
      })
      .afterClosed()
      .subscribe(() => this.focusSelf());
  }

  addAttachment(): void {
    this._matDialog
      .open(DialogEditTaskAttachmentComponent, {
        data: {},
      })
      .afterClosed()
      .subscribe((result) => {
        if (result) {
          this._attachmentService.addAttachment(this.task().id, result);
        }
        this.focusSelf();
      });
  }

  addSubTask(): void {
    const task = this.task();
    const parentId = task.parentId || task.id;
    if (!task.parentId && task._hideSubTasksMode === HideSubTasksMode.HideAll) {
      this._taskService.showSubTasks(task.id);
    }
    this._addSubtaskInputService.requestOpen(parentId);
  }

  onAddSubtaskInputClosed(reason: AddSubtaskInputCloseReason): void {
    this.isAddSubtaskInputVisible.set(false);
    const originTaskId = this._subtaskInputOriginTaskId;
    this._subtaskInputOriginTaskId = null;
    if (reason === 'escape') {
      // Return focus to the task the draft was opened from (which may be a
      // subtask) so keyboard navigation continues from there after cancelling.
      this._refocusTaskAfterDraftCancel(originTaskId);
    }
  }

  private _refocusTaskAfterDraftCancel(taskId: string | null): void {
    if (isTouchActive()) {
      return;
    }
    const targetId = taskId ?? this.task().id;
    // Deferred so focus lands after the input's removal settles.
    window.setTimeout(() => this._focusTaskById(targetId));
  }

  private _focusTaskById(taskId: string): void {
    // A task can render in two places at once (main list + detail side panel);
    // prefer the last instance — the side-panel one — mirroring the inline-edit
    // focus resolution above.
    const els = document.querySelectorAll<HTMLElement>('#t-' + CSS.escape(taskId));
    els[els.length - 1]?.focus();
  }

  @throttle(200, { leading: true, trailing: false })
  toggleDoneKeyboard(): void {
    this.toggleTaskDone();
  }

  onSwipeRightTriggered(isTriggered: boolean): void {
    if (this.task().isDone) {
      this.showUndoneAnimation.set(isTriggered);
    } else {
      this.showDoneAnimation.set(isTriggered);
    }
  }

  toggleTaskDone(): void {
    window.clearTimeout(this._doneAnimationTimeout);
    this.focusNext(true, true);
    this._doneAnimationTimeout = this._taskService.toggleDoneWithAnimation(
      this.task().id,
      this.task().isDone,
      (v) => this.showDoneAnimation.set(v),
    );
  }

  showDetailPanel(): void {
    this._taskService.setSelectedId(this.task().id);
    this.focusSelf();
  }

  hideDetailPanel(): void {
    // Explicitly deselect to close the panel; do NOT re-select (that's showDetailPanel's job).
    this._taskService.setSelectedId(null);
    this.focusSelf();
  }

  private _wasClickedInDoubleClickRange = false;

  // Clicking the detail-panel toggle button while it shows the accent "issue
  // updated" icon also dismisses that badge (toggleButtonIcon() === 'update'
  // iff issueWasUpdated). This is the always-available way to clear it — unlike
  // the issue-content "mark as checked" button, it does not depend on the issue
  // data (re)loading, so the badge can never get stuck (e.g. removed remote issue).
  onToggleDetailPanelBtnClick(ev?: MouseEvent): void {
    const task = this.task();
    if (task.issueWasUpdated) {
      this._taskService.markIssueUpdatesAsRead(task.id);
    }
    this.toggleShowDetailPanel(ev);
  }

  toggleShowDetailPanel(ev?: MouseEvent): void {
    const isInTaskDetailPanel =
      this._elementRef.nativeElement.closest('task-detail-panel');
    if (isInTaskDetailPanel && !this._wasClickedInDoubleClickRange) {
      this._wasClickedInDoubleClickRange = true;
      window.clearTimeout(this._doubleClickTimeout);
      this._doubleClickTimeout = window.setTimeout(() => {
        this._wasClickedInDoubleClickRange = false;
      }, 400);
      return;
    }

    if (this.isSelected()) {
      this._taskService.setSelectedId(null);
    } else {
      this._taskService.setSelectedId(this.task().id);
    }
    if (ev) {
      ev.preventDefault();
      ev.stopPropagation();
    }
  }

  toggleShowAttachments(): void {
    this._taskService.setSelectedId(this.task().id, TaskDetailTargetPanel.Attachments);
    this.focusSelf();
  }

  openNotesPanel(): void {
    this._taskService.setSelectedId(this.task().id, TaskDetailTargetPanel.Notes);
    this.focusSelf();
  }

  toggleSubTaskMode(): void {
    this._taskService.toggleSubTaskMode(this.task().id, true, true);
    this.focusSelf();
  }

  isTagMenuVisible = signal(false);

  async editTags(): Promise<void> {
    this.isTagMenuVisible.set(true);
    setTimeout(() => {
      this.tagToggleMenuList()?.openMenu();
    });
  }

  toggleTag(tagId: string): void {
    const task = this.task();
    const tagIds = task.tagIds.includes(tagId)
      ? task.tagIds.filter((id) => id !== tagId)
      : [...task.tagIds, tagId];

    this.onTagsUpdated(tagIds);
  }

  addToMyDay(): void {
    const task = this.task();
    this._store.dispatch(
      TaskSharedActions.planTasksForToday({
        taskIds: [task.id],
        today: this._dateService.todayStr(),
        startOfNextDayDiffMs: this._dateService.getStartOfNextDayDiffMs(),
        parentTaskMap: { [task.id]: task.parentId },
      }),
    );
  }

  unschedule(): void {
    this._store.dispatch(
      TaskSharedActions.unscheduleTask({
        id: this.task().id,
      }),
    );
  }

  titleBarClick(event: MouseEvent): void {
    const targetEl = event.target as HTMLElement;
    if (targetEl.closest('task-title')) {
      return;
    }
    if (isTouchActive() && this.task().title.length) {
      this.toggleShowDetailPanel(event);
    } else {
      this.focusSelf();
    }
  }

  focusPrevious(isFocusReverseIfNotPossible: boolean = false): void {
    if (isTouchActive()) {
      return;
    }

    const taskEls = Array.from(document.querySelectorAll('task'));
    const activeEl =
      document.activeElement?.tagName.toLowerCase() === 'task'
        ? document.activeElement
        : document.activeElement?.closest('task');
    const currentIndex = taskEls.findIndex((el) => el === activeEl);
    const prevEl = taskEls[currentIndex - 1] as HTMLElement;

    if (prevEl) {
      prevEl.focus();
      // hacky but works
      setTimeout(() => {
        if (document.activeElement !== prevEl) {
          prevEl.focus();
        }
      });
    } else if (isFocusReverseIfNotPossible) {
      this.focusNext();
    }
  }

  focusNext(
    isFocusReverseIfNotPossible: boolean = false,
    isTaskMovedInList = false,
  ): void {
    if (isTouchActive()) {
      return;
    }

    const nextEl = this._getNextFocusEl(isTaskMovedInList);
    this._nextFocusTaskEl = undefined;

    if (nextEl) {
      nextEl.focus();
      // hacky but works
      setTimeout(() => {
        if (document.activeElement !== nextEl) {
          nextEl.focus();
        }
      });
    } else if (isFocusReverseIfNotPossible) {
      this.focusPrevious();
    }
  }

  focusSelf(): void {
    if (isTouchActive()) {
      return;
    }
    this._focusSelfElement();
  }

  focusSelfOrNextIfNotPossible(): void {
    if (isTouchActive()) {
      return;
    }

    this.focusSelf();
    // we don't clear the timeout since this should be executed if task is gone
    window.setTimeout(() => {
      if (
        !document.activeElement ||
        document.activeElement.tagName.toLowerCase() !== 'task'
      ) {
        this.focusNext(true);
      }
    }, 200);
  }

  private _focusSelfElement(): void {
    this._elementRef.nativeElement.focus();
  }

  focusTitleForEdit(): void {
    const taskTitleEditEl = this.taskTitleEditEl();
    if (!taskTitleEditEl) {
      TaskLog.log(taskTitleEditEl);
      throw new Error('No el');
    }
    taskTitleEditEl.focusInput();
  }

  onHostTouchStart(): void {
    this._dragReadyTimeout = window.setTimeout(() => {
      this.isDragReady.set(true);
    }, DRAG_DELAY_FOR_TOUCH);
  }

  onHostTouchEnd(): void {
    this._cancelDragReady();
  }

  // A confirmed horizontal swipe (open menu / mark done) is not a drag, so
  // cancel the pending long-press drag-ready state before it can fire mid-swipe.
  onSwipeStart(): void {
    this._cancelDragReady();
  }

  private _cancelDragReady(): void {
    window.clearTimeout(this._dragReadyTimeout);
    this.isDragReady.set(false);
  }

  onHostContextMenu(event: MouseEvent): void {
    if (isTouchActive()) {
      event.preventDefault();
      return;
    }
    this.openContextMenu(event);
  }

  openContextMenu(event?: TouchEvent | MouseEvent | KeyboardEvent): void {
    this.taskTitleEditEl()?.cancelEditing();
    if (event) {
      event.preventDefault();
      event.stopPropagation();
      if ('stopImmediatePropagation' in event) {
        event.stopImmediatePropagation();
      }
    }

    if (!this.isContextMenuLoaded()) {
      this.isContextMenuLoaded.set(true);
      setTimeout(() => {
        this.taskContextMenu()?.open(event);
      });
      return;
    }

    this.taskContextMenu()?.open(event);
  }

  onTagsUpdated(tagIds: string[]): void {
    this._taskService.updateTags(this.task(), tagIds);
  }

  // TODO extract so service
  moveTaskToProject(projectId: string): void {
    const t = this.task();
    if (projectId === t.projectId) {
      return;
    } else if (!t.repeatCfgId) {
      this._taskService.moveToProject(t, projectId);
      setTimeout(() => this.focusNext(true));
    } else {
      forkJoin([
        this._taskRepeatCfgService
          .getTaskRepeatCfgByIdAllowUndefined$(t.repeatCfgId)
          .pipe(first()),
        this._taskService.getTasksWithSubTasksByRepeatCfgId$(t.repeatCfgId).pipe(first()),
        this._taskService.getArchiveTasksForRepeatCfgId(t.repeatCfgId),
        this._projectService.getByIdOnce$(projectId),
      ])
        .pipe(
          concatMap(
            ([
              reminderCfg,
              nonArchiveInstancesWithSubTasks,
              archiveInstances,
              targetProject,
            ]) => {
              TaskLog.log({
                reminderCfg,
                nonArchiveInstancesWithSubTasks,
                archiveInstances,
              });

              // Repeat config was deleted (e.g. via cross-client sync) but the task
              // still references it — treat it as a plain task move instead of
              // crashing on the missing config. (#8715)
              if (!reminderCfg) {
                this._taskService.moveToProject(this.task(), projectId);
                setTimeout(() => this.focusNext(true));
                return EMPTY;
              }

              // if there is only a single instance (probably just created) than directly update the task repeat cfg
              if (
                nonArchiveInstancesWithSubTasks.length === 1 &&
                archiveInstances.length === 0
              ) {
                this._taskRepeatCfgService.updateTaskRepeatCfg(reminderCfg.id, {
                  projectId,
                });
                this._taskService.moveToProject(this.task(), projectId);
                setTimeout(() => this.focusNext(true));
                return EMPTY;
              }

              return this._matDialog
                .open(DialogConfirmComponent, {
                  data: {
                    okTxt: T.F.TASK_REPEAT.D_CONFIRM_MOVE_TO_PROJECT.OK,
                    message: T.F.TASK_REPEAT.D_CONFIRM_MOVE_TO_PROJECT.MSG,
                    translateParams: {
                      projectName: targetProject?.title ?? _MISSING_PROJECT_,
                      tasksNr:
                        nonArchiveInstancesWithSubTasks.length + archiveInstances.length,
                    },
                  },
                })
                .afterClosed()
                .pipe(
                  tap((isConfirm) => {
                    if (isConfirm) {
                      this._taskRepeatCfgService.updateTaskRepeatCfg(reminderCfg.id, {
                        projectId,
                      });
                      nonArchiveInstancesWithSubTasks.forEach((nonArchiveTask) => {
                        this._taskService.moveToProject(nonArchiveTask, projectId);
                      });

                      const archiveUpdates: Update<TaskCopy>[] = [];
                      archiveInstances.forEach((archiveTask) => {
                        archiveUpdates.push({
                          id: archiveTask.id,
                          changes: { projectId },
                        });
                        if (archiveTask.subTaskIds.length) {
                          archiveTask.subTaskIds.forEach((subId) => {
                            archiveUpdates.push({
                              id: subId,
                              changes: { projectId },
                            });
                          });
                        }
                      });
                      this._taskService.updateArchiveTasks(archiveUpdates);
                      setTimeout(() => this.focusNext(true));
                    }
                  }),
                );
            },
          ),
        )
        .subscribe(() => this.focusSelf());
    }
  }

  moveToBacklog(): void {
    const t = this.task();
    if (t.projectId && !t.parentId) {
      // Moving to the backlog is a list-position change only; it must not
      // alter the task's schedule (#8592).
      this._projectService.moveTaskToBacklog(t.id, t.projectId);
    }
  }

  moveToToday(): void {
    const t = this.task();
    if (!t.projectId) {
      return;
    }
    // An overdue task is never in the backlog, so the position-only move below
    // early-returns for it (moveProjectTaskToRegularListAuto) and Shift+T would
    // no-op. Schedule it for today instead — the same thing the "Add to My Day"
    // button and Schedule → Today do (#8851). Overdue vs. backlog→regular are
    // cleanly separated because overdue tasks are never in the backlog. Exclude
    // done tasks: a done task with a stale past dueDay can still sit in the
    // backlog, and it should take the position-only move, not be re-added to
    // Today. (isTaskOverdue stays done-agnostic — selectOverdueTasks needs
    // done tasks included.)
    if (
      !t.isDone &&
      isTaskOverdue(
        t,
        this._dateService.todayStr(),
        this._dateService.getStartOfNextDayDiffMs(),
      )
    ) {
      this.addToMyDay();
      return;
    }
    // Moving to the regular list is a list-position change only; it must not
    // schedule the task for today (#8592).
    this._projectService.moveTaskToTodayList(t.id, t.projectId);
  }

  trackByProjectId(i: number, project: Project): string {
    return project.id;
  }

  private _storeNextFocusEl(): void {
    this._nextFocusTaskEl = this._getNextFocusEl();
  }

  private _getNextFocusEl(isTaskMovedInList = false): HTMLElement | undefined {
    if (this._nextFocusTaskEl) {
      return this._nextFocusTaskEl;
    }

    const taskEls = Array.from(document.querySelectorAll('task'));
    const activeEl =
      document.activeElement?.tagName.toLowerCase() === 'task'
        ? document.activeElement
        : document.activeElement?.closest('task');
    const currentIndex = taskEls.findIndex((el) => el === activeEl);
    const nextEl = isTaskMovedInList
      ? (() => {
          // if a parent task is moved in list, as it is for when toggling done,
          // we don't want to focus the next sub-task, but the next main task instead
          if (this.task().subTaskIds.length) {
            return taskEls.find((el, i) => {
              return i > currentIndex && el.parentElement?.closest('task');
            }) as HTMLElement | undefined;
          }
          return taskEls[currentIndex + 1] as HTMLElement;
        })()
      : (taskEls[currentIndex + 1] as HTMLElement);
    return nextEl;
  }

  get kb(): KeyboardConfig {
    if (isTouchActive()) {
      return EMPTY_KEYBOARD_CONFIG;
    }
    return keyboardConfigOrEmpty(this._configService.cfg()?.keyboard as KeyboardConfig);
  }

  protected readonly ICAL_TYPE = ICAL_TYPE;
}
