import type { Item, ItemField, Attachment, TotpCode, FieldHistory } from '@/utils/dist/core/models/vault';
import { FieldKey, MAX_FIELD_HISTORY_RECORDS } from '@/utils/dist/core/models/vault';
import { getFolderPath } from '@/utils/FolderUtils';

import { BaseRepository, type IDatabaseClient } from '../BaseRepository';
import { FieldMapper, type FieldRow } from '../mappers/FieldMapper';
import { ItemMapper, type ItemRow, type TagRow, type ItemWithDeletedAt } from '../mappers/ItemMapper';
import {
  ItemQueries,
  FieldValueQueries,
  FieldDefinitionQueries,
  FieldHistoryQueries,
  TotpCodeQueries,
  AttachmentQueries
} from '../queries/ItemQueries';

import type { Folder } from './FolderRepository';
import type { LogoRepository } from './LogoRepository';

/**
 * Repository for Item CRUD operations.
 * Handles items, field values, field definitions, and field history.
 */
export class ItemRepository extends BaseRepository {
  /**
   * Constructor for the ItemRepository class.
   * @param client - The database client to use for the repository
   * @param logoRepository - The logo repository to use for the repository
   */
  public constructor(
    client: IDatabaseClient,
    private logoRepository: LogoRepository
  ) {
    super(client);
  }

  /**
   * Build folder paths for all folders using the shared utility.
   * Returns a map of FolderId -> path array.
   * @returns Map of folder ID to path array
   */
  private buildFolderPaths(): Map<string, string[]> {
    const folderPathMap = new Map<string, string[]>();

    try {
      // Get all folders from database
      const folders = this.client.executeQuery<Folder>(
        'SELECT Id, Name, ParentFolderId, Weight FROM Folders WHERE IsDeleted = 0'
      );

      if (folders.length === 0) {
        return folderPathMap;
      }

      // Use shared utility to build paths for all folders
      for (const folder of folders) {
        const path = getFolderPath(folder.Id, folders);
        if (path.length > 0) {
          folderPathMap.set(folder.Id, path);
        }
      }

      return folderPathMap;
    } catch (error) {
      // Folders table may not exist in older vault versions
      if (error instanceof Error && error.message.includes('no such table')) {
        return folderPathMap;
      }
      throw error;
    }
  }

  /**
   * Fetch all active items with their dynamic fields and tags.
   * @returns Array of Item objects (empty array if Items table doesn't exist yet)
   */
  public getAll(): Item[] {
    let itemRows: ItemRow[];
    try {
      itemRows = this.client.executeQuery<ItemRow>(ItemQueries.GET_ALL_ACTIVE);
    } catch (error) {
      // Items table may not exist in older vault versions - return empty array
      if (error instanceof Error && error.message.includes('no such table')) {
        return [];
      }
      throw error;
    }

    if (itemRows.length === 0) {
      return [];
    }

    const itemIds = itemRows.map(i => i.Id);

    // Get all field values
    const fieldRows = this.client.executeQuery<FieldRow>(
      ItemQueries.getFieldValuesForItems(itemIds.length),
      itemIds
    );
    const fieldsByItem = FieldMapper.processFieldRows(fieldRows);

    // Get all tags
    const tagRows = this.client.executeQuery<TagRow>(
      ItemQueries.getTagsForItems(itemIds.length),
      itemIds
    );
    const tagsByItem = ItemMapper.groupTagsByItem(tagRows);

    // Build folder paths
    const folderPaths = this.buildFolderPaths();

    return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem, folderPaths);
  }

  /**
   * Fetch a single item by ID with its dynamic fields and tags.
   * @param itemId - The ID of the item to fetch
   * @returns Item object or null if not found
   */
  public getById(itemId: string): Item | null {
    const results = this.client.executeQuery<ItemRow>(ItemQueries.GET_BY_ID, [itemId]);
    if (results.length === 0) {
      return null;
    }

    // Get field values
    const fieldRows = this.client.executeQuery<Omit<FieldRow, 'ItemId'>>(
      ItemQueries.GET_FIELD_VALUES_FOR_ITEM,
      [itemId]
    );
    const fields = FieldMapper.processFieldRowsForSingleItem(fieldRows);

    // Get tags
    const tagRows = this.client.executeQuery<Omit<TagRow, 'ItemId'>>(
      ItemQueries.GET_TAGS_FOR_ITEM,
      [itemId]
    );
    const tags = ItemMapper.mapTagRows(tagRows);

    // Get folder path if item is in a folder
    let folderPath: string[] | undefined;
    if (results[0].FolderId) {
      const folderPaths = this.buildFolderPaths();
      folderPath = folderPaths.get(results[0].FolderId);
    }

    return ItemMapper.mapRow(results[0], fields, tags, folderPath);
  }

  /**
   * Fetch all unique email addresses from all items.
   * @returns Array of email addresses
   */
  public getAllEmailAddresses(): string[] {
    const results = this.client.executeQuery<{ Email: string }>(
      ItemQueries.GET_ALL_EMAIL_ADDRESSES,
      [FieldKey.LoginEmail]
    );
    return results.map(row => row.Email);
  }

  /**
   * Find the item (id + name) associated with a given email address, if any.
   * @param email - The full email address (local@domain) to look up
   * @returns Object with Id and Name, or null when no active item uses this address
   */
  public findIdByEmail(email: string): { Id: string; Name: string | null } | null {
    const results = this.client.executeQuery<{ Id: string; Name: string | null }>(
      ItemQueries.GET_ITEM_BY_EMAIL,
      [FieldKey.LoginEmail, email]
    );
    return results.length > 0 ? results[0] : null;
  }

  /**
   * Create a new item with field-based structure.
   * @param item The item object to insert
   * @param attachments Optional attachments to associate with the item
   * @param totpCodes Optional TOTP codes to associate with the item
   * @returns The ID of the created item
   */
  public async create(
    item: Item,
    attachments: Attachment[] = [],
    totpCodes: TotpCode[] = []
  ): Promise<string> {
    return this.withTransaction(async () => {
      const currentDateTime = this.now();
      const itemId = item.Id || this.generateId();

      // 1. Handle Logo
      const logoId = this.resolveLogoId(item, currentDateTime);

      // 2. Insert Item
      this.client.executeUpdate(ItemQueries.INSERT_ITEM, [
        itemId,
        item.Name ?? null,
        item.ItemType,
        logoId,
        item.FolderId ?? null,
        currentDateTime,
        currentDateTime,
        0
      ]);

      // 3. Insert FieldValues for all fields
      if (item.Fields && item.Fields.length > 0) {
        this.insertFieldValues(itemId, item.Fields, item.ItemType, currentDateTime);
      }

      // 4. Insert TOTP codes
      this.insertTotpCodes(itemId, totpCodes, currentDateTime);

      // 5. Insert attachments
      this.insertAttachments(itemId, attachments, currentDateTime);

      return itemId;
    });
  }

  /**
   * Duplicate an item including all fields. Data that is not duplicated is passkeys and field history.
   * @param itemId - The ID of the item to duplicate
   * @returns The ID of the newly created item
   */
  public async duplicate(itemId: string): Promise<string> {
    return this.withTransaction(async () => {
      const currentDateTime = this.now();
      const newItemId = this.generateId();

      const sourceRows = this.client.executeQuery<{ Name: string | null }>(
        'SELECT Name FROM Items WHERE Id = ? AND IsDeleted = 0',
        [itemId]
      );
      if (sourceRows.length === 0) {
        throw new Error(`Item not found: ${itemId}`);
      }

      const existingNames = this.client.executeQuery<{ Name: string | null }>(
        'SELECT Name FROM Items WHERE IsDeleted = 0 AND DeletedAt IS NULL'
      );
      const newName = ItemRepository.generateCopyName(
        sourceRows[0].Name,
        existingNames.map(row => row.Name)
      );

      // 1. Copy the item row itself (same logo, folder and type).
      this.client.executeUpdate(`
        INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted)
        SELECT ?, ?, ItemType, LogoId, FolderId, ?, ?, 0 FROM Items WHERE Id = ?`,
      [newItemId, newName, currentDateTime, currentDateTime, itemId]);

      /*
       * 2. Copy custom field definitions so later edits to the duplicate's
       * custom fields don't affect the original item.
       */
      const definitionRows = this.client.executeQuery<{ Id: string }>(
        `SELECT DISTINCT FieldDefinitionId as Id FROM FieldValues
         WHERE ItemId = ? AND IsDeleted = 0 AND FieldDefinitionId IS NOT NULL`,
        [itemId]
      );
      const definitionIdMap = new Map<string, string>();
      for (const definition of definitionRows) {
        const newDefinitionId = this.generateId();
        definitionIdMap.set(definition.Id, newDefinitionId);
        this.client.executeUpdate(`
          INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted)
          SELECT ?, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, ?, ?, 0 FROM FieldDefinitions WHERE Id = ?`,
        [newDefinitionId, currentDateTime, currentDateTime, definition.Id]);
      }

      // 3. Copy field values, remapping custom fields to the copied definitions.
      const fieldValueRows = this.client.executeQuery<{ Id: string; FieldDefinitionId: string | null }>(
        'SELECT Id, FieldDefinitionId FROM FieldValues WHERE ItemId = ? AND IsDeleted = 0',
        [itemId]
      );
      for (const row of fieldValueRows) {
        this.client.executeUpdate(`
          INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted)
          SELECT ?, ?, ?, FieldKey, Value, Weight, ?, ?, 0 FROM FieldValues WHERE Id = ?`,
        [
          this.generateId(),
          newItemId,
          row.FieldDefinitionId ? definitionIdMap.get(row.FieldDefinitionId) ?? null : null,
          currentDateTime,
          currentDateTime,
          row.Id
        ]);
      }

      /*
       * 4. Copy remaining child rows with fresh IDs.
       */
      const childCopies = [
        {
          table: 'TotpCodes',
          sql: `INSERT INTO TotpCodes (Id, ItemId, Name, SecretKey, CreatedAt, UpdatedAt, IsDeleted)
                SELECT ?, ?, Name, SecretKey, ?, ?, 0 FROM TotpCodes WHERE Id = ?`,
        },
        {
          table: 'Attachments',
          sql: `INSERT INTO Attachments (Id, ItemId, Filename, Blob, CreatedAt, UpdatedAt, IsDeleted)
                SELECT ?, ?, Filename, Blob, ?, ?, 0 FROM Attachments WHERE Id = ?`,
        },
        {
          table: 'ItemTags',
          sql: `INSERT INTO ItemTags (Id, ItemId, TagId, CreatedAt, UpdatedAt, IsDeleted)
                SELECT ?, ?, TagId, ?, ?, 0 FROM ItemTags WHERE Id = ?`,
        },
      ];

      for (const copy of childCopies) {
        const rows = this.client.executeQuery<{ Id: string }>(
          `SELECT Id FROM ${copy.table} WHERE ItemId = ? AND IsDeleted = 0`,
          [itemId]
        );
        for (const row of rows) {
          this.client.executeUpdate(copy.sql, [this.generateId(), newItemId, currentDateTime, currentDateTime, row.Id]);
        }
      }

      return newItemId;
    });
  }

  /**
   * Generate a unique name for a duplicated item: "Name (1)", "Name (2)", etc.
   * If the source name already ends with a "(n)" suffix, the counter is incremented
   * instead of stacking suffixes.
   */
  private static generateCopyName(sourceName: string | null, existingNames: (string | null)[]): string | null {
    if (!sourceName) {
      return sourceName;
    }

    const base = sourceName.replace(/ \(\d+\)$/, '');
    const taken = new Set(existingNames.filter((name): name is string => name !== null));

    let candidate = `${base} (1)`;
    for (let counter = 2; taken.has(candidate); counter++) {
      candidate = `${base} (${counter})`;
    }
    return candidate;
  }

  /**
   * Update an existing item with field-based structure.
   * @param item The item object to update
   * @param originalAttachmentIds Original attachment IDs for tracking changes
   * @param attachments Current attachments list
   * @param originalTotpCodeIds Original TOTP code IDs for tracking changes
   * @param totpCodes Current TOTP codes list
   * @returns The number of rows modified
   */
  public async update(
    item: Item,
    originalAttachmentIds: string[] = [],
    attachments: Attachment[] = [],
    originalTotpCodeIds: string[] = [],
    totpCodes: TotpCode[] = []
  ): Promise<number> {
    return this.withTransaction(async () => {
      const currentDateTime = this.now();

      // 1. Handle Logo
      const logoId = this.resolveLogoId(item, currentDateTime);

      // 2. Update Item only if item-level fields changed
      const existing = this.client.executeQuery<{
        Name: string | null;
        ItemType: number;
        FolderId: string | null;
        LogoId: string | null;
      }>(ItemQueries.GET_ITEM_FIELDS, [item.Id])[0];

      if (existing) {
        const nameChanged = (item.Name ?? null) !== existing.Name;
        const itemTypeChanged = String(item.ItemType) !== String(existing.ItemType);
        const folderIdChanged = (item.FolderId ?? null) !== existing.FolderId;
        // Logo is considered changed if: new logo differs from existing, OR logo was cleared (undefined/null in item.Logo while existing has one)
        const logoIdChanged = logoId !== existing.LogoId;

        if (nameChanged || itemTypeChanged || folderIdChanged || logoIdChanged) {
          // Use UPDATE_ITEM_WITH_LOGO to allow explicit clearing of LogoId
          this.client.executeUpdate(ItemQueries.UPDATE_ITEM_WITH_LOGO, [
            item.Name ?? null,
            item.ItemType,
            item.FolderId ?? null,
            logoId,
            currentDateTime,
            item.Id
          ]);
        }
      }

      // 3. Track history for fields that have EnableHistory=true before updating
      await this.trackFieldHistory(item.Id, item.Fields, currentDateTime);

      // 4. Update field values
      this.updateFieldValues(item, currentDateTime);

      // 5. Handle TOTP codes
      this.handleTotpCodes(item.Id, totpCodes, originalTotpCodeIds, currentDateTime);

      // 6. Handle attachments
      this.handleAttachments(item.Id, attachments, originalAttachmentIds, currentDateTime);

      return 1;
    });
  }

  /**
   * Move an item to "Recently Deleted" (trash).
   * @param itemId - The ID of the item to trash
   * @returns The number of rows updated
   */
  public async trash(itemId: string): Promise<number> {
    return this.withTransaction(async () => {
      const currentDateTime = this.now();
      return this.client.executeUpdate(ItemQueries.TRASH_ITEM, [
        currentDateTime,
        currentDateTime,
        itemId
      ]);
    });
  }

  /**
   * Restore an item from "Recently Deleted".
   * @param itemId - The ID of the item to restore
   * @returns The number of rows updated
   */
  public async restore(itemId: string): Promise<number> {
    return this.withTransaction(async () => {
      const currentDateTime = this.now();
      return this.client.executeUpdate(ItemQueries.RESTORE_ITEM, [
        currentDateTime,
        itemId
      ]);
    });
  }

  /**
   * Permanently delete an item - converts to tombstone for sync.
   * @param itemId - The ID of the item to permanently delete
   * @returns The number of rows updated
   */
  public async permanentlyDelete(itemId: string): Promise<number> {
    return this.withTransaction(async () => {
      const currentDateTime = this.now();

      // Hard delete all related entities
      this.hardDeleteByForeignKey('FieldValues', 'ItemId', itemId);
      this.hardDeleteByForeignKey('FieldHistories', 'ItemId', itemId);
      this.hardDeleteByForeignKey('Passkeys', 'ItemId', itemId);
      this.hardDeleteByForeignKey('TotpCodes', 'ItemId', itemId);
      this.hardDeleteByForeignKey('Attachments', 'ItemId', itemId);
      this.hardDeleteByForeignKey('ItemTags', 'ItemId', itemId);

      // Convert item to tombstone.
      return this.client.executeUpdate(ItemQueries.TOMBSTONE_ITEM, [
        currentDateTime,
        itemId
      ]);
    });
  }

  /**
   * Get all items in "Recently Deleted".
   * @returns Array of trashed Item objects with DeletedAt
   */
  public getRecentlyDeleted(): ItemWithDeletedAt[] {
    let itemRows: (ItemRow & { DeletedAt: string })[];
    try {
      const query = `
        SELECT
          i.Id,
          i.Name,
          i.ItemType,
          i.FolderId,
          l.FileData as Logo,
          i.DeletedAt,
          CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey,
          CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment,
          CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp,
          i.CreatedAt,
          i.UpdatedAt
        FROM Items i
        LEFT JOIN Logos l ON i.LogoId = l.Id
        WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL
        ORDER BY i.DeletedAt DESC`;

      itemRows = this.client.executeQuery(query);
    } catch (error) {
      if (error instanceof Error && error.message.includes('no such table')) {
        return [];
      }
      throw error;
    }

    if (itemRows.length === 0) {
      return [];
    }

    const itemIds = itemRows.map(i => i.Id);

    // Get all field values
    const fieldRows = this.client.executeQuery<FieldRow>(
      ItemQueries.getFieldValuesForItems(itemIds.length),
      itemIds
    );
    const fieldsByItem = FieldMapper.processFieldRows(fieldRows);

    // Build folder paths
    const folderPaths = this.buildFolderPaths();

    return itemRows.map(row => ItemMapper.mapDeletedItemRow(
      row,
      fieldsByItem.get(row.Id) || [],
      row.FolderId ? folderPaths.get(row.FolderId) : undefined
    ));
  }

  /**
   * Get count of items in "Recently Deleted".
   * @returns Number of trashed items
   */
  public getRecentlyDeletedCount(): number {
    try {
      const result = this.client.executeQuery<{ count: number }>(ItemQueries.COUNT_RECENTLY_DELETED);
      return result[0]?.count || 0;
    } catch (error) {
      if (error instanceof Error && error.message.includes('no such table')) {
        return 0;
      }
      throw error;
    }
  }

  /**
   * Get field history for a specific field.
   * @param itemId - The ID of the item
   * @param fieldKey - The field key to get history for
   * @returns Array of field history records
   */
  public getFieldHistory(itemId: string, fieldKey: string): FieldHistory[] {
    const results = this.client.executeQuery<{
      Id: string;
      ItemId: string;
      FieldKey: string;
      ValueSnapshot: string;
      ChangedAt: string;
      CreatedAt: string;
      UpdatedAt: string;
    }>(FieldHistoryQueries.GET_FOR_FIELD, [itemId, fieldKey, MAX_FIELD_HISTORY_RECORDS]);

    return results.map(row => ({
      Id: row.Id,
      ItemId: row.ItemId,
      FieldKey: row.FieldKey,
      ValueSnapshot: row.ValueSnapshot,
      ChangedAt: row.ChangedAt,
      CreatedAt: row.CreatedAt,
      UpdatedAt: row.UpdatedAt
    }));
  }

  /**
   * Delete a specific field history record.
   * @param historyId - The ID of the history record to delete
   * @returns Number of rows affected
   */
  public async deleteFieldHistory(historyId: string): Promise<number> {
    return this.withTransaction(async () => {
      const currentDateTime = this.now();
      return this.client.executeUpdate(FieldHistoryQueries.SOFT_DELETE, [
        currentDateTime,
        historyId
      ]);
    });
  }

  // ===== Private Helper Methods =====

  /**
   * Resolve the logo ID for an item (create new or reuse existing).
   */
  private resolveLogoId(item: Item, currentDateTime: string): string | null {
    const urlField = item.Fields?.find(f => f.FieldKey === 'login.url');
    const urlValue = urlField?.Value;
    const urlString = Array.isArray(urlValue) ? urlValue[0] : urlValue;
    const source = this.logoRepository.extractSourceFromUrl(urlString);

    if (item.Logo) {
      const logoData = this.logoRepository.convertLogoToUint8Array(item.Logo);
      if (logoData) {
        return this.logoRepository.getOrCreate(source, logoData, currentDateTime);
      }
    } else if (source !== 'unknown') {
      return this.logoRepository.getIdForSource(source);
    }

    return null;
  }

  /**
   * Insert field values for a new item.
   * Also creates history records for fields with EnableHistory=true.
   */
  private insertFieldValues(
    itemId: string,
    fields: ItemField[],
    itemType: string,
    currentDateTime: string
  ): void {
    for (const field of fields) {
      // Skip empty system fields, but always persist custom fields (even if empty)
      const isEmpty = !field.Value || (typeof field.Value === 'string' && field.Value.trim() === '');
      if (isEmpty && !field.IsCustomField) {
        continue;
      }

      let fieldDefinitionId = null;

      // For custom fields, create or get FieldDefinition
      if (field.IsCustomField) {
        fieldDefinitionId = this.ensureFieldDefinition(field, itemType, currentDateTime);
      }

      // Handle multi-value fields
      const values = Array.isArray(field.Value) ? field.Value : [field.Value];
      const filteredValues = values.filter(v => v && v.trim() !== '');

      // For custom fields with no values, insert with empty string to preserve the field
      const valuesToInsert = field.IsCustomField && filteredValues.length === 0
        ? ['']
        : filteredValues;

      for (const value of valuesToInsert) {
        this.client.executeUpdate(FieldValueQueries.INSERT, [
          this.generateId(),
          itemId,
          fieldDefinitionId,
          field.IsCustomField ? null : field.FieldKey,
          value,
          field.DisplayOrder ?? 0,
          currentDateTime,
          currentDateTime,
          0
        ]);
      }

      // Create history record for fields with EnableHistory=true
      if (field.EnableHistory && filteredValues.length > 0) {
        const historyId = this.generateId();
        const valueSnapshot = JSON.stringify(filteredValues);

        this.client.executeUpdate(FieldHistoryQueries.INSERT, [
          historyId,
          itemId,
          null,
          field.FieldKey,
          valueSnapshot,
          currentDateTime,
          currentDateTime,
          currentDateTime,
          0
        ]);
      }
    }
  }

  /**
   * Ensure a field definition exists for a custom field.
   */
  private ensureFieldDefinition(
    field: ItemField,
    itemType: string,
    currentDateTime: string
  ): string {
    const existingDef = this.client.executeQuery<{ Id: string }>(
      FieldDefinitionQueries.EXISTS,
      [field.FieldKey]
    );

    if (existingDef.length === 0) {
      this.client.executeUpdate(FieldDefinitionQueries.INSERT, [
        field.FieldKey,
        field.FieldType,
        field.Label,
        0, // IsMultiValue
        field.IsHidden ? 1 : 0,
        0, // EnableHistory
        field.DisplayOrder ?? 0,
        itemType,
        currentDateTime,
        currentDateTime,
        0
      ]);
    }

    return field.FieldKey;
  }

  /**
   * Update field values for an existing item.
   */
  private updateFieldValues(item: Item, currentDateTime: string): void {
    // Get existing FieldValues
    const existingFieldValues = this.client.executeQuery<{
      Id: string;
      FieldKey: string | null;
      FieldDefinitionId: string | null;
      Value: string;
      Weight: number;
    }>(FieldValueQueries.GET_EXISTING_FOR_ITEM, [item.Id]);

    // Build a map of existing FieldValues by key:index
    const existingByKey = new Map<string, { Id: string; Value: string; Weight: number }>();
    const fieldValueCounts = new Map<string, number>();

    for (const fv of existingFieldValues) {
      const key = fv.FieldKey || fv.FieldDefinitionId || '';
      const count = fieldValueCounts.get(key) || 0;
      existingByKey.set(`${key}:${count}`, { Id: fv.Id, Value: fv.Value, Weight: fv.Weight });
      fieldValueCounts.set(key, count + 1);
    }

    const processedIds = new Set<string>();

    // Update existing or insert new FieldValues
    if (item.Fields && item.Fields.length > 0) {
      for (const field of item.Fields) {
        // Skip empty system fields, but always persist custom fields (even if empty)
        const isEmpty = !field.Value || (typeof field.Value === 'string' && field.Value.trim() === '');
        if (isEmpty && !field.IsCustomField) {
          continue;
        }

        let fieldDefinitionId = null;

        if (field.IsCustomField) {
          fieldDefinitionId = this.ensureOrUpdateFieldDefinition(field, item.ItemType, currentDateTime);
        }

        const values = Array.isArray(field.Value) ? field.Value : [field.Value];
        const effectiveKey = field.FieldKey;

        // For custom fields with no values, use empty string to preserve the field
        const filteredValues = values.filter(v => v && (typeof v !== 'string' || v.trim() !== ''));
        const valuesToProcess = field.IsCustomField && filteredValues.length === 0
          ? ['']
          : filteredValues;

        for (let i = 0; i < valuesToProcess.length; i++) {
          const value = valuesToProcess[i];

          const lookupKey = `${effectiveKey}:${i}`;
          const existing = existingByKey.get(lookupKey);

          if (existing) {
            processedIds.add(existing.Id);
            const newWeight = field.DisplayOrder ?? 0;
            if (existing.Value !== value || existing.Weight !== newWeight) {
              this.client.executeUpdate(FieldValueQueries.UPDATE, [
                value,
                newWeight,
                currentDateTime,
                existing.Id
              ]);
            }
          } else {
            this.client.executeUpdate(FieldValueQueries.INSERT, [
              this.generateId(),
              item.Id,
              fieldDefinitionId,
              field.IsCustomField ? null : field.FieldKey,
              value,
              field.DisplayOrder ?? 0,
              currentDateTime,
              currentDateTime,
              0
            ]);
          }
        }
      }
    }

    // Soft-delete any FieldValues that were not processed
    for (const fv of existingFieldValues) {
      if (!processedIds.has(fv.Id)) {
        this.client.executeUpdate(FieldValueQueries.SOFT_DELETE, [currentDateTime, fv.Id]);
      }
    }
  }

  /**
   * Ensure a field definition exists and is up-to-date.
   */
  private ensureOrUpdateFieldDefinition(
    field: ItemField,
    itemType: string,
    currentDateTime: string
  ): string {
    const existingDef = this.client.executeQuery<{ Id: string }>(
      FieldDefinitionQueries.EXISTS_ACTIVE,
      [field.FieldKey]
    );

    if (existingDef.length === 0) {
      this.client.executeUpdate(FieldDefinitionQueries.INSERT, [
        field.FieldKey,
        field.FieldType,
        field.Label,
        0,
        field.IsHidden ? 1 : 0,
        0,
        field.DisplayOrder ?? 0,
        itemType,
        currentDateTime,
        currentDateTime,
        0
      ]);
    } else {
      this.client.executeUpdate(FieldDefinitionQueries.UPDATE, [
        field.Label,
        field.FieldType,
        field.IsHidden ? 1 : 0,
        field.DisplayOrder ?? 0,
        currentDateTime,
        field.FieldKey
      ]);
    }

    return field.FieldKey;
  }

  /**
   * Track field history for fields with EnableHistory=true.
   *
   * This saves the NEW value to history on every change. Since each value is saved
   * when it's set, we don't need to save the old value (it was already saved when
   * it was first set). This ensures that during merge conflicts, no values are ever
   * lost since history records sync independently via LWW and each has a unique ID.
   */
  private async trackFieldHistory(
    itemId: string,
    newFields: ItemField[],
    currentDateTime: string
  ): Promise<void> {
    const existingFields = this.client.executeQuery<{ FieldKey: string; Value: string }>(
      FieldValueQueries.GET_FOR_HISTORY,
      [itemId]
    );

    // Create a map of existing values by FieldKey
    const existingValuesMap: { [key: string]: string[] } = {};
    for (const field of existingFields) {
      if (!existingValuesMap[field.FieldKey]) {
        existingValuesMap[field.FieldKey] = [];
      }
      existingValuesMap[field.FieldKey].push(field.Value);
    }

    for (const newField of newFields) {
      /**
       * Check if history tracking is enabled for this field.
       * EnableHistory comes from SystemFieldRegistry for system fields,
       * or from the FieldDefinitions table for custom fields.
       */
      if (!newField.EnableHistory) {
        continue;
      }

      const oldValues = existingValuesMap[newField.FieldKey] || [];
      const newValues = Array.isArray(newField.Value) ? newField.Value : [newField.Value];

      // Filter out empty values for comparison
      const filteredNewValues = newValues.filter(v => v && v.trim() !== '');

      const valuesChanged = oldValues.length !== filteredNewValues.length ||
        !oldValues.every((val, idx) => val === filteredNewValues[idx]);

      // Save new values to history when they change (ensures they survive merge conflicts)
      if (valuesChanged && filteredNewValues.length > 0) {
        const historyId = this.generateId();
        const valueSnapshot = JSON.stringify(filteredNewValues);

        this.client.executeUpdate(FieldHistoryQueries.INSERT, [
          historyId,
          itemId,
          null,
          newField.FieldKey,
          valueSnapshot,
          currentDateTime,
          currentDateTime,
          currentDateTime,
          0
        ]);

        await this.pruneFieldHistory(itemId, newField.FieldKey, currentDateTime);
      }
    }
  }

  /**
   * Prune old field history records.
   */
  private async pruneFieldHistory(
    itemId: string,
    fieldKey: string,
    currentDateTime: string
  ): Promise<void> {
    const matchingHistory = this.client.executeQuery<{ Id: string; ChangedAt: string }>(
      FieldHistoryQueries.GET_FOR_PRUNING,
      [itemId, fieldKey]
    );

    if (matchingHistory.length > MAX_FIELD_HISTORY_RECORDS) {
      const recordsToDelete = matchingHistory.slice(MAX_FIELD_HISTORY_RECORDS);
      const idsToDelete = recordsToDelete.map(r => r.Id);

      if (idsToDelete.length > 0) {
        this.client.executeUpdate(
          FieldHistoryQueries.softDeleteOld(idsToDelete.length),
          [currentDateTime, ...idsToDelete]
        );
      }
    }
  }

  /**
   * Insert TOTP codes for a new item.
   */
  private insertTotpCodes(itemId: string, totpCodes: TotpCode[], currentDateTime: string): void {
    for (const totpCode of totpCodes) {
      this.client.executeUpdate(
        TotpCodeQueries.INSERT,
        [
          totpCode.Id || this.generateId(),
          totpCode.Name,
          totpCode.SecretKey,
          itemId,
          currentDateTime,
          currentDateTime,
          0
        ]
      );
    }
  }

  /**
   * Handle TOTP code updates.
   */
  private handleTotpCodes(
    itemId: string,
    totpCodes: TotpCode[],
    originalIds: string[],
    currentDateTime: string
  ): void {
    // Fetch existing TOTP codes to compare values
    const existingTotpCodes = this.client.executeQuery<{
      Id: string;
      Name: string;
      SecretKey: string;
    }>(TotpCodeQueries.GET_BY_ITEM_ID, [itemId]);

    const existingByIdMap = new Map(existingTotpCodes.map(tc => [tc.Id, tc]));

    for (const totpCode of totpCodes) {
      const wasOriginal = originalIds.includes(totpCode.Id);

      if (totpCode.IsDeleted) {
        if (wasOriginal) {
          this.client.executeUpdate(
            TotpCodeQueries.SOFT_DELETE,
            [currentDateTime, totpCode.Id]
          );
        }
      } else if (wasOriginal) {
        // Only update if values actually changed
        const existing = existingByIdMap.get(totpCode.Id);
        if (existing && (existing.Name !== totpCode.Name || existing.SecretKey !== totpCode.SecretKey)) {
          this.client.executeUpdate(
            TotpCodeQueries.UPDATE,
            [totpCode.Name, totpCode.SecretKey, currentDateTime, totpCode.Id]
          );
        }
      } else {
        this.client.executeUpdate(
          TotpCodeQueries.INSERT,
          [
            totpCode.Id || this.generateId(),
            totpCode.Name,
            totpCode.SecretKey,
            itemId,
            currentDateTime,
            currentDateTime,
            0
          ]
        );
      }
    }
  }

  /**
   * Insert attachments for a new item.
   */
  private insertAttachments(itemId: string, attachments: Attachment[], currentDateTime: string): void {
    for (const attachment of attachments) {
      const blobData = attachment.Blob instanceof Uint8Array
        ? attachment.Blob
        : new Uint8Array(attachment.Blob);

      this.client.executeUpdate(
        AttachmentQueries.INSERT,
        [
          attachment.Id || this.generateId(),
          attachment.Filename,
          blobData,
          itemId,
          currentDateTime,
          currentDateTime,
          0
        ]
      );
    }
  }

  /**
   * Handle attachment updates.
   */
  private handleAttachments(
    itemId: string,
    attachments: Attachment[],
    originalIds: string[],
    currentDateTime: string
  ): void {
    // Track which original attachments are still present
    const currentAttachmentIds = new Set(attachments.map(a => a.Id));

    // Soft-delete any original attachments that are no longer in the list
    for (const originalId of originalIds) {
      if (!currentAttachmentIds.has(originalId)) {
        this.client.executeUpdate(
          AttachmentQueries.SOFT_DELETE,
          [currentDateTime, originalId]
        );
      }
    }

    // Process current attachments
    for (const attachment of attachments) {
      const wasOriginal = originalIds.includes(attachment.Id);

      if (attachment.IsDeleted) {
        if (wasOriginal) {
          this.client.executeUpdate(
            AttachmentQueries.SOFT_DELETE,
            [currentDateTime, attachment.Id]
          );
        }
      } else if (!wasOriginal) {
        const blobData = attachment.Blob instanceof Uint8Array
          ? attachment.Blob
          : new Uint8Array(attachment.Blob);

        this.client.executeUpdate(
          AttachmentQueries.INSERT,
          [
            attachment.Id || this.generateId(),
            attachment.Filename,
            blobData,
            itemId,
            currentDateTime,
            currentDateTime,
            0
          ]
        );
      }
    }
  }
}
