import { Action, ActionReducer, MetaReducer } from '@ngrx/store';
import { EntityAdapter } from '@ngrx/entity';
import { RootState } from '../../root-state';
import {
  getEntityConfig,
  isAdapterEntity,
  isSingletonEntity,
} from '../../../op-log/core/entity-registry';
import { getLwwEntityType } from '../../../op-log/core/lww-update-action-types';
import { devError } from '../../../util/dev-error';
import {
  PROJECT_FEATURE_NAME,
  projectAdapter,
} from '../../../features/project/store/project.reducer';
import { Project } from '../../../features/project/project.model';
import { TAG_FEATURE_NAME, tagAdapter } from '../../../features/tag/store/tag.reducer';
import { Tag } from '../../../features/tag/tag.model';
import { TODAY_TAG } from '../../../features/tag/tag.const';
import {
  TASK_FEATURE_NAME,
  taskAdapter,
} from '../../../features/tasks/store/task.reducer';
import { Task } from '../../../features/tasks/task.model';
import { INBOX_PROJECT } from '../../../features/project/project.const';
import { RECREATE_FALLBACK } from '../../../op-log/core/recreate-fallback.const';
import { OpLog } from '../../../core/log';
import { filterTaskIdArraysFromTagOrProjectPayload } from '../../../op-log/apply/bulk-archive-filter.util';
import { appStateFeatureKey } from '../../app-state/app-state.reducer';
import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str';
import { isTodayWithOffset } from '../../../util/is-today.util';

/**
 * Updates project.taskIds arrays when a task's project membership changes via LWW Update.
 *
 * When LWW conflict resolution updates a task's projectId or parentId, we must
 * also update the corresponding project.taskIds arrays to maintain consistency:
 * - Remove task from old project's taskIds if it moved away or became a subtask
 * - Add task to new project's taskIds if it is now a root task
 *
 * This is necessary because the original moveToOtherProject action updates both
 * the task and project entities atomically, but LWW Update only syncs the TASK
 * entity state.
 */
const syncProjectTaskIds = (
  state: RootState,
  taskId: string,
  oldProjectId: string | undefined,
  newProjectId: string | undefined,
  oldIsSubTask: boolean,
  newIsSubTask: boolean,
): RootState => {
  let projectState = state[PROJECT_FEATURE_NAME];
  const shouldRemoveFromOldProject =
    !!oldProjectId && (oldProjectId !== newProjectId || newIsSubTask);
  const shouldAddToNewProject =
    !!newProjectId && !newIsSubTask && (oldProjectId !== newProjectId || oldIsSubTask);

  // Remove from old project's taskIds
  if (shouldRemoveFromOldProject && oldProjectId && projectState.entities[oldProjectId]) {
    const oldProject = projectState.entities[oldProjectId] as Project;
    const filteredTaskIds = oldProject.taskIds.filter((id) => id !== taskId);
    const filteredBacklogTaskIds = oldProject.backlogTaskIds.filter(
      (id) => id !== taskId,
    );

    // Only update if the task was actually in the list
    if (
      filteredTaskIds.length !== oldProject.taskIds.length ||
      filteredBacklogTaskIds.length !== oldProject.backlogTaskIds.length
    ) {
      projectState = projectAdapter.updateOne(
        {
          id: oldProjectId,
          changes: {
            taskIds: filteredTaskIds,
            backlogTaskIds: filteredBacklogTaskIds,
          },
        },
        projectState,
      );
    }
  } else if (shouldRemoveFromOldProject && oldProjectId) {
    // Old project was deleted before LWW update arrived - benign race condition
    OpLog.warn(
      `lwwUpdateMetaReducer: syncProjectTaskIds: old project ${oldProjectId} not found for task ${taskId}`,
    );
  }

  // Add to new project's taskIds
  if (shouldAddToNewProject && newProjectId && projectState.entities[newProjectId]) {
    const newProject = projectState.entities[newProjectId] as Project;
    // Only add if not already present
    if (!newProject.taskIds.includes(taskId)) {
      projectState = projectAdapter.updateOne(
        {
          id: newProjectId,
          changes: {
            taskIds: [...newProject.taskIds, taskId],
          },
        },
        projectState,
      );
    }
  } else if (shouldAddToNewProject && newProjectId) {
    // New project was deleted before LWW update arrived - benign race condition
    OpLog.warn(
      `lwwUpdateMetaReducer: syncProjectTaskIds: new project ${newProjectId} not found for task ${taskId}`,
    );
  }

  return projectState === state[PROJECT_FEATURE_NAME]
    ? state
    : {
        ...state,
        [PROJECT_FEATURE_NAME]: projectState,
      };
};

/**
 * Updates tag.taskIds arrays when a task's tagIds changes via LWW Update.
 *
 * When LWW conflict resolution updates a task's tagIds, we must also update
 * the corresponding tag.taskIds arrays to maintain bidirectional consistency:
 * - Remove task from tags that were removed from the task's tagIds
 * - Add task to tags that were added to the task's tagIds
 *
 * This is necessary because the original updateTags action updates both
 * the task and tag entities atomically, but LWW Update only syncs the TASK
 * entity state.
 */
const syncTagTaskIds = (
  state: RootState,
  taskId: string,
  oldTagIds: string[],
  newTagIds: string[],
): RootState => {
  // Find tags that were removed and added
  const oldTagSet = new Set(oldTagIds);
  const newTagSet = new Set(newTagIds);

  const removedTags = oldTagIds.filter((id) => !newTagSet.has(id));
  const addedTags = newTagIds.filter((id) => !oldTagSet.has(id));

  // If no changes, nothing to do
  if (removedTags.length === 0 && addedTags.length === 0) {
    return state;
  }

  let tagState = state[TAG_FEATURE_NAME];

  // Remove task from removed tags' taskIds
  for (const tagId of removedTags) {
    if (tagState.entities[tagId]) {
      const tag = tagState.entities[tagId] as Tag;
      if (tag.taskIds.includes(taskId)) {
        tagState = tagAdapter.updateOne(
          {
            id: tagId,
            changes: {
              taskIds: tag.taskIds.filter((id) => id !== taskId),
            },
          },
          tagState,
        );
      }
    } else {
      // Tag was deleted before LWW update arrived - benign race condition
      OpLog.warn(
        `lwwUpdateMetaReducer: syncTagTaskIds: removed tag ${tagId} not found for task ${taskId}`,
      );
    }
  }

  // Add task to added tags' taskIds
  for (const tagId of addedTags) {
    if (tagState.entities[tagId]) {
      const tag = tagState.entities[tagId] as Tag;
      if (!tag.taskIds.includes(taskId)) {
        tagState = tagAdapter.updateOne(
          {
            id: tagId,
            changes: {
              taskIds: [...tag.taskIds, taskId],
            },
          },
          tagState,
        );
      }
    } else {
      // Tag was deleted before LWW update arrived - benign race condition
      OpLog.warn(
        `lwwUpdateMetaReducer: syncTagTaskIds: added tag ${tagId} not found for task ${taskId}`,
      );
    }
  }

  return {
    ...state,
    [TAG_FEATURE_NAME]: tagState,
  };
};

/**
 * Updates TODAY_TAG.taskIds when a task's dueDay or dueWithTime changes via LWW Update.
 *
 * TODAY_TAG is a virtual tag where membership is determined by task.dueDay OR
 * task.dueWithTime (mutually exclusive). When LWW Update recreates a task or
 * changes either field, we must update TODAY_TAG.taskIds accordingly.
 *
 * See: docs/ai/today-tag-architecture.md
 */
const syncTodayTagTaskIds = (
  state: RootState,
  taskId: string,
  oldDueDay: string | undefined,
  newDueDay: string | undefined,
  oldDueWithTime: number | undefined,
  newDueWithTime: number | undefined,
): RootState => {
  const todayStr = state[appStateFeatureKey]?.todayStr ?? getDbDateStr();
  const offsetMs = state[appStateFeatureKey]?.startOfNextDayDiffMs ?? 0;

  const isDueToday = (
    dueDay: string | undefined,
    dueWithTime: number | undefined,
  ): boolean =>
    dueDay === todayStr ||
    (!!dueWithTime && isTodayWithOffset(dueWithTime, todayStr, offsetMs));

  const wasToday = isDueToday(oldDueDay, oldDueWithTime);
  const isNowToday = isDueToday(newDueDay, newDueWithTime);

  // No change in TODAY membership
  if (wasToday === isNowToday) {
    return state;
  }

  let tagState = state[TAG_FEATURE_NAME];
  const todayTag = tagState.entities[TODAY_TAG.id] as Tag | undefined;

  if (!todayTag) {
    // TODAY_TAG doesn't exist yet (shouldn't happen in normal operation)
    return state;
  }

  if (!wasToday && isNowToday) {
    // Task moved to today (or recreated with dueDay = today)
    if (!todayTag.taskIds.includes(taskId)) {
      tagState = tagAdapter.updateOne(
        {
          id: TODAY_TAG.id,
          changes: {
            taskIds: [...todayTag.taskIds, taskId],
          },
        },
        tagState,
      );
    }
  } else if (wasToday && !isNowToday) {
    // Task moved away from today
    if (todayTag.taskIds.includes(taskId)) {
      tagState = tagAdapter.updateOne(
        {
          id: TODAY_TAG.id,
          changes: {
            taskIds: todayTag.taskIds.filter((id) => id !== taskId),
          },
        },
        tagState,
      );
    }
  }

  return {
    ...state,
    [TAG_FEATURE_NAME]: tagState,
  };
};

/**
 * Updates parent task's subTaskIds arrays when a task's parentId changes via LWW Update.
 *
 * When LWW conflict resolution updates a task's parentId (making it a subtask or
 * moving it to a different parent), we must also update the corresponding parent
 * task's subTaskIds arrays to maintain bidirectional consistency:
 * - Remove task from old parent's subTaskIds (if it was a subtask)
 * - Add task to new parent's subTaskIds (if it becomes a subtask)
 *
 * This is necessary because the original moveToOtherProject or convertToSubtask
 * actions update both the task and parent entities atomically, but LWW Update
 * only syncs the TASK entity state.
 */
const syncParentSubTaskIds = (
  state: RootState,
  taskId: string,
  oldParentId: string | undefined,
  newParentId: string | undefined,
): RootState => {
  // If parentId didn't change, nothing to do
  if (oldParentId === newParentId) {
    return state;
  }

  let taskState = state[TASK_FEATURE_NAME];

  // Remove from old parent's subTaskIds
  if (oldParentId && taskState.entities[oldParentId]) {
    const oldParent = taskState.entities[oldParentId] as Task;
    if (oldParent.subTaskIds.includes(taskId)) {
      taskState = taskAdapter.updateOne(
        {
          id: oldParentId,
          changes: {
            subTaskIds: oldParent.subTaskIds.filter((id) => id !== taskId),
          },
        },
        taskState,
      );
    }
  } else if (oldParentId) {
    // Old parent was deleted before LWW update arrived - benign race condition
    OpLog.warn(
      `lwwUpdateMetaReducer: syncParentSubTaskIds: old parent ${oldParentId} not found for task ${taskId}`,
    );
  }

  // Add to new parent's subTaskIds
  if (newParentId && taskState.entities[newParentId]) {
    const newParent = taskState.entities[newParentId] as Task;
    // Only add if not already present
    if (!newParent.subTaskIds.includes(taskId)) {
      taskState = taskAdapter.updateOne(
        {
          id: newParentId,
          changes: {
            subTaskIds: [...newParent.subTaskIds, taskId],
          },
        },
        taskState,
      );
    }
  } else if (newParentId) {
    // New parent was deleted before LWW update arrived - benign race condition
    OpLog.warn(
      `lwwUpdateMetaReducer: syncParentSubTaskIds: new parent ${newParentId} not found for task ${taskId}`,
    );
  }

  return {
    ...state,
    [TASK_FEATURE_NAME]: taskState,
  };
};

/**
 * Filters orphaned taskIds (and backlogTaskIds for PROJECT) from entity data
 * before applying LWW updates. This prevents TAG and PROJECT entities from
 * referencing tasks that no longer exist in the store.
 *
 * This is necessary because LWW conflict resolution replaces entire entities
 * without checking whether referenced tasks still exist locally.
 *
 * Wraps the shared `filterTaskIdArraysFromTagOrProjectPayload` helper. The
 * sibling filter for in-batch archives is `stripBatchArchivedTaskIdsFromLwwPayload`
 * in op-log/apply/bulk-archive-filter.util.ts — the two run at different
 * layers because their predicates resolve at different times.
 */
const filterOrphanedTaskIdsFromEntityData = (
  entityData: Record<string, unknown>,
  entityType: string,
  rootState: RootState,
): Record<string, unknown> => {
  const taskState = rootState[TASK_FEATURE_NAME];
  if (!taskState) return entityData;
  const existingTaskIds = new Set(taskState.ids as string[]);
  const cleaned = filterTaskIdArraysFromTagOrProjectPayload(
    entityData,
    entityType,
    (id) => !existingTaskIds.has(id),
    {
      warnMessage: `lwwUpdateMetaReducer: Filtered orphaned taskIds from ${entityType} LWW Update`,
      entityId:
        typeof entityData['id'] === 'string' ? (entityData['id'] as string) : undefined,
    },
  );
  return cleaned ?? entityData;
};

/**
 * Meta-reducer that handles LWW (Last-Write-Wins) Update actions.
 *
 * When a LWW conflict is resolved and local state wins, a `[ENTITY_TYPE] LWW Update`
 * operation is created and synced to other clients. This meta-reducer applies those
 * operations by REPLACING the entire entity with the winning state.
 *
 * Unlike regular update actions that merge changes, LWW Update replaces the entity
 * entirely because it represents the "winning" state after conflict resolution.
 *
 * The action payload has entity fields spread at the top level:
 * ```
 * {
 *   type: '[TASK] LWW Update',
 *   id: 'xxx',
 *   title: 'Winning title',
 *   ... other entity fields
 *   meta: { isPersistent: true, ... }
 * }
 * ```
 */
export const lwwUpdateMetaReducer: MetaReducer = (
  reducer: ActionReducer<any, Action>,
) => {
  return (state: unknown, action: Action) => {
    if (!state) return reducer(state, action);

    const entityType = getLwwEntityType(action.type);
    if (!entityType) {
      // Not an LWW Update action, pass through
      return reducer(state, action);
    }
    const config = getEntityConfig(entityType);

    if (!config) {
      OpLog.warn(`lwwUpdateMetaReducer: Unknown entity type: ${entityType}`);
      devError(`lwwUpdateMetaReducer: Unknown entity type: ${entityType}`);
      return reducer(state, action);
    }

    const { featureName } = config;
    if (!featureName) {
      OpLog.warn(`lwwUpdateMetaReducer: Missing featureName for: ${entityType}`);
      devError(`lwwUpdateMetaReducer: Missing featureName for: ${entityType}`);
      return reducer(state, action);
    }

    const rootState = state as RootState;
    const featureState = rootState[featureName as keyof RootState];

    if (!featureState) {
      OpLog.warn(`lwwUpdateMetaReducer: Feature state not found: ${featureName}`);
      devError(`lwwUpdateMetaReducer: Feature state not found: ${featureName}`);
      return reducer(state, action);
    }

    // Extract entity data from action (exclude 'type' and 'meta').
    // NOTE: This assumes no entity state has top-level 'type' or 'meta' keys.
    // If a singleton or adapter state gains such a key, it would be silently dropped.
    const actionAny = action as unknown as Record<string, unknown>;
    let entityData: Record<string, unknown> = {};
    for (const key of Object.keys(actionAny)) {
      if (key !== 'type' && key !== 'meta') {
        entityData[key] = actionAny[key];
      }
    }

    // Filter orphaned taskIds/backlogTaskIds for TAG and PROJECT entities
    entityData = filterOrphanedTaskIdsFromEntityData(entityData, entityType, rootState);

    // Singleton entities: replace entire feature state with the winning data
    if (isSingletonEntity(config)) {
      if (Object.keys(entityData).length === 0) {
        OpLog.warn(`lwwUpdateMetaReducer: Empty singleton data for: ${entityType}`);
        devError(`lwwUpdateMetaReducer: Empty singleton data for: ${entityType}`);
        return reducer(state, action);
      }
      const updatedState: RootState = {
        ...rootState,
        [featureName]: { ...entityData },
      };
      return reducer(updatedState, action);
    }

    if (!isAdapterEntity(config)) {
      OpLog.warn(`lwwUpdateMetaReducer: Unsupported storage pattern for: ${entityType}`);
      devError(`lwwUpdateMetaReducer: Unsupported storage pattern for: ${entityType}`);
      return reducer(state, action);
    }

    const { adapter } = config;
    if (!adapter) {
      OpLog.warn(`lwwUpdateMetaReducer: Missing adapter for: ${entityType}`);
      devError(`lwwUpdateMetaReducer: Missing adapter for: ${entityType}`);
      return reducer(state, action);
    }

    // Note (#7330): backfill of payload.id for adapter LWW Updates lives in
    // convertOpToAction at the apply boundary — every applied op has its id
    // set from op.entityId before reaching this reducer. Producers also
    // force the canonical id on-disk. The check below remains as a hard
    // guard for actions arriving with no usable id at all.
    if (!entityData['id']) {
      OpLog.warn('lwwUpdateMetaReducer: Entity data has no id');
      return reducer(state, action);
    }

    const entityId = entityData['id'] as string;

    // Sanitize date string fields to prevent corrupted data from sync (#6908)
    if (entityType === 'TASK') {
      for (const field of ['dueDay', 'deadlineDay'] as const) {
        const val = entityData[field];
        if (typeof val === 'string' && !isDBDateStr(val)) {
          entityData[field] = undefined;
          devError(
            `lwwUpdateMetaReducer: Invalid ${field} "${val}" on task ${entityId}, clearing`,
          );
        }
      }
    }

    const existingEntity = (
      featureState as unknown as {
        entities?: Record<string, Record<string, unknown>>;
      }
    ).entities?.[entityId];

    let updatedFeatureState: unknown;

    if (!existingEntity) {
      // Entity was deleted locally but UPDATE won via LWW.
      // This means another client's update beat our delete, so we need to
      // recreate the entity with the winning state.
      OpLog.log(
        `lwwUpdateMetaReducer: Entity ${entityType}:${entityId} not found, recreating from LWW update`,
      );

      // Issue #7330: An LWW Update payload from a partial-delta producer
      // (e.g. _convertToLWWUpdatesIfNeeded fallback) can lack required fields.
      // Calling adapter.addOne with such a payload creates an entity that fails
      // Typia validation (e.g. task with undefined `title` / `timeSpentOnDay`),
      // which dataRepair has no rule for, leaving the user stuck on the
      // "Repair attempted but failed" dialog. For TASK entities we merge with
      // DEFAULT_TASK so the recreated entity is always schema-valid.
      //
      // INTENTIONAL: We set modified to Date.now() (local time), not the original timestamp.
      // Rationale:
      // - Vector clocks are the authoritative conflict resolution mechanism, not `modified`
      // - The `modified` field is used for UI display ("last edited X minutes ago")
      // - Setting it to local time reflects when THIS client applied the winning state
      // - The original timestamp from the winning client is preserved in entityData but
      //   gets overwritten here because local display should show local application time
      let entityToAdd: Record<string, unknown>;
      const fallback = RECREATE_FALLBACK[entityType];
      if (fallback) {
        // null and undefined both count as "missing": producers that emit
        // explicit `null` for a required field would otherwise slip past the
        // warn AND have `null` overwrite the default in the spread below.
        const partialKeys = fallback.requiredKeys.filter((k) => entityData[k] == null);
        if (partialKeys.length > 0) {
          OpLog.warn(
            `lwwUpdateMetaReducer: ${entityType} LWW Update payload missing required ` +
              `fields [${partialKeys.join(', ')}] for ${entityId} — backfilling from ` +
              `defaults. Likely cause: the local DELETE op carried only {id} ` +
              `(or a similarly minimal payload), so _convertToLWWUpdatesIfNeeded ` +
              `produced a partial merged entity on its happy path.`,
          );
        }
        // Spread does not skip null/undefined-valued keys; strip them so they
        // can't clobber a backfilled default.
        const stripped: Record<string, unknown> = { modified: Date.now() };
        for (const k of Object.keys(entityData)) {
          if (entityData[k] != null) stripped[k] = entityData[k];
        }
        entityToAdd = { ...fallback.defaults, ...stripped };

        // INBOX_PROJECT may legitimately be missing from state (corrupted
        // import, partial migration). Mirror normalizeRestoredTask's guard at
        // task-shared-lifecycle.reducer.ts:110 so the recreated task points
        // at a project that actually exists.
        if (entityType === 'TASK' && entityToAdd['projectId'] === INBOX_PROJECT.id) {
          const projectEntities =
            (state[PROJECT_FEATURE_NAME] as { entities?: Record<string, unknown> })
              ?.entities ?? {};
          if (!projectEntities[INBOX_PROJECT.id]) {
            const firstProjectId = Object.keys(projectEntities)[0];
            if (firstProjectId) entityToAdd['projectId'] = firstProjectId;
          }
        }
      } else {
        entityToAdd = { ...entityData, modified: Date.now() };
      }
      updatedFeatureState = (adapter as EntityAdapter<any>).addOne(
        entityToAdd as any,
        featureState as any,
      );
    } else {
      // Entity exists - replace it entirely with the LWW winning state
      // Use updateOne with all fields as changes to preserve adapter behavior
      updatedFeatureState = (adapter as EntityAdapter<any>).updateOne(
        {
          id: entityId,
          changes: {
            ...entityData,
            // INTENTIONAL: We set modified to Date.now() (local time), not the original timestamp.
            // See comment above for rationale - vector clocks drive conflict resolution,
            // `modified` is for UI display of "when this client last saw this change"
            modified: Date.now(),
          },
        },
        featureState as any,
      );
    }

    let updatedState: RootState = {
      ...rootState,
      [featureName]: updatedFeatureState,
    };
    const updatedEntity = (
      updatedFeatureState as unknown as {
        entities?: Record<string, Record<string, unknown>>;
      }
    ).entities?.[entityId];

    // For TASK entities, sync related entities when relationships change
    if (entityType === 'TASK' && updatedEntity) {
      // Sync project.taskIds when projectId changes
      const oldProjectId = existingEntity?.projectId as string | undefined;
      const newProjectId = updatedEntity.projectId as string | undefined;
      const oldIsSubTask = !!existingEntity?.parentId;
      const newParentId = updatedEntity.parentId as string | undefined;
      const newIsSubTask = !!newParentId;

      updatedState = syncProjectTaskIds(
        updatedState,
        entityId,
        oldProjectId,
        newProjectId,
        oldIsSubTask,
        newIsSubTask,
      );

      // Sync tag.taskIds when tagIds changes
      const oldTagIds = (existingEntity?.tagIds as string[]) || [];
      const newTagIds = (updatedEntity.tagIds as string[]) || [];

      updatedState = syncTagTaskIds(updatedState, entityId, oldTagIds, newTagIds);

      // Sync TODAY_TAG.taskIds when dueDay or dueWithTime changes (virtual tag based on dueDay/dueWithTime)
      const oldDueDay = existingEntity?.dueDay as string | undefined;
      const newDueDay = updatedEntity.dueDay as string | undefined;
      const oldDueWithTime = existingEntity?.dueWithTime as number | undefined;
      const newDueWithTime = updatedEntity.dueWithTime as number | undefined;
      updatedState = syncTodayTagTaskIds(
        updatedState,
        entityId,
        oldDueDay,
        newDueDay,
        oldDueWithTime,
        newDueWithTime,
      );

      // Sync parent.subTaskIds when parentId changes
      const oldParentId = existingEntity?.parentId as string | undefined;
      updatedState = syncParentSubTaskIds(
        updatedState,
        entityId,
        oldParentId,
        newParentId,
      );
    }

    return reducer(updatedState, action);
  };
};
