import { computed, inject, Injectable, Injector, OnDestroy, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { SnackService } from '../core/snack/snack.service';
import { NotifyService } from '../core/notify/notify.service';
import {
  DialogCfg,
  DialogResult,
  Hooks,
  NotifyCfg,
  PluginCreateTaskData,
  PluginHeaderBtnCfg,
  PluginHookHandler,
  PluginMenuEntryCfg,
  PluginNodeScriptRequest,
  PluginNodeScriptResult,
  PluginShortcutCfg,
  PluginSidePanelBtnCfg,
  PluginWorkContextHeaderBtnCfg,
  ActiveWorkContext,
  Task,
} from './plugin-api.model';
import { toActiveWorkContext } from './util/active-work-context.util';
import {
  assertPluginPersistenceKey,
  composeId,
} from './util/plugin-persistence-key.util';

import {
  BatchTaskCreate,
  BatchUpdateRequest,
  BatchUpdateResult,
  OAuthFlowConfig,
  OAuthTokenResult,
  PluginAppState,
  PluginManifest,
  PluginNote,
  PluginRequestOptions,
  PluginSimpleCounterFull,
  PluginTaskRepeatCfg,
  SnackCfg,
} from '@super-productivity/plugin-api';
import { snackCfgToSnackParams } from './plugin-api-mapper';
import { PluginHooksService } from './plugin-hooks';
import { TaskService } from '../features/tasks/task.service';
import { TaskFocusService } from '../features/tasks/task-focus.service';
import { addSubTask } from '../features/tasks/store/task.actions';
import { selectTaskFeatureState } from '../features/tasks/store/task.selectors';
import { parseTimeSpentChanges } from '../features/tasks/short-syntax';
import { GlobalConfigService } from '../features/config/global-config.service';
import { DEFAULT_GLOBAL_CONFIG } from '../features/config/default-global-config.const';
import { selectConfigFeatureState } from '../features/config/store/global-config.reducer';
import { TaskSharedActions } from '../root-store/meta/task-shared.actions';
import { nanoid } from 'nanoid';
import { WorkContextService } from '../features/work-context/work-context.service';
import { ProjectService } from '../features/project/project.service';
import { TagService } from '../features/tag/tag.service';
import typia from 'typia';
import { distinctUntilChanged, first, map, take, timeout } from 'rxjs/operators';
import { firstValueFrom } from 'rxjs';
import { selectProjectFeatureState } from '../features/project/store/project.selectors';
import { selectNoteFeatureState } from '../features/note/store/note.reducer';
import { selectTagFeatureState } from '../features/tag/store/tag.reducer';
import { selectTaskByIdWithSubTaskData } from '../features/tasks/store/task.selectors';
import { PluginUserPersistenceService } from './plugin-user-persistence.service';
import { PluginConfigService } from './plugin-config.service';
import { TaskArchiveService } from '../features/archive/task-archive.service';
import { Router } from '@angular/router';
import { PluginDialogComponent } from './ui/plugin-dialog/plugin-dialog.component';
import { IS_ELECTRON } from '../app.constants';
import { isAllowedPluginAction } from './allowed-plugin-actions.const';
import { TranslateService } from '@ngx-translate/core';
import { T } from '../t.const';
import { SyncWrapperService } from '../imex/sync/sync-wrapper.service';
import { Log, PluginLog } from '../core/log';
import { TaskCopy } from '../features/tasks/task.model';
import { ProjectCopy } from '../features/project/project.model';
import { TagCopy } from '../features/tag/tag.model';
import { MAX_BATCH_OPERATIONS_SIZE } from '../op-log/core/operation-log.const';
import { PluginIssueProviderRegistryService } from './issue-provider/plugin-issue-provider-registry.service';
import { IssueProviderPluginDefinition } from './issue-provider/plugin-issue-provider.model';
import { GlobalThemeService } from '../core/theme/global-theme.service';
import { IssueSyncAdapterRegistryService } from '../features/issue/two-way-sync/issue-sync-adapter-registry.service';
import { PluginHttpService } from './issue-provider/plugin-http.service';
import { createPluginSyncAdapter } from './issue-provider/plugin-sync-adapter.service';
import { PluginOAuthBridgeService } from './oauth/plugin-oauth-bridge.service';
import { PluginSecretService } from './secret/plugin-secret.service';
import { ISSUE_PROVIDER_TYPES } from '../features/issue/issue.const';
import { PluginService } from './plugin.service';
import { PluginI18nService } from './plugin-i18n.service';
import { formatDateForPlugin } from './plugin-i18n-date.util';

const toPluginTaskCopy = (
  task: (TaskCopy & { subTasks?: unknown }) | null | undefined,
): TaskCopy | null => {
  if (!task) {
    return null;
  }

  const taskCopy = { ...task };
  delete taskCopy.subTasks;
  return taskCopy;
};

// New imports for simple counters
import { selectAllSimpleCounters } from '../features/simple-counter/store/simple-counter.reducer';
import { selectTaskRepeatCfgFeatureState } from '../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
import {
  SimpleCounter,
  SimpleCounterType,
} from '../features/simple-counter/simple-counter.model';
import { EMPTY_SIMPLE_COUNTER } from '../features/simple-counter/simple-counter.const';
import {
  deleteSimpleCounter,
  toggleSimpleCounterCounter,
  updateSimpleCounter,
  upsertSimpleCounter,
} from '../features/simple-counter/store/simple-counter.actions';
import { getDbDateStr } from '../util/get-db-date-str';
import { DataInitService } from '../core/data-init/data-init.service';
import { PluginNodeExecutionElectronApi } from '../../../electron/shared-with-frontend/plugin-node-execution.model';

type PluginDateFormat = 'short' | 'medium' | 'long' | 'time' | 'datetime';

/**
 * PluginBridge acts as an intermediary layer between plugins and the main application services.
 * This provides:
 * - Clean separation of concerns
 * - Controlled access to app functionality
 * - Easy testing and mocking
 * - Centralized plugin-to-app communication
 */
@Injectable({
  providedIn: 'root',
})
export class PluginBridgeService implements OnDestroy {
  private _snackService = inject(SnackService);
  private _notifyService = inject(NotifyService);
  private _dialog = inject(MatDialog);
  private _store = inject(Store);
  private _pluginHooksService = inject(PluginHooksService);
  private _taskService = inject(TaskService);
  private _taskFocusService = inject(TaskFocusService);
  private _workContextService = inject(WorkContextService);
  private _projectService = inject(ProjectService);
  private _tagService = inject(TagService);
  private _pluginUserPersistenceService = inject(PluginUserPersistenceService);
  private _pluginConfigService = inject(PluginConfigService);
  private _taskArchiveService = inject(TaskArchiveService);
  private _router = inject(Router);
  private _injector = inject(Injector);
  private _translateService = inject(TranslateService);
  private _syncWrapperService = inject(SyncWrapperService);
  private _pluginIssueProviderRegistry = inject(PluginIssueProviderRegistryService);
  private _globalThemeService = inject(GlobalThemeService);
  private _syncAdapterRegistry = inject(IssueSyncAdapterRegistryService);
  private _pluginHttpService = inject(PluginHttpService);
  private _pluginOAuthBridge = inject(PluginOAuthBridgeService);
  private _pluginSecretService = inject(PluginSecretService);
  private _dataInitService = inject(DataInitService);
  private _globalConfigService = inject(GlobalConfigService);
  readonly #nodeExecutionGrantTokens = new Map<string, string>();
  readonly #nodeExecutionApi = this._consumeNodeExecutionApi();

  // Track header buttons registered by plugins
  private readonly _headerButtons = signal<PluginHeaderBtnCfg[]>([]);
  public readonly headerButtons = this._headerButtons.asReadonly();

  // Track menu entries registered by plugins
  private readonly _menuEntries = signal<PluginMenuEntryCfg[]>([]);
  public readonly menuEntries = this._menuEntries.asReadonly();

  // Track shortcuts registered by plugins
  readonly shortcuts = signal<PluginShortcutCfg[]>([]);

  // Track side panel buttons registered by plugins
  private readonly _sidePanelButtons = signal<PluginSidePanelBtnCfg[]>([]);
  public readonly sidePanelButtons = this._sidePanelButtons.asReadonly();

  // Track work-context-scoped header buttons registered by plugins. These are
  // filtered against the active work context (project or TODAY tag) before
  // rendering — see workContextHeaderButtons below.
  private readonly _workContextHeaderButtons = signal<PluginWorkContextHeaderBtnCfg[]>(
    [],
  );

  // Snapshot of the active work context, used to filter context-scoped
  // buttons. Distinct on (id, type) so it does not churn when task/tag/
  // project data changes within the same context (e.g. a task is added).
  private readonly _activeWorkContextSig = toSignal(
    this._workContextService.activeWorkContext$.pipe(
      distinctUntilChanged((a, b) => a?.id === b?.id && a?.type === b?.type),
    ),
    { initialValue: null },
  );

  public readonly workContextHeaderButtons = computed(() => {
    const ctx = this._activeWorkContextSig();
    const buttons = this._workContextHeaderButtons();
    if (!ctx) return [] as PluginWorkContextHeaderBtnCfg[];
    let key: 'PROJECT' | 'TAG' | 'TODAY';
    if (ctx.type === 'PROJECT') {
      key = 'PROJECT';
    } else if (ctx.id === 'TODAY') {
      key = 'TODAY';
    } else {
      key = 'TAG';
    }
    return buttons.filter((b) => b.showFor.includes(key));
  });

  // Holds the pluginId currently embedded in the work-view body, or null when
  // the normal task list should render. Set via showInWorkContext().
  private readonly _workContextEmbedPluginId = signal<string | null>(null);
  public readonly workContextEmbedPluginId = this._workContextEmbedPluginId.asReadonly();

  // Track config handlers registered by plugins (for settings button on plugin card)
  private readonly _configHandlers = new Map<string, () => void>();

  constructor() {
    // Initialize window focus tracking
    this._initWindowFocusTracking();
  }

  /**
   * Create bound methods for a specific plugin
   * This ensures each plugin has its own set of methods with the pluginId already bound
   */
  createBoundMethods(
    pluginId: string,
    manifest?: PluginManifest,
  ): {
    persistDataSynced: (dataStr: string, key?: string) => Promise<void>;
    loadPersistedData: (key?: string) => Promise<string | null>;
    getConfig: () => Promise<unknown>;
    downloadFile: (filename: string, data: string) => Promise<void>;
    registerHeaderButton: (cfg: PluginHeaderBtnCfg) => void;
    registerMenuEntry: (cfg: Omit<PluginMenuEntryCfg, 'pluginId'>) => void;
    registerSidePanelButton: (cfg: Omit<PluginSidePanelBtnCfg, 'pluginId'>) => void;
    registerWorkContextHeaderButton: (
      cfg: Omit<PluginWorkContextHeaderBtnCfg, 'pluginId'>,
    ) => void;
    registerShortcut: (cfg: PluginShortcutCfg) => void;
    showIndexHtmlAsView: () => void;
    showInWorkContext: () => void;
    closeWorkContextView: () => void;
    getActiveWorkContext: () => Promise<ActiveWorkContext | null>;
    getSelectedTask: () => Promise<TaskCopy | null>;
    getFocusedTask: () => Promise<TaskCopy | null>;
    triggerSync: () => Promise<void>;
    dispatchAction: (action: { type: string; [key: string]: unknown }) => void;
    executeNodeScript: (
      request: PluginNodeScriptRequest,
    ) => Promise<PluginNodeScriptResult>;
    getAllCounters: () => Promise<{ [key: string]: number }>;
    getCounter: (id: string) => Promise<number | null>;
    setCounter: (id: string, value: number) => Promise<void>;
    incrementCounter: (id: string, incrementBy?: number) => Promise<number>;
    decrementCounter: (id: string, decrementBy?: number) => Promise<number>;
    deleteCounter: (id: string) => Promise<void>;
    getAllSimpleCounters: () => Promise<SimpleCounter[]>;
    getSimpleCounter: (id: string) => Promise<SimpleCounter | undefined>;
    updateSimpleCounter: (id: string, updates: Partial<SimpleCounter>) => Promise<void>;
    toggleSimpleCounter: (id: string) => Promise<void>;
    setSimpleCounterEnabled: (id: string, isEnabled: boolean) => Promise<void>;
    deleteSimpleCounter: (id: string) => Promise<void>;
    setSimpleCounterToday: (id: string, value: number) => Promise<void>;
    setSimpleCounterDate: (id: string, date: string, value: number) => Promise<void>;
    registerConfigHandler: (handler: () => void) => void;
    registerIssueProvider: (definition: IssueProviderPluginDefinition) => void;
    unregisterIssueProvider: () => void;
    startOAuthFlow: (config: OAuthFlowConfig) => Promise<OAuthTokenResult>;
    getOAuthToken: () => Promise<string | null>;
    clearOAuthToken: () => Promise<void>;
    setSecret: (key: string, value: string) => Promise<void>;
    getSecret: (key: string) => Promise<string | null>;
    deleteSecret: (key: string) => Promise<void>;
    request: <T = unknown>(url: string, options?: PluginRequestOptions) => Promise<T>;
    translate: (key: string, params?: Record<string, string | number>) => string;
    formatDate: (date: Date | string | number, format: PluginDateFormat) => string;
    getCurrentLanguage: () => string;
    log: ReturnType<typeof Log.withContext>;
  } {
    return {
      // Data persistence
      persistDataSynced: (dataStr: string, key?: string) =>
        this._persistDataSynced(pluginId, dataStr, key),
      loadPersistedData: (key?: string) => this._loadPersistedData(pluginId, key),
      getConfig: () => this._getConfig(pluginId),
      downloadFile: (filename: string, data: string) =>
        this._downloadFile(filename, data),

      // UI registration
      registerHeaderButton: (cfg: PluginHeaderBtnCfg) =>
        this._registerHeaderButton(pluginId, cfg),
      registerMenuEntry: (cfg: Omit<PluginMenuEntryCfg, 'pluginId'>) =>
        this._registerMenuEntry(pluginId, cfg),
      registerSidePanelButton: (cfg: Omit<PluginSidePanelBtnCfg, 'pluginId'>) =>
        this._registerSidePanelButton(pluginId, cfg),
      registerWorkContextHeaderButton: (
        cfg: Omit<PluginWorkContextHeaderBtnCfg, 'pluginId'>,
      ) => this._registerWorkContextHeaderButton(pluginId, cfg),
      registerShortcut: (cfg: PluginShortcutCfg) => this._registerShortcut(pluginId, cfg),
      registerConfigHandler: (handler: () => void) =>
        this._configHandlers.set(pluginId, handler),

      // Navigation
      showIndexHtmlAsView: () => this._showIndexHtmlAsView(pluginId),
      showInWorkContext: () => this._showInWorkContext(pluginId),
      closeWorkContextView: () => this._closeWorkContextView(pluginId),
      getActiveWorkContext: () => this.getActiveWorkContext(),
      getSelectedTask: () => this.getSelectedTask(),
      getFocusedTask: () => this.getFocusedTask(),

      // Sync
      triggerSync: () => this._triggerSync(pluginId),

      // Actions
      dispatchAction: (action: { type: string; [key: string]: unknown }) =>
        this._dispatchAction(pluginId, action),

      // Node execution
      executeNodeScript: (request: PluginNodeScriptRequest) =>
        this._executeNodeScript(pluginId, manifest || null, request),

      // Basic counter methods (existing)
      getAllCounters: () => this.getAllCounters(),
      getCounter: (id: string) => this.getCounter(id),
      setCounter: (id: string, value: number) => this.setCounter(id, value),
      incrementCounter: (id: string, incrementBy = 1) =>
        this.incrementCounter(id, incrementBy),
      decrementCounter: (id: string, decrementBy = 1) =>
        this.decrementCounter(id, decrementBy),
      deleteCounter: (id: string) => this.deleteSimpleCounter(id),

      // Full SimpleCounter methods (new)
      getAllSimpleCounters: () => this.getAllSimpleCounters(),
      getSimpleCounter: (id: string) => this.getSimpleCounter(id),
      updateSimpleCounter: (id: string, updates: Partial<SimpleCounter>) =>
        this.updateSimpleCounter(id, updates),
      toggleSimpleCounter: (id: string) => this.toggleSimpleCounter(id),
      setSimpleCounterEnabled: (id: string, isEnabled: boolean) =>
        this.setSimpleCounterEnabled(id, isEnabled),
      deleteSimpleCounter: (id: string) => this.deleteSimpleCounter(id),
      setSimpleCounterToday: (id: string, value: number) =>
        this.setSimpleCounterToday(id, value),
      setSimpleCounterDate: (id: string, date: string, value: number) =>
        this.setSimpleCounterDate(id, date, value),

      // Issue provider registration
      registerIssueProvider: (definition: IssueProviderPluginDefinition) =>
        this._registerIssueProvider(pluginId, manifest, definition),
      unregisterIssueProvider: () => {
        const registeredKey =
          this._pluginIssueProviderRegistry.getRegisteredKey(pluginId);
        this._pluginIssueProviderRegistry.unregister(pluginId);
        if (registeredKey) {
          this._syncAdapterRegistry.unregister(registeredKey);
        }
      },

      // OAuth
      startOAuthFlow: (config: OAuthFlowConfig): Promise<OAuthTokenResult> =>
        this._pluginOAuthBridge.startOAuthFlow(pluginId, config),
      getOAuthToken: (): Promise<string | null> =>
        this._pluginOAuthBridge.getOAuthToken(
          pluginId,
          this._getOAuthConfigForPlugin(pluginId),
        ),
      clearOAuthToken: (): Promise<void> =>
        this._pluginOAuthBridge.clearOAuthTokens(pluginId),

      // Secret storage (local-only, per-plugin, never synced)
      setSecret: (key: string, value: string): Promise<void> =>
        this._pluginSecretService.setSecret(pluginId, key, value),
      getSecret: (key: string): Promise<string | null> =>
        this._pluginSecretService.getSecret(pluginId, key),
      deleteSecret: (key: string): Promise<void> =>
        this._pluginSecretService.deleteSecret(pluginId, key),
      request: <T = unknown>(url: string, options?: PluginRequestOptions): Promise<T> =>
        this.request<T>(url, options, manifest?.allowedHosts, manifest?.permissions),

      // i18n
      translate: (key: string, params?: Record<string, string | number>): string =>
        this._injector.get(PluginI18nService).translate(pluginId, key, params),
      formatDate: (date: Date | string | number, format: PluginDateFormat): string =>
        formatDateForPlugin(
          date,
          format,
          this._injector.get(PluginI18nService).getCurrentLanguage(),
        ),
      getCurrentLanguage: (): string =>
        this._injector.get(PluginI18nService).getCurrentLanguage(),

      // Logging
      log: Log.withContext(`${pluginId}`),
    };
  }

  private _isPluginBundled(pluginId: string): boolean {
    const pluginService = this._injector.get(PluginService);
    const path = pluginService.getPluginPath(pluginId);
    return !!path && path.startsWith('assets/bundled-plugins/');
  }

  /**
   * Register an issue provider plugin.
   */
  private _registerIssueProvider(
    pluginId: string,
    manifest: PluginManifest | undefined,
    definition: IssueProviderPluginDefinition,
  ): void {
    if (typeof definition?.getHeaders !== 'function') {
      throw new Error('IssueProviderPluginDefinition.getHeaders must be a function');
    }
    if (typeof definition?.searchIssues !== 'function') {
      throw new Error('IssueProviderPluginDefinition.searchIssues must be a function');
    }
    if (typeof definition?.getById !== 'function') {
      throw new Error('IssueProviderPluginDefinition.getById must be a function');
    }
    if (typeof definition?.getIssueLink !== 'function') {
      throw new Error('IssueProviderPluginDefinition.getIssueLink must be a function');
    }
    if (!Array.isArray(definition?.issueDisplay)) {
      throw new Error('IssueProviderPluginDefinition.issueDisplay must be an array');
    }
    if (!Array.isArray(definition?.configFields)) {
      throw new Error('IssueProviderPluginDefinition.configFields must be an array');
    }

    const issueProviderCfg = manifest?.issueProvider;
    const customKey = issueProviderCfg?.issueProviderKey;
    if (customKey && (ISSUE_PROVIDER_TYPES as readonly string[]).includes(customKey)) {
      throw new Error(`Plugin cannot register under built-in key "${customKey}"`);
    }
    const name = manifest?.name ?? pluginId;
    const humanReadableName = issueProviderCfg?.humanReadableName ?? name;
    const customIconName = `plugin-${pluginId}-icon`;
    const icon = this._globalThemeService.hasPluginIcon(customIconName)
      ? customIconName
      : (issueProviderCfg?.icon ?? 'extension');
    const pollIntervalMs = issueProviderCfg?.pollIntervalMs ?? 0;
    const issueStrings = issueProviderCfg?.issueStrings ?? {
      singular: 'Issue',
      plural: 'Issues',
    };

    this._pluginIssueProviderRegistry.register({
      pluginId,
      definition,
      name,
      humanReadableName,
      icon,
      pollIntervalMs,
      issueStrings,
      issueProviderKey: customKey,
      useAgendaView: issueProviderCfg?.useAgendaView,
      defaultAutoAddToBacklog: issueProviderCfg?.defaultAutoAddToBacklog,
      allowPrivateNetwork:
        issueProviderCfg?.allowPrivateNetwork && this._isPluginBundled(pluginId),
    });

    const registeredKey = this._pluginIssueProviderRegistry.getRegisteredKey(pluginId);
    if (!registeredKey) {
      PluginLog.warn(`Plugin ${pluginId} registration failed (duplicate?), skipping`);
      return;
    }

    // Register adapter when plugin supports any issue side effects. Push support
    // still requires updateIssue; create/delete can work without it.
    if (
      definition.createIssue ||
      definition.deleteIssue ||
      (definition.fieldMappings?.length && definition.updateIssue)
    ) {
      const registered = this._pluginIssueProviderRegistry.getProvider(registeredKey);
      const httpOpts = { allowPrivateNetwork: registered?.allowPrivateNetwork };
      const adapter = createPluginSyncAdapter(
        definition,
        (getHeaders) => this._pluginHttpService.createHttpHelper(getHeaders, httpOpts),
        this._tagService,
      );
      this._syncAdapterRegistry.register(registeredKey, adapter);
      PluginLog.log(
        `Plugin ${pluginId} registered sync adapter under '${registeredKey}'`,
      );
    }

    PluginLog.log(
      `Plugin ${pluginId} registered issue provider under '${registeredKey}'`,
    );
  }

  async startOAuthFlow(
    pluginId: string,
    config: OAuthFlowConfig,
  ): Promise<OAuthTokenResult> {
    return this._pluginOAuthBridge.startOAuthFlow(pluginId, config);
  }

  async clearOAuthTokens(pluginId: string): Promise<void> {
    return this._pluginOAuthBridge.clearOAuthTokens(pluginId);
  }

  async request<T = unknown>(
    url: string,
    options?: PluginRequestOptions,
    allowedHosts?: string[],
    permissions?: string[],
  ): Promise<T> {
    // Enforce the plugin's declared capability + host allowlist (both fail-closed)
    // BEFORE the shared HTTP layer applies its URL/private-network (SSRF) guards.
    this._assertRequestAllowed(url, allowedHosts, permissions);

    const { method = 'GET', body } = options ?? {};
    const requestOptions = options
      ? {
          params: options.params,
          headers: options.headers,
          timeout: options.timeout,
          responseType: options.responseType,
        }
      : undefined;

    return this._pluginHttpService
      .createHttpHelper(() => ({}), { blockRedirects: true })
      .request<T>(method, url, body, requestOptions);
  }

  /**
   * Gate `PluginAPI.request`. Two fail-closed checks, both host-enforced (never
   * in plugin code):
   *   1. Capability — the plugin must declare `"permissions": ["http"]`. Network
   *      egress is an opt-in capability, like `nodeExecution`; it is never an
   *      implicit grant from merely listing hosts.
   *   2. Host allowlist — the exact hostnames in `allowedHosts`. Host-only,
   *      case-insensitive, trailing-dot tolerant, port-agnostic exact match.
   * Either check failing (or an empty/undefined value) blocks the request.
   */
  private _assertRequestAllowed(
    url: string,
    allowedHosts?: string[],
    permissions?: string[],
  ): void {
    if (!(permissions ?? []).includes('http')) {
      throw new Error(
        '[PluginHttp] PluginAPI.request is blocked: this plugin does not declare the "http" permission. Add "http" to the manifest "permissions".',
      );
    }
    let hostname: string;
    try {
      // URL parsing resolves userinfo tricks (https://ok.com@evil.com -> evil.com).
      // NOTE: unlike PluginHttpService._validateUrl we deliberately do NOT strip
      // IPv6 brackets here. validatePluginManifest rejects any allowedHosts entry
      // containing ':' (so no IPv6 literal can ever be declared), which means a
      // bracketed IPv6 request hostname can never match an allowed entry and is
      // always fail-closed. Keeping the normalizations distinct on purpose.
      hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, '');
    } catch {
      throw new Error(`[PluginHttp] Invalid URL for PluginAPI.request: ${url}`);
    }
    const allowed = (allowedHosts ?? [])
      .map((h) => h.trim().toLowerCase().replace(/\.$/, ''))
      .filter((h) => h.length > 0);
    if (allowed.length === 0) {
      throw new Error(
        '[PluginHttp] PluginAPI.request is blocked: this plugin declares no "allowedHosts" in its manifest. Declare the exact host(s) it needs.',
      );
    }
    if (!allowed.includes(hostname)) {
      throw new Error(
        `[PluginHttp] PluginAPI.request to "${hostname}" is blocked: not in the plugin's declared allowedHosts (${allowed.join(', ')}).`,
      );
    }
  }

  async restoreAndCheckOAuthTokens(pluginId: string): Promise<boolean> {
    return this._pluginOAuthBridge.restoreAndCheckOAuthTokens(
      pluginId,
      this._getOAuthConfigForPlugin(pluginId),
    );
  }

  private _getOAuthConfigForPlugin(pluginId: string): OAuthFlowConfig | undefined {
    const registeredKey = this._pluginIssueProviderRegistry.getRegisteredKey(pluginId);
    if (!registeredKey) {
      return undefined;
    }
    return this._pluginIssueProviderRegistry
      .getConfigFields(registeredKey)
      .find((f) => f.type === 'oauthButton' && f.oauthConfig)?.oauthConfig;
  }

  private async _downloadFile(filename: string, data: string): Promise<void> {
    typia.assert<string>(filename);
    typia.assert<string>(data);

    const { download } = await import('../util/download');
    await download(filename, data);
  }

  /**
   * Show a snack message to the user
   */
  showSnack(snackCfg: SnackCfg): void {
    typia.assert<SnackCfg>(snackCfg);
    const snackParams = snackCfgToSnackParams(snackCfg);
    this._snackService.open(snackParams);
  }

  /**
   * Show a notification to the user
   */
  async notify(notifyCfg: NotifyCfg): Promise<void> {
    typia.assert<NotifyCfg>(notifyCfg);

    // Use the app's NotifyService for better integration
    await this._notifyService.notify({
      title: notifyCfg.title,
      body: notifyCfg.body,
      icon: 'assets/icons/icon-128x128.png',
      duration: 5000, // 5 seconds default duration
    });

    // No notifyCfg — title/body are user content; log history is exportable (rule #9).
    PluginLog.log('PluginBridge: Notification sent successfully');
  }

  /**
   * Open a dialog using Angular Material
   */
  async openDialog(dialogCfg: DialogCfg): Promise<DialogResult> {
    typia.assert<DialogCfg>(dialogCfg);

    return new Promise<DialogResult>((resolve, reject) => {
      try {
        const dialogRef = this._dialog.open(PluginDialogComponent, {
          data: dialogCfg,
          // TODO make configurable
          // width: '500px',
          // maxWidth: '90vw',
          // maxHeight: '80vh',
          autoFocus: true,
          restoreFocus: true,
          disableClose: false,
          closeOnNavigation: false,
        });

        dialogRef.afterClosed().subscribe((result) => {
          PluginLog.log('PluginBridge: Dialog closed');
          resolve(result);
        });
      } catch (error) {
        PluginLog.err('PluginBridge: Failed to open dialog:', error);
        reject(error);
      }
    });
  }

  /**
   * Internal method to show plugin index.html as view
   */
  private _showIndexHtmlAsView(pluginId: string): void {
    PluginLog.log('PluginBridge: Navigating to plugin index view', {
      pluginId,
    });
    // Navigate to the plugin index route
    this._router.navigate(['/plugins', pluginId, 'index']);
  }

  /**
   * Mount this plugin's index.html inside the work-view body for the active
   * work context. Work-view renders it in place of the task list.
   */
  private _showInWorkContext(pluginId: string): void {
    this._workContextEmbedPluginId.set(pluginId);
  }

  /**
   * Clear the work-view embed slot if this plugin currently owns it.
   */
  private _closeWorkContextView(pluginId: string): void {
    if (this._workContextEmbedPluginId() === pluginId) {
      this._workContextEmbedPluginId.set(null);
    }
  }

  /**
   * Snapshot of the active work context for plugins. A context is always
   * active once the app has loaded — it stays set even on non-work-view
   * routes — so this normally resolves to that context. It resolves to
   * null only if the initial data load has not completed within the
   * timeout (e.g. a plugin calling this very early from its init hook),
   * which is preferable to a Promise that never resolves.
   */
  async getActiveWorkContext(): Promise<ActiveWorkContext | null> {
    try {
      const ctx = await firstValueFrom(
        this._workContextService.activeWorkContext$.pipe(
          // 10s is well past normal data-load time but still bounded so
          // a plugin can fall back to other behaviour instead of hanging.
          timeout({ first: 10_000 }),
        ),
      );
      return toActiveWorkContext(ctx);
    } catch {
      return null;
    }
  }

  /**
   * Get all tasks
   */
  async getTasks(): Promise<TaskCopy[]> {
    const tasks = await this._taskService.allTasks$.pipe(first()).toPromise();
    return tasks || [];
  }

  /**
   * Get archived tasks
   */
  async getArchivedTasks(): Promise<TaskCopy[]> {
    try {
      const taskArchive = await this._taskArchiveService.load();

      // Convert the archive format to TaskCopy array
      const archivedTasks: TaskCopy[] = taskArchive.ids.map((id) => {
        const task = taskArchive.entities[id];
        if (!task) {
          throw new Error(`Archived task with id ${id} not found in entities`);
        }
        return task as TaskCopy;
      });

      PluginLog.log('PluginBridge: Retrieved archived tasks', {
        count: archivedTasks.length,
      });
      return archivedTasks;
    } catch (error) {
      PluginLog.err('PluginBridge: Failed to load archived tasks:', error);
      return [];
    }
  }

  /**
   * Get current context tasks
   */
  async getCurrentContextTasks(): Promise<TaskCopy[]> {
    const contextTasks = await this._workContextService.mainListTasks$
      .pipe(first())
      .toPromise();
    return contextTasks || [];
  }

  /**
   * Returns a read-only snapshot of the application state for plugins.
   *
   * Credential surfaces are stripped before returning:
   * - `globalConfig.sync` (WebDAV/Nextcloud passwords, SuperSync access
   *   tokens, encryption keys)
   * - `globalConfig.misc.unsplashApiKey`
   * - per-project `issueIntegrationCfgs` (Jira/CalDAV passwords,
   *   GitLab/Redmine tokens, OpenProject/Trello/Linear keys)
   */
  async getAppState(): Promise<PluginAppState> {
    const [
      taskState,
      projectState,
      tagState,
      noteState,
      taskRepeatCfgState,
      allSimpleCounters,
      globalConfig,
    ] = await Promise.all([
      firstValueFrom(this._store.select(selectTaskFeatureState)),
      firstValueFrom(this._store.select(selectProjectFeatureState)),
      firstValueFrom(this._store.select(selectTagFeatureState)),
      firstValueFrom(this._store.select(selectNoteFeatureState)),
      firstValueFrom(this._store.select(selectTaskRepeatCfgFeatureState)),
      firstValueFrom(this._store.select(selectAllSimpleCounters)),
      firstValueFrom(this._store.select(selectConfigFeatureState)),
    ]);

    const simpleCounters: Record<string, PluginSimpleCounterFull> = {};
    (allSimpleCounters ?? []).forEach((counter) => {
      simpleCounters[counter.id] = {
        id: counter.id,
        title: counter.title,
        type: String(counter.type),
        isEnabled: counter.isEnabled,
        isOn: (counter as { isOn?: boolean }).isOn,
        countOnDay: counter.countOnDay ?? {},
      };
    });

    const projects: Record<string, ProjectCopy> = {};
    const rawProjects = (projectState?.entities ?? {}) as Record<
      string,
      ProjectCopy | undefined
    >;
    for (const id of Object.keys(rawProjects)) {
      const p = rawProjects[id];
      if (!p) continue;
      const safe = { ...p };
      delete (safe as { issueIntegrationCfgs?: unknown }).issueIntegrationCfgs;
      projects[id] = safe as ProjectCopy;
    }

    const safeGlobalConfig: Record<string, unknown> = { ...(globalConfig ?? {}) };
    delete safeGlobalConfig['sync'];
    if (safeGlobalConfig['misc']) {
      const safeMisc = { ...(safeGlobalConfig['misc'] as Record<string, unknown>) };
      delete safeMisc['unsplashApiKey'];
      safeGlobalConfig['misc'] = safeMisc;
    }

    return {
      tasks: (taskState?.entities ?? {}) as Record<string, Task>,
      projects,
      tags: (tagState?.entities ?? {}) as Record<string, TagCopy>,
      notes: (noteState?.entities ?? {}) as Record<string, PluginNote>,
      taskRepeatCfgs: (taskRepeatCfgState?.entities ?? {}) as Record<
        string,
        PluginTaskRepeatCfg
      >,
      simpleCounters,
      globalConfig: safeGlobalConfig,
    };
  }

  async reInitData(): Promise<void> {
    PluginLog.log('PluginBridge: Re-initializing app data');
    await this._dataInitService.reInit();
  }
  /**
   * Update a task
   */
  async updateTask(taskId: string, updates: Partial<TaskCopy>): Promise<void> {
    typia.assert<string>(taskId);
    typia.assert<Partial<TaskCopy>>(updates);

    // Validate that referenced project, tags and parent task exist if they are being updated
    await this._validateTaskReferences(
      updates.projectId,
      updates.tagIds,
      updates.parentId,
    );

    const { projectId, ...otherUpdates } = updates;

    if (projectId !== undefined) {
      const taskWithSubTasks = await firstValueFrom(
        this._store.select((state) =>
          selectTaskByIdWithSubTaskData(state, { id: taskId }),
        ),
      );

      if (!taskWithSubTasks?.id || taskWithSubTasks.id !== taskId) {
        throw new Error(
          this._translateService.instant(T.PLUGINS.TASK_NOT_FOUND, { taskId }),
        );
      }

      if (taskWithSubTasks.parentId) {
        throw new Error(
          'Subtasks cannot be moved directly. Move the parent task instead.',
        );
      }

      if (taskWithSubTasks.projectId === projectId) {
        PluginLog.log('PluginBridge: Task already in target project', {
          taskId,
          projectId,
        });
      } else {
        this._taskService.moveToProject(taskWithSubTasks, projectId);

        PluginLog.log('PluginBridge: Task moved to project successfully', {
          taskId,
          projectId,
        });
      }
    }

    if (Object.keys(otherUpdates).length > 0) {
      this._taskService.update(taskId, otherUpdates);
    }

    PluginLog.log('PluginBridge: Task updated successfully', { taskId });
  }

  /**
   * Add a new task
   */
  async addTask(taskData: PluginCreateTaskData): Promise<string> {
    typia.assert<PluginCreateTaskData>(taskData);

    // Validate that referenced project, tags, and parent task exist
    await this._validateTaskReferences(
      taskData.projectId,
      taskData.tagIds,
      taskData.parentId,
    );

    let createdTask: Task;
    if (taskData.parentId) {
      // For subtasks, we use the addSubTask action to properly update the parent.
      // ShortSyntaxEffects now also parses addSubTask, but we opt this path out via
      // `isIgnoreShortSyntax` and parse only time here (mirroring MarkdownPasteService):
      // subtasks created via the plugin API intentionally inherit tags/project from
      // the parent and must not have them reassigned from the title (see addSubTask
      // reducer).
      const subTaskTitleProps = this._parseSubTaskTitleTimeProps(taskData.title);
      const newTask = this._taskService.createNewTaskWithDefaults({
        title: subTaskTitleProps.title,
        additional: {
          notes: taskData.notes || '',
          timeEstimate: taskData.timeEstimate || 0,
          isDone: (taskData as { isDone?: boolean }).isDone || false,
          tagIds: [], // Subtasks don't have tags
          projectId: taskData.projectId || undefined,
          ...subTaskTitleProps.timeProps,
        },
      });

      // Dispatch the addSubTask action which properly updates parent's subTaskIds
      this._store.dispatch(
        addSubTask({
          task: newTask,
          parentId: taskData.parentId,
          isIgnoreShortSyntax: true,
        }),
      );
      createdTask = newTask;

      PluginLog.log('PluginBridge: Subtask added successfully', {
        taskId: createdTask.id,
      });

      return createdTask.id;
    } else {
      // For main tasks, use the regular add method
      const additional: Partial<TaskCopy> = {
        projectId: taskData.projectId || undefined,
        tagIds: taskData.tagIds || [],
        notes: taskData.notes || '',
        timeEstimate: taskData.timeEstimate || 0,
        isDone: (taskData as { isDone?: boolean }).isDone || false,
        dueDay: taskData.dueDay ?? undefined,
      };

      // Add the task using TaskService
      const taskId = this._taskService.add(
        taskData.title,
        false, // isAddToBacklog
        additional,
        false, // isAddToBottom
      );

      PluginLog.log('PluginBridge: Task added successfully', { taskId });
      return taskId;
    }
  }

  /**
   * Delete a task
   */
  async deleteTask(taskId: string): Promise<void> {
    typia.assert<string>(taskId);

    try {
      // Get the task with its subtasks
      const taskWithSubTasks = await this._store
        .select(selectTaskByIdWithSubTaskData, { id: taskId })
        .pipe(first())
        .toPromise();

      if (!taskWithSubTasks) {
        throw new Error(
          this._translateService.instant(T.PLUGINS.TASK_NOT_FOUND, { taskId }),
        );
      }

      // Use the TaskService remove method which handles deletion properly
      this._taskService.remove(taskWithSubTasks);

      PluginLog.log('PluginBridge: Task deleted successfully', {
        taskId,
        hadSubTasks: taskWithSubTasks.subTasks.length > 0,
      });
    } catch (error) {
      PluginLog.err('PluginBridge: Failed to delete task:', error);
      throw error;
    }
  }

  /**
   * Get all projects
   */
  async getAllProjects(): Promise<ProjectCopy[]> {
    const projects = await this._projectService.list$.pipe(first()).toPromise();
    return projects || [];
  }

  /**
   * Add a new project
   */
  async addProject(projectData: Partial<ProjectCopy>): Promise<string> {
    typia.assert<Partial<ProjectCopy>>(projectData);

    PluginLog.log('PluginBridge: Project add');
    return this._projectService.add(projectData);
  }

  /**
   * Update a project
   */
  async updateProject(projectId: string, updates: Partial<ProjectCopy>): Promise<void> {
    typia.assert<string>(projectId);
    typia.assert<Partial<ProjectCopy>>(updates);

    // Update the project using ProjectService (ProjectCopy is compatible with Project)
    this._projectService.update(projectId, updates);

    PluginLog.log('PluginBridge: Project updated successfully', { projectId });
  }

  /**
   * Get all tags
   */
  async getAllTags(): Promise<TagCopy[]> {
    const tags = await this._tagService.tags$.pipe(first()).toPromise();
    return tags || [];
  }

  /**
   * Add a new tag
   */
  async addTag(tagData: Partial<TagCopy>): Promise<string> {
    typia.assert<Partial<TagCopy>>(tagData);

    // Add the tag using TagService (TagCopy is compatible with Tag)
    const tagId = this._tagService.addTag(tagData);
    PluginLog.log('PluginBridge: Tag added successfully', { tagId });
    return tagId;
  }

  /**
   * Update a tag
   */
  async updateTag(tagId: string, updates: Partial<TagCopy>): Promise<void> {
    typia.assert<string>(tagId);
    typia.assert<Partial<TagCopy>>(updates);

    // Update the tag using TagService (TagCopy is compatible with Tag)
    this._tagService.updateTag(tagId, updates);
    PluginLog.log('PluginBridge: Tag updated successfully', { tagId });
  }

  /**
   * Reorder tasks in a project or parent task
   * @param taskIds - Array of task IDs in the new order
   * @param contextId - Project ID or parent task ID
   * @param contextType - 'project' or 'task' to indicate if contextId is a project or parent task
   */
  async reorderTasks(
    taskIds: string[],
    contextId: string,
    contextType: 'project' | 'task',
  ): Promise<void> {
    typia.assert<string[]>(taskIds);
    typia.assert<string>(contextId);
    typia.assert<'project' | 'task'>(contextType);

    if (contextType === 'project') {
      // Update project's taskIds to reflect new order
      const project = await this._projectService
        .getByIdOnce$(contextId)
        .pipe(first())
        .toPromise();

      if (!project) {
        throw new Error(
          this._translateService.instant(T.PLUGINS.PROJECT_NOT_FOUND, { contextId }),
        );
      }

      // Validate all taskIds belong to the project
      const allProjectTaskIds = [
        ...(project.taskIds || []),
        ...(project.backlogTaskIds || []),
      ];

      // Also check if tasks actually belong to this project by their projectId
      const allTasks = await this._taskService.allTasks$.pipe(first()).toPromise();
      const tasksInProject =
        allTasks?.filter((t) => t.projectId === contextId && !t.parentId) || [];
      const taskIdsInProject = tasksInProject.map((t) => t.id);

      PluginLog.log('PluginBridge: Validating task reorder', {
        requestedTaskIds: taskIds,
        projectTaskIds: allProjectTaskIds,
        actualTasksInProject: taskIdsInProject,
        projectId: contextId,
      });

      // Use a more lenient validation - check if tasks have the correct projectId
      const invalidTaskIds = taskIds.filter((id) => {
        const task = allTasks?.find((t) => t.id === id);
        return !task || task.projectId !== contextId || task.parentId;
      });

      if (invalidTaskIds.length > 0) {
        throw new Error(
          this._translateService.instant(T.PLUGINS.TASKS_NOT_IN_PROJECT, {
            taskIds: invalidTaskIds.join(', '),
            contextId,
          }),
        );
      }

      // Update the project with new task order
      // Note: This assumes all tasks are in the regular list, not backlog
      this._projectService.update(contextId, { taskIds });

      PluginLog.log('PluginBridge: Project tasks reordered successfully', {
        projectId: contextId,
        newOrder: taskIds,
      });
    } else {
      // Update parent task's subTaskIds to reflect new order
      const parentTask = await this._taskService
        .getByIdOnce$(contextId)
        .pipe(first())
        .toPromise();

      if (!parentTask) {
        throw new Error(
          this._translateService.instant(T.PLUGINS.PARENT_TASK_NOT_FOUND, { contextId }),
        );
      }

      // Validate all taskIds are subtasks of the parent
      const invalidSubTaskIds = taskIds.filter(
        (id) => !parentTask.subTaskIds.includes(id),
      );

      if (invalidSubTaskIds.length > 0) {
        throw new Error(
          this._translateService.instant(T.PLUGINS.TASKS_NOT_SUBTASKS, {
            taskIds: invalidSubTaskIds.join(', '),
            contextId,
          }),
        );
      }

      // Update the task with new subtask order
      this._taskService.update(contextId, { subTaskIds: taskIds });

      PluginLog.log('PluginBridge: Subtasks reordered successfully', {
        parentTaskId: contextId,
        newOrder: taskIds,
      });
    }
  }

  /**
   * Select a task, opening its detail panel in the right-hand panel. Works
   * regardless of the active view, including while a plugin embed occupies
   * the work-view body.
   */
  async selectTask(taskId: string): Promise<void> {
    typia.assert<string>(taskId);

    const task = await firstValueFrom(this._taskService.getByIdOnce$(taskId));
    if (!task) {
      throw new Error(
        this._translateService.instant(T.PLUGINS.TASK_NOT_FOUND, { taskId }),
      );
    }

    this._taskService.setSelectedId(taskId);
    PluginLog.log('PluginBridge: Task selected', { taskId });
  }

  async getSelectedTask(): Promise<TaskCopy | null> {
    return toPluginTaskCopy(await firstValueFrom(this._taskService.selectedTask$));
  }

  async getFocusedTask(): Promise<TaskCopy | null> {
    const focusedTaskId = this._taskFocusService.focusedTaskId();
    if (!focusedTaskId) {
      return null;
    }

    return toPluginTaskCopy(
      await firstValueFrom(this._taskService.getByIdOnce$(focusedTaskId)),
    );
  }

  /**
   * Batch update tasks for a project
   * Only generate IDs here - let the reducer handle all validation
   * Large batches are automatically chunked to prevent oversized operation payloads
   */
  async batchUpdateForProject(request: BatchUpdateRequest): Promise<BatchUpdateResult> {
    typia.assert<BatchUpdateRequest>(request);

    // Generate IDs for all create operations upfront
    // We need consistent IDs across all chunks
    const createdTaskIds: { [tempId: string]: string } = {};
    request.operations.forEach((op) => {
      if (op.type === 'create') {
        const createOp = op as BatchTaskCreate;
        createdTaskIds[createOp.tempId] = nanoid();
      }
    });

    // Chunk large operations to prevent oversized payloads
    const chunks = this._chunkOperations(request.operations);
    const createdTaskTimestamp = Date.now();

    if (chunks.length > 1) {
      PluginLog.log('PluginBridge: Chunking large batch operation', {
        totalOperations: request.operations.length,
        chunks: chunks.length,
        chunkSize: MAX_BATCH_OPERATIONS_SIZE,
      });
    }

    // Dispatch each chunk as a separate action
    chunks.forEach((chunk) => {
      this._store.dispatch(
        TaskSharedActions.batchUpdateForProject({
          projectId: request.projectId,
          operations: chunk,
          createdTaskIds, // Same IDs mapping for all chunks
          createdTaskTimestamp,
        }),
      );
    });

    // Return the generated IDs immediately
    // The reducer will validate everything including project existence
    return {
      success: true,
      createdTaskIds,
    };
  }

  /**
   * Chunks operations array into smaller batches to prevent oversized payloads
   */
  private _chunkOperations<T>(operations: T[]): T[][] {
    if (operations.length <= MAX_BATCH_OPERATIONS_SIZE) {
      return [operations];
    }

    const chunks: T[][] = [];
    for (let i = 0; i < operations.length; i += MAX_BATCH_OPERATIONS_SIZE) {
      chunks.push(operations.slice(i, i + MAX_BATCH_OPERATIONS_SIZE));
    }
    return chunks;
  }

  /**
   * Internal method to persist plugin data.
   * Includes size and rate limit validation; composeId enforces the
   * `pluginId` keyspace contract at this transport boundary so the throw
   * covers both iframe and direct API callers.
   */
  private async _persistDataSynced(
    pluginId: string,
    dataStr: string,
    key?: string,
  ): Promise<void> {
    typia.assert<string>(dataStr);
    assertPluginPersistenceKey(key);

    try {
      // Validates the pluginId synchronously; bubbles through the try/catch
      // below as a normal Error.
      const entityId = composeId(pluginId, key);
      this._pluginUserPersistenceService.persistPluginUserData(entityId, dataStr);
      PluginLog.log('PluginBridge: Plugin data persisted successfully', {
        pluginId,
        keyLen: key?.length ?? 0,
        dataSize: new Blob([dataStr]).size,
      });
    } catch (error) {
      // Log the specific error (rate limit, size, or composeId)
      PluginLog.err('PluginBridge: Failed to persist plugin data:', error);

      // Rethrow with the original error message for better debugging
      if (error instanceof Error) {
        throw error;
      }
      throw new Error(this._translateService.instant(T.PLUGINS.UNABLE_TO_PERSIST_DATA));
    }
  }

  /**
   * Internal method to load persisted plugin data.
   *
   * `composeId` runs outside the try/catch so a bad pluginId throws to the
   * caller symmetrically with the persist path — silently returning `null`
   * for "your pluginId is malformed" would look indistinguishable from "no
   * data yet" and could mask a misconfiguration.
   */
  private async _loadPersistedData(
    pluginId: string,
    key?: string,
  ): Promise<string | null> {
    assertPluginPersistenceKey(key);
    const entityId = composeId(pluginId, key);
    try {
      return await this._pluginUserPersistenceService.loadPluginUserData(entityId);
    } catch (error) {
      PluginLog.err('PluginBridge: Failed to get persisted plugin data:', error);
      return null;
    }
  }

  /**
   * Internal method to get plugin configuration
   */
  private async _getConfig(pluginId: string): Promise<unknown> {
    try {
      return await this._pluginConfigService.getPluginConfig(pluginId);
    } catch (error) {
      PluginLog.err('PluginBridge: Failed to get plugin config:', error);
      return null;
    }
  }

  /**
   * Internal method to trigger sync
   */
  private async _triggerSync(pluginId: string): Promise<void> {
    try {
      PluginLog.log('PluginBridge: Triggering sync for plugin', pluginId);
      await this._syncWrapperService.sync();
      PluginLog.log('PluginBridge: Sync completed successfully');
    } catch (error) {
      PluginLog.err('PluginBridge: Sync failed:', error);
      throw error;
    }
  }

  /**
   * Register a hook handler for a plugin
   */
  registerHook<T extends Hooks>(
    pluginId: string,
    hook: T,
    handler: PluginHookHandler<T>,
  ): void {
    typia.assert<string>(pluginId);
    typia.assert<Hooks>(hook);
    // Note: Can't assert generic function type with typia

    this._pluginHooksService.registerHookHandler(pluginId, hook, handler);
  }

  /**
   * Unregister all hooks for a plugin
   */
  unregisterPluginHooks(pluginId: string): void {
    typia.assert<string>(pluginId);

    this._pluginHooksService.unregisterPluginHooks(pluginId);
    this._removePluginHeaderButtons(pluginId);
    this._removePluginMenuEntries(pluginId);
    this._removePluginSidePanelButtons(pluginId);
    this._removePluginWorkContextHeaderButtons(pluginId);
    this.unregisterPluginShortcuts(pluginId);
    this._configHandlers.delete(pluginId);

    // Clean up window focus handler
    this._windowFocusHandlers.delete(pluginId);

    // Clean up issue provider and sync adapter
    const registeredKey = this._pluginIssueProviderRegistry.getRegisteredKey(pluginId);
    this._pluginIssueProviderRegistry.unregister(pluginId);
    if (registeredKey) {
      this._syncAdapterRegistry.unregister(registeredKey);
    }

    PluginLog.log('PluginBridge: All hooks unregistered for plugin', { pluginId });
  }

  /**
   * Internal method to register header button
   */
  private _registerHeaderButton(
    pluginId: string,
    headerBtnCfg: PluginHeaderBtnCfg,
  ): void {
    typia.assert<Omit<PluginHeaderBtnCfg, 'pluginId'>>(headerBtnCfg);

    const newButton: PluginHeaderBtnCfg = {
      ...headerBtnCfg,
      pluginId,
    };

    const currentButtons = this._headerButtons();
    this._headerButtons.set([...currentButtons, newButton]);

    PluginLog.log('PluginBridge: Header button registered', {
      pluginId,
      headerBtnCfg,
    });
  }

  /**
   * Internal method to register menu entry
   */
  private _registerMenuEntry(
    pluginId: string,
    menuEntryCfg: Omit<PluginMenuEntryCfg, 'pluginId'>,
  ): void {
    // Validate required fields manually since typia has issues with optional fields
    if (!menuEntryCfg.label || typeof menuEntryCfg.label !== 'string') {
      throw new Error(
        this._translateService.instant(T.PLUGINS.MENU_ENTRY_LABEL_REQUIRED),
      );
    }
    if (!menuEntryCfg.onClick || typeof menuEntryCfg.onClick !== 'function') {
      throw new Error(
        this._translateService.instant(T.PLUGINS.MENU_ENTRY_ONCLICK_REQUIRED),
      );
    }
    if (menuEntryCfg.icon !== undefined && typeof menuEntryCfg.icon !== 'string') {
      throw new Error(this._translateService.instant(T.PLUGINS.MENU_ENTRY_ICON_STRING));
    }

    const newMenuEntry: PluginMenuEntryCfg = {
      ...menuEntryCfg,
      pluginId,
    };

    const currentEntries = this._menuEntries();

    // Check for duplicate entry (same plugin ID and label)
    const isDuplicate = currentEntries.some(
      (entry) => entry.pluginId === pluginId && entry.label === menuEntryCfg.label,
    );

    if (isDuplicate) {
      PluginLog.err(
        'PluginBridge: Duplicate menu entry detected, skipping registration',
        {
          pluginId,
          label: menuEntryCfg.label,
        },
      );
      return;
    }

    this._menuEntries.set([...currentEntries, newMenuEntry]);

    PluginLog.log('PluginBridge: Menu entry registered', {
      pluginId,
      menuEntryCfg,
    });
  }

  /**
   * Register a work-context-scoped header button. Visibility is controlled by
   * the `showFor` field on cfg, evaluated against the active context's type
   * (PROJECT/TAG) or the special TODAY tag.
   */
  private _registerWorkContextHeaderButton(
    pluginId: string,
    cfg: Omit<PluginWorkContextHeaderBtnCfg, 'pluginId'>,
  ): void {
    if (!cfg.label || typeof cfg.label !== 'string') {
      throw new Error('PluginBridge: registerWorkContextHeaderButton requires label');
    }
    if (!cfg.onClick || typeof cfg.onClick !== 'function') {
      throw new Error('PluginBridge: registerWorkContextHeaderButton requires onClick');
    }
    if (!Array.isArray(cfg.showFor) || cfg.showFor.length === 0) {
      throw new Error(
        'PluginBridge: registerWorkContextHeaderButton requires non-empty showFor',
      );
    }
    // Reject unknown showFor entries — otherwise typos are silently
    // accepted and the button never shows up, which is hard to debug.
    const allowedShowFor = new Set(['PROJECT', 'TAG', 'TODAY']);
    const invalid = cfg.showFor.filter((v) => !allowedShowFor.has(v as string));
    if (invalid.length > 0) {
      throw new Error(
        `PluginBridge: registerWorkContextHeaderButton showFor contains invalid value(s): ${invalid.join(', ')}. Expected one of ${[...allowedShowFor].join(', ')}.`,
      );
    }
    const button: PluginWorkContextHeaderBtnCfg = { ...cfg, pluginId };
    const current = this._workContextHeaderButtons();
    const existingIdx = current.findIndex(
      (b) => b.pluginId === pluginId && b.label === cfg.label,
    );
    if (existingIdx >= 0) {
      // Re-registration: iframe reloads (e.g. work-view embed re-mount with
      // skipCleanupOnDestroy: true) produce a fresh onClick closure
      // pointing at the *new* iframe Window. Replace the old entry so the
      // host's wrapper posts back into the live iframe instead of a
      // detached one.
      const next = current.slice();
      next[existingIdx] = button;
      this._workContextHeaderButtons.set(next);
      return;
    }
    this._workContextHeaderButtons.set([...current, button]);
  }

  private _removePluginWorkContextHeaderButtons(pluginId: string): void {
    this._workContextHeaderButtons.set(
      this._workContextHeaderButtons().filter((b) => b.pluginId !== pluginId),
    );
    if (this._workContextEmbedPluginId() === pluginId) {
      this._workContextEmbedPluginId.set(null);
    }
  }

  /**
   * Remove all header buttons for a specific plugin
   */
  private _removePluginHeaderButtons(pluginId: string): void {
    const currentButtons = this._headerButtons();
    const filteredButtons = currentButtons.filter(
      (button) => button.pluginId !== pluginId,
    );
    this._headerButtons.set(filteredButtons);

    PluginLog.log('PluginBridge: Header buttons removed for plugin', { pluginId });
  }

  hasConfigHandler(pluginId: string): boolean {
    return this._configHandlers.has(pluginId);
  }

  invokeConfigHandler(pluginId: string): void {
    this._configHandlers.get(pluginId)?.();
  }

  /**
   * Remove all menu entries for a specific plugin
   */
  private _removePluginMenuEntries(pluginId: string): void {
    const currentEntries = this._menuEntries();
    const filteredEntries = currentEntries.filter((entry) => entry.pluginId !== pluginId);
    this._menuEntries.set(filteredEntries);

    PluginLog.log('PluginBridge: Menu entries removed for plugin', { pluginId });
  }

  /**
   * Internal method to register side panel button
   */
  private _registerSidePanelButton(
    pluginId: string,
    sidePanelBtnCfg: Omit<PluginSidePanelBtnCfg, 'pluginId'>,
  ): void {
    // Validate required fields
    if (!sidePanelBtnCfg.label || typeof sidePanelBtnCfg.label !== 'string') {
      throw new Error(
        this._translateService.instant(T.PLUGINS.SIDE_PANEL_LABEL_REQUIRED),
      );
    }
    if (!sidePanelBtnCfg.onClick || typeof sidePanelBtnCfg.onClick !== 'function') {
      throw new Error(
        this._translateService.instant(T.PLUGINS.SIDE_PANEL_ONCLICK_REQUIRED),
      );
    }

    const newButton: PluginSidePanelBtnCfg = {
      ...sidePanelBtnCfg,
      pluginId,
    };

    const currentButtons = this._sidePanelButtons();

    // Check for duplicate button (same plugin ID)
    const isDuplicate = currentButtons.some((button) => button.pluginId === pluginId);

    if (isDuplicate) {
      PluginLog.err(
        'PluginBridge: Duplicate side panel button detected, skipping registration',
        {
          pluginId,
          label: sidePanelBtnCfg.label,
        },
      );
      return;
    }

    this._sidePanelButtons.set([...currentButtons, newButton]);

    PluginLog.log('PluginBridge: Side panel button registered', {
      pluginId,
      sidePanelBtnCfg,
    });
  }

  /**
   * Remove all side panel buttons for a specific plugin
   */
  private _removePluginSidePanelButtons(pluginId: string): void {
    const currentButtons = this._sidePanelButtons();
    const filteredButtons = currentButtons.filter(
      (button) => button.pluginId !== pluginId,
    );
    this._sidePanelButtons.set(filteredButtons);

    PluginLog.log('PluginBridge: Side panel buttons removed for plugin', { pluginId });
  }

  /**
   * Internal method to register shortcut
   */
  private _registerShortcut(pluginId: string, shortcutCfg: PluginShortcutCfg): void {
    const shortcutWithPluginId: PluginShortcutCfg = {
      ...shortcutCfg,
      pluginId,
    };

    const currentShortcuts = this.shortcuts();
    this.shortcuts.set([...currentShortcuts, shortcutWithPluginId]);

    PluginLog.log('PluginBridge: Shortcut registered', {
      pluginId,
      shortcut: shortcutWithPluginId,
    });
  }

  /**
   * Execute a shortcut by its ID (pluginId:id)
   */
  async executeShortcut(shortcutId: string): Promise<boolean> {
    const shortcuts = this.shortcuts();
    const shortcut = shortcuts.find((s) => `${s.pluginId}:${s.id}` === shortcutId);

    if (shortcut) {
      try {
        await Promise.resolve(shortcut.onExec());
        PluginLog.log(
          `Executed shortcut "${shortcut.label}" from plugin ${shortcut.pluginId}`,
        );
        return true;
      } catch (error) {
        PluginLog.err(`Failed to execute shortcut "${shortcut.label}":`, error);
        return false;
      }
    }

    return false;
  }

  /**
   * Unregister all shortcuts for a specific plugin
   */
  unregisterPluginShortcuts(pluginId: string): void {
    const currentShortcuts = this.shortcuts();
    const filteredShortcuts = currentShortcuts.filter(
      (shortcut) => shortcut.pluginId !== pluginId,
    );

    if (filteredShortcuts.length !== currentShortcuts.length) {
      this.shortcuts.set(filteredShortcuts);
      PluginLog.log(
        `Unregistered ${currentShortcuts.length - filteredShortcuts.length} shortcuts for plugin ${pluginId}`,
      );
    }
  }

  /**
   * Validate that referenced project, tags, and parent task exist
   */
  // Mirrors MarkdownPasteService._parseTimeProps: respects the user's
  // shortSyntax.isEnableDue config, returns the cleaned title and any parsed
  // time fields. Used for subtasks created via the plugin API, which opt out of
  // the ShortSyntaxEffects pipeline (isIgnoreShortSyntax) to parse time only and
  // keep tags/project inherited from the parent.
  private _parseSubTaskTitleTimeProps(originalTitle: string): {
    title: string;
    timeProps: Partial<TaskCopy>;
  } {
    const shortSyntaxConfig =
      this._globalConfigService.cfg()?.shortSyntax ?? DEFAULT_GLOBAL_CONFIG.shortSyntax;
    if (!shortSyntaxConfig.isEnableDue) {
      return { title: originalTitle, timeProps: {} };
    }
    const { title: cleanedTitle, ...timeProps } = parseTimeSpentChanges({
      title: originalTitle,
    });
    return { title: cleanedTitle ?? originalTitle, timeProps };
  }

  private async _validateTaskReferences(
    projectId?: string | null,
    tagIds?: string[],
    parentId?: string | null,
  ): Promise<void> {
    const errors: string[] = [];

    // Validate project exists if provided
    if (projectId) {
      const projects = await this._projectService.list$.pipe(first()).toPromise();

      const projectExists = projects?.some((project) => project.id === projectId);
      if (!projectExists) {
        errors.push(
          this._translateService.instant(T.PLUGINS.PROJECT_DOES_NOT_EXIST, { projectId }),
        );
      }
    }

    // Validate tags exist if provided
    if (tagIds && tagIds.length > 0) {
      const tags = await this._tagService.tags$.pipe(first()).toPromise();

      const existingTagIds = tags?.map((tag) => tag.id) || [];
      const nonExistentTags = tagIds.filter((tagId) => !existingTagIds.includes(tagId));

      if (nonExistentTags.length > 0) {
        errors.push(
          this._translateService.instant(T.PLUGINS.TAGS_DO_NOT_EXIST, {
            tagIds: nonExistentTags.join(', '),
          }),
        );
      }
    }

    // Validate parent task exists if provided
    if (parentId) {
      const tasks = await this._taskService.allTasks$.pipe(first()).toPromise();

      const parentExists = tasks?.some((task) => task.id === parentId);
      if (!parentExists) {
        errors.push(
          this._translateService.instant(T.PLUGINS.PARENT_TASK_DOES_NOT_EXIST, {
            parentId,
          }),
        );
      }
    }

    // Throw error if any validation failed
    if (errors.length > 0) {
      throw new Error(
        this._translateService.instant(T.PLUGINS.VALIDATION_FAILED, {
          errors: errors.join('; '),
        }),
      );
    }
  }

  /**
   * Internal method to dispatch action
   */
  private _dispatchAction(
    pluginId: string,
    action: { type: string; [key: string]: unknown },
  ): void {
    // Check if the action is in the allowed list
    if (!isAllowedPluginAction(action)) {
      PluginLog.err(
        `PluginBridge: Action type '${action.type}' is not allowed for plugins`,
      );
      throw new Error(
        this._translateService.instant(T.PLUGINS.ACTION_TYPE_NOT_ALLOWED, {
          actionType: action.type,
        }),
      );
    }

    // Dispatch the action
    this._store.dispatch(action);
    // Log the action TYPE only — the full action carries user content
    // and the log history is user-exportable. See core/log.ts header / rule #9.
    PluginLog.log(
      `PluginBridge: Dispatched action '${action.type}' for plugin ${pluginId}`,
    );
  }

  setNodeExecutionGrantToken(pluginId: string, grantToken: string): void {
    this.#nodeExecutionGrantTokens.set(pluginId, grantToken);
  }

  hasNodeExecutionGrantToken(pluginId: string): boolean {
    return this.#nodeExecutionGrantTokens.has(pluginId);
  }

  getNodeExecutionGrantToken(pluginId: string): string | undefined {
    return this.#nodeExecutionGrantTokens.get(pluginId);
  }

  async requestNodeExecutionGrant(
    pluginId: string,
    displayInfo?: { name?: string; version?: string },
  ): Promise<{ token: string } | null> {
    return (await this.#nodeExecutionApi?.requestGrant(pluginId, displayInfo)) ?? null;
  }

  revokeNodeExecutionGrantToken(pluginId: string): string | undefined {
    const token = this.#nodeExecutionGrantTokens.get(pluginId);
    this.#nodeExecutionGrantTokens.delete(pluginId);
    return token;
  }

  async revokeNodeExecutionGrant(pluginId: string, grantToken: string): Promise<void> {
    await this.#nodeExecutionApi?.revokeGrant(pluginId, grantToken);
  }

  /**
   * Drop both the in-renderer session token and the main-owned persisted consent for a
   * plugin (issue #8512 Phase 2). Used on disable / uninstall / re-upload so the next
   * node call re-prompts. No-op on web (no node execution API).
   */
  async clearNodeExecutionConsent(pluginId: string): Promise<void> {
    this.#nodeExecutionGrantTokens.delete(pluginId);
    await this.#nodeExecutionApi?.clearConsent(pluginId);
  }

  /**
   * Internal method to execute Node.js script
   */
  private async _executeNodeScript(
    pluginId: string,
    manifest: PluginManifest | null,
    request: PluginNodeScriptRequest,
  ): Promise<PluginNodeScriptResult> {
    if (!this._isElectronRuntime()) {
      return {
        success: false,
        error: this._translateService.instant(T.PLUGINS.NODE_ONLY_DESKTOP),
      };
    }

    try {
      typia.assert<PluginNodeScriptRequest>(request);

      if (!manifest) {
        return {
          success: false,
          error: this._translateService.instant(T.PLUGINS.NO_PLUGIN_MANIFEST_NODE),
        };
      }

      const grantToken = this.#nodeExecutionGrantTokens.get(pluginId);
      if (!grantToken) {
        return {
          success: false,
          error: this._translateService.instant(
            T.PLUGINS.NODE_EXECUTION_PERMISSION_DENIED,
          ),
        };
      }

      if (!this.#nodeExecutionApi) {
        return {
          success: false,
          error: this._translateService.instant(T.PLUGINS.ELECTRON_API_NOT_AVAILABLE),
        };
      }

      // Call Electron main process via IPC
      const result = await this.#nodeExecutionApi.executeScript(
        pluginId,
        grantToken,
        request,
      );

      return result;
    } catch (error) {
      PluginLog.err('PluginBridge: Failed to execute Node.js script:', error);
      return {
        success: false,
        error:
          error instanceof Error
            ? error.message
            : this._translateService.instant(T.PLUGINS.FAILED_TO_EXECUTE_SCRIPT),
      };
    }
  }

  private _isElectronRuntime(): boolean {
    return IS_ELECTRON;
  }

  /**
   * Consume the one-shot node-execution IPC handed off by the preload.
   *
   * SECURITY INVARIANT — must run at app bootstrap, before any plugin code
   * executes. The handoff lives on the shared `window.ea`, and plugin code
   * runs via `new Function` in this same renderer realm, so whoever calls
   * `consumePluginNodeExecutionApi()` first owns the privileged node channel.
   * This service is constructed during startup (PluginService injects it)
   * and plugins only load later (post-sync, in `initializePlugins`), so the
   * trusted side wins the race. The preload makes the handoff one-shot
   * (returns null after the first read) as a backstop, but the ordering is
   * the real guarantee: do NOT make this service lazy, and do not run plugin
   * code before it is instantiated. Real fix = plugin realm isolation
   * (tracked with the broader window.ea hardening).
   */
  private _consumeNodeExecutionApi(): PluginNodeExecutionElectronApi | null {
    if (!window.ea || typeof window.ea.consumePluginNodeExecutionApi !== 'function') {
      return null;
    }
    return window.ea.consumePluginNodeExecutionApi();
  }

  /**
   * Ping the Node.js IPC bridge for a plugin using a trivial vm-executed script.
   * Returns true if the bridge responds successfully, false otherwise.
   */
  async pingNodeBridge(pluginId: string, manifest: PluginManifest): Promise<boolean> {
    // 1.5s is plenty for an in-process vm script that returns true — the ping
    // is a bridge health check, not a long-running operation. Combined with
    // pingWithRetry's defaults (3 attempts, 1s+2s delays) this caps cold-boot
    // failure detection at ~7.5s instead of ~17s.
    const result = await this._executeNodeScript(pluginId, manifest, {
      script: 'return true',
      timeout: 1500,
    });
    return result.success;
  }

  /**
   * Send a message to a plugin's message handler
   */
  async sendMessageToPlugin(pluginId: string, message: unknown): Promise<unknown> {
    // Import and get the plugin runner service
    // Using dynamic import to avoid circular dependency at compile time
    const { PluginRunner } = await import('./plugin-runner');
    const pluginRunner = this._injector.get(PluginRunner);
    return pluginRunner.sendMessageToPlugin(pluginId, message);
  }

  /**
   * Track window focus state
   */
  private _isWindowFocused = true;
  private _windowFocusHandlers = new Map<string, (isFocused: boolean) => void>();

  // Named listener methods for proper cleanup
  private _onWindowFocus = (): void => {
    this._isWindowFocused = true;
    this._notifyFocusHandlers(true);
  };

  private _onWindowBlur = (): void => {
    this._isWindowFocused = false;
    this._notifyFocusHandlers(false);
  };

  private _onVisibilityChange = (): void => {
    const isFocused = !document.hidden;
    this._isWindowFocused = isFocused;
    this._notifyFocusHandlers(isFocused);
  };

  /**
   * Initialize window focus tracking
   */
  private _initWindowFocusTracking(): void {
    // Track window focus/blur events
    window.addEventListener('focus', this._onWindowFocus);
    window.addEventListener('blur', this._onWindowBlur);
    // Also track document visibility changes
    document.addEventListener('visibilitychange', this._onVisibilityChange);
  }

  /**
   * Clean up window focus tracking listeners
   */
  private _cleanupWindowFocusTracking(): void {
    window.removeEventListener('focus', this._onWindowFocus);
    window.removeEventListener('blur', this._onWindowBlur);
    document.removeEventListener('visibilitychange', this._onVisibilityChange);
  }

  /**
   * Notify all registered focus handlers
   */
  private _notifyFocusHandlers(isFocused: boolean): void {
    this._windowFocusHandlers.forEach((handler) => {
      try {
        handler(isFocused);
      } catch (error) {
        PluginLog.err('Error in window focus handler:', error);
      }
    });
  }

  /**
   * Check if the window is currently focused
   */
  isWindowFocused(): boolean {
    return this._isWindowFocused;
  }

  /**
   * Register a handler for window focus changes
   */
  onWindowFocusChange(pluginId: string, handler: (isFocused: boolean) => void): void {
    this._windowFocusHandlers.set(pluginId, handler);

    // Immediately notify the handler of the current state
    handler(this._isWindowFocused);
  }

  /**
   * Gets all simple counters as { [key: string]: number }.
   */
  async getAllCounters(): Promise<{ [key: string]: number }> {
    const today = getDbDateStr();
    const countersArray = await this._store
      .select(selectAllSimpleCounters)
      .pipe(
        take(1),
        map((counters: SimpleCounter[]) =>
          counters.reduce(
            (acc, c) => ({ ...acc, [c.id]: c.countOnDay?.[today] ?? 0 }),
            {} as { [key: string]: number },
          ),
        ),
      )
      .toPromise();
    return countersArray || {};
  }

  /**
   * Gets a single simple counter value (undefined if unset).
   * @param key The counter key (e.g., 'daily-commits').
   */
  async getCounter(id: string): Promise<number | null> {
    typia.assert<string>(id);
    // Allow uppercase, lowercase, numbers, hyphens, underscores
    if (!/^[A-Za-z0-9_-]+$/.test(id)) {
      throw new Error('Invalid counter key: must be alphanumeric with hyphens');
    }
    const counters = await this.getAllCounters();
    return counters[id] ?? null;
  }

  async setCounter(id: string, value: number): Promise<void> {
    typia.assert<string>(id);
    typia.assert<number>(value);
    // Allow uppercase, lowercase, numbers, hyphens, underscores
    if (!/^[A-Za-z0-9_-]+$/.test(id)) {
      throw new Error('Invalid counter key: must be alphanumeric with hyphens');
    }
    if (typeof value !== 'number' || !isFinite(value) || value < 0) {
      throw new Error('Invalid counter value: must be a non-negative number');
    }
    const today = getDbDateStr();

    // Check if counter already exists
    const existingCounter = await this.getSimpleCounter(id);

    if (existingCounter) {
      // Update existing counter's countOnDay only
      this._store.dispatch(
        updateSimpleCounter({
          simpleCounter: {
            id,
            changes: {
              countOnDay: {
                ...existingCounter.countOnDay,
                [today]: value,
              },
            },
          },
        }),
      );
    } else {
      // Create new counter with all mandatory fields
      this._store.dispatch(
        upsertSimpleCounter({
          simpleCounter: {
            ...EMPTY_SIMPLE_COUNTER,
            id,
            title: id,
            isEnabled: true,
            type: SimpleCounterType.ClickCounter,
            countOnDay: { [today]: value },
          },
        }),
      );
    }
  }

  async incrementCounter(id: string, incrementBy = 1): Promise<number> {
    typia.assert<string>(id);
    typia.assert<number>(incrementBy);
    if (typeof incrementBy !== 'number' || !isFinite(incrementBy) || incrementBy <= 0) {
      throw new Error('Invalid increment amount: must be a positive number');
    }
    const current = (await this.getCounter(id)) ?? 0;
    const newValue = current + incrementBy;
    await this.setCounter(id, newValue);
    return newValue;
  }

  async decrementCounter(id: string, decrementBy = 1): Promise<number> {
    typia.assert<string>(id);
    typia.assert<number>(decrementBy);
    if (typeof decrementBy !== 'number' || !isFinite(decrementBy) || decrementBy <= 0) {
      throw new Error('Invalid decrement amount: must be a positive number');
    }
    const current = (await this.getCounter(id)) ?? 0;
    const newValue = Math.max(0, current - decrementBy);
    await this.setCounter(id, Math.max(0, newValue));
    return newValue;
  }

  /**
   * Gets all simple counters as SimpleCounter[].
   */
  async getAllSimpleCounters(): Promise<SimpleCounter[]> {
    return this._store.select(selectAllSimpleCounters).pipe(take(1)).toPromise();
  }

  /**
   * Gets a single simple counter by ID.
   * @param id The counter ID.
   */
  async getSimpleCounter(id: string): Promise<SimpleCounter | undefined> {
    const all = await this.getAllSimpleCounters();
    return all.find((c) => c.id === id);
  }

  /**
   * Updates a simple counter (partial).
   * @param id The counter ID.
   * @param updates Partial updates.
   */
  async updateSimpleCounter(id: string, updates: Partial<SimpleCounter>): Promise<void> {
    this._store.dispatch(
      updateSimpleCounter({
        simpleCounter: { id, changes: updates },
      }),
    );
  }

  /**
   * Toggles a simple counter's isOn state.
   * @param id The counter ID.
   */
  async toggleSimpleCounter(id: string): Promise<void> {
    const counter = await this.getSimpleCounter(id);
    if (!counter) {
      throw new Error(`Counter ${id} not found`);
    }
    this._store.dispatch(toggleSimpleCounterCounter({ id }));
  }

  /**
   * Sets a simple counter's isEnabled state.
   * @param id The counter ID.
   * @param isEnabled Enabled state.
   */
  async setSimpleCounterEnabled(id: string, isEnabled: boolean): Promise<void> {
    return this.updateSimpleCounter(id, { isEnabled });
  }

  /**
   * Deletes a simple counter.
   * @param id The counter ID.
   */
  async deleteSimpleCounter(id: string): Promise<void> {
    this._store.dispatch(deleteSimpleCounter({ id }));
  }

  /**
   * Sets a simple counter value for today.
   * @param id The counter ID.
   * @param value The numeric value.
   */
  async setSimpleCounterToday(id: string, value: number): Promise<void> {
    const today = getDbDateStr();
    return this.setSimpleCounterDate(id, today, value);
  }

  /**
   * Sets a simple counter value for a specific date.
   * @param id The counter ID.
   * @param date The date (`YYYY-MM-DD`).
   * @param value The numeric value.
   */
  async setSimpleCounterDate(id: string, date: string, value: number): Promise<void> {
    if (!date.match(/^\d{4}-\d{2}-\d{2}$/)) {
      throw new Error('Invalid date format: use YYYY-MM-DD');
    }
    const dateObj = new Date(date);
    if (isNaN(dateObj.getTime())) {
      throw new Error('Invalid date: must be valid YYYY-MM-DD');
    }
    const counter = await this.getSimpleCounter(id);
    if (!counter) {
      throw new Error(`Counter ${id} not found`);
    }
    if (typeof value !== 'number' || !isFinite(value) || value < 0) {
      throw new Error('Invalid counter value: must be a non-negative number');
    }
    const newCountOnDay = { ...counter.countOnDay, [date]: value };
    return this.updateSimpleCounter(id, { countOnDay: newCountOnDay });
  }

  /**
   * Clean up all resources when service is destroyed
   */
  ngOnDestroy(): void {
    PluginLog.log('PluginBridgeService: Cleaning up resources');
    this._cleanupWindowFocusTracking();
    // Note: Signals don't need explicit cleanup like BehaviorSubjects
    PluginLog.log('PluginBridgeService: Cleanup complete');
  }
}
