import * as vscode from "vscode";
import { basename, join, resolve } from "node:path";
import { existsSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import {
  CachedDirectoryWalker,
  isContentRelevantPath,
  isWatcherNoisePath,
} from "./indexer/fileScanner";
import { ModIndexer } from "./indexer/indexer";
import {
  DocumentCache,
  IncludeResolveCache,
  IndexRecordsCache,
  InvalidationsEpoch,
  normKey,
} from "./indexer/caches";
import {
  DiskRecordsCache,
  diskCacheKey,
  type DiskCacheRecord,
  type DiskCacheIdentity,
  type DiskCacheLoadStats,
} from "./indexer/diskCache";
import { buildSearchPaths, type SearchPaths } from "./indexer/includeResolver";
import { extractIndexRecords } from "./indexer/records";
import { LineMap, parseXml, stripBom } from "./language/xmlParser";
import {
  buildDocumentScope,
  withLocalOverlay,
  type DocumentScope,
} from "./indexer/localScope";
import type { RecordsSyncWorkspace } from "./indexer/referenceIndex";
import type { ModIndex, ParsedFile } from "./indexer/types";
import { readSettings, type ExtensionSettings } from "./settings";
import {
  discoverProjects,
  findProjectRootForFile,
  findProjectRootUpward,
} from "./projectRoot";
import { t } from "./localize";

const REBUILD_DEBOUNCE_MS = 1500;
/** Log a disk-cache validation progress line every N validated records. */
const CACHE_PROGRESS_LOG_EVERY = 1000;

/** Local time `HH:mm:ss.mmm` prefix for output-channel log lines. */
function logTime(): string {
  const d = new Date();
  const pad = (n: number, len = 2) => String(n).padStart(len, "0");
  return `[${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}]`;
}

/**
 * Per-project state. Caches (document / records / include resolution / dir
 * walker) are shared across projects because they are keyed by absolute
 * path; builds are serialized through a global queue so the shared caches
 * are never written concurrently.
 */
interface ProjectState {
  /** Absolute project root (the directory carrying the marker). */
  root: string;
  index: ModIndex | null;
  indexer: ModIndexer | null;
  building: boolean;
  dirty: boolean;
  buildCount: number;
  lastTrigger: string;
  pendingTrigger: string | null;
  rebuildTimer: ReturnType<typeof setTimeout> | null;
  epoch: InvalidationsEpoch;
  diskCache: DiskRecordsCache | null;
  diskCacheStats: DiskCacheLoadStats;
  diskSaved: boolean;
  saving: Promise<void> | null;
}

function emptyDiskCacheStats(): DiskCacheLoadStats {
  return {
    fileExists: false,
    keyMatched: false,
    loaded: 0,
    validated: 0,
    dropped: 0,
    loadMs: 0,
    validateMs: 0,
  };
}

export class ModWorkspace {
  settings: ExtensionSettings;
  /** Called whenever a new index snapshot is published (phase or final). */
  onIndexUpdate?: () => void;
  /** Called whenever a project rebuild starts (used for refresh safety nets). */
  onBuildStart?: () => void;

  private states = new Map<string, ProjectState>();
  /** Caches owned here survive rebuilds and are shared by every project. */
  private walker = new CachedDirectoryWalker();
  private documentCache = new DocumentCache();
  private recordsCache = new IndexRecordsCache();
  private resolveCache = new IncludeResolveCache();
  /** Document-local scopes (parse + expanded tree + overlay), per open doc. */
  private localScopes = new Map<
    string,
    { version: number; indexEpoch: number; scope: DocumentScope }
  >();
  private localScopeBuilds = new Map<string, Promise<DocumentScope>>();
  /** Global epoch: any published snapshot invalidates cached doc scopes. */
  private indexEpochValue = 0;
  /** file (normKey) -> project root (or null). Avoids per-keystroke fs walks. */
  private rootForPathCache = new Map<string, string | null>();
  /** Global serialized build queue (shared caches forbid concurrent builds). */
  private buildChain: Promise<void> = Promise.resolve();
  private output: vscode.OutputChannel;
  /** Called whenever a new index snapshot is published (phase or final). */
  private statusBar: vscode.StatusBarItem;
  private context: vscode.ExtensionContext;
  private storageDir: string | null;
  private watchers: vscode.FileSystemWatcher[] = [];

  constructor(context: vscode.ExtensionContext) {
    this.context = context;
    this.settings = readSettings();
    this.storageDir = (context.storageUri ?? context.globalStorageUri)?.fsPath ?? null;
    this.output = vscode.window.createOutputChannel("RA3 Mod XML");
    this.statusBar = vscode.window.createStatusBarItem(
      vscode.StatusBarAlignment.Left,
      100,
    );
    this.statusBar.name = "RA3 Mod XML";
    this.statusBar.command = "ra3modxml.openIndexReport";
    context.subscriptions.push(this.statusBar);
    context.subscriptions.push(
      vscode.workspace.onDidCloseTextDocument((document) => {
        this.localScopes.delete(document.uri.toString());
      }),
    );
  }

  /** True when at least one project has been discovered. */
  isRa3Workspace(): boolean {
    return this.states.size > 0;
  }

  /** Appends a line to the "RA3 Mod XML" output channel (debug/troubleshooting). */
  log(message: string): void {
    this.output.appendLine(`${logTime()} ${message}`);
  }

  /** True while any project rebuild is running (before its snapshot lands). */
  get isBuilding(): boolean {
    for (const st of this.states.values()) {
      if (st.building) return true;
    }
    return false;
  }

  /** Number of index builds performed for the active project in this session. */
  get buildCount(): number {
    return this.activeState()?.buildCount ?? 0;
  }

  /** Why the active project's last build started. */
  get lastTrigger(): string {
    return this.activeState()?.lastTrigger ?? "";
  }

  /** Active project root (fallback: first discovered project). */
  get projectRoot(): string | null {
    return this.activeState()?.root ?? this.states.values().next().value?.root ?? null;
  }

  /** Active project index (fallback: first discovered project). */
  get index(): ModIndex | null {
    return this.activeIndex();
  }

  /** Active project indexer (fallback: first discovered project). */
  get indexer(): ModIndexer | null {
    return this.activeIndexer();
  }

  /** All discovered project roots (normalized absolute paths). */
  getProjectRoots(): string[] {
    return [...this.states.values()].map((s) => s.root);
  }

  /** Project root for a document, or null when it does not belong to any. */
  getProjectRootFor(document: vscode.TextDocument): string | null {
    return this.stateForDocument(document)?.root ?? null;
  }

  /** Sync index lookup for a document (no lazy build side effects). */
  indexForDocument(document: vscode.TextDocument): ModIndex | null {
    const state = this.stateForDocument(document);
    if (!state) return null;
    this.ensureBuild(state, "feature-request");
    return state.index;
  }

  /** Indexer that owns a file (fallback: active project's indexer). */
  indexerForFile(file: string): ModIndexer | null {
    const key = normKey(file);
    for (const st of this.states.values()) {
      if (st.indexer?.isIndexedFile(file) || st.index?.files.has(key)) {
        return st.indexer;
      }
    }
    return this.activeIndexer();
  }

  /** Index that owns a file (fallback: active project's index). */
  indexForFile(file: string): ModIndex | null {
    const key = normKey(file);
    for (const st of this.states.values()) {
      if (st.indexer?.isIndexedFile(file) || st.index?.files.has(key)) {
        return st.index;
      }
    }
    return this.activeIndex();
  }

  activeIndex(): ModIndex | null {
    return this.activeState()?.index ?? null;
  }

  activeIndexer(): ModIndexer | null {
    return this.activeState()?.indexer ?? null;
  }

  /**
   * Discovers project roots from the current workspace folders and open
   * documents, registers per-project state and starts the initial build(s):
   * a single discovered project is indexed immediately (existing UX), while
   * multiple projects are lazy — only the active editor's project starts.
   */
  async initialize(): Promise<void> {
    this.settings = readSettings();
    this.refreshProjects();
    if (!this.states.size) {
      this.statusBar.hide();
      this.updateContext();
      return;
    }
    const builds: Promise<void>[] = [];
    for (const st of this.states.values()) {
      if (
        !st.index &&
        !st.building &&
        (this.states.size === 1 || this.stateBelongsToActiveEditor(st))
      ) {
        builds.push(this.rebuildFor(st, false, "initial"));
      }
    }
    if (builds.length) {
      await Promise.all(builds);
    } else {
      this.updateStatusBar();
    }
  }

  /** Re-runs discovery after workspace folders changed. */
  onWorkspaceFoldersChanged(): void {
    this.refreshProjects();
    for (const st of this.states.values()) {
      this.maybeBuildInitial(st, "workspace-folders");
    }
  }

  /** Registers the document's project (if any) and starts its lazy build. */
  onDocumentOpened(document: vscode.TextDocument): void {
    if (document.languageId !== "xml") return;
    const root = this.resolveRootForFile(document.uri.fsPath);
    if (root) {
      const state = this.registerRoot(root);
      if (state) this.ensureBuild(state, "doc-open");
    }
  }

  /**
   * Invalidates cached documents for a path (called by the file watcher and
   * on document save), so the next rebuild re-reads it instead of trusting
   * the cached copy. Marks every project's epoch: a shared file may belong
   * to several projects and over-marking is only an extra stale flag.
   */
  invalidate(path: string): void {
    if (!path) return;
    for (const st of this.states.values()) st.epoch.mark();
    this.documentCache.invalidate(path);
    this.recordsCache.invalidate(path);
  }

  /**
   * Called when files are created or deleted: include-resolution results
   * (which encode file existence) and cached root-for-path lookups are no
   * longer trustworthy.
   */
  invalidateExistence(): void {
    for (const st of this.states.values()) st.epoch.mark();
    this.resolveCache.clear();
    this.rootForPathCache.clear();
  }

  /**
   * Watches every project root plus the SDK / extra DATA roots for file
   * changes. Create/delete events invalidate existence and schedule every
   * project; content changes schedule only the projects whose index
   * contains the file.
   */
  private startWatching(): void {
    this.disposeWatchers();
    const roots = new Map<string, string>();
    for (const st of this.states.values()) roots.set(normKey(st.root), st.root);
    if (this.settings.sdkPath) roots.set(normKey(this.settings.sdkPath), this.settings.sdkPath);
    for (const p of this.settings.additionalDataSearchPaths) {
      roots.set(normKey(p), p);
    }
    for (const root of roots.values()) {
      if (!existsSync(root)) continue;
      try {
        const watcher = vscode.workspace.createFileSystemWatcher(
          new vscode.RelativePattern(root, "**/*"),
        );
        watcher.onDidCreate((uri) => {
          if (isWatcherNoisePath(uri.fsPath)) return;
          this.log(`[watcher-create] ${uri.fsPath}`);
          this.handleWatcherEvent(uri.fsPath, "watcher-create", true);
        });
        watcher.onDidChange((uri) => {
          if (isWatcherNoisePath(uri.fsPath)) return;
          // Content changes only matter for files that can change index
          // records (XML-ish documents); textures/binary art changes do not.
          if (
            !isContentRelevantPath(uri.fsPath) &&
            !this.isIndexedPath(uri.fsPath)
          ) {
            return;
          }
          this.log(`[watcher-change] ${uri.fsPath}`);
          this.handleWatcherEvent(uri.fsPath, "watcher-change", false);
        });
        watcher.onDidDelete((uri) => {
          if (isWatcherNoisePath(uri.fsPath)) return;
          this.log(`[watcher-delete] ${uri.fsPath}`);
          this.handleWatcherEvent(uri.fsPath, "watcher-delete", true);
        });
        this.watchers.push(watcher);
        this.context.subscriptions.push(watcher);
      } catch {
        // The root may be temporarily unavailable (e.g. removable drive);
        // indexing still works, just without watcher-based invalidation.
      }
    }
  }

  private disposeWatchers(): void {
    for (const watcher of this.watchers) watcher.dispose();
    this.watchers = [];
  }

  private handleWatcherEvent(
    path: string,
    trigger: string,
    existenceChanged: boolean,
  ): void {
    this.invalidate(path);
    if (existenceChanged) {
      this.invalidateExistence();
      for (const st of this.states.values()) this.scheduleRebuildFor(st, trigger);
      return;
    }
    const key = normKey(path);
    for (const st of this.states.values()) {
      if (st.indexer?.isIndexedFile(path) || st.index?.files.has(key)) {
        this.scheduleRebuildFor(st, trigger);
      }
    }
  }

  /** True when the path is part of any project's index (any build state). */
  private isIndexedPath(fsPath: string): boolean {
    const key = normKey(fsPath);
    for (const st of this.states.values()) {
      if (st.indexer?.isIndexedFile(fsPath)) return true;
      if (st.index?.files.has(key)) return true;
    }
    return false;
  }

  scheduleRebuild(reason = "unknown", document?: vscode.TextDocument): void {
    const state = document ? this.stateForDocument(document) : this.activeState();
    if (!state) return;
    this.scheduleRebuildFor(state, reason);
  }

  /** Schedules a rebuild for every discovered project (settings changes). */
  scheduleRebuildAll(reason = "unknown"): void {
    for (const st of this.states.values()) this.scheduleRebuildFor(st, reason);
  }

  private scheduleRebuildFor(state: ProjectState, reason = "unknown"): void {
    state.pendingTrigger = reason;
    if (state.rebuildTimer) clearTimeout(state.rebuildTimer);
    state.rebuildTimer = setTimeout(() => {
      void this.rebuildFor(state, false, state.pendingTrigger ?? reason);
    }, REBUILD_DEBOUNCE_MS);
  }

  /** Rebuilds a project through the serialized queue (active by default). */
  rebuild(
    force = false,
    trigger = "unknown",
    document?: vscode.TextDocument,
  ): Promise<void> {
    const state = document ? this.stateForDocument(document) : this.activeState();
    if (!state) return Promise.resolve();
    return this.rebuildFor(state, force, trigger);
  }

  private rebuildFor(
    state: ProjectState,
    force: boolean,
    trigger: string,
  ): Promise<void> {
    if (state.building) {
      state.dirty = true;
      return Promise.resolve();
    }
    if (force) this.resolveCache.clear();
    state.building = true;
    state.buildCount++;
    state.lastTrigger = trigger;
    this.onBuildStart?.();
    this.log(
      `[build #${state.buildCount}] project=${state.root} trigger=${trigger} force=${force} start=${new Date().toISOString()}`,
    );
    this.settings = readSettings();
    const epochAtStart = state.epoch.snapshot();
    this.updateStatusBar();
    return this.enqueue(() => this.runBuild(state, force, epochAtStart));
  }

  private enqueue<T>(task: () => Promise<T>): Promise<T> {
    const run = this.buildChain.then(task);
    this.buildChain = run.then(
      () => undefined,
      () => undefined,
    );
    return run;
  }

  private async runBuild(
    state: ProjectState,
    force: boolean,
    epochAtStart: number,
  ): Promise<void> {
    const wallStart = Date.now();
    try {
      // Cold start per project: seed the shared records cache from that
      // project's on-disk cache so a fresh session does not re-read
      // unchanged files (Corona: 2.6 GB of art assets). Stat validation is
      // a prerequisite: entries are only trusted after their multi-signal
      // stamp matches the current disk state, otherwise the fast build
      // could publish an index built from stale records.
      const pendingArt = await this.seedRecordsFromDisk(state);
      this.statusBar.text = t("$(sync~spin) RA3 XML: indexing…");
      this.statusBar.show();
      const indexer = new ModIndexer({
        projectDir: state.root,
        sdkDir: this.settings.sdkPath,
        builtmodsDirs: this.settings.builtmodsDirs,
        indexSageXml: this.settings.indexSageXml,
        additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
        walker: this.walker,
        documentCache: this.documentCache,
        recordsCache: this.recordsCache,
        resolveCache: this.resolveCache,
        // Trust cache entries unless the user explicitly asked for a full
        // verification (ra3modxml.reindex).
        trustUnchanged: !force,
      });
      state.indexer = indexer;
      // The XML phase is published as soon as it is ready, so completion /
      // navigation / diagnostics work while the art scan continues.
      const finalIndex = await indexer.build(async (phaseIndex) => {
        this.publishIndex(state, phaseIndex, epochAtStart);
        // Phase A is published and usable; validate the remaining art
        // records before phase B so it can trust the cache instead of
        // re-scanning 2.6 GB of models.
        if (!phaseIndex.complete && pendingArt.length) {
          await this.validateAndSeedCache(state, pendingArt, t("art cache"));
        }
      });
      this.publishIndex(state, finalIndex, epochAtStart);
      this.log(
        `[build #${state.buildCount}] project=${state.root} done in ${(finalIndex.stats.elapsedMs / 1000).toFixed(1)}s (phase=${finalIndex.phase}, assets=${finalIndex.stats.assetCount}, stale=${finalIndex.stale === true}, walk=${(finalIndex.stats.walkMs / 1000).toFixed(1)}s, candidates=${(finalIndex.stats.candidatesMs / 1000).toFixed(1)}s, art=${(finalIndex.stats.artScanMs / 1000).toFixed(1)}s)`,
      );
      this.saveRecordsToDisk(state);
      this.log(
        `[build #${state.buildCount}] project=${state.root} wall time ${((Date.now() - wallStart) / 1000).toFixed(1)}s (cache load + validation + index build)`,
      );
    } catch (err) {
      if (state.index) {
        // Keep the last good snapshot (marked stale) instead of disabling
        // the extension entirely; a later rebuild can recover.
        state.index.stale = true;
        this.statusBar.text = t("$(error) RA3 XML: indexing failed (stale index kept)");
        this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
        this.onIndexUpdate?.();
      } else {
        this.statusBar.text = t("$(error) RA3 XML: indexing failed");
        this.statusBar.tooltip = err instanceof Error ? err.message : String(err);
      }
    } finally {
      state.building = false;
      // The snapshot was published while `building` was still true, so the
      // status bar still said "indexing". Refresh it now that the build is
      // fully over.
      this.updateStatusBar();
      if (state.dirty) {
        state.dirty = false;
        void this.rebuildFor(state, false, `dirty-followup (${state.lastTrigger})`);
      } else {
        // Build is fully over: refresh diagnostics with full local scopes
        // (the snapshot published while `building` was still true only got
        // cheap parse-only scopes).
        this.onIndexUpdate?.();
      }
    }
  }

  private diskCacheIdentityFor(state: ProjectState): DiskCacheIdentity {
    return {
      projectDir: state.root,
      sdkDir: this.settings.sdkPath,
      indexSageXml: this.settings.indexSageXml,
      additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
      builtmodsDirs: this.settings.builtmodsDirs,
    };
  }

  private diskCachePathFor(identity: DiskCacheIdentity): string | null {
    if (!this.storageDir) return null;
    return join(this.storageDir, `index-records-v3-${diskCacheKey(identity)}.json.gz`);
  }

  /**
   * Loads one project's disk cache. Full-XML records are stat-validated
   * BEFORE the build (phase A depends on them); shallow art records are
   * returned as "pending" and validated in the phase-A callback before
   * phase B starts. Only validated entries are seeded, so the fast build
   * never trusts unverified cache data.
   */
  private async seedRecordsFromDisk(
    state: ProjectState,
  ): Promise<DiskCacheRecord[]> {
    if (state.diskCache) return [];
    const identity = this.diskCacheIdentityFor(state);
    const path = this.diskCachePathFor(identity);
    if (!path) return [];
    const diskCache = new DiskRecordsCache(path, identity);
    state.diskCache = diskCache;
    const { records, stats } = await diskCache.load();
    state.diskCacheStats = stats;
    state.diskSaved = false;
    this.log(
      `[disk-cache] project=${state.root} loaded ${records.length} records in ${(stats.loadMs / 1000).toFixed(1)}s (file=${stats.fileExists}, keyMatched=${stats.keyMatched})`,
    );
    if (!records.length || !stats.keyMatched) return [];

    const full = records.filter((r) => r.kind !== "shallow");
    const shallow = records.filter((r) => r.kind === "shallow");
    if (!full.length) {
      // No XML records to gate phase A on; validate everything up front.
      await this.validateAndSeedCache(state, records, "cache");
      return [];
    }
    await this.validateAndSeedCache(state, full, "cache");
    // Pre-seed shallow records as unvalidated: phase A can register art
    // files without statting 2.6 GB of models, and phase B only consumes
    // them after `validateAndSeedCache` flips them to validated.
    for (const rec of shallow) {
      this.recordsCache.set(rec.key, {
        stat: rec.stat,
        records: rec.records,
        kind: rec.kind,
        contentHash: rec.contentHash,
        validated: false,
      });
    }
    if (shallow.length) {
      this.log(
        `[disk-cache] project=${state.root} pre-seeded ${shallow.length} art records (unvalidated) for deferred registration`,
      );
    }
    return shallow;
  }

  /**
   * Stat-validates a batch of cached records, seeds the matching ones and
   * reports progress through the status bar / output channel. Multiple
   * batches accumulate into `state.diskCacheStats` (e.g. XML first, then
   * art before phase B).
   */
  private async validateAndSeedCache(
    state: ProjectState,
    records: DiskCacheRecord[],
    label: string,
  ): Promise<void> {
    const total = records.length;
    const start = Date.now();
    let lastLogCount = 0;
    const onProgress = (done: number): void => {
      this.statusBar.text = t(
        "$(sync~spin) RA3 XML: validating {0} {1}/{2}…",
        label,
        done,
        total,
      );
      this.statusBar.show();
      if (done - lastLogCount >= CACHE_PROGRESS_LOG_EVERY) {
        lastLogCount = done;
        this.log(
          `[disk-cache] project=${state.root} validating ${label} ${done}/${total} in ${((Date.now() - start) / 1000).toFixed(1)}s`,
        );
      }
    };
    try {
      const { stats: validationStats, kept, invalidKeys } =
        await state.diskCache!.validate(records, onProgress);
      state.diskCacheStats = {
        ...state.diskCacheStats,
        validated: state.diskCacheStats.validated + validationStats.validated,
        dropped: state.diskCacheStats.dropped + validationStats.dropped,
        validateMs: state.diskCacheStats.validateMs + validationStats.validateMs,
      };
      for (const rec of kept) {
        this.recordsCache.set(rec.key, {
          stat: rec.stat,
          records: rec.records,
          kind: rec.kind,
          contentHash: rec.contentHash,
          validated: true,
        });
      }
      for (const key of invalidKeys) {
        this.recordsCache.invalidate(key);
      }
      this.log(
        `[disk-cache] project=${state.root} validated ${label} ${validationStats.validated}/${total} records in ${(validationStats.validateMs / 1000).toFixed(1)}s (dropped=${validationStats.dropped})`,
      );
      if (invalidKeys.length) {
        this.log(
          `[disk-cache] project=${state.root} ${invalidKeys.length} ${label} entries out of date; they will be re-read during the build`,
        );
      }
    } catch (err) {
      this.log(
        `[disk-cache] project=${state.root} ${label} validation failed: ${err instanceof Error ? err.message : String(err)}; affected entries will be re-read`,
      );
    }
  }

  /** Persists a project's records after a successful build (best-effort). */
  private saveRecordsToDisk(state: ProjectState): void {
    if (!state.diskCache) return;
    // Snapshot the entries now: the save runs in the background while the
    // next rebuild may already be mutating the live cache.
    const entries = [...this.recordsCache.entries()];
    const prev = state.saving ?? Promise.resolve();
    const start = Date.now();
    state.saving = prev
      .then(async () => {
        await state.diskCache!.save(entries);
        state.diskSaved = true;
        this.log(
          `[disk-cache] project=${state.root} saved ${entries.length} records in ${(Date.now() - start) / 1000}s`,
        );
      })
      .catch(() => {
        // Disk persistence is best-effort; the in-memory cache still works.
      });
  }

  /**
   * Clears every cache (in-memory + disk + directory walker) and starts
   * forced rebuilds for every project (serialized by the build queue).
   */
  clearCaches(): void {
    this.localScopes.clear();
    this.rootForPathCache.clear();
    this.documentCache.clear();
    this.recordsCache.clear();
    this.resolveCache.clear();
    this.walker.clear();
    for (const st of this.states.values()) {
      st.diskCacheStats = emptyDiskCacheStats();
      st.diskSaved = false;
      void st.diskCache?.clear();
      void this.rebuildFor(st, true, "clear-cache");
    }
  }

  /** Human-readable cache status for the `ra3modxml.showCacheReport` command. */
  async cacheReport(): Promise<string> {
    const lines: string[] = [t("RA3 Mod XML cache report")];
    lines.push(t("Projects: {0}", this.states.size));
    for (const st of this.states.values()) {
      lines.push(t("Project: {0}", st.root));
      lines.push(
        t("  builds: #{0} (last trigger: {1})", st.buildCount, st.lastTrigger),
      );
      lines.push(t("  disk cache: {0}", st.diskCache?.path ?? t("not loaded")));
      if (st.diskCache) {
        const status = await st.diskCache.status();
        lines.push(
          t(
            "    file: {0}",
            status?.exists
              ? t("{0} KB", (status.sizeBytes / 1024).toFixed(1))
              : t("missing"),
          ),
        );
        lines.push(
          t(
            "    last load: file={0} keyMatched={1} loaded={2} validated={3} dropped={4} (load {5}ms, validate {6}ms)",
            st.diskCacheStats.fileExists,
            st.diskCacheStats.keyMatched,
            st.diskCacheStats.loaded,
            st.diskCacheStats.validated,
            st.diskCacheStats.dropped,
            st.diskCacheStats.loadMs,
            st.diskCacheStats.validateMs,
          ),
        );
        lines.push(
          t(
            "    saved after last build: {0}",
            st.diskSaved ? t("yes") : t("no"),
          ),
        );
      }
      if (st.index) {
        const s = st.index.stats;
        lines.push(
          t(
            "    last build: phase={0} assets={1} snapshotHits={2} snapshotFallbacks={3} recordsCacheHits={4} shallowCacheHits={5}",
            s.phase,
            s.assetCount,
            s.snapshotHits,
            s.snapshotFallbacks,
            s.recordsCacheHits,
            s.shallowCacheHits,
          ),
        );
      }
    }
    lines.push(
      t(
        "Shared in-memory: {0} record entries · {1} documents ({2} elements) · {3} include resolutions",
        this.recordsCache.size,
        this.documentCache.size,
        this.documentCache.elements,
        this.resolveCache.size,
      ),
    );
    return lines.join("\n");
  }

  /**
   * Publishes an index snapshot (intermediate phase or final) for one
   * project. If files changed while the snapshot was built, it is marked
   * stale; the dirty/rebuild mechanism converges shortly after.
   */
  private publishIndex(
    state: ProjectState,
    index: ModIndex,
    epochAtStart: number,
  ): void {
    if (state.epoch.changedSince(epochAtStart)) index.stale = true;
    state.index = index;
    this.indexEpochValue++;
    // The merged index attached to a document scope changes with every
    // published snapshot, so cached scopes are rebuilt lazily on next use.
    this.localScopes.clear();
    this.updateStatusBar();
    this.onIndexUpdate?.();
    if (!index.complete) {
      this.log(
        `[build #${state.buildCount}] project=${state.root} phase A published in ${(index.stats.elapsedMs / 1000).toFixed(1)}s (${index.stats.assetCount} assets, ${index.stats.deferredArtFiles} art files pending)`,
      );
    }
  }

  private updateStatusBar(): void {
    if (!this.states.size) {
      this.statusBar.hide();
      return;
    }
    const building = [...this.states.values()].find((s) => s.building);
    if (building) {
      this.statusBar.text = t("$(sync~spin) RA3 XML: indexing…");
      this.statusBar.show();
      return;
    }
    const st = this.activeState();
    const idx = st?.index;
    if (!idx || !st) {
      this.statusBar.text = t(
        "$(symbol-misc) RA3 XML: {0} project(s) — open a mod XML to index",
        this.states.size,
      );
      this.statusBar.tooltip = [...this.states.values()]
        .map((s) => s.root)
        .join("\n");
      this.statusBar.show();
      return;
    }
    const s = idx.stats;
    const stale = idx.stale ? ` ${t("(stale)")}` : "";
    this.statusBar.text = t(
      "$(symbol-misc) RA3 XML: {0} · {1} assets{2}",
      basename(st.root),
      formatCount(s.assetCount),
      stale,
    );
    this.statusBar.tooltip = [
      st.root,
      t(
        "{0} files indexed ({1} parsed, {2} art assets shallow-scanned, {3}s)",
        s.indexedFiles,
        s.parsedFiles,
        s.shallowScannedFiles,
        (s.elapsedMs / 1000).toFixed(1),
      ),
      t(
        "{0} assets ({1} from {2} manifests)",
        s.assetCount,
        s.manifestAssetCount,
        s.manifestFiles,
      ),
      t("{0} reference sites", s.referenceCount),
      t(
        "{0} defines, {1} streams, {2} include candidates",
        s.defineCount,
        s.streams,
        s.sourceCandidates,
      ),
      t("Phase: {0} · Complete: {1}{2}", s.phase, s.complete, stale),
      t(
        "Disk cache: load {0}s, validate {1}s ({2}/{3} ok)",
        (st.diskCacheStats.loadMs / 1000).toFixed(1),
        (st.diskCacheStats.validateMs / 1000).toFixed(1),
        st.diskCacheStats.validated,
        st.diskCacheStats.loaded,
      ),
    ].join("\n");
    this.statusBar.show();
  }

  /**
   * Search paths derived from the current settings for a document's project
   * (active project when no document is given), usable even before the
   * first index snapshot exists (include links / hover / diagnostics).
   */
  searchPaths(document?: vscode.TextDocument): SearchPaths | null {
    const state = document ? this.stateForDocument(document) : this.activeState();
    if (!state) return null;
    return buildSearchPaths(this.settings.sdkPath, state.root);
  }

  /**
   * Returns the document scope for the current text: original parse,
   * expanded logical tree, local overlay and overlay-aware merged index.
   * Cached by URI + document version + global index epoch. Lazy projects
   * are registered and their build started on first request.
   */
  async getScope(document: vscode.TextDocument): Promise<DocumentScope> {
    const state = this.stateForDocument(document);
    if (!state) throw new Error("RA3 workspace root is not available");
    this.ensureBuild(state, "doc-request");
    const key = document.uri.toString();
    const cached = this.localScopes.get(key);
    if (
      cached &&
      cached.version === document.version &&
      cached.indexEpoch === this.indexEpochValue
    ) {
      return cached.scope;
    }
    // While this project's rebuild is running, avoid competing with the
    // indexer for disk I/O: serve a parse-only scope (current file + XSD
    // context, no include chain / logical expansion). The published
    // snapshot clears this cache, so the next provider call after the build
    // gets the full local scope.
    if (state.building) {
      return this.buildCheapScope(document, state);
    }
    const pending = this.localScopeBuilds.get(key);
    if (pending) return pending;
    const versionAtStart = document.version;
    const promise = this.buildScope(document, state)
      .then((scope) => {
        this.localScopes.set(key, {
          version: versionAtStart,
          indexEpoch: this.indexEpochValue,
          scope,
        });
        return scope;
      })
      .finally(() => {
        this.localScopeBuilds.delete(key);
      });
    this.localScopeBuilds.set(key, promise);
    return promise;
  }

  /**
   * Returns the global index with this document's local overlay attached,
   * or a minimal local-only index while the global index is still building.
   */
  async getIndex(document: vscode.TextDocument): Promise<ModIndex | null> {
    if (!this.isRa3Workspace()) return null;
    return (await this.getScope(document)).merged;
  }

  /**
   * Lightweight scope for CodeLens: parses only the current document and
   * attaches the current global index. Unlike `getScope` it never follows
   * the include chain or expands the logical tree, so refreshing counts
   * after a snapshot is fast. CodeLens only renders top-level assets of the
   * current document, which the cheap overlay already covers.
   */
  async getCodeLensScope(document: vscode.TextDocument): Promise<DocumentScope> {
    const state = this.stateForDocument(document);
    if (!state) throw new Error("RA3 workspace root is not available");
    this.ensureBuild(state, "doc-request");
    return this.buildCheapScope(document, state);
  }

  private async buildScope(
    document: vscode.TextDocument,
    state: ProjectState,
  ): Promise<DocumentScope> {
    const searchPaths =
      this.searchPaths(document) ?? buildSearchPaths(this.settings.sdkPath, state.root);
    const readRecords = async (path: string): Promise<ParsedFile | null> =>
      state.indexer ? state.indexer.readDocument(path) : this.fallbackRead(path);
    const readDom = async (path: string): Promise<ParsedFile | null> =>
      state.indexer ? state.indexer.readDom(path) : this.fallbackRead(path);
    const scope = await buildDocumentScope(
      document.uri.fsPath,
      document.getText(),
      document.version,
      {
        projectDir: state.root,
        sdkDir: this.settings.sdkPath,
        searchPaths,
        readRecords,
        readDom,
      },
    );
    scope.merged = withLocalOverlay(
      state.index,
      scope.overlay,
      state.root,
      this.settings.sdkPath,
    );
    return scope;
  }

  private async buildCheapScope(
    document: vscode.TextDocument,
    state: ProjectState,
  ): Promise<DocumentScope> {
    const searchPaths =
      this.searchPaths(document) ?? buildSearchPaths(this.settings.sdkPath, state.root);
    const scope = await buildDocumentScope(
      document.uri.fsPath,
      document.getText(),
      document.version,
      {
        projectDir: state.root,
        sdkDir: this.settings.sdkPath,
        searchPaths,
        readRecords: async () => null,
        readDom: async () => null,
      },
    );
    scope.merged = withLocalOverlay(
      state.index,
      scope.overlay,
      state.root,
      this.settings.sdkPath,
    );
    return scope;
  }

  /**
   * Fallback used before a project's first ModIndexer exists: parses an XML
   * file directly so the document-local scope can still follow small
   * include chains.
   */
  private async fallbackRead(path: string): Promise<ParsedFile | null> {
    try {
      const st = await stat(path);
      if (st.size > 4 * 1024 * 1024) return null;
      const text = stripBom(await readFile(path, "utf8"));
      const lineMap = new LineMap(text);
      const parse = parseXml(text);
      return {
        file: { path: resolve(path), stat: null },
        parse,
        records: extractIndexRecords(parse, lineMap, text),
        lineMap,
      };
    } catch {
      return null;
    }
  }

  /** Parses the (possibly unsaved) in-memory text of the active document. */
  async parseText(path: string, text: string) {
    const { parseXml, LineMap } = await import("./language/xmlParser");
    const parse = parseXml(text);
    return {
      file: { path, stat: null },
      parse,
      records: null,
      lineMap: new LineMap(text),
    };
  }

  /**
   * Minimal workspace surface for the records-desync self-heal, routed to
   * the document's own project (not the active one).
   */
  recordsSyncSurfaceFor(document: vscode.TextDocument): RecordsSyncWorkspace {
    const state = this.stateForDocument(document);
    return {
      get index() {
        return state?.index ?? null;
      },
      invalidate: (path: string) => this.invalidate(path),
      scheduleRebuild: (reason: string) => {
        if (state) this.scheduleRebuildFor(state, reason);
      },
    };
  }

  // ── Discovery / project state ─────────────────────────────────────

  /**
   * Recomputes the desired project set from workspace folders + open XML
   * documents, drops states that are no longer reachable, registers new
   * ones and refreshes watchers / context / status bar.
   */
  private refreshProjects(): void {
    const desired = new Map<string, string>();
    const addRoot = (root: string | null | undefined): void => {
      if (root) desired.set(normKey(root), root);
    };
    for (const folder of vscode.workspace.workspaceFolders ?? []) {
      addRoot(findProjectRootUpward(folder.uri.fsPath));
      for (const root of discoverProjects(folder.uri.fsPath)) addRoot(root);
    }
    for (const doc of vscode.workspace.textDocuments ?? []) {
      if (doc.languageId === "xml") addRoot(findProjectRootForFile(doc.uri.fsPath));
    }
    const active = vscode.window.activeTextEditor;
    if (active?.document.languageId === "xml") {
      addRoot(findProjectRootForFile(active.document.uri.fsPath));
    }
    for (const [key, st] of [...this.states]) {
      if (!desired.has(key)) this.removeState(st);
    }
    for (const root of desired.values()) this.registerRoot(root);
    this.rootForPathCache.clear();
    this.startWatching();
    this.updateContext();
    this.updateStatusBar();
  }

  private maybeBuildInitial(state: ProjectState, trigger: string): void {
    if (state.index || state.building) return;
    if (this.states.size === 1 || this.stateBelongsToActiveEditor(state)) {
      void this.rebuildFor(state, false, trigger);
    }
  }

  private removeState(state: ProjectState): void {
    if (state.rebuildTimer) clearTimeout(state.rebuildTimer);
    this.states.delete(normKey(state.root));
  }

  private registerRoot(root: string): ProjectState | null {
    const key = normKey(root);
    const existing = this.states.get(key);
    if (existing) return existing;
    const state: ProjectState = {
      root: resolve(root),
      index: null,
      indexer: null,
      building: false,
      dirty: false,
      buildCount: 0,
      lastTrigger: "registered",
      pendingTrigger: null,
      rebuildTimer: null,
      epoch: new InvalidationsEpoch(),
      diskCache: null,
      diskCacheStats: emptyDiskCacheStats(),
      diskSaved: false,
      saving: null,
    };
    this.states.set(key, state);
    this.startWatching();
    this.updateContext();
    this.updateStatusBar();
    return state;
  }

  private ensureBuild(state: ProjectState, trigger: string): void {
    if (state.index || state.building) return;
    void this.rebuildFor(state, false, trigger);
  }

  private stateBelongsToActiveEditor(state: ProjectState): boolean {
    const editor = vscode.window.activeTextEditor;
    return editor ? isPathInside(editor.document.uri.fsPath, state.root) : false;
  }

  /**
   * The project state for a document: its own nearest root when it belongs
   * to a project (registering it on demand), otherwise the first discovered
   * project (best-effort fallback, matching the old single-project behavior).
   */
  private stateForDocument(document: vscode.TextDocument): ProjectState | null {
    const file = document.uri.fsPath;
    if (file && (document.uri.scheme == null || document.uri.scheme === "file")) {
      const root = this.resolveRootForFile(file);
      if (root) {
        const state = this.registerRoot(root);
        if (state) return state;
      }
    }
    return this.states.values().next().value ?? null;
  }

  /** Active editor's project, falling back to the first discovered one. */
  private activeState(): ProjectState | null {
    const editor = vscode.window.activeTextEditor;
    if (editor) {
      const state = this.stateForDocument(editor.document);
      if (state) return state;
    }
    return this.states.values().next().value ?? null;
  }

  /**
   * Resolves a file's project root: cached containment in registered roots
   * first (no fs), then upward discovery. The cache is cleared when files
   * are created/deleted or workspace folders change.
   */
  private resolveRootForFile(file: string): string | null {
    if (!file) return null;
    const key = normKey(file);
    const cached = this.rootForPathCache.get(key);
    if (cached !== undefined) return cached;
    for (const st of this.states.values()) {
      if (isPathInside(file, st.root)) {
        this.rootForPathCache.set(key, st.root);
        return st.root;
      }
    }
    const root = findProjectRootForFile(file);
    this.rootForPathCache.set(key, root);
    return root;
  }

  private updateContext(): void {
    void vscode.commands.executeCommand(
      "setContext",
      "ra3modxml.active",
      this.states.size > 0,
    );
  }

  dispose(): void {
    for (const st of this.states.values()) {
      if (st.rebuildTimer) clearTimeout(st.rebuildTimer);
    }
    this.disposeWatchers();
    this.statusBar.dispose();
    this.output.dispose();
  }
}

function isPathInside(file: string, root: string): boolean {
  const f = normKey(file);
  const r = normKey(root);
  return f === r || f.startsWith(r + "\\");
}

function formatCount(n: number): string {
  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
