import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  inject,
  signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
  MAT_DIALOG_DATA,
  MatDialog,
  MatDialogActions,
  MatDialogContent,
  MatDialogRef,
  MatDialogTitle,
} from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { T } from '../../../t.const';
import {
  IssueIntegrationCfg,
  IssueProvider,
  IssueProviderKey,
  IssueProviderTypeMap,
  BuiltInIssueProviderKey,
} from '../issue.model';
import {
  DEFAULT_ISSUE_PROVIDER_CFGS,
  ICAL_TYPE,
  ISSUE_PROVIDER_DEFAULT_COMMON_CFG,
  ISSUE_PROVIDER_FORM_CFGS_MAP,
  ISSUE_PROVIDER_HUMANIZED,
} from '../issue.const';
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import {
  ConfigFormSection,
  LimitedFormlyFieldConfig,
} from '../../config/global-config.model';
import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component';
import { IssueProviderActions } from '../store/issue-provider.actions';
import { NgClass } from '@angular/common';
import { OpenProjectAdditionalCfgComponent } from '../providers/open-project/open-project-view-components/openproject-cfg/open-project-additional-cfg.component';
import { nanoid } from 'nanoid';
import { HelperClasses, IS_ELECTRON, IS_WEB_BROWSER } from '../../../app.constants';
import { MatInputModule } from '@angular/material/input';
import { IssueService } from '../issue.service';
import { SnackService } from '../../../core/snack/snack.service';
import { CalendarContextInfoTarget } from '../providers/calendar/calendar.model';
import { IssueIconPipe } from '../issue-icon/issue-icon.pipe';
import { JiraAdditionalCfgComponent } from '../providers/jira/jira-view-components/jira-cfg/jira-additional-cfg.component';
import { JiraCfg } from '../providers/jira/jira.model';
import { HelpSectionComponent } from '../../../ui/help-section/help-section.component';
import { TranslatePipe } from '@ngx-translate/core';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { IssueLog } from '../../../core/log';
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
import { PluginBridgeService } from '../../../plugins/plugin-bridge.service';
import { PluginHttpService } from '../../../plugins/issue-provider/plugin-http.service';
import { OAuthFlowConfig, PluginSyncDirection } from '@super-productivity/plugin-api';
import { applyPluginOAuthOverrides } from './plugin-oauth-config-overrides.util';
import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform';
// Trello is now a plugin — board selection is a dynamic `loadOptions` select field
// ClickUp is now a plugin — no built-in config component needed
import { NextcloudDeckAdditionalCfgComponent } from '../providers/nextcloud-deck/nextcloud-deck-additional-cfg.component';
import { TaskService } from '../../tasks/task.service';
import { firstValueFrom } from 'rxjs';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../common-issue-form-stuff.const';
import { TagService } from '../../tag/tag.service';
import { ChipListInputComponent } from '../../../ui/chip-list-input/chip-list-input.component';
import { unique } from '../../../util/unique';
import { mergeIssueProviderModelUpdates } from './issue-provider-model-merge.util';

@Component({
  selector: 'dialog-edit-issue-provider',
  imports: [
    OpenProjectAdditionalCfgComponent,
    FormsModule,
    MatInputModule,
    NgClass,
    IssueIconPipe,
    JiraAdditionalCfgComponent,
    ReactiveFormsModule,
    MatDialogContent,
    HelpSectionComponent,
    TranslatePipe,
    MatSlideToggle,
    FormlyModule,
    MatDialogActions,
    MatButton,
    MatIcon,
    MatDialogTitle,
    NextcloudDeckAdditionalCfgComponent,
    ChipListInputComponent,
  ],
  templateUrl: './dialog-edit-issue-provider.component.html',
  styleUrl: './dialog-edit-issue-provider.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DialogEditIssueProviderComponent {
  readonly T: typeof T = T;
  readonly HelperClasses = HelperClasses;
  readonly d = inject<{
    issueProvider?: IssueProvider;
    issueProviderKey?: IssueProviderKey;
    calendarContextInfoTarget?: CalendarContextInfoTarget;
    isDuplicate?: boolean;
  }>(MAT_DIALOG_DATA);

  isConnectionWorks = signal(false);
  isOAuthConnected = signal(false);
  isOAuthConnecting = signal(false);
  optionsLoadState = signal<'idle' | 'loading' | 'loaded' | 'failed'>('idle');
  form = new FormGroup({});
  showLoadOptionsButton = false;

  private _pluginRegistry = inject(PluginIssueProviderRegistryService);
  private _pluginBridge = inject(PluginBridgeService);
  private _pluginHttp = inject(PluginHttpService);
  private _cdr = inject(ChangeDetectorRef);

  issueProviderKey: IssueProviderKey = (this.d.issueProvider?.issueProviderKey ||
    this.d.issueProviderKey) as IssueProviderKey;
  issueProvider?: IssueProvider = this.d.issueProvider;
  isEdit: boolean = !!this.issueProvider && !this.d.isDuplicate;

  model: Partial<IssueProvider> = this.isEdit
    ? this._migratePluginConfigForEdit({ ...this.issueProvider })
    : this.d.isDuplicate && this.issueProvider
      ? this._migratePluginConfigForEdit({
          ...this.issueProvider,
          id: nanoid(),
          migratedFromProjectId: undefined,
        })
      : ({
          ...ISSUE_PROVIDER_DEFAULT_COMMON_CFG,
          ...(this._pluginRegistry.hasProvider(this.issueProviderKey)
            ? {
                pluginId:
                  this._pluginRegistry.getProvider(this.issueProviderKey)?.pluginId ??
                  this.issueProviderKey.replace('plugin:', ''),
                pluginConfig: this._getDefaultPluginConfig(),
                isAutoAddToBacklog:
                  this._pluginRegistry.getProvider(this.issueProviderKey)
                    ?.defaultAutoAddToBacklog ?? false,
              }
            : DEFAULT_ISSUE_PROVIDER_CFGS[
                this.issueProviderKey as BuiltInIssueProviderKey
              ]),
          id: nanoid(),
          isEnabled: true,
          issueProviderKey: this.issueProviderKey,
        } as IssueProviderTypeMap<IssueProviderKey>);

  title: string = this._pluginRegistry.hasProvider(this.issueProviderKey)
    ? this._pluginRegistry.getName(this.issueProviderKey) || this.issueProviderKey
    : ISSUE_PROVIDER_HUMANIZED[this.issueProviderKey as BuiltInIssueProviderKey];

  isAgendaView = this._pluginRegistry.getUseAgendaView(this.issueProviderKey);

  configFormSection: ConfigFormSection<IssueIntegrationCfg> | undefined =
    this._pluginRegistry.hasProvider(this.issueProviderKey)
      ? this._getPluginFormSection()
      : ISSUE_PROVIDER_FORM_CFGS_MAP[this.issueProviderKey as BuiltInIssueProviderKey];

  fields = this.configFormSection?.items ?? [];

  oauthButtons = this._getOAuthButtons();

  private _matDialogRef: MatDialogRef<DialogEditIssueProviderComponent> =
    inject(MatDialogRef);

  private _matDialog = inject(MatDialog);
  private _store = inject(Store);
  private _issueService = inject(IssueService);
  private _snackService = inject(SnackService);
  private _taskService = inject(TaskService);
  private _tagService = inject(TagService);

  tagSuggestions = toSignal(this._tagService.tagsNoMyDayAndNoList$, { initialValue: [] });

  addTag(id: string): void {
    this.model = {
      ...this.model,
      defaultTagIds: unique([...(this.model.defaultTagIds || []), id]),
    };
  }

  addNewTag(title: string): void {
    const id = this._tagService.addTag({ title });
    this.model = {
      ...this.model,
      defaultTagIds: unique([...(this.model.defaultTagIds || []), id]),
    };
  }

  removeTag(id: string): void {
    this.model = {
      ...this.model,
      defaultTagIds: (this.model.defaultTagIds || []).filter((tagId) => tagId !== id),
    };
  }

  constructor() {
    this._initOAuthAndOptions().catch((err) => {
      IssueLog.err(
        '[DialogEditIssueProvider] OAuth init failed',
        getSafeErrorLogMeta(err),
      );
    });
  }

  submit(isSkipClose = false): void {
    if (this.form.valid) {
      if (this.isEdit) {
        this._store.dispatch(
          IssueProviderActions.updateIssueProvider({
            issueProvider: {
              id: this.issueProvider!.id,
              changes: this.model as IssueProvider,
            },
          }),
        );
      } else {
        this._store.dispatch(
          IssueProviderActions.addIssueProvider({
            issueProvider: this.model as IssueProvider,
          }),
        );
      }
      if (!isSkipClose) {
        this._matDialogRef.close(this.model);
      }
    }
  }

  cancel(): void {
    this._matDialogRef.close();
  }

  duplicate(): void {
    const providerData = structuredClone(this.model) as IssueProvider;
    this._matDialogRef.close();
    this._matDialog.open(DialogEditIssueProviderComponent, {
      restoreFocus: true,
      data: {
        issueProvider: providerData,
        isDuplicate: true,
      },
    });
  }

  formlyModelChange(model: Partial<IssueProvider>): void {
    // Assign Formly's emitted model by reference. Formly runs in immutable mode
    // and emits a clone of the full model, short-circuiting its own rebuild only
    // when it receives that exact reference back (`_modelChangeValue === model`).
    // Running it through mergeIssueProviderModelUpdates() would create a new
    // object, defeat that guard, and — because immutable mode re-clones array
    // field values on every rebuild — spin an infinite
    // rebuild→patchValue→modelChange loop that freezes the app whenever a
    // multiSelect field (e.g. "Calendars to display") holds an array value (#8777).
    // Partial updates from custom cfg components still go through updateModel().
    this.model = model;
    this.isConnectionWorks.set(false);
  }

  customCfgCmpSave(cfgUpdates: IssueIntegrationCfg): void {
    IssueLog.log('Issue provider config component saved updates', {
      issueProviderKey: this.issueProviderKey,
      issueProviderId: this.model.id,
      ...getSafeCfgUpdateLogMeta(cfgUpdates),
    });
    this.updateModel(cfgUpdates);
  }

  updateModel(model: Partial<IssueProvider>): void {
    this.model = mergeIssueProviderModelUpdates(this.model, model);
    this.isConnectionWorks.set(false);
  }

  async testConnection(): Promise<void> {
    try {
      const isSuccess = await this._issueService.testConnection(
        this.model as IssueProvider,
      );
      this.isConnectionWorks.set(isSuccess);
      if (isSuccess) {
        this._snackService.open({
          type: 'SUCCESS',
          msg: T.F.ISSUE.S.CONNECTION_SUCCESS,
        });
        // Reload dynamic options (e.g. calendar lists) after successful connection
        await this._loadDynamicOptions();
      } else {
        this._snackService.open({
          type: 'ERROR',
          msg: T.F.ISSUE.S.CONNECTION_FAILED,
        });
      }
    } catch (error) {
      this.isConnectionWorks.set(false);
      this._snackService.open({
        type: 'ERROR',
        msg: T.F.ISSUE.S.CONNECTION_FAILED,
      });
    }
  }

  remove(): void {
    this._matDialog
      .open(DialogConfirmComponent, {
        restoreFocus: true,
        data: {
          cancelTxt: T.G.CANCEL,
          okTxt: T.G.DELETE,
          message: T.F.ISSUE.DIALOG.DELETE_CONFIRM,
        },
      })
      .afterClosed()
      .subscribe(async (isConfirm: boolean) => {
        if (isConfirm) {
          const providerId = this.issueProvider!.id;

          // Gather task IDs to unlink - this ensures atomic sync
          const allTasks = await firstValueFrom(this._taskService.allTasks$);
          const taskIdsToUnlink = allTasks
            .filter((task) => task.issueProviderId === providerId)
            .map((task) => task.id);

          this._store.dispatch(
            TaskSharedActions.deleteIssueProvider({
              issueProviderId: providerId,
              taskIdsToUnlink,
            }),
          );
          this._matDialogRef.close();
        }
      });
  }

  changeEnabled(isEnabled: boolean): void {
    // this.model.isEnabled = isEnabled;
    this.model = {
      ...this.model,
      isEnabled,
    };
    this.submit(true);
    this.isConnectionWorks.set(false);
  }

  async connectOAuth(oauthConfig: OAuthFlowConfig): Promise<void> {
    if (this.isOAuthUnavailableInWeb(oauthConfig)) {
      return;
    }
    const pluginId = this._pluginRegistry.getProvider(this.issueProviderKey)?.pluginId;
    if (!pluginId) {
      return;
    }
    const effectiveOAuthConfig = this._withPluginOAuthOverrides(oauthConfig);
    this.isOAuthConnecting.set(true);
    try {
      await this._pluginBridge.startOAuthFlow(pluginId, effectiveOAuthConfig);
    } catch (e) {
      const detail = (e instanceof Error ? e.message : String(e))
        .replace(/\s+/g, ' ')
        .slice(0, 200);
      IssueLog.err('OAuth connect failed', getSafeErrorLogMeta(e));
      this._snackService.open({
        type: 'ERROR',
        msg: detail ? T.F.ISSUE.S.OAUTH_FAILED_WITH_DETAIL : T.F.ISSUE.S.OAUTH_FAILED,
        translateParams: { detail },
      });
      return;
    } finally {
      this.isOAuthConnecting.set(false);
    }
    this.isOAuthConnected.set(true);
    this._snackService.open({
      type: 'SUCCESS',
      msg: T.F.ISSUE.S.OAUTH_CONNECTED,
    });
    // _loadDynamicOptions surfaces its own per-field error snacks; failures
    // here must not be reported as OAuth failures (the connection succeeded).
    await this._loadDynamicOptions();
  }

  async disconnectOAuth(): Promise<void> {
    const pluginId = this._pluginRegistry.getProvider(this.issueProviderKey)?.pluginId;
    if (!pluginId) {
      return;
    }
    await this._pluginBridge.clearOAuthTokens(pluginId);
    this.isOAuthConnected.set(false);
  }

  protected readonly ICAL_TYPE = ICAL_TYPE;
  protected readonly IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW;
  protected readonly IS_ELECTRON = IS_ELECTRON;
  protected readonly IS_WEB_EXTENSION_REQUIRED_FOR_JIRA = IS_WEB_BROWSER;

  protected get isJiraDirectFetchEnabled(): boolean {
    return !!(this.model as Partial<JiraCfg>).allowFetchFallback;
  }

  protected isOAuthUnavailableInWeb(oauthConfig: OAuthFlowConfig): boolean {
    return !IS_ELECTRON && !IS_NATIVE_PLATFORM && !oauthConfig.webClientId;
  }

  /**
   * @returns true if all fields loaded successfully, false if any failed
   */
  private async _loadDynamicOptions(): Promise<boolean> {
    const provider = this._pluginRegistry.getProvider(this.issueProviderKey);
    if (!provider) {
      return false;
    }
    const configFields = this._pluginRegistry.getConfigFields(this.issueProviderKey);
    const dynamicFields = configFields.filter((f) => typeof f.loadOptions === 'function');
    if (!dynamicFields.length) {
      return true;
    }

    const pluginConfig = (this.model as Record<string, unknown>)['pluginConfig'] ?? {};
    const http = this._pluginHttp.createHttpHelper(
      () => provider.definition.getHeaders(pluginConfig as Record<string, unknown>),
      { allowPrivateNetwork: provider.allowPrivateNetwork },
    );

    let anyFailed = false;
    for (const field of dynamicFields) {
      try {
        const options = await field.loadOptions!(
          pluginConfig as Record<string, unknown>,
          http,
        );
        const formlyField = this._findFormlyField(
          this.fields as FormlyFieldConfig[],
          'pluginConfig.' + field.key,
        );
        if (formlyField?.templateOptions) {
          formlyField.templateOptions.options = options;
        } else if (formlyField?.props) {
          formlyField.props.options = options;
        }
      } catch (e) {
        anyFailed = true;
        IssueLog.err('[DialogEditIssueProvider] loadOptions failed', {
          issueProviderKey: this.issueProviderKey,
          hasFieldKey: !!field.key,
          ...getSafeErrorLogMeta(e),
        });
        this._snackService.open({
          type: 'ERROR',
          msg: T.F.ISSUE.S.LOAD_OPTIONS_FAILED,
          translateParams: { fieldKey: field.key },
        });
      }
    }
    // Trigger formly refresh — reassign both fields and model so mat-select
    // re-evaluates display labels for already-selected values.
    // Use detectChanges() instead of markForCheck() because plugin bridge
    // async calls may resolve outside Zone.js (e.g. Electron IPC).
    this.fields = [...this.fields];
    const currentPluginCfg = (this.model as Record<string, unknown>)['pluginConfig'];
    this.model = currentPluginCfg
      ? {
          ...this.model,
          pluginConfig: { ...(currentPluginCfg as Record<string, unknown>) },
        }
      : { ...this.model };
    this._cdr.detectChanges();
    return !anyFailed;
  }

  private _findFormlyField(
    fields: FormlyFieldConfig[],
    key: string,
  ): FormlyFieldConfig | undefined {
    for (const f of fields) {
      if (f.key === key) {
        return f;
      }
      if (f.fieldGroup) {
        const found = this._findFormlyField(f.fieldGroup, key);
        if (found) {
          return found;
        }
      }
    }
    return undefined;
  }

  /**
   * For plugin providers, run any necessary config migrations so the edit dialog
   * displays migrated values instead of showing empty required fields.
   */
  private _migratePluginConfigForEdit(
    model: Partial<IssueProvider>,
  ): Partial<IssueProvider> {
    const pluginConfig = (model as { pluginConfig?: Record<string, unknown> })
      .pluginConfig;
    if (!pluginConfig) return model;
    // Shallow-clone pluginConfig to avoid mutating store state
    const migrated = { ...pluginConfig };
    // Migrate old single-calendar calendarId to multi-calendar config shape
    if (
      migrated['calendarId'] &&
      !((migrated['readCalendarIds'] as string[])?.length > 0)
    ) {
      migrated['readCalendarIds'] = [migrated['calendarId'] as string];
      migrated['writeCalendarId'] = migrated['writeCalendarId'] || migrated['calendarId'];
    }
    const defaultPluginConfig = this._getDefaultPluginConfig();
    const defaultTwoWaySync = defaultPluginConfig['twoWaySync'] as
      | Record<string, PluginSyncDirection>
      | undefined;
    if (defaultTwoWaySync) {
      const provider = this._pluginRegistry.getProvider(this.issueProviderKey);
      const isPushSupported = !!provider?.definition.updateIssue;
      const currentTwoWaySync =
        (migrated['twoWaySync'] as Record<string, PluginSyncDirection> | undefined) ?? {};
      const normalizedCurrentTwoWaySync = Object.fromEntries(
        Object.entries(currentTwoWaySync).map(([field, direction]) => [
          field,
          this._normalizeSyncDirectionForCapabilities(direction, isPushSupported),
        ]),
      );
      migrated['twoWaySync'] = {
        ...defaultTwoWaySync,
        ...normalizedCurrentTwoWaySync,
      };
    }
    return { ...model, pluginConfig: migrated } as Partial<IssueProvider>;
  }

  private _getDefaultPluginConfig(): Record<string, unknown> {
    if (!this._pluginRegistry.hasProvider(this.issueProviderKey)) {
      return {};
    }
    const provider = this._pluginRegistry.getProvider(this.issueProviderKey);
    const isPushSupported = !!provider?.definition.updateIssue;
    const fieldMappings = this._pluginRegistry.getFieldMappings(this.issueProviderKey);
    if (!fieldMappings?.length) {
      return {};
    }
    const twoWaySync: Record<string, string> = {};
    for (const m of fieldMappings) {
      twoWaySync[m.taskField] = this._normalizeSyncDirectionForCapabilities(
        m.defaultDirection as PluginSyncDirection,
        isPushSupported,
      );
    }
    return { twoWaySync };
  }

  private _normalizeSyncDirectionForCapabilities(
    direction: PluginSyncDirection,
    isPushSupported: boolean,
  ): PluginSyncDirection {
    if (!isPushSupported && (direction === 'pushOnly' || direction === 'both')) {
      return 'pullOnly';
    }
    return direction;
  }

  private _getPluginFormSection(): ConfigFormSection<IssueIntegrationCfg> | undefined {
    const pluginKey = this.issueProviderKey;
    const configFields = this._pluginRegistry.getConfigFields(pluginKey);
    const fieldMappings = this._pluginRegistry.getFieldMappings(pluginKey);
    if (!configFields?.length && !fieldMappings?.length) {
      return undefined;
    }

    const isAgendaView = this._pluginRegistry.getUseAgendaView(pluginKey);

    const regularFields = configFields.filter(
      (f) => !f.advanced && f.type !== 'oauthButton',
    );
    const advancedFields = configFields.filter(
      (f) => f.advanced && f.type !== 'oauthButton',
    );

    const items = regularFields.map((f) =>
      this._mapPluginConfigField(f),
    ) as LimitedFormlyFieldConfig<IssueIntegrationCfg>[];

    // For agenda-view providers (e.g. Google Calendar), skip generic issue provider
    // fields (auto-import, polling, default note) and two-way sync config — the
    // ownership model makes them unnecessary.
    const advancedFieldGroup = isAgendaView
      ? advancedFields.map((f) => this._mapPluginConfigField(f))
      : [
          ...(ISSUE_PROVIDER_COMMON_FORM_FIELDS as any[]),
          ...advancedFields.map((f) => this._mapPluginConfigField(f)),
        ];

    if (advancedFieldGroup.length) {
      items.push({
        type: 'collapsible' as any,
        props: { label: T.F.ISSUE.DIALOG.ADVANCED_CONFIG },
        fieldGroup: advancedFieldGroup,
      } as any);
    }

    if (!isAgendaView && fieldMappings?.length) {
      items.push(this._buildTwoWaySyncSection(pluginKey, fieldMappings) as any);
    }

    return {
      title: this.title,
      key: 'EMPTY' as ConfigFormSection<IssueIntegrationCfg>['key'],
      items,
    };
  }

  private _mapPluginConfigField(f: {
    key: string;
    type: string;
    label: string;
    required?: boolean;
    description?: string;
    url?: string;
    pattern?: string;
    options?: { value: string; label: string }[];
    showIf?: string;
  }): unknown {
    if (f.type === 'link') {
      return {
        type: 'link',
        props: { url: f.url ?? f.key, txt: f.label },
      };
    }
    const formlyType =
      f.type === 'checkbox'
        ? 'checkbox'
        : f.type === 'select' || f.type === 'multiSelect'
          ? 'select'
          : f.type === 'textarea'
            ? 'textarea'
            : 'input';
    return {
      key: ('pluginConfig.' + f.key) as keyof IssueIntegrationCfg,
      type: formlyType,
      ...(f.showIf
        ? {
            hideExpression: (m: Record<string, unknown>) =>
              !(m['pluginConfig'] as Record<string, unknown> | undefined)?.[f.showIf!],
          }
        : {}),
      templateOptions: {
        label: f.label,
        required: f.required ?? false,
        ...(f.description ? { description: f.description } : {}),
        ...(f.type === 'password' ? { type: 'password' } : {}),
        ...(f.type === 'select' || f.type === 'multiSelect'
          ? { options: f.options }
          : {}),
        ...(f.type === 'multiSelect' ? { multiple: true } : {}),
        ...(f.pattern ? { pattern: f.pattern } : {}),
      },
    };
  }

  private _buildTwoWaySyncSection(
    pluginKey: IssueProviderKey,
    fieldMappings: { taskField: string; issueField: string; defaultDirection: string }[],
  ): unknown {
    const provider = this._pluginRegistry.getProvider(pluginKey);
    const isPushSupported = !!provider?.definition.updateIssue;
    const syncDirectionOptions = [
      { value: 'off', label: T.F.ISSUE.TWO_WAY_SYNC.OFF },
      { value: 'pullOnly', label: T.F.ISSUE.TWO_WAY_SYNC.PULL_ONLY },
      ...(isPushSupported
        ? [
            { value: 'pushOnly', label: T.F.ISSUE.TWO_WAY_SYNC.PUSH_ONLY },
            { value: 'both', label: T.F.ISSUE.TWO_WAY_SYNC.BOTH },
          ]
        : []),
    ];
    const TASK_FIELD_LABELS: Record<string, string> = {
      isDone: T.F.ISSUE.TWO_WAY_SYNC.STATUS,
      title: T.F.ISSUE.TWO_WAY_SYNC.TITLE,
      notes: T.F.ISSUE.TWO_WAY_SYNC.NOTES,
      dueDay: T.F.ISSUE.TWO_WAY_SYNC.DUE_DAY,
      dueWithTime: T.F.ISSUE.TWO_WAY_SYNC.DUE_WITH_TIME,
      timeEstimate: T.F.ISSUE.TWO_WAY_SYNC.TIME_ESTIMATE,
      tagIds: T.F.ISSUE.TWO_WAY_SYNC.TAGS,
    };
    const syncFields: any[] = fieldMappings.map((m) => ({
      key: ('pluginConfig.twoWaySync.' + m.taskField) as keyof IssueIntegrationCfg,
      type: 'select' as const,
      props: {
        label: TASK_FIELD_LABELS[m.taskField] ?? m.taskField,
        options: syncDirectionOptions,
      },
    }));
    if (provider?.definition.createIssue) {
      syncFields.push({
        key: 'pluginConfig.isAutoCreateIssues',
        type: 'checkbox',
        expressions: {
          // eslint-disable-next-line @typescript-eslint/naming-convention
          'props.disabled': '!model.defaultProjectId',
        },
        props: {
          label: T.F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_ISSUES,
          description: T.F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_ISSUES_DESCRIPTION,
        },
      });
    }
    if (provider?.definition.fieldMappings?.some((m) => m.taskField === 'tagIds')) {
      syncFields.push({
        key: 'pluginConfig.isAutoCreateTags',
        type: 'checkbox',
        props: {
          label: T.F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_TAGS,
          description: T.F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_TAGS_DESCRIPTION,
        },
      });
    }
    return {
      type: 'collapsible',
      props: { label: T.F.ISSUE.TWO_WAY_SYNC.SECTION },
      fieldGroup: syncFields,
    };
  }

  private _withPluginOAuthOverrides(oauthConfig: OAuthFlowConfig): OAuthFlowConfig {
    return applyPluginOAuthOverrides(
      oauthConfig,
      ((this.model as Record<string, unknown>)['pluginConfig'] || {}) as Record<
        string,
        unknown
      >,
      IS_ELECTRON,
    );
  }

  private _getOAuthButtons(): {
    label: string;
    oauthConfig: OAuthFlowConfig;
  }[] {
    if (!this._pluginRegistry.hasProvider(this.issueProviderKey)) {
      return [];
    }
    const configFields = this._pluginRegistry.getConfigFields(this.issueProviderKey);
    return configFields
      .filter((f) => f.type === 'oauthButton' && f.oauthConfig)
      .map((f) => ({ label: f.label, oauthConfig: f.oauthConfig! }));
  }

  private async _initOAuthAndOptions(): Promise<void> {
    const provider = this._pluginRegistry.getProvider(this.issueProviderKey);
    if (!provider) {
      return;
    }
    const hasTokens = await this._pluginBridge.restoreAndCheckOAuthTokens(
      provider.pluginId,
    );
    this.isOAuthConnected.set(hasTokens);
    if (hasTokens) {
      await this._loadDynamicOptions();
    } else {
      // For non-OAuth plugins (e.g. CalDAV with Basic Auth), show a "Load Calendars"
      // button and attempt to load dynamic options if credentials are already saved.
      const configFields = this._pluginRegistry.getConfigFields(this.issueProviderKey);
      const hasLoadOptions = configFields.some(
        (f) => typeof f.loadOptions === 'function',
      );
      const hasNoOAuth = !configFields.some((f) => f.type === 'oauthButton');
      if (hasLoadOptions && hasNoOAuth) {
        this.showLoadOptionsButton = true;
        if (this.isEdit) {
          await this.loadDynamicOptions();
        }
      }
    }
  }

  async loadDynamicOptions(): Promise<void> {
    this.optionsLoadState.set('loading');
    const success = await this._loadDynamicOptions();
    this.optionsLoadState.set(success ? 'loaded' : 'failed');
  }
}

const CREDENTIAL_LIKE_CFG_KEY_RE = /token|secret|key|password|auth|credential/i;

const getSafeCfgUpdateLogMeta = (
  cfgUpdates: IssueIntegrationCfg,
): {
  updateCount: number;
  hasCredentialLikeUpdate: boolean;
  hasPluginConfigUpdate: boolean;
  hasDefaultNoteUpdate: boolean;
  hasDefaultProjectUpdate: boolean;
  defaultTagUpdateCount: number;
} => {
  const cfgUpdateRecord = cfgUpdates as unknown as Record<string, unknown>;
  const updateKeys = Object.keys(cfgUpdateRecord);
  const defaultTagIds = cfgUpdateRecord['defaultTagIds'];

  return {
    updateCount: updateKeys.length,
    hasCredentialLikeUpdate: hasCredentialLikeCfgKey(cfgUpdateRecord, new WeakSet()),
    hasPluginConfigUpdate: updateKeys.includes('pluginConfig'),
    hasDefaultNoteUpdate: updateKeys.includes('defaultNote'),
    hasDefaultProjectUpdate: updateKeys.includes('defaultProjectId'),
    defaultTagUpdateCount: Array.isArray(defaultTagIds) ? defaultTagIds.length : 0,
  };
};

const hasCredentialLikeCfgKey = (value: unknown, seen: WeakSet<object>): boolean => {
  if (!value || typeof value !== 'object') {
    return false;
  }
  if (seen.has(value)) {
    return false;
  }
  seen.add(value);

  return Object.entries(value as Record<string, unknown>).some(
    ([key, nestedValue]) =>
      CREDENTIAL_LIKE_CFG_KEY_RE.test(key) || hasCredentialLikeCfgKey(nestedValue, seen),
  );
};

const getSafeErrorLogMeta = (
  e: unknown,
): {
  errorName: string;
  errorCode?: string | number;
} => {
  const errorName =
    e instanceof Error
      ? e.name
      : typeof e === 'object' && e !== null && 'name' in e && typeof e.name === 'string'
        ? e.name
        : 'UnknownError';
  const errorCode =
    typeof e === 'object' &&
    e !== null &&
    'code' in e &&
    (typeof e.code === 'string' || typeof e.code === 'number')
      ? e.code
      : undefined;

  return errorCode === undefined ? { errorName } : { errorName, errorCode };
};
