/**
 * Sync service - orchestrates file ingestion.
 * Walks collections, converts files, chunks content, updates store.
 *
 * @module src/ingestion/sync
 */

// node:fs/promises for stat (no Bun equivalent for file stats)
import { stat } from "node:fs/promises";
// node:path for join (no Bun path utils)
import { join } from "node:path";

import type { Collection } from "../config/types";
import type {
  ChunkInput,
  DocLinkInput,
  DocumentRow,
  IngestErrorInput,
  StorePort,
  StoreResult,
} from "../store/types";
import type {
  ChunkerPort,
  CollectionSyncResult,
  FileSyncResult,
  ProcessDecision,
  SyncOptions,
  SyncResult,
  WalkEntry,
  WalkerPort,
} from "./types";

import { getDefaultMimeDetector, type MimeDetector } from "../converters/mime";
import {
  type ConversionPipeline,
  getDefaultPipeline,
} from "../converters/pipeline";
import { DEFAULT_LIMITS } from "../converters/types";
import {
  normalizeMarkdownPath,
  normalizeWikiName,
  parseLinks,
} from "../core/links";
import { normalizeTag, validateTag } from "../core/tags";
import { defaultChunker } from "./chunker";
import {
  extractHashtags,
  parseFrontmatter,
  stripFrontmatter,
} from "./frontmatter";
import { buildLineOffsets } from "./position";
import { getExcludedRanges } from "./strip";
import { collectionToWalkConfig, DEFAULT_CHUNK_PARAMS } from "./types";
import { defaultWalker } from "./walker";

/** Default concurrency for file processing */
const DEFAULT_CONCURRENCY = 1;

/** Batch size for grouping writes into single transaction (Windows perf) */
const TX_BATCH_SIZE = 50;

/** Max concurrency to prevent resource exhaustion */
const MAX_CONCURRENCY = 16;

/**
 * Current ingest schema version.
 * Increment when ingestion adds new derived data (tags, metadata, etc.)
 * Documents with ingestVersion < INGEST_VERSION will be re-processed.
 */
export const INGEST_VERSION = 5;

/**
 * Decide whether to process a file or skip it.
 * Handles repair cases where sourceHash matches but content is incomplete.
 * Also triggers re-processing for documents with outdated ingest version.
 */
function decideAction(
  existing: DocumentRow | null,
  sourceHash: string
): ProcessDecision {
  // No existing doc - must process
  if (!existing) {
    return { kind: "process", reason: "new file" };
  }

  // Source hash changed - must process
  if (existing.sourceHash !== sourceHash) {
    return { kind: "process", reason: "content changed" };
  }

  // Source unchanged, but check for repair cases:

  // 1. Previous conversion failed (mirrorHash is null)
  if (!existing.mirrorHash) {
    return { kind: "repair", reason: "previous conversion failed" };
  }

  // 2. Document has error recorded
  if (existing.lastErrorCode) {
    return { kind: "repair", reason: "previous error recorded" };
  }

  // 3. Ingest version is outdated (new derived data available)
  if (
    existing.ingestVersion === null ||
    existing.ingestVersion < INGEST_VERSION
  ) {
    return { kind: "repair", reason: "ingest version outdated" };
  }

  // All good - skip
  return { kind: "skip", reason: "unchanged" };
}

/**
 * Extract tags from markdown content.
 * Combines frontmatter tags and inline hashtags, normalized and validated.
 */
function extractTags(markdown: string): string[] {
  const tags = new Set<string>();

  // 1. Extract from frontmatter
  const frontmatter = parseFrontmatter(markdown);
  for (const tag of frontmatter.tags) {
    const normalized = normalizeTag(tag);
    if (validateTag(normalized)) {
      tags.add(normalized);
    }
  }

  // 2. Extract hashtags from body (after stripping frontmatter)
  const body = stripFrontmatter(markdown);
  const hashtags = extractHashtags(body);
  for (const tag of hashtags) {
    const normalized = normalizeTag(tag);
    if (validateTag(normalized)) {
      tags.add(normalized);
    }
  }

  return [...tags];
}

interface DocumentMetadata {
  contentType?: string;
  categories?: string[];
  author?: string;
  frontmatterDate?: string;
  dateFields?: Record<string, string>;
}

const CODE_EXTENSIONS = new Set([
  ".c",
  ".cc",
  ".cpp",
  ".cs",
  ".go",
  ".java",
  ".js",
  ".jsx",
  ".m",
  ".mm",
  ".php",
  ".py",
  ".rb",
  ".rs",
  ".swift",
  ".ts",
  ".tsx",
]);

const AUTHOR_KEYS = ["author", "by", "owner", "creator"] as const;
const DATE_KEYS = [
  "date",
  "published",
  "published_at",
  "created",
  "created_at",
  "updated",
  "updated_at",
] as const;
const DATE_FIELD_KEY_REGEX =
  /(^|_)(date|time|created|updated|published|modified|deadline|expires|expiry|start|end)(_|$)/;

function normalizeMetadataKey(rawKey: string): string {
  return rawKey
    .trim()
    .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "")
    .replace(/_+/g, "_");
}

function normalizeDate(value: unknown): string | undefined {
  if (value instanceof Date) {
    return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
  }
  if (typeof value !== "string" && typeof value !== "number") {
    return undefined;
  }
  const normalizedValue =
    typeof value === "string"
      ? value.trim().replace(/^["'](.*)["']$/, "$1")
      : value;
  const parsed = new Date(normalizedValue);
  if (Number.isNaN(parsed.getTime())) {
    return undefined;
  }
  return parsed.toISOString();
}

function inferContentType(relPath: string, ext: string): string {
  const lowerPath = relPath.toLowerCase();
  if (CODE_EXTENSIONS.has(ext.toLowerCase())) {
    return "code";
  }
  if (/(meeting|standup|retro|minutes)/.test(lowerPath)) {
    return "meeting";
  }
  if (/(spec|rfc|adr|design)/.test(lowerPath)) {
    return "spec";
  }
  if (/(notes|journal|log)/.test(lowerPath)) {
    return "notes";
  }
  return "prose";
}

function parseCategories(input: unknown): string[] {
  if (Array.isArray(input)) {
    return input
      .filter((v): v is string => typeof v === "string")
      .map((v) => v.trim().toLowerCase())
      .filter((v) => v.length > 0);
  }
  if (typeof input === "string") {
    return input
      .split(",")
      .map((v) => v.trim().toLowerCase())
      .filter((v) => v.length > 0);
  }
  return [];
}

function extractDocumentMetadata(
  markdown: string,
  relPath: string,
  ext: string
): DocumentMetadata {
  const parsed = parseFrontmatter(markdown);
  const metadata = parsed.metadata;
  const contentType = inferContentType(relPath, ext);
  const categories = new Set<string>([contentType]);

  const fmCategories = parseCategories(
    metadata.category ?? metadata.categories ?? metadata.type
  );
  for (const category of fmCategories) {
    categories.add(category);
  }

  let author: string | undefined;
  for (const key of AUTHOR_KEYS) {
    const value = metadata[key];
    if (typeof value === "string" && value.trim().length > 0) {
      author = value.trim();
      break;
    }
  }

  const normalizedMetadata = new Map<string, unknown>();
  for (const [rawKey, value] of Object.entries(metadata)) {
    const key = normalizeMetadataKey(rawKey);
    if (key.length > 0 && !normalizedMetadata.has(key)) {
      normalizedMetadata.set(key, value);
    }
  }

  let frontmatterDate: string | undefined;
  for (const key of DATE_KEYS) {
    const normalized = normalizeDate(normalizedMetadata.get(key));
    if (normalized) {
      frontmatterDate = normalized;
      break;
    }
  }

  const dateFields: Record<string, string> = {};
  for (const [key, value] of normalizedMetadata.entries()) {
    if (!DATE_FIELD_KEY_REGEX.test(key)) {
      continue;
    }
    const normalized = normalizeDate(value);
    if (normalized) {
      dateFields[key] = normalized;
    }
  }

  return {
    contentType,
    categories: [...categories],
    author,
    frontmatterDate,
    dateFields: Object.keys(dateFields).length > 0 ? dateFields : undefined,
  };
}

/**
 * Check if path is a git repository (supports worktrees and submodules).
 * Uses git rev-parse which handles all git directory layouts.
 */
async function isGitRepo(path: string): Promise<boolean> {
  try {
    const result = await Bun.$`git -C ${path} rev-parse --is-inside-work-tree`
      .quiet()
      .nothrow();
    return result.exitCode === 0;
  } catch {
    return false;
  }
}

/**
 * Run git pull in directory (best effort).
 */
async function gitPull(path: string): Promise<void> {
  try {
    await Bun.$`git -C ${path} pull`.quiet().nothrow();
  } catch {
    // Ignore git pull failures
  }
}

/**
 * Run collection update command (best effort).
 */
async function runUpdateCmd(path: string, cmd: string): Promise<void> {
  try {
    await Bun.$`sh -c ${cmd}`.cwd(path).quiet().nothrow();
  } catch {
    // Ignore update command failures
  }
}

/**
 * Helper to unwrap Result and throw on error.
 * Provides consistent error handling for store operations.
 */
function mustOk<T>(
  result: StoreResult<T>,
  operation: string,
  context: Record<string, unknown>
): T {
  if (!result.ok) {
    const error = new Error(
      `Store operation failed: ${operation} - ${result.error.message}`
    );
    (error as Error & { context: unknown }).context = context;
    throw error;
  }
  return result.value;
}

/**
 * Simple semaphore for bounded concurrency.
 */
class Semaphore {
  private permits: number;
  private readonly waiting: Array<() => void> = [];

  constructor(permits: number) {
    this.permits = permits;
  }

  async acquire(): Promise<void> {
    if (this.permits > 0) {
      this.permits -= 1;
      return;
    }
    await new Promise<void>((resolve) => {
      this.waiting.push(resolve);
    });
  }

  release(): void {
    const next = this.waiting.shift();
    if (next) {
      next();
    } else {
      this.permits += 1;
    }
  }
}

/**
 * Sync service implementation.
 */
export class SyncService {
  private readonly walker: WalkerPort;
  private readonly chunker: ChunkerPort;
  private readonly mimeDetector: MimeDetector;
  private readonly pipeline: ConversionPipeline;

  constructor(
    walker?: WalkerPort,
    chunker?: ChunkerPort,
    mimeDetector?: MimeDetector,
    pipeline?: ConversionPipeline
  ) {
    this.walker = walker ?? defaultWalker;
    this.chunker = chunker ?? defaultChunker;
    this.mimeDetector = mimeDetector ?? getDefaultMimeDetector();
    this.pipeline = pipeline ?? getDefaultPipeline();
  }

  /**
   * Process a single file through the ingestion pipeline.
   * All store operations are checked and errors are propagated.
   */
  // oxlint-disable-next-line max-lines-per-function -- file processing with multiple extraction paths
  private async processFile(
    collection: Collection,
    entry: WalkEntry,
    store: StorePort,
    options: SyncOptions
  ): Promise<FileSyncResult> {
    const limits = {
      maxBytes: options.limits?.maxBytes ?? DEFAULT_LIMITS.maxBytes,
      timeoutMs: options.limits?.timeoutMs ?? DEFAULT_LIMITS.timeoutMs,
      maxOutputChars:
        options.limits?.maxOutputChars ?? DEFAULT_LIMITS.maxOutputChars,
    };

    try {
      // 1. Re-stat before read to enforce maxBytes on current file size
      let sourceSize = entry.size;
      let sourceMtime = entry.mtime;
      let sourceCtime = entry.ctime;
      try {
        const sourceStat = await stat(entry.absPath);
        if (!sourceStat.isFile()) {
          return {
            relPath: entry.relPath,
            status: "error",
            errorCode: "NOT_FILE",
            errorMessage: "Path is not a file",
          };
        }
        sourceSize = sourceStat.size;
        sourceMtime = sourceStat.mtime.toISOString();
        sourceCtime = (
          sourceStat.birthtime ??
          sourceStat.ctime ??
          sourceStat.mtime
        ).toISOString();
      } catch {
        return {
          relPath: entry.relPath,
          status: "error",
          errorCode: "NOT_FOUND",
          errorMessage: "File not found",
        };
      }

      if (sourceSize > limits.maxBytes) {
        const message = `File size ${sourceSize} exceeds limit ${limits.maxBytes}`;
        await store
          .recordError({
            collection: collection.name,
            relPath: entry.relPath,
            code: "TOO_LARGE",
            message,
          })
          .catch(() => undefined);
        return {
          relPath: entry.relPath,
          status: "skipped",
          errorCode: "TOO_LARGE",
          errorMessage: message,
        };
      }

      // 2. Read file bytes
      const bytes = await Bun.file(entry.absPath).bytes();

      // 3. Compute sourceHash
      const hasher = new Bun.CryptoHasher("sha256");
      hasher.update(bytes);
      const sourceHash = hasher.digest("hex");

      // 4. Check existing doc for skip/repair decision
      const existingResult = await store.getDocument(
        collection.name,
        entry.relPath
      );
      const existing = existingResult.ok ? existingResult.value : null;
      const decision = decideAction(existing, sourceHash);

      if (decision.kind === "skip") {
        return { relPath: entry.relPath, status: "unchanged" };
      }

      // 5. Detect MIME (bytes is already Uint8Array from Bun.file().bytes())
      const mime = this.mimeDetector.detect(entry.absPath, bytes);

      // 6. Convert via pipeline
      const convertResult = await this.pipeline.convert({
        sourcePath: entry.absPath,
        relativePath: entry.relPath,
        collection: collection.name,
        bytes,
        mime: mime.mime,
        ext: mime.ext,
        limits,
      });

      if (!convertResult.ok) {
        // Record error (checked)
        const errorInput: IngestErrorInput = {
          collection: collection.name,
          relPath: entry.relPath,
          code: convertResult.error.code,
          message: convertResult.error.message,
          details: convertResult.error.details,
        };
        const recordResult = await store.recordError(errorInput);
        if (!recordResult.ok) {
          // Log but continue - error recording is best-effort
        }

        // Upsert document with error info, explicitly clear mirrorHash
        const upsertResult = await store.upsertDocument({
          collection: collection.name,
          relPath: entry.relPath,
          sourceHash,
          sourceMime: mime.mime,
          sourceExt: mime.ext,
          sourceSize,
          sourceMtime,
          sourceCtime,
          lastErrorCode: convertResult.error.code,
          lastErrorMessage: convertResult.error.message,
          // mirrorHash intentionally omitted (will be null)
        });

        if (!upsertResult.ok) {
          return {
            relPath: entry.relPath,
            status: "error",
            errorCode: "STORE_ERROR",
            errorMessage: upsertResult.error.message,
          };
        }

        return {
          relPath: entry.relPath,
          status: "error",
          errorCode: convertResult.error.code,
          errorMessage: convertResult.error.message,
        };
      }

      const artifact = convertResult.value;
      const extractedMetadata = extractDocumentMetadata(
        artifact.markdown,
        entry.relPath,
        mime.ext
      );

      // 7. Upsert document - EXPLICITLY clear error fields on success
      const docidResult = await store.upsertDocument({
        collection: collection.name,
        relPath: entry.relPath,
        sourceHash,
        sourceMime: mime.mime,
        sourceExt: mime.ext,
        sourceSize,
        sourceMtime,
        sourceCtime,
        title: artifact.title,
        mirrorHash: artifact.mirrorHash,
        converterId: artifact.meta.converterId,
        converterVersion: artifact.meta.converterVersion,
        languageHint: artifact.languageHint ?? collection.languageHint,
        contentType: extractedMetadata.contentType,
        categories: extractedMetadata.categories,
        author: extractedMetadata.author,
        frontmatterDate: extractedMetadata.frontmatterDate,
        dateFields: extractedMetadata.dateFields,
        // Clear error fields on success (requires store to handle undefined → null)
        lastErrorCode: undefined,
        lastErrorMessage: undefined,
        ingestVersion: INGEST_VERSION,
      });

      const { id: docId, docid } = mustOk(docidResult, "upsertDocument", {
        collection: collection.name,
        relPath: entry.relPath,
      });

      // 8. Upsert content (content-addressed dedupe) - CHECKED
      const contentResult = await store.upsertContent(
        artifact.mirrorHash,
        artifact.markdown
      );
      mustOk(contentResult, "upsertContent", {
        mirrorHash: artifact.mirrorHash,
      });

      // 9. Chunk content
      const chunks = this.chunker.chunk(
        artifact.markdown,
        DEFAULT_CHUNK_PARAMS,
        artifact.languageHint ?? collection.languageHint,
        entry.relPath
      );

      // 10. Convert to ChunkInput for store
      const chunkInputs: ChunkInput[] = chunks.map((c) => ({
        seq: c.seq,
        pos: c.pos,
        text: c.text,
        startLine: c.startLine,
        endLine: c.endLine,
        language: c.language ?? undefined,
        tokenCount: c.tokenCount ?? undefined,
      }));

      // 11. Upsert chunks - CHECKED
      const chunksResult = await store.upsertChunks(
        artifact.mirrorHash,
        chunkInputs
      );
      mustOk(chunksResult, "upsertChunks", {
        mirrorHash: artifact.mirrorHash,
        chunkCount: chunkInputs.length,
      });

      // 12. Rebuild FTS for this hash - CHECKED
      const ftsResult = await store.rebuildFtsForHash(artifact.mirrorHash);
      mustOk(ftsResult, "rebuildFtsForHash", {
        mirrorHash: artifact.mirrorHash,
      });

      // 13. Extract and store tags from frontmatter and body hashtags
      // Always call setDocTags to clear removed tags on re-sync
      const extractedTags = extractTags(artifact.markdown);
      const tagsResult = await store.setDocTags(
        docId,
        extractedTags,
        "frontmatter"
      );
      mustOk(tagsResult, "setDocTags", {
        docId,
        tagCount: extractedTags.length,
      });

      // 14. Extract and store links (wiki and markdown links)
      const excludedRanges = getExcludedRanges(artifact.markdown);
      const lineOffsets = buildLineOffsets(artifact.markdown);
      const parsedLinks = parseLinks(
        artifact.markdown,
        lineOffsets,
        excludedRanges
      );

      const linkInputs: DocLinkInput[] = [];
      for (const link of parsedLinks) {
        // Compute target_ref_norm based on link type
        let targetRefNorm: string;
        if (link.kind === "wiki") {
          targetRefNorm = normalizeWikiName(link.targetRef);
        } else {
          // Markdown links with collection prefix are not supported
          // (use wiki links for cross-collection references)
          if (link.targetCollection) {
            continue;
          }
          const resolved = normalizeMarkdownPath(link.targetRef, entry.relPath);
          if (!resolved) {
            // Link escapes collection root - skip silently
            continue;
          }
          targetRefNorm = resolved;
        }

        linkInputs.push({
          targetRef: link.targetRef,
          targetRefNorm,
          targetAnchor: link.targetAnchor,
          targetCollection: link.targetCollection,
          linkType: link.kind,
          linkText: link.displayText,
          startLine: link.startLine,
          startCol: link.startCol,
          endLine: link.endLine,
          endCol: link.endCol,
        });
      }

      const linksResult = await store.setDocLinks(docId, linkInputs, "parsed");
      mustOk(linksResult, "setDocLinks", {
        docId,
        linkCount: linkInputs.length,
      });

      const status = existing ? "updated" : "added";
      return {
        relPath: entry.relPath,
        status,
        docid,
        mirrorHash: artifact.mirrorHash,
      };
    } catch (error) {
      const message = error instanceof Error ? error.message : "Unknown error";
      // Distinguish store errors from other internal errors
      const isStoreError =
        message.startsWith("Store operation failed:") ||
        (error instanceof Error &&
          (error as Error & { context?: unknown }).context !== undefined);
      const code = isStoreError ? "STORE_ERROR" : "INTERNAL";

      // Record internal error to store (best-effort)
      try {
        await store.recordError({
          collection: collection.name,
          relPath: entry.relPath,
          code,
          message,
          details: {
            stack: error instanceof Error ? error.stack : undefined,
          },
        });

        // Also update document with error info if it exists
        const existingResult = await store.getDocument(
          collection.name,
          entry.relPath
        );
        if (existingResult.ok && existingResult.value) {
          await store.upsertDocument({
            collection: collection.name,
            relPath: entry.relPath,
            sourceHash: existingResult.value.sourceHash,
            sourceMime: existingResult.value.sourceMime,
            sourceExt: existingResult.value.sourceExt,
            sourceSize: existingResult.value.sourceSize,
            sourceMtime: existingResult.value.sourceMtime,
            sourceCtime:
              existingResult.value.sourceCtime ??
              existingResult.value.sourceMtime,
            lastErrorCode: code,
            lastErrorMessage: message,
          });
        }
      } catch {
        // Best-effort error recording
      }

      return {
        relPath: entry.relPath,
        status: "error",
        errorCode: code,
        errorMessage: message,
      };
    }
  }

  /**
   * Sync a specific set of files within a collection.
   */
  async syncFiles(
    collection: Collection,
    store: StorePort,
    relPaths: string[],
    options: SyncOptions = {}
  ): Promise<FileSyncResult[]> {
    const results: FileSyncResult[] = [];

    for (const relPath of relPaths) {
      const absPath = join(collection.path, relPath);
      let stats: Awaited<ReturnType<typeof stat>>;
      try {
        stats = await stat(absPath);
      } catch {
        results.push({
          relPath,
          status: "error",
          errorCode: "NOT_FOUND",
          errorMessage: "File not found",
        });
        continue;
      }

      if (!stats.isFile()) {
        results.push({
          relPath,
          status: "error",
          errorCode: "NOT_FILE",
          errorMessage: "Path is not a file",
        });
        continue;
      }

      const entry: WalkEntry = {
        absPath,
        relPath,
        size: stats.size,
        mtime: stats.mtime.toISOString(),
        ctime: (stats.birthtime ?? stats.ctime ?? stats.mtime).toISOString(),
      };

      const result = await this.processFile(collection, entry, store, options);
      results.push(result);
    }

    return results;
  }

  /**
   * Sync a single collection.
   */
  // oxlint-disable-next-line max-lines-per-function -- sync orchestration with git and progress
  async syncCollection(
    collection: Collection,
    store: StorePort,
    options: SyncOptions = {}
  ): Promise<CollectionSyncResult> {
    const startTime = Date.now();
    const errors: Array<{ relPath: string; code: string; message: string }> =
      [];

    // 1. Run preflight commands
    if (options.runUpdateCmd !== false && collection.updateCmd) {
      await runUpdateCmd(collection.path, collection.updateCmd);
    }

    if (options.gitPull && (await isGitRepo(collection.path))) {
      await gitPull(collection.path);
    }

    // 2. Walk collection
    const maxBytes = options.limits?.maxBytes ?? DEFAULT_LIMITS.maxBytes;
    const walkConfig = collectionToWalkConfig(collection, maxBytes);
    const { entries, skipped } = await this.walker.walk(walkConfig);

    // Track seen paths for marking inactive
    // Only include TOO_LARGE files (they exist but are unprocessable)
    // EXCLUDED files should NOT be in seenPaths - if config changes to exclude
    // a previously-included file, that doc SHOULD be marked inactive
    const seenPaths = new Set<string>();
    for (const skip of skipped) {
      if (skip.reason === "TOO_LARGE") {
        seenPaths.add(skip.relPath);
      }
    }

    // 3. Record TOO_LARGE errors and track in seenPaths
    for (const skip of skipped) {
      if (skip.reason === "TOO_LARGE") {
        const recordResult = await store.recordError({
          collection: collection.name,
          relPath: skip.relPath,
          code: "TOO_LARGE",
          message: `File size ${skip.size} exceeds limit ${maxBytes}`,
        });
        // Log failure but continue
        if (!recordResult.ok) {
          errors.push({
            relPath: skip.relPath,
            code: "STORE_ERROR",
            message: `Failed to record error: ${recordResult.error.message}`,
          });
        }
        errors.push({
          relPath: skip.relPath,
          code: "TOO_LARGE",
          message: `File size ${skip.size} exceeds limit ${maxBytes}`,
        });
      }
    }

    // 4. Process files with bounded concurrency
    const concurrency = Math.max(
      1,
      Math.min(MAX_CONCURRENCY, options.concurrency ?? DEFAULT_CONCURRENCY)
    );

    let added = 0;
    let updated = 0;
    let unchanged = 0;
    let errored = 0;
    let dynamicSkipped = 0;

    if (concurrency === 1) {
      // Sequential processing with batched transactions (Windows perf)
      for (let i = 0; i < entries.length; i += TX_BATCH_SIZE) {
        const batch = entries.slice(i, i + TX_BATCH_SIZE);

        const runBatch = async (): Promise<void> => {
          for (const entry of batch) {
            seenPaths.add(entry.relPath);
            const result = await this.processFile(
              collection,
              entry,
              store,
              options
            );
            switch (result.status) {
              case "added":
                added += 1;
                break;
              case "updated":
                updated += 1;
                break;
              case "unchanged":
                unchanged += 1;
                break;
              case "error":
                errored += 1;
                if (result.errorCode && result.errorMessage) {
                  errors.push({
                    relPath: result.relPath,
                    code: result.errorCode,
                    message: result.errorMessage,
                  });
                }
                break;
              case "skipped":
                dynamicSkipped += 1;
                if (result.errorCode && result.errorMessage) {
                  errors.push({
                    relPath: result.relPath,
                    code: result.errorCode,
                    message: result.errorMessage,
                  });
                }
                break;
            }
          }
        };

        // Wrap batch in single transaction when supported (reduces commits)
        if (store.withTransaction) {
          const txResult = await store.withTransaction(runBatch);
          if (!txResult.ok) {
            errors.push({
              relPath: "(transaction batch)",
              code: txResult.error.code,
              message: txResult.error.message,
            });
            break; // Abort on transaction failure
          }
        } else {
          await runBatch();
        }
      }
    } else {
      // Concurrent processing with semaphore
      const semaphore = new Semaphore(concurrency);
      const results: FileSyncResult[] = [];

      await Promise.all(
        entries.map(async (entry) => {
          seenPaths.add(entry.relPath);
          await semaphore.acquire();
          try {
            const result = await this.processFile(
              collection,
              entry,
              store,
              options
            );
            results.push(result);
          } finally {
            semaphore.release();
          }
        })
      );

      // Aggregate results
      for (const result of results) {
        switch (result.status) {
          case "added":
            added += 1;
            break;
          case "updated":
            updated += 1;
            break;
          case "unchanged":
            unchanged += 1;
            break;
          case "error":
            errored += 1;
            if (result.errorCode && result.errorMessage) {
              errors.push({
                relPath: result.relPath,
                code: result.errorCode,
                message: result.errorMessage,
              });
            }
            break;
          case "skipped":
            dynamicSkipped += 1;
            if (result.errorCode && result.errorMessage) {
              errors.push({
                relPath: result.relPath,
                code: result.errorCode,
                message: result.errorMessage,
              });
            }
            break;
        }
      }
    }

    // 5. Mark missing files as inactive
    let markedInactive = 0;
    const existingDocsResult = await store.listDocuments(collection.name);
    if (existingDocsResult.ok) {
      const missingPaths = existingDocsResult.value
        .filter((d) => d.active && !seenPaths.has(d.relPath))
        .map((d) => d.relPath);

      if (missingPaths.length > 0) {
        const markResult = await store.markInactive(
          collection.name,
          missingPaths
        );
        if (markResult.ok) {
          markedInactive = markResult.value;
        }
      }
    }

    return {
      collection: collection.name,
      filesProcessed: entries.length,
      filesAdded: added,
      filesUpdated: updated,
      filesUnchanged: unchanged,
      filesErrored: errored,
      filesSkipped: skipped.length + dynamicSkipped,
      filesMarkedInactive: markedInactive,
      durationMs: Date.now() - startTime,
      errors,
    };
  }

  /**
   * Sync all collections.
   */
  async syncAll(
    collections: Collection[],
    store: StorePort,
    options: SyncOptions = {}
  ): Promise<SyncResult> {
    const startTime = Date.now();
    const results: CollectionSyncResult[] = [];

    for (const collection of collections) {
      const result = await this.syncCollection(collection, store, options);
      results.push(result);
    }

    // Aggregate totals
    const totals = results.reduce(
      (acc, r) => ({
        processed: acc.processed + r.filesProcessed,
        added: acc.added + r.filesAdded,
        updated: acc.updated + r.filesUpdated,
        errored: acc.errored + r.filesErrored,
        skipped: acc.skipped + r.filesSkipped,
      }),
      { processed: 0, added: 0, updated: 0, errored: 0, skipped: 0 }
    );

    return {
      collections: results,
      totalDurationMs: Date.now() - startTime,
      totalFilesProcessed: totals.processed,
      totalFilesAdded: totals.added,
      totalFilesUpdated: totals.updated,
      totalFilesErrored: totals.errored,
      totalFilesSkipped: totals.skipped,
    };
  }
}

/**
 * Default sync service instance.
 */
export const defaultSyncService = new SyncService();
