import { inject, Injectable } from '@angular/core';
import { unique } from '../../util/unique';
import { generateCalendarTaskId } from '../calendar-integration/generate-calendar-task-id';
import { generatePlainspaceTaskId } from './providers/plainspace/generate-plainspace-task-id';
import {
  BuiltInIssueProviderKey,
  IssueData,
  IssueDataReduced,
  IssueIntegrationCfg,
  IssueProvider,
  IssueProviderKey,
  SearchResultItem,
  SearchResultItemWithProviderId,
} from './issue.model';
import { TaskAttachment } from '../tasks/task-attachment/task-attachment.model';
import { firstValueFrom, forkJoin, from, merge, Observable, of, Subject } from 'rxjs';
import {
  CALDAV_TYPE,
  GITLAB_TYPE,
  ICAL_TYPE,
  ISSUE_PROVIDER_HUMANIZED,
  ISSUE_PROVIDER_ICON_MAP,
  ISSUE_STR_MAP,
  DEFAULT_ISSUE_STRS,
  JIRA_TYPE,
  OPEN_PROJECT_TYPE,
  REDMINE_TYPE,
  NEXTCLOUD_DECK_TYPE,
  PLAINSPACE_TYPE,
} from './issue.const';
import { TaskService } from '../tasks/task.service';
import { IssueTask, Task, TaskCopy, TaskWithSubTasks } from '../tasks/task.model';
import { IssueServiceInterface } from './issue-service-interface';
import { JiraCommonInterfacesService } from './providers/jira/jira-common-interfaces.service';
// Trello is now a plugin — no built-in service needed
import { catchError, map, switchMap } from 'rxjs/operators';
import { IssueLog } from '../../core/log';
import { GitlabCommonInterfacesService } from './providers/gitlab/gitlab-common-interfaces.service';
import { CaldavCommonInterfacesService } from './providers/caldav/caldav-common-interfaces.service';
import { OpenProjectCommonInterfacesService } from './providers/open-project/open-project-common-interfaces.service';
// Gitea is now a plugin — no built-in service needed
import { RedmineCommonInterfacesService } from './providers/redmine/redmine-common-interfaces.service';
// Linear is now a plugin — no built-in service needed
// ClickUp is now a plugin — no built-in service needed
// Azure DevOps is now a plugin — no built-in service needed
import { NextcloudDeckCommonInterfacesService } from './providers/nextcloud-deck/nextcloud-deck-common-interfaces.service';
import { PlainspaceCommonInterfacesService } from './providers/plainspace/plainspace-common-interfaces.service';
import { SnackService } from '../../core/snack/snack.service';
import { T } from '../../t.const';
import { TranslateService } from '@ngx-translate/core';
import { CalendarCommonInterfacesService } from './providers/calendar/calendar-common-interfaces.service';
import { ICalIssueReduced } from './providers/calendar/calendar.model';
import { WorkContextType } from '../work-context/work-context.model';
import { WorkContextService } from '../work-context/work-context.service';
import { ProjectService } from '../project/project.service';
import { _MISSING_PROJECT_ } from '../project/project.const';
import { IssueProviderService } from './issue-provider.service';
import { CalendarIntegrationService } from '../calendar-integration/calendar-integration.service';
import { getCalendarEventIdCandidates } from '../calendar-integration/get-calendar-event-id-candidates';
import { Store } from '@ngrx/store';
import { selectEnabledIssueProviders } from './store/issue-provider.selectors';
import { getErrorTxt } from '../../util/get-error-text';
import { getDbDateStr } from '../../util/get-db-date-str';
import { TODAY_TAG } from '../tag/tag.const';
import typia from 'typia';
import { GlobalProgressBarService } from '../../core-ui/global-progress-bar/global-progress-bar.service';
import { NavigateToTaskService } from '../../core-ui/navigate-to-task/navigate-to-task.service';
import { PluginIssueProviderAdapterService } from '../../plugins/issue-provider/plugin-issue-provider-adapter.service';
import { PluginIssueProviderRegistryService } from '../../plugins/issue-provider/plugin-issue-provider-registry.service';

@Injectable({
  providedIn: 'root',
})
export class IssueService {
  private _taskService = inject(TaskService);
  private _jiraCommonInterfacesService = inject(JiraCommonInterfacesService);
  private _gitlabCommonInterfacesService = inject(GitlabCommonInterfacesService);
  private _caldavCommonInterfaceService = inject(CaldavCommonInterfacesService);
  private _openProjectInterfaceService = inject(OpenProjectCommonInterfacesService);
  private _redmineInterfaceService = inject(RedmineCommonInterfacesService);
  private _nextcloudDeckCommonInterfaceService = inject(
    NextcloudDeckCommonInterfacesService,
  );
  private _plainspaceCommonInterfaceService = inject(PlainspaceCommonInterfacesService);
  private _calendarCommonInterfaceService = inject(CalendarCommonInterfacesService);
  private _issueProviderService = inject(IssueProviderService);
  private _workContextService = inject(WorkContextService);
  private _snackService = inject(SnackService);
  private _translateService = inject(TranslateService);
  private _projectService = inject(ProjectService);
  private _calendarIntegrationService = inject(CalendarIntegrationService);
  private _store = inject(Store);
  private _globalProgressBarService = inject(GlobalProgressBarService);
  private _navigateToTaskService = inject(NavigateToTaskService);
  private _pluginAdapter = inject(PluginIssueProviderAdapterService);
  private _pluginRegistry = inject(PluginIssueProviderRegistryService);

  ISSUE_SERVICE_MAP: { [key: string]: IssueServiceInterface } = {
    [GITLAB_TYPE]: this._gitlabCommonInterfacesService,
    [JIRA_TYPE]: this._jiraCommonInterfacesService,
    [CALDAV_TYPE]: this._caldavCommonInterfaceService,
    [OPEN_PROJECT_TYPE]: this._openProjectInterfaceService,
    [REDMINE_TYPE]: this._redmineInterfaceService,
    [ICAL_TYPE]: this._calendarCommonInterfaceService,
    [NEXTCLOUD_DECK_TYPE]: this._nextcloudDeckCommonInterfaceService,
    [PLAINSPACE_TYPE]: this._plainspaceCommonInterfaceService,
  };

  ISSUE_REFRESH_MAP: {
    [issueProviderId: string]: { [issueId: string]: Subject<IssueData> };
  } = {};

  testConnection(issueProviderCfg: IssueProvider): Promise<boolean> {
    const service = this._getService(issueProviderCfg.issueProviderKey);
    if (!service) {
      return Promise.resolve(false);
    }
    // Cast is safe: for built-in providers, IssueProvider extends IssueIntegrationCfg;
    // for plugin providers, the adapter internally casts to IssueProviderPluginType
    return service.testConnection(issueProviderCfg as IssueIntegrationCfg);
  }

  getById(
    issueType: IssueProviderKey,
    id: string | number,
    issueProviderId: string,
  ): Promise<IssueData | null> {
    const service = this._getService(issueType);
    if (!service) {
      return Promise.resolve(null);
    }
    return service.getById(id, issueProviderId);
  }

  // Keep Observable version for components that need real-time updates via refresh
  getById$(
    issueType: IssueProviderKey,
    id: string | number,
    issueProviderId: string,
  ): Observable<IssueData | null> {
    // account for (manual) issue refreshing
    if (!this.ISSUE_REFRESH_MAP[issueProviderId]) {
      this.ISSUE_REFRESH_MAP[issueProviderId] = {};
    }
    if (!this.ISSUE_REFRESH_MAP[issueProviderId][id]) {
      this.ISSUE_REFRESH_MAP[issueProviderId][id] = new Subject<IssueData>();
    }
    const service = this._getService(issueType);
    if (!service) {
      return of(null);
    }
    return from(service.getById(id, issueProviderId)).pipe(
      switchMap((issue) => merge(of(issue), this.ISSUE_REFRESH_MAP[issueProviderId][id])),
    );
  }

  searchIssues(
    searchTerm: string,
    issueProviderId: string,
    issueProviderKey: IssueProviderKey,
    isEmptySearch = false,
  ): Promise<SearchResultItem[]> {
    // check if text is more than just special chars
    if (searchTerm.replace(/[^\p{L}\p{N}]+/gu, '').length === 0 && !isEmptySearch) {
      return Promise.resolve([]);
    }
    const service = this._getService(issueProviderKey);
    if (!service) {
      return Promise.resolve([]);
    }
    return service.searchIssues(searchTerm, issueProviderId);
  }

  searchAllEnabledIssueProviders$(
    searchTerm: string,
  ): Observable<SearchResultItemWithProviderId[]> {
    return this._store.select(selectEnabledIssueProviders).pipe(
      switchMap((enabledProviders) => {
        if (enabledProviders.length === 0) {
          return of([]);
        }

        const searchObservables = enabledProviders.map((provider) =>
          from(
            this.searchIssues(searchTerm, provider.id, provider.issueProviderKey),
          ).pipe(
            map((results) =>
              results.map((result) => ({
                ...result,
                issueProviderId: provider.id,
              })),
            ),
            catchError((err: unknown) => {
              this._snackService.open({
                svgIco: this._getProviderIcon(provider.issueProviderKey),
                msg: T.F.ISSUE.S.ERR_GENERIC,
                type: 'ERROR',
                translateParams: {
                  issueProviderName: this._getProviderName(provider.issueProviderKey),
                  errTxt: getErrorTxt(err),
                },
              });
              // Re-throw original error to preserve stack trace
              throw err;
            }),
          ),
        );
        return forkJoin(searchObservables).pipe(map((results) => results.flat()));
      }),
    );
  }

  issueLink(
    issueType: IssueProviderKey,
    issueId: string | number,
    issueProviderId: string,
  ): Promise<string> {
    const service = this._getService(issueType);
    if (!service) {
      return Promise.resolve('');
    }
    return service.issueLink(issueId, issueProviderId);
  }

  getPollInterval(providerKey: IssueProviderKey): number {
    return this._getPollInterval(providerKey);
  }

  getMappedAttachments(
    issueType: IssueProviderKey,
    issueDataIN: IssueData,
  ): TaskAttachment[] {
    const service = this._getService(issueType);
    if (!service?.getMappedAttachments) {
      return [];
    }
    return service.getMappedAttachments(issueDataIN);
  }

  async checkAndImportNewIssuesToBacklogForProject(
    providerKey: IssueProviderKey,
    issueProviderId: string,
    isBackgroundPoll = false,
  ): Promise<void> {
    const service = this._getService(providerKey);
    if (!service?.getNewIssuesToAddToBacklog) {
      return;
    }
    // Background ('always'-mode) polls run every few minutes regardless of
    // navigation, so keep them quiet — only the import result snack below is
    // shown, and only when something is actually added.
    if (!isBackgroundPoll) {
      this._snackService.open({
        svgIco: this._getProviderIcon(providerKey),
        msg: T.F.ISSUE.S.POLLING_BACKLOG,
        isSpinner: true,
        translateParams: {
          issueProviderName: this._getProviderName(providerKey),
          issuesStr: this._translateService.instant(
            this._getIssueStrings(providerKey).ISSUES_STR,
          ),
        },
      });
    }

    const allExistingIssueIds: string[] | number[] =
      await this._taskService.getAllIssueIdsForProviderEverywhere(issueProviderId);
    const potentialIssuesToAdd = await service.getNewIssuesToAddToBacklog!(
      issueProviderId,
      allExistingIssueIds,
    );

    const issuesToAdd: IssueDataReduced[] = potentialIssuesToAdd.filter(
      (issue: IssueDataReduced): boolean =>
        !(allExistingIssueIds as string[]).includes(issue.id as string),
    );

    issuesToAdd.forEach((issue: IssueDataReduced) => {
      // TODO add correct project id
      // Every import here is an automatic backlog poll targeting the provider's
      // default project, so the currently-viewed context is incidental and must
      // not leak its tag onto the task (#8673). Flagging it here (rather than in
      // the effect) also covers the classic poll's mid-fetch context-switch race
      // — getTaskDefaults reads the *live* context after several awaits.
      this.addTaskFromIssue({
        issueDataReduced: issue,
        issueProviderId,
        issueProviderKey: providerKey,
        isAddToBacklog: true,
        isAutoImport: true,
      });
    });

    if (issuesToAdd.length === 1) {
      const issueTitle = this._getAddTaskData(providerKey, issuesToAdd[0]).title;
      this._snackService.open({
        svgIco: this._getProviderIcon(providerKey),
        msg: T.F.ISSUE.S.IMPORTED_SINGLE_ISSUE,
        translateParams: {
          issueProviderName: this._getProviderName(providerKey),
          issueStr: this._translateService.instant(
            this._getIssueStrings(providerKey).ISSUE_STR,
          ),
          issueTitle,
        },
      });
    } else if (issuesToAdd.length > 1) {
      this._snackService.open({
        svgIco: this._getProviderIcon(providerKey),
        msg: T.F.ISSUE.S.IMPORTED_MULTIPLE_ISSUES,
        translateParams: {
          issueProviderName: this._getProviderName(providerKey),
          issuesStr: this._translateService.instant(
            this._getIssueStrings(providerKey).ISSUES_STR,
          ),
          nr: issuesToAdd.length,
        },
      });
    }
  }

  async refreshIssueTask(
    task: Task,
    isNotifySuccess: boolean = true,
    isNotifyNoUpdateRequired: boolean = false,
  ): Promise<void> {
    const { issueId, issueType, issueProviderId } = task;

    if (!issueId || !issueType || !issueProviderId) {
      throw new Error('No issue task');
    }
    const service = this._getService(issueType);
    if (!service?.getFreshDataForIssueTask) {
      throw new Error(
        `Issue method getFreshDataForIssueTask not available for ${issueType}`,
      );
    }

    // NOTE: Interface defines single param but implementations may use additional params
    // TODO: Consider updating interface or refactoring implementations
    const update = await (
      service.getFreshDataForIssueTask as (
        task: Task,
        isNotifySuccess?: boolean,
        isNotifyNoUpdateRequired?: boolean,
      ) => ReturnType<IssueServiceInterface['getFreshDataForIssueTask']>
    )(task, isNotifySuccess, isNotifyNoUpdateRequired);

    if (update) {
      if (this.ISSUE_REFRESH_MAP[issueProviderId]?.[issueId]) {
        this.ISSUE_REFRESH_MAP[issueProviderId][issueId].next(update.issue);
      }
      this._taskService.update(task.id, update.taskChanges);

      if (isNotifySuccess) {
        this._snackService.open({
          svgIco: this._getProviderIcon(issueType),
          msg: T.F.ISSUE.S.ISSUE_UPDATE_SINGLE,
          translateParams: {
            issueProviderName: this._getProviderName(issueType),
            issueStr: this._translateService.instant(
              this._getIssueStrings(issueType).ISSUE_STR,
            ),
            issueTitle: update.issueTitle,
          },
        });
      }
    } else if (isNotifyNoUpdateRequired) {
      this._snackService.open({
        svgIco: this._getProviderIcon(issueType),
        msg: T.F.ISSUE.S.ISSUE_NO_UPDATE_REQUIRED,
        translateParams: {
          issueProviderName: this._getProviderName(issueType),
        },
      });
    }
  }

  // TODO given we have issueProvider available, we could also just pass that
  async refreshIssueTasks(tasks: Task[], issueProvider: IssueProvider): Promise<void> {
    // dynamic map that has a list of tasks for every entry where the entry is an issue type
    const tasksIssueIdsByIssueProviderKey: Record<string, Task[]> = {};
    const tasksWithoutIssueId: Readonly<Task>[] = [];

    for (const task of tasks) {
      if (!task.issueId || !task.issueType) {
        tasksWithoutIssueId.push(task);
      } else if (!tasksIssueIdsByIssueProviderKey[task.issueType]) {
        tasksIssueIdsByIssueProviderKey[task.issueType] = [task];
      } else {
        tasksIssueIdsByIssueProviderKey[task.issueType].push(task);
      }
    }

    for (const pKey of Object.keys(tasksIssueIdsByIssueProviderKey)) {
      const providerKey = pKey as IssueProviderKey;
      IssueLog.log(
        'POLLING CHANGES FOR ' + providerKey,
        tasksIssueIdsByIssueProviderKey[providerKey],
      );
      const pollingLabelParams = {
        issueProviderName: this._getProviderName(providerKey),
        issuesStr: this._translateService.instant(
          this._getIssueStrings(providerKey).ISSUES_STR,
        ),
      };

      this._globalProgressBarService.countUp('POLL', {
        labelParams: pollingLabelParams,
      });

      const service = this._getService(providerKey);
      if (!service) {
        this._globalProgressBarService.countDown();
        continue;
      }

      let updates: {
        task: Task;
        taskChanges: Partial<Task>;
        issue: IssueData;
      }[] = [];
      try {
        updates = await service.getFreshDataForIssueTasks(
          tasksIssueIdsByIssueProviderKey[providerKey],
        );
      } finally {
        this._globalProgressBarService.countDown();
      }

      if (updates.length > 0) {
        for (const update of updates) {
          if (this.ISSUE_REFRESH_MAP[issueProvider.id]?.[update.task.issueId as string]) {
            this.ISSUE_REFRESH_MAP[issueProvider.id][update.task.issueId as string].next(
              update.issue,
            );
          }
          this._taskService.update(update.task.id, update.taskChanges);
        }

        if (updates.length === 1) {
          this._snackService.open({
            svgIco: this._getProviderIcon(providerKey),
            msg: T.F.ISSUE.S.ISSUE_UPDATE_SINGLE,
            translateParams: {
              issueProviderName: this._getProviderName(providerKey),
              issueStr: this._translateService.instant(
                this._getIssueStrings(providerKey).ISSUE_STR,
              ),
              issueTitle: updates[0].taskChanges.title || updates[0].task.title,
            },
          });
        } else if (updates.length > 1) {
          this._snackService.open({
            svgIco: this._getProviderIcon(providerKey),
            msg: T.F.ISSUE.S.ISSUE_UPDATE_MULTIPLE,
            translateParams: {
              issueProviderName: this._getProviderName(providerKey),
              issuesStr: this._translateService.instant(
                this._getIssueStrings(providerKey).ISSUES_STR,
              ),
              nr: updates.length,
            },
          });
        }
      }

      if (service.getRemovedRemoteTasks) {
        await this._removeOrphanedRemoteTasks(
          service,
          tasksIssueIdsByIssueProviderKey[providerKey],
        );
      }
    }

    for (const taskWithoutIssueId of tasksWithoutIssueId) {
      throw new Error('No issue task ' + taskWithoutIssueId.id);
    }
  }

  /**
   * Removes local tasks that are gone from the provider (deleted or reassigned
   * away), but only when nothing has been invested in them locally. Failures are
   * logged and swallowed so a detection hiccup never aborts the rest of the poll.
   */
  private async _removeOrphanedRemoteTasks(
    service: IssueServiceInterface,
    tasks: Task[],
  ): Promise<void> {
    try {
      const orphaned = await service.getRemovedRemoteTasks!(tasks);
      const removable = orphaned.filter((task) => !this._hasLocalContent(task));
      if (removable.length === 0) {
        return;
      }
      // The deleteTask effect that mirrors deletions back to the provider no-ops
      // here (Plainspace has no deleteIssue adapter) — which is also why removing
      // a reassigned task never deletes the still-living remote item. Each device
      // polls independently and the delete op is idempotent, so a concurrent
      // same-task delete on another device is harmless. Tasks are childless by
      // the _hasLocalContent guard, so an empty subTasks list is accurate.
      if (removable.length === 1) {
        // Single-task path keeps the built-in deleteTask UNDO snackbar.
        this._taskService.remove({
          ...removable[0],
          subTasks: [],
        } as TaskWithSubTasks);
      } else {
        // A bulk vanish (e.g. many reassigned at once) collapses to ONE deleteTasks
        // op instead of N rapid deleteTask dispatches (sync rule #3: one
        // reconciliation = one op). The bulk path has no per-task undo snackbar.
        this._taskService.removeMultipleTasks(removable.map((task) => task.id));
      }
    } catch (e) {
      // Never log the raw error object — it may be an HttpErrorResponse whose
      // url/body carry the issue id or task content, and the log is exportable.
      IssueLog.err(
        'Failed to remove orphaned issue tasks',
        e instanceof Error ? e : String(e),
      );
    }
  }

  /**
   * Whether the task holds local work or a completion record that removal would
   * lose. Time tracking is the headline case; notes, sub-tasks, attachments and a
   * local repeat config all count. `isDone` is kept too: a finished task is a
   * record worth preserving, it covers "reassigned away after I completed it",
   * and it decouples removal from the server keeping done items in its list.
   * Deliberately excluded: dueDay/dueWithTime and tagIds are seeded by the import
   * itself, so every imported task has them.
   */
  private _hasLocalContent(task: Task): boolean {
    return (
      task.timeSpent > 0 ||
      (task.subTaskIds?.length ?? 0) > 0 ||
      !!task.notes ||
      (task.attachments?.length ?? 0) > 0 ||
      !!task.repeatCfgId ||
      task.isDone
    );
  }

  async addTaskFromIssue({
    issueDataReduced,
    issueProviderId,
    issueProviderKey,
    additional = {},
    isAddToBacklog = false,
    isForceDefaultProject = false,
    isAutoImport = false,
  }: {
    issueDataReduced: IssueDataReduced;
    issueProviderId: string;
    issueProviderKey: IssueProviderKey;
    additional?: Partial<Task>;
    isAddToBacklog?: boolean;
    isForceDefaultProject?: boolean;
    // Automatic (non-user-initiated) import — a background backlog poll or
    // calendar auto-import. Such imports fire regardless of what the user is
    // viewing, so they must not inherit the active context's tag (#8673).
    isAutoImport?: boolean;
  }): Promise<string | undefined> {
    if (!issueDataReduced || !issueDataReduced.id || !issueProviderId) {
      throw new Error('No issueData');
    }

    const issueIdCandidates = this._getIssueIdCandidates(
      issueProviderKey,
      issueDataReduced,
    );

    if (
      await this._checkAndHandleIssueAlreadyAdded(
        issueProviderKey,
        issueProviderId,
        issueDataReduced.id.toString(),
        { issueIdCandidates },
      )
    ) {
      return undefined;
    }

    // For calendar events, use deterministic ID to prevent duplicates across devices
    if (issueProviderKey === ICAL_TYPE) {
      additional = {
        ...additional,
        id: generateCalendarTaskId(issueProviderId, issueDataReduced.id.toString()),
      };
    } else if (issueProviderKey === PLAINSPACE_TYPE) {
      // Plainspace auto-imports in the background on every device, so concurrent
      // imports of the same issue must converge on one task id (see
      // generatePlainspaceTaskId) instead of creating cross-device duplicates.
      additional = {
        ...additional,
        id: generatePlainspaceTaskId(issueProviderId, issueDataReduced.id.toString()),
      };
    }

    const providerCfg = await firstValueFrom(
      this._issueProviderService.getCfgOnce$(issueProviderId, issueProviderKey),
    );

    const {
      title = null,
      related_to,
      ...additionalFromProviderIssueService
    } = this._getAddTaskData(issueProviderKey, issueDataReduced, providerCfg);
    IssueLog.log({
      related_to,
      additionalKeys: Object.keys(additionalFromProviderIssueService),
    });

    const getTaskDefaults = (): Partial<TaskCopy> => {
      const defaultProjectId = providerCfg.defaultProjectId;
      const defaultTagIds = (providerCfg.defaultTagIds || []).filter(
        (id) => id !== TODAY_TAG.id,
      );
      const defaultNote = providerCfg.defaultNote;

      if (typeof this._workContextService.activeWorkContextId !== 'string') {
        throw new Error('No active work context id');
      }

      const result: Partial<TaskCopy> = {};
      if (
        defaultNote &&
        !(additionalFromProviderIssueService as Partial<TaskCopy>).notes
      ) {
        result.notes = defaultNote;
      }

      if (
        this._workContextService.activeWorkContextType === WorkContextType.PROJECT &&
        !isForceDefaultProject
      ) {
        result.projectId =
          defaultProjectId || this._workContextService.activeWorkContextId;
        if (defaultTagIds.length) {
          result.tagIds = [...defaultTagIds];
        }
        return result;
      } else {
        // An automatic import (background backlog poll / calendar auto-import)
        // fires regardless of what the user is currently viewing, so the active
        // tag is incidental. Inheriting it would stamp an unrelated tag onto the
        // imported task and sync that stray tag to every device. Only inherit the
        // ambient tag for user-initiated (foreground) imports.
        const contextTagIds =
          !isAutoImport &&
          this._workContextService.activeWorkContextType === WorkContextType.TAG &&
          this._workContextService.activeWorkContextId !== TODAY_TAG.id
            ? [this._workContextService.activeWorkContextId]
            : [];
        result.tagIds = unique([...contextTagIds, ...defaultTagIds]);
        result.projectId = defaultProjectId || undefined;
        return result;
      }
    };

    const taskDefaults = getTaskDefaults();
    const providerTagIds = (additionalFromProviderIssueService as Partial<TaskCopy>)
      .tagIds;
    if (Array.isArray(providerTagIds)) {
      taskDefaults.tagIds = unique([...(taskDefaults.tagIds ?? []), ...providerTagIds]);
    }

    const taskData = {
      issueType: issueProviderKey,
      issueProviderId: issueProviderId,
      issueId: issueDataReduced.id.toString(),
      issueWasUpdated: false,
      issueLastUpdated: Date.now(),
      // Default plan for today unless a precise time is provided by provider.
      // Skip when going to backlog — a backlog task that's also "due today"
      // shows up in the Today tab, defeating the point of the backlog.
      ...(isAddToBacklog ? {} : { dueDay: getDbDateStr() }),
      ...additionalFromProviderIssueService,
      ...taskDefaults,
      ...additional,
    };

    // If a precise start time is provided by the provider, avoid setting dueDay as well
    if ((taskData as Partial<TaskCopy>).dueWithTime) {
      (taskData as Partial<TaskCopy>).dueDay = undefined;
    }

    let taskId: string | undefined;

    // parentTaskId is the SP task under which _addSubTasks should attach children.
    // When the task is added as a sub-task, this is the root SP parent (not the
    // newly-added sub-task itself) so that CalDAV grandchildren are flattened to
    // the same nesting level instead of creating unsupported grandchildren.
    let subTaskParentId: string | undefined;

    if (related_to) {
      const subTaskResult = await this._tryAddSubTask({
        title: title as string,
        taskData,
        issueParentId: related_to,
        issueProviderId,
        issueProviderKey,
      });
      taskId = subTaskResult?.taskId;
      subTaskParentId = subTaskResult?.parentTaskId;
    }

    // add new task (also fallback when parent id of subtask is not found)
    if (!taskId) {
      taskId = taskData.dueWithTime
        ? await this._taskService.addAndSchedule(title, taskData, taskData.dueWithTime)
        : this._taskService.add(title, isAddToBacklog, taskData);

      // TODO more elegant solution for skipped calendar events
      if (issueProviderKey === ICAL_TYPE) {
        this._calendarIntegrationService.skipCalendarEvent(
          issueDataReduced as ICalIssueReduced,
        );
      }

      subTaskParentId = taskId;
    }

    // Handle subtasks if provider supports it
    if (this._getService(issueProviderKey)?.getSubTasks && subTaskParentId) {
      await this._addSubTasks(
        issueDataReduced,
        subTaskParentId,
        issueProviderId,
        issueProviderKey,
      );
    }

    return taskId;
  }

  private async _addSubTasks(
    issueDataReduced: IssueDataReduced,
    parentTaskId: string,
    issueProviderId: string,
    issueProviderKey: IssueProviderKey,
  ): Promise<void> {
    const provider = this._getService(issueProviderKey);
    if (!provider?.getSubTasks) {
      return;
    }
    try {
      const subtasks = await provider.getSubTasks(
        issueDataReduced.id,
        issueProviderId,
        issueDataReduced,
      );

      if (!subtasks || subtasks.length === 0) {
        return;
      }

      for (const subtask of subtasks) {
        const subTaskData = this._getAddTaskData(issueProviderKey, subtask);
        const { title: subTaskTitle, ...subTaskAdditional } = subTaskData;

        this._taskService.addSubTaskTo(parentTaskId, {
          title: subTaskTitle,
          issueType: issueProviderKey,
          issueProviderId: issueProviderId,
          issueId: subtask.id.toString(),
          issueWasUpdated: false,
          issueLastUpdated: Date.now(),
          ...subTaskAdditional,
        });
      }
    } catch (e) {
      IssueLog.warn('Failed to add subtasks for ' + issueProviderKey, e);
    }
  }

  private async _tryAddSubTask({
    title,
    taskData,
    issueParentId,
    issueProviderId,
    issueProviderKey,
  }: {
    title: string;
    taskData: Partial<Task>;
    issueParentId: string;
    issueProviderId: string;
    issueProviderKey: IssueProviderKey;
  }): Promise<{ taskId: string; parentTaskId: string } | undefined> {
    const parentTask = await this._taskService.checkForTaskWithIssueEverywhere(
      issueParentId,
      issueProviderKey,
      issueProviderId,
    );

    // Archived parents cannot receive new sub-tasks (the reducer no-ops silently).
    // Fall through so the child is added as a top-level task instead.
    if (!parentTask || parentTask.isFromArchive) {
      return undefined;
    }

    const subTaskData = { title, ...taskData } as Partial<TaskCopy>;
    if (subTaskData.dueWithTime) {
      subTaskData.dueDay = undefined;
    }

    // SP supports only one nesting level. If the resolved parent is itself a
    // sub-task (has a parentId), attach to its root parent so the new task
    // becomes a sibling of the parent rather than a grandchild.
    const effectiveParentId = parentTask.task.parentId || parentTask.task.id;
    const taskId = this._taskService.addSubTaskTo(effectiveParentId, subTaskData);
    return { taskId, parentTaskId: effectiveParentId };
  }

  private async _checkAndHandleIssueAlreadyAdded(
    issueType: IssueProviderKey,
    issueProviderId: string,
    issueId: string,
    opts?: { issueIdCandidates?: string[] },
  ): Promise<boolean> {
    const idsToCheck = Array.from(
      new Set(
        opts?.issueIdCandidates && opts.issueIdCandidates.length
          ? [issueId, ...opts.issueIdCandidates]
          : [issueId],
      ),
    );

    let res: Awaited<
      ReturnType<typeof this._taskService.checkForTaskWithIssueEverywhere>
    > | null = null;
    for (const candidateId of idsToCheck) {
      res = await this._taskService.checkForTaskWithIssueEverywhere(
        candidateId,
        issueType,
        issueProviderId,
      );
      if (res) {
        break;
      }
    }

    if (res?.isFromArchive) {
      this._taskService.restoreTask(res.task, res.subTasks || []);
      this._snackService.open({
        ico: 'info',
        msg: T.F.TASK.S.FOUND_RESTORE_FROM_ARCHIVE,
        translateParams: { title: res.task.title },
      });
      return true;
    } else if (res?.task) {
      if (
        res.task.projectId &&
        res.task.projectId === this._workContextService.activeWorkContextId
      ) {
        // If the existing task is already in this project's backlog, don't
        // yank it to Today — that's the whole point of the backlog. Without
        // this guard, every poll that surfaces an already-imported issue
        // promotes the task, which spams Today with issues the user
        // consciously parked in Backlog.
        const project = await firstValueFrom(
          this._projectService.getByIdOnce$(res.task.projectId),
        );
        const isInBacklog = !!project?.backlogTaskIds?.includes(res.task.id);
        if (isInBacklog) {
          const taskId = res.task.id;
          this._snackService.open({
            ico: 'info',
            msg: T.F.TASK.S.TASK_ALREADY_EXISTS,
            translateParams: { title: res.task.title },
            actionStr: T.F.TASK.S.GO_TO_TASK,
            actionFn: () => {
              this._navigateToTaskService.navigate(taskId, false);
            },
          });
          return true;
        }
        this._projectService.moveTaskToTodayList(res.task.id, res.task.projectId);
        this._snackService.open({
          ico: 'arrow_upward',
          msg: T.F.TASK.S.FOUND_MOVE_FROM_BACKLOG,
          translateParams: { title: res.task.title },
        });
        return true;
      } else if (issueType === ICAL_TYPE) {
        // For calendar events, don't move to today - just show snackbar with navigation
        const taskId = res.task.id;
        this._snackService.open({
          ico: 'info',
          msg: T.F.TASK.S.TASK_ALREADY_EXISTS,
          translateParams: { title: res.task.title },
          actionStr: T.F.TASK.S.GO_TO_TASK,
          actionFn: () => {
            this._navigateToTaskService.navigate(taskId, false);
          },
        });
        return true;
      } else {
        const taskWithTaskSubTasks = await this._taskService
          .getByIdWithSubTaskData$(res.task.id)
          .toPromise();
        this._taskService.moveToCurrentWorkContext(taskWithTaskSubTasks);
        this._snackService.open({
          ico: 'arrow_upward',
          msg: T.F.TASK.S.FOUND_MOVE_FROM_OTHER_LIST,
          translateParams: {
            title: res.task.title,
            contextTitle: res.task.projectId
              ? ((await this._projectService.getByIdOnce$(res.task.projectId).toPromise())
                  ?.title ?? _MISSING_PROJECT_)
              : 'another tag',
          },
        });
        return true;
      }
    }

    return false;
  }

  private _getService(key: IssueProviderKey): IssueServiceInterface | undefined {
    // Registry-first: check plugin registry before built-in map.
    // This handles both 'plugin:*' keys AND migrated keys like 'GITHUB'.
    if (this._pluginRegistry.hasProvider(key)) {
      return this._pluginAdapter;
    }
    return this.ISSUE_SERVICE_MAP[key];
  }

  private _getProviderIcon(key: IssueProviderKey): string {
    if (this._pluginRegistry.hasProvider(key)) {
      return this._pluginRegistry.getIcon(key);
    }
    return ISSUE_PROVIDER_ICON_MAP[key as BuiltInIssueProviderKey];
  }

  private _getProviderName(key: IssueProviderKey): string {
    if (this._pluginRegistry.hasProvider(key)) {
      return this._pluginRegistry.getName(key);
    }
    return ISSUE_PROVIDER_HUMANIZED[key as BuiltInIssueProviderKey];
  }

  private _getIssueStrings(key: IssueProviderKey): {
    ISSUE_STR: string;
    ISSUES_STR: string;
  } {
    if (this._pluginRegistry.hasProvider(key)) {
      return this._pluginRegistry.getIssueStrings(key);
    }
    return ISSUE_STR_MAP[key] ?? DEFAULT_ISSUE_STRS;
  }

  private _getPollInterval(key: IssueProviderKey): number {
    if (this._pluginRegistry.hasProvider(key)) {
      return this._pluginRegistry.getPollIntervalMs(key);
    }
    const service = this.ISSUE_SERVICE_MAP[key];
    return service?.pollInterval ?? 0;
  }

  private _getAddTaskData(
    issueProviderKey: IssueProviderKey,
    issueReduced: IssueDataReduced,
    cfg?: unknown,
  ): IssueTask {
    const service = this._getService(issueProviderKey);
    if (!service?.getAddTaskData) {
      throw new Error('Issue method not available');
    }
    const serviceWithCfg = service as IssueServiceInterface & {
      getAddTaskDataForCfg?: (issueData: IssueDataReduced, cfg: unknown) => IssueTask;
    };
    const r =
      cfg && serviceWithCfg.getAddTaskDataForCfg
        ? serviceWithCfg.getAddTaskDataForCfg(issueReduced, cfg)
        : service.getAddTaskData(issueReduced);
    typia.assert<IssueTask>(r);
    return r;
  }

  private _getIssueIdCandidates(
    issueProviderKey: IssueProviderKey,
    issueDataReduced: IssueDataReduced,
  ): string[] | undefined {
    if (issueProviderKey !== ICAL_TYPE) {
      return undefined;
    }
    return getCalendarEventIdCandidates(issueDataReduced as ICalIssueReduced);
  }

  // TODO if we need to refresh data on after add, this is how we would do it
  // try {
  //   const freshIssueData = await this.ISSUE_SERVICE_MAP[issueProviderKey]
  //     .getById$(issueDataReduced.issueData.id, issueProvider.id)
  //     .toPromise();
  //   // eslint-disable-next-line @typescript-eslint/no-shadow
  //   const { title = null, ...additionalFields } =
  //     this.ISSUE_SERVICE_MAP[issueProviderKey].getAddTaskData(freshIssueData);
  //   this._taskService.update(taskId, {});
  // } catch (e) {
  //   IssueLog.err(e);
  //   this._taskService.remove(taskId);
  //   // TODO show error msg
  // }
}
