/**
 * Source resolution for CLI commands (v0.18.0).
 *
 * Resolution priority (highest first):
 *   1. Explicit --source <id> flag (caller passes this as `explicit`)
 *   2. GBRAIN_SOURCE env var
 *   3. .gbrain-source dotfile in CWD or any ancestor directory
 *   4. Registered source whose local_path contains CWD
 *   5. Brain-level default via `gbrain sources default <id>`
 *   6. Literal 'default' (backward compat for pre-v0.17 brains)
 *
 * This helper is shared by the sources CLI, future sync/extract/query
 * commands (Steps 4/5), and the operation layer (Step 2+).
 */

import { readFileSync, lstatSync, type Stats } from 'fs';
import { join, dirname, resolve } from 'path';
import type { BrainEngine } from './engine.ts';
import { isSourceFederated, parseSourceConfig } from './sources-load.ts';
import { SOURCE_ID_RE, isValidSourceId, ALL_SOURCES } from './source-id.ts';
import { isTrustedDotfile, realpathOrResolve } from './path-confine.ts';

// Re-export so scope-resolution call sites can import the sentinel from
// either module (#1712).
export { ALL_SOURCES };

const DOTFILE = '.gbrain-source';
// Canonical SOURCE_ID_RE imported from `source-id.ts` (single source of truth).
// Re-exported below as `__testing.SOURCE_ID_RE` for legacy test imports.
// Two validator shapes per codex r2 P1-F:
//   - `isValidSourceId(s)`: boolean — used by tiers that silently fall through
//     on invalid input (dotfile tier 3, brain_default tier 5)
//   - explicit throw — used by tiers that must reject loudly with a tailored
//     message (explicit `--source` flag tier 1, GBRAIN_SOURCE env tier 2).
//     Tier-specific messages are clearer than the generic assertValidSourceId
//     error, so the throws stay inline.

function readDotfileWalk(startDir: string): string | null {
  let dir = resolve(startDir);
  // Guard against infinite loops on malformed paths.
  for (let i = 0; i < 50; i++) {
    const candidate = join(dir, DOTFILE);
    // lstatSync (NOT statSync) so a planted symlink is seen here, not silently
    // followed-then-trusted. Any stat error (ENOENT / permission) → skip this
    // candidate and keep walking (fail-closed). On a multi-user host an
    // attacker who can write a shared ancestor dir could otherwise plant a
    // forged `.gbrain-source`; `isTrustedDotfile` refuses symlinks,
    // foreign-owned, and world-writable files (#418).
    let st: Stats | null = null;
    try { st = lstatSync(candidate); } catch { st = null; }
    if (st && isTrustedDotfile(st)) {
      try {
        const content = readFileSync(candidate, 'utf8').trim().split('\n')[0].trim();
        // Silent-fallback tier per codex P1-F: invalid dotfile content
        // (legacy ids with underscores, hand-edits with whitespace, etc.)
        // falls through to the next tier instead of throwing. The CLI's
        // explicit/env tiers throw; dotfiles are operator-edited and the
        // forgiving behavior preserves the resolver's existing semantics.
        if (isValidSourceId(content)) return content;
      } catch {
        // Unreadable dotfile — skip and keep walking.
      }
    }
    const parent = dirname(dir);
    if (parent === dir) break; // reached filesystem root
    dir = parent;
  }
  return null;
}

/**
 * Resolve the source id for a CLI command.
 *
 * @param engine  Connected brain engine (for sources table lookups).
 * @param explicit  The --source <id> flag value, if the caller parsed one.
 * @param cwd  The working directory to walk for .gbrain-source. Defaults
 *             to process.cwd(). Exposed for testability.
 * @returns  The resolved source id. Falls back to 'default' if no other
 *           signal is present. Never returns null — every command must
 *           target exactly one default source.
 * @throws  If the resolved id doesn't correspond to a registered source
 *          (prevents silently writing to a nonexistent source and bloating
 *          pages with a dead FK).
 */
export async function resolveSourceId(
  engine: BrainEngine,
  explicit: string | null | undefined,
  cwd: string = process.cwd(),
): Promise<string> {
  // 1. Explicit flag wins. The __all__ sentinel passes through verbatim
  //    (#1712) — it is not a source id, so it skips both the regex and
  //    assertSourceExists; sourceScopeOpts gives it span-everything semantics.
  if (explicit) {
    if (explicit === ALL_SOURCES) return ALL_SOURCES;
    if (!SOURCE_ID_RE.test(explicit)) {
      throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
    }
    await assertSourceExists(engine, explicit);
    return explicit;
  }

  // 2. Env var. Same __all__ pass-through (#2140).
  const env = process.env.GBRAIN_SOURCE;
  if (env && env.length > 0) {
    if (env === ALL_SOURCES) return ALL_SOURCES;
    if (!SOURCE_ID_RE.test(env)) {
      throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
    }
    await assertSourceExists(engine, env);
    return env;
  }

  // 3. .gbrain-source dotfile walk-up.
  const dotfile = readDotfileWalk(cwd);
  if (dotfile) {
    await assertSourceExists(engine, dotfile);
    return dotfile;
  }

  // 4. Registered source whose local_path contains CWD.
  //    Uses longest-prefix match so nested-path configurations (e.g.
  //    gstack at ~/gstack + plans at ~/gstack/plans) pick the deepest.
  const registered = await engine.executeRaw<{ id: string; local_path: string }>(
    `SELECT id, local_path FROM sources WHERE local_path IS NOT NULL`,
  );
  // realpath BOTH sides (not bare resolve) so a symlinked CWD can't forge a
  // prefix match against a registered local_path it doesn't really live under
  // (codex #9). realpathOrResolve falls back to lexical resolve() for a stale
  // registration whose path no longer exists. Resolving both sides keeps a
  // legitimately symlinked vault matching — only one-sided symlinks break.
  const cwdResolved = realpathOrResolve(cwd);
  let best: { id: string; pathLen: number } | null = null;
  for (const r of registered) {
    const p = realpathOrResolve(r.local_path);
    if (cwdResolved === p || cwdResolved.startsWith(p + '/')) {
      if (!best || p.length > best.pathLen) {
        best = { id: r.id, pathLen: p.length };
      }
    }
  }
  if (best) return best.id;

  // 5. Brain-level default.
  // Silent-fallback tier per codex P1-F: an invalid `sources.default` config
  // value (operator hand-edit gone wrong, legacy underscore id) falls through
  // to tier 6 rather than throwing. Resolver stays robust to bad config.
  const globalDefault = await engine.getConfig('sources.default');
  if (globalDefault && isValidSourceId(globalDefault)) {
    await assertSourceExists(engine, globalDefault);
    return globalDefault;
  }

  // 5.5. Single-non-default-source convenience (v0.41.13, #1434).
  //      When NO brain_default is set AND exactly one registered source has
  //      local_path set AND it isn't 'default', route there. This closes
  //      the "532 silent edit failures" bug class where users with a single
  //      Vault-mounted source ran `gbrain sync` without --source and routed
  //      to source_id='default' (which held 0 pages). Conservative: fires
  //      only when there's literally one option — multi-source brains still
  //      require explicit --source or sources.default.
  //
  //      Placed AFTER brain_default per codex review: a user who explicitly
  //      set sources.default has stated intent, that wins over auto-routing.
  const soleNonDefault = await pickSoleNonDefaultSource(engine);
  if (soleNonDefault) return soleNonDefault;

  // 6. Fallback: the seeded 'default' source. Always exists post-migration
  //    v16 so this is a safe terminal.
  return 'default';
}

/**
 * Engine-free tiers (1-3) of the resolution chain: explicit flag →
 * GBRAIN_SOURCE env → .gbrain-source dotfile walk. Used by the thin-client
 * CLI path (#2098), which has no local engine to run tiers 4-6 or
 * assertSourceExists against — the remote server enforces existence + grant.
 * Returns null when no engine-free tier fires.
 */
export function resolveSourceIdEngineFree(
  explicit: string | null | undefined,
  cwd: string = process.cwd(),
): string | null {
  if (explicit) {
    if (explicit === ALL_SOURCES) return ALL_SOURCES; // #1712 sentinel pass-through
    if (!SOURCE_ID_RE.test(explicit)) {
      throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
    }
    return explicit;
  }
  const env = process.env.GBRAIN_SOURCE;
  if (env && env.length > 0) {
    if (env === ALL_SOURCES) return ALL_SOURCES; // #2140 sentinel pass-through
    if (!SOURCE_ID_RE.test(env)) {
      throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
    }
    return env;
  }
  return readDotfileWalk(cwd);
}

/**
 * Returns the id of the SINGLE registered non-default source with a
 * local_path, when exactly one such row exists. Returns null when:
 *   - zero non-default sources are registered (fresh install)
 *   - 2+ non-default sources are registered (ambiguous — user must pick)
 *   - the only non-default source has a NULL local_path (no on-disk shape)
 *   - the only registered source IS 'default'
 *
 * Excludes archived sources (`archived = false`) so a soft-deleted source
 * doesn't auto-resolve. Shared by `resolveSourceId` and `resolveSourceWithTier`
 * so the heuristic can't drift between the two entry points.
 *
 * NOTE (#2928): this tier deliberately does NOT consult config.federated —
 * `--no-federated` governs READ mixing, not write routing, and unqualified
 * `sync`/`import` on a single-vault brain must keep landing in the vault
 * (#1434, pinned by test/sync-sole-non-default-routing.test.ts). The
 * unfederate read fix lives in `localFederatedSourceIds` below.
 */
async function pickSoleNonDefaultSource(engine: BrainEngine): Promise<string | null> {
  // archived column was added in v34 (v0.26.5). Older brains may not have
  // it — fall back to the un-archived query in that case via try/catch.
  let rows: Array<{ id: string }>;
  try {
    rows = await engine.executeRaw<{ id: string }>(
      `SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default' AND archived = false`,
    );
  } catch {
    rows = await engine.executeRaw<{ id: string }>(
      `SELECT id FROM sources WHERE local_path IS NOT NULL AND id != 'default'`,
    );
  }
  if (rows.length === 1) return rows[0].id;
  return null;
}

/**
 * Format the one-line stderr nudge that fires when source resolution falls
 * through to the `sole_non_default` tier. Returns null when suppressed via
 * `GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1` (CI / scripted-pipeline ergonomics).
 *
 * Single source of truth so the wording stays consistent across every CLI
 * dispatch site that fires the nudge (sync, import, extract, etc.). Callers
 * print to stderr; this helper just builds the line.
 */
export function formatSoleNonDefaultNudge(sourceId: string): string | null {
  if (process.env.GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE === '1') return null;
  return `[gbrain] routing to source '${sourceId}' (sole non-default source registered; pass --source to override).`;
}

async function assertSourceExists(engine: BrainEngine, id: string): Promise<void> {
  const rows = await engine.executeRaw<{ id: string }>(
    `SELECT id FROM sources WHERE id = $1`,
    [id],
  );
  if (rows.length === 0) {
    throw new Error(
      `Source "${id}" not found. Available sources: ` +
      `run \`gbrain sources list\` to see registered sources, ` +
      `or \`gbrain sources add ${id}\` to create it.`,
    );
  }
}

/**
 * Get the local_path of the resolved source (per the resolveSourceId chain).
 *
 * Returns the on-disk brain repo path for the source the user is currently
 * operating against. Used by `gbrain storage status` and `gbrain export
 * --restore-only` to find the brain repo without raw SQL or bare try/catch.
 *
 * Resolution order:
 *   1. `sources.local_path` for the resolved source id (multi-source v0.18+ path)
 *   2. Legacy global `sync.repo_path` config key (pre-v0.18 default-source brains)
 *   3. null
 *
 * @returns local_path string, or null if no path is configured anywhere.
 * @throws  If DB error occurs (does NOT silently swallow). Callers handle
 *          the null case to provide their own fallback (typically a hard error
 *          telling the user to pass --repo).
 */
export async function getDefaultSourcePath(
  engine: BrainEngine,
  cwd: string = process.cwd(),
): Promise<string | null> {
  const sourceId = await resolveSourceId(engine, null, cwd);
  const rows = await engine.executeRaw<{ local_path: string | null }>(
    `SELECT local_path FROM sources WHERE id = $1`,
    [sourceId],
  );
  if (rows[0]?.local_path) return rows[0].local_path;

  // Legacy fallback: pre-v0.18 brains stored the repo path in the global
  // config table under sync.repo_path. The sources table exists but its
  // local_path is NULL for the seeded 'default' row. Fall back so storage
  // tiering works without forcing a `gbrain sources add . --path .` migration.
  const legacyPath = await engine.getConfig('sync.repo_path');
  return legacyPath ?? null;
}

/**
 * v0.37.7.0 — tier labels for `resolveSourceWithTier()`. Exported so
 * `gbrain sources current --json` and downstream consumers share a
 * canonical vocabulary instead of redefining strings inline.
 *
 * Order matches the 1-6 priority of `resolveSourceId()`.
 */
export const SOURCE_TIER_NAMES = [
  'flag',
  'env',
  'dotfile',
  'local_path',
  'brain_default',
  'sole_non_default',
  'seed_default',
] as const;
export type SourceTier = typeof SOURCE_TIER_NAMES[number];

/**
 * Same resolution chain as `resolveSourceId()`, but also returns
 * WHICH tier won. Additive — does not duplicate the logic; runs the
 * same six steps in the same order. Used by `gbrain sources current`
 * so users can verify the resolved source AND the reason it resolved
 * before destructive ops.
 *
 * @returns `{ source_id, tier, detail? }` where `detail` is an
 *          optional human-readable extra (e.g. the env-var name or
 *          the matched dotfile / local_path).
 */
export async function resolveSourceWithTier(
  engine: BrainEngine,
  explicit: string | null | undefined,
  cwd: string = process.cwd(),
): Promise<{ source_id: string; tier: SourceTier; detail?: string }> {
  // 1. Explicit flag wins. __all__ sentinel passes through verbatim (#1712).
  if (explicit) {
    if (explicit === ALL_SOURCES) {
      return { source_id: ALL_SOURCES, tier: 'flag', detail: `--source ${ALL_SOURCES} (spans all sources)` };
    }
    if (!SOURCE_ID_RE.test(explicit)) {
      throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`);
    }
    await assertSourceExists(engine, explicit);
    return { source_id: explicit, tier: 'flag', detail: `--source ${explicit}` };
  }

  // 2. Env var. Same __all__ pass-through (#2140).
  const env = process.env.GBRAIN_SOURCE;
  if (env && env.length > 0) {
    if (env === ALL_SOURCES) {
      return { source_id: ALL_SOURCES, tier: 'env', detail: `GBRAIN_SOURCE=${ALL_SOURCES} (spans all sources)` };
    }
    if (!SOURCE_ID_RE.test(env)) {
      throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`);
    }
    await assertSourceExists(engine, env);
    return { source_id: env, tier: 'env', detail: `GBRAIN_SOURCE=${env}` };
  }

  // 3. .gbrain-source dotfile walk-up.
  const dotfile = readDotfileWalk(cwd);
  if (dotfile) {
    await assertSourceExists(engine, dotfile);
    return { source_id: dotfile, tier: 'dotfile', detail: `.gbrain-source` };
  }

  // 4. Registered source whose local_path contains CWD.
  const registered = await engine.executeRaw<{ id: string; local_path: string }>(
    `SELECT id, local_path FROM sources WHERE local_path IS NOT NULL`,
  );
  // realpath both sides — see the matching block in resolveSourceId (codex #9).
  const cwdResolved = realpathOrResolve(cwd);
  let best: { id: string; path: string; pathLen: number } | null = null;
  for (const r of registered) {
    const p = realpathOrResolve(r.local_path);
    if (cwdResolved === p || cwdResolved.startsWith(p + '/')) {
      if (!best || p.length > best.pathLen) {
        best = { id: r.id, path: p, pathLen: p.length };
      }
    }
  }
  if (best) return { source_id: best.id, tier: 'local_path', detail: best.path };

  // 5. Brain-level default. Silent-fallback (P1-F) like tier 5 in resolveSourceId.
  const globalDefault = await engine.getConfig('sources.default');
  if (globalDefault && isValidSourceId(globalDefault)) {
    await assertSourceExists(engine, globalDefault);
    return { source_id: globalDefault, tier: 'brain_default', detail: 'sources.default config' };
  }

  // 5.5. Single-non-default-source convenience (v0.41.13, #1434).
  //      See resolveSourceId for the design rationale. Same helper, same
  //      precedence (AFTER brain_default).
  const soleNonDefault = await pickSoleNonDefaultSource(engine);
  if (soleNonDefault) {
    return {
      source_id: soleNonDefault,
      tier: 'sole_non_default',
      detail: `only non-default registered source with local_path`,
    };
  }

  // 6. Fallback: seeded 'default' source.
  return { source_id: 'default', tier: 'seed_default' };
}

/**
 * #2561 — compute the federated read scope for an UNQUALIFIED local CLI call.
 *
 * `sources add --federated` promises that a `config.federated = true` source
 * "participates in unqualified `gbrain search` results"
 * (docs/guides/multi-source-brains.md). This helper turns that promise into a
 * scope: given the resolved source and WHICH tier resolved it, return
 * `[resolvedSource, ...other federated source ids]` — or `undefined` when the
 * expansion must not apply:
 *
 *   - explicit tiers (`flag` / `env` / `dotfile`): the user named a source;
 *     scalar scope stands (that IS the qualified case);
 *   - no other federated source exists: keep the scalar fast path unchanged;
 *   - #2928: the resolved source is explicitly isolated (config.federated =
 *     false): it must not be mixed into a cross-source read in EITHER
 *     direction, so the scalar scope stands.
 *
 * Archived sources are excluded (same rationale as pickSoleNonDefaultSource);
 * the archived column is v34+, so fall back to the un-archived query on older
 * brains. Callers put the result on `OperationContext.localFederatedSourceIds`
 * — consumed only by `federatedSearchScope` and only when `remote === false`.
 */
export async function localFederatedSourceIds(
  engine: BrainEngine,
  sourceId: string,
  tier: SourceTier,
): Promise<string[] | undefined> {
  if (tier === 'flag' || tier === 'env' || tier === 'dotfile') return undefined;
  let rows: Array<{ id: string; config: unknown; archived?: boolean }>;
  try {
    rows = await engine.executeRaw<{ id: string; config: unknown; archived?: boolean }>(
      `SELECT id, config, archived FROM sources WHERE archived = false ORDER BY id`,
    );
  } catch {
    rows = await engine.executeRaw<{ id: string; config: unknown }>(
      `SELECT id, config FROM sources ORDER BY id`,
    );
  }
  // #2928: an EXPLICITLY isolated anchor (`sources unfederate` /
  // `--no-federated` → config.federated = false) opted out of cross-source
  // read mixing — never widen it into the federated set (which would drag
  // other sources' pages into its unqualified reads and vice versa). Scalar
  // scope stands. UNSET federated keeps the pre-#2928 widening behavior;
  // write routing (tier 5.5 above) is deliberately untouched.
  const resolvedRow = rows.find((row) => row.id === sourceId);
  if (resolvedRow && parseSourceConfig(resolvedRow.config).federated === false) {
    return undefined;
  }
  const ids = [
    sourceId,
    ...rows
      .filter((row) => row.archived !== true && isSourceFederated(row.config))
      .map((row) => row.id)
      .filter((id) => id !== sourceId),
  ];
  return ids.length > 1 ? ids : undefined;
}

/** Exposed for tests. */
export const __testing = {
  readDotfileWalk,
  SOURCE_ID_RE,
};
