// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

/**
 * `akm show` — entry point.
 *
 * Spec §6.2:
 *
 *   show(ref) → indexer.lookup(ref) → readFile(entry.filePath)
 *
 * The richer presentation logic (matchers, renderers, wiki-root handling,
 * edit-hints, summary-detail truncation) lives below in this file. The flow:
 *
 *   1. Special-case wiki-root refs (`wiki:<name>` with no page path).
 *   2. Auto-index when stale so the index is current.
 *   3. Ask `indexer.lookup(ref)` for the row in the FTS index.
 *   4. Render the file via the matcher/renderer pipeline.
 */

import fs from "node:fs";
import path from "node:path";
import { parseAssetRef } from "../../core/asset/asset-ref";
import { parseFrontmatter } from "../../core/asset/frontmatter";
import { META_DIR, type MetaRef, parseMetaRef, resolveMetaFilePath } from "../../core/asset/stash-meta";
import { asNonEmptyString } from "../../core/common";
import { loadConfig } from "../../core/config/config";
import { NotFoundError, rethrowIfTestIsolationError, UsageError } from "../../core/errors";
import { appendEvent, readEvents } from "../../core/events";
import { findEntryIdByRef } from "../../indexer/db/db";
import { ensureIndex } from "../../indexer/ensure-index";
import { listRelatedPathsForFile } from "../../indexer/graph/graph-boost";
import { lookup } from "../../indexer/indexer";
import type { StashEntryScope } from "../../indexer/passes/metadata";
import { buildEditHint, findSourceForPath, isEditable, resolveSourceEntries } from "../../indexer/search/search-source";
import { insertUsageEvent } from "../../indexer/usage/usage-events";
import { buildFileContext, buildRenderContext, getRenderer, runMatchers } from "../../indexer/walk/file-context";
import { resolveAssetPath } from "../../indexer/walk/path-resolver";
import { resolveSourcesForOrigin } from "../../registry/origin-resolve";
import { withIndexDb } from "../../storage/repositories/index-db";
// Eagerly import source providers to trigger self-registration.
import "../../sources/providers/index";
import type { KnowledgeView, ShowDetailLevel, ShowResponse } from "../../sources/types";
import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key";
import { getActiveWorkflowRun } from "../../workflows/runtime/runs";

/**
 * Show a wiki root (no page path) — returns the same payload as
 * `akm wiki show <name>`.
 */
async function showWikiRoot(stashDir: string, wikiName: string): Promise<ShowResponse> {
  const { showWiki } = await import("../../wiki/wiki.js");
  const result = showWiki(stashDir, wikiName);
  return {
    type: "wiki",
    name: result.ref,
    path: result.path,
    ...(result.description ? { description: result.description } : {}),
    origin: null,
    editable: false,
    pages: result.pages,
    raws: result.raws,
    ...(result.lastModified ? { lastModified: result.lastModified } : {}),
    recentLog: result.recentLog,
  } as unknown as ShowResponse;
}

async function showWikiRootForSource(
  stashDir: string,
  source: { path: string; wikiName?: string },
  wikiName: string,
): Promise<ShowResponse> {
  const { showWikiAtPath } = await import("../../wiki/wiki.js");
  if (source.wikiName === wikiName) {
    const result = showWikiAtPath(wikiName, source.path);
    return {
      type: "wiki",
      name: result.ref,
      path: result.path,
      ...(result.description ? { description: result.description } : {}),
      origin: null,
      editable: false,
      pages: result.pages,
      raws: result.raws,
      ...(result.lastModified ? { lastModified: result.lastModified } : {}),
      recentLog: result.recentLog,
    } as unknown as ShowResponse;
  }
  return showWikiRoot(stashDir, wikiName);
}

function resolveRegisteredWikiAssetPath(wikiRoot: string, wikiName: string, assetName: string): string {
  const pageName = assetName === wikiName ? "" : assetName.slice(wikiName.length + 1);
  if (!pageName) {
    throw new NotFoundError(`Wiki page not found: wiki:${assetName}`);
  }
  const candidate = path.resolve(wikiRoot, `${pageName}.md`);
  const resolvedRoot = fs.realpathSync(wikiRoot);
  if (!candidate.startsWith(resolvedRoot + path.sep)) {
    throw new UsageError("Ref resolves outside the stash root.", "PATH_ESCAPE_VIOLATION");
  }
  if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
    throw new NotFoundError(`Stash asset not found for ref: wiki:${assetName}`);
  }
  const realTarget = fs.realpathSync(candidate);
  if (!realTarget.startsWith(resolvedRoot + path.sep)) {
    throw new UsageError("Ref resolves outside the stash root.", "PATH_ESCAPE_VIOLATION");
  }
  return realTarget;
}

/**
 * Unified show: queries the local FTS5 index, then falls back to on-disk
 * type-dir resolution if the index has no row. Spec §6.2; no remote provider
 * fallback.
 *
 * When `detail` is `"brief"` or `"summary"`, the response omits
 * content/template/prompt and returns compact metadata.
 */
export async function akmShowUnified(input: {
  ref: string;
  view?: KnowledgeView;
  detail?: ShowDetailLevel;
  /**
   * Optional scope filter. When supplied, the resolved asset's frontmatter
   * `scope_user`/`scope_agent`/`scope_run`/`scope_channel` keys must match
   * every supplied filter value. A mismatch (or no scope on disk) raises a
   * {@link NotFoundError} so callers can distinguish "asset exists but is
   * out of scope" from "asset truly absent" via the standard error envelope.
   */
  scope?: StashEntryScope;
  /**
   * Event source for usage logging. Defaults to `"user"`. Set to
   * `"improve"` when called from improve's reflect/distill agents
   * so events can be filtered out of user-facing history.
   */
  eventSource?: "user" | "improve";
}): Promise<ShowResponse> {
  const ref = input.ref.trim();

  // 0a. Stash `.meta/` convention: `[origin//]meta[:name]` direct-reads a
  //     human-authored orientation doc from the stash's `.meta/` directory.
  //     These files are not indexed (the walker skips dot-dirs), so they are
  //     resolved here before the index lookup and the `type:name` parser,
  //     which would otherwise reject the non-asset-type `meta`.
  {
    const metaRef = parseMetaRef(ref);
    if (metaRef) return showStashMeta(metaRef);
  }

  // 0. Wiki-root shortcut: `wiki:<name>` with no page path routes to the
  //    wiki summary (same payload as `akm wiki show <name>`). Honour
  //    `parsed.origin` by resolving against the matching stash source(s),
  //    falling back to the primary stash when no origin is given.
  {
    const parsed = parseAssetRef(ref);
    if (parsed.type === "wiki" && !parsed.name.includes("/")) {
      const allSources = resolveSourceEntries();
      const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
      let lastError: NotFoundError | undefined;
      for (const source of searchSources) {
        try {
          return await showWikiRootForSource(allSources[0]?.path ?? source.path, source, parsed.name);
        } catch (err) {
          if (!(err instanceof NotFoundError)) throw err;
          lastError = err;
        }
      }
      throw (
        lastError ??
        new NotFoundError(`Wiki not found: ${parsed.name}. Run \`akm wiki create ${parsed.name}\` to create it.`)
      );
    }
  }

  // Auto-index when stale so the index is current before lookup.
  const allSources = resolveSourceEntries();
  if (allSources.length > 0) {
    await ensureIndex(allSources[0].path);
  }

  // Try local filesystem (FTS5 index lookup)
  const result = await showLocal(input);
  // Scope filter narrows resolution: if --scope was supplied, the asset's
  // frontmatter scope must satisfy every supplied key. We re-read the file
  // (cheap — already on the show hot path) so we don't have to thread scope
  // through the renderer chain just for one verification step.
  if (input.scope && hasAnyScopeKey(input.scope) && result.path) {
    enforceScopeOrThrow(result.path, ref, input.scope);
  }
  // Count prior shows of this ref before logging the current one.
  const priorShowCount = recentShowCount(ref);
  logShowEvent(ref, input.eventSource);
  if (priorShowCount >= 2) {
    // Agent has shown this same asset 3+ times — inject a loop-break hint.
    (result as unknown as Record<string, unknown>).showLoopWarning = priorShowCount + 1;
  }
  return result;
}

/**
 * Resolve a stash `.meta/` doc and return it as a lightweight ShowResponse.
 *
 * With no origin the working stash (and other configured sources, in order)
 * is searched and the first hit wins. With an origin the lookup is narrowed
 * to that stash; an uninstalled origin yields an actionable "not installed"
 * error. The file is read directly from disk — `.meta/` is never indexed.
 */
async function showStashMeta(metaRef: MetaRef): Promise<ShowResponse> {
  const allSources = resolveSourceEntries();
  const sources = resolveSourcesForOrigin(metaRef.origin, allSources);

  if (metaRef.origin && sources.length === 0) {
    throw new NotFoundError(
      `Stash "${metaRef.origin}" is not installed, so its ${META_DIR}/ docs are unavailable. ` +
        `Run: akm add ${metaRef.origin}`,
    );
  }

  const config = loadConfig();
  for (const source of sources) {
    const filePath = resolveMetaFilePath(source.path, metaRef.name);
    if (!filePath) continue;
    const content = fs.readFileSync(filePath, "utf8");
    const editable = isEditable(filePath, config);
    appendEvent({ eventType: "show", ref: `meta:${metaRef.name}`, metadata: { type: "meta", name: metaRef.name } });
    return {
      type: "meta",
      name: metaRef.name,
      path: filePath,
      content,
      origin: source.registryId ?? null,
      editable,
    } as ShowResponse;
  }

  throw new NotFoundError(
    `No ${META_DIR}/${metaRef.name} doc found${metaRef.origin ? ` in "${metaRef.origin}"` : ""}. ` +
      `Stash maintainers can create ${META_DIR}/${metaRef.name}.md to describe this stash ` +
      `(purpose, key assets, conventions, maintainer).`,
  );
}

function hasAnyScopeKey(scope: StashEntryScope): boolean {
  return Boolean(scope.user || scope.agent || scope.run || scope.channel);
}

/**
 * Read the asset file's frontmatter and verify its `scope_*` keys satisfy
 * every supplied filter. Throws a {@link NotFoundError} on mismatch so the
 * caller surfaces a uniform "not found in this scope" envelope rather than
 * leaking out-of-scope content.
 */
function enforceScopeOrThrow(filePath: string, ref: string, scope: StashEntryScope): void {
  let raw: string;
  try {
    raw = fs.readFileSync(filePath, "utf8");
  } catch {
    // The file path was just resolved by the indexer/disk-walk — a read
    // failure here means the on-disk state moved out from under us. Treat
    // that as "not found in this scope" so the caller does not learn the
    // file's prior contents.
    throw new NotFoundError(`Asset not found for scope filter: ${ref}`);
  }
  const fm = parseFrontmatter(raw).data;
  const expected: Array<[keyof StashEntryScope, string | undefined]> = [
    ["user", scope.user],
    ["agent", scope.agent],
    ["run", scope.run],
    ["channel", scope.channel],
  ];
  for (const [key, expectedValue] of expected) {
    if (expectedValue === undefined) continue;
    const actual = asNonEmptyString(fm[`scope_${key}`]);
    if (actual !== expectedValue) {
      throw new NotFoundError(`Asset "${ref}" exists but is out of scope (expected scope_${key}="${expectedValue}").`);
    }
  }
}

/**
 * Count how many times `ref` has been shown in the current session by reading
 * recent events. Returns the count BEFORE the current invocation.
 */
function recentShowCount(ref: string): number {
  try {
    const { events } = readEvents({
      type: "show",
      ref,
      since: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
    });
    return events.length;
  } catch {
    return 0;
  }
}

function logShowEvent(ref: string, eventSource: "user" | "improve" = "user"): void {
  // Emit a structured event to events.jsonl so workflow-trace consumers
  // detect akm show invocations without relying on stdout scraping.
  const parsed = parseAssetRef(ref);
  appendEvent({ eventType: "show", ref, metadata: { type: parsed.type, name: parsed.name } });

  // Detect if this show is a selection from a recent search result.
  try {
    // D7: bound the query to the last 60 s so we never scan unbounded history
    const { events: recentSearches } = readEvents({
      type: "search",
      since: new Date(Date.now() - 60_000).toISOString(),
    });
    const cutoffMs = Date.now() - 60_000;
    const matchingSearch = [...recentSearches].reverse().find((e) => {
      if (!e.ts || new Date(e.ts).getTime() < cutoffMs) return false;
      const refs = (e.metadata?.resultRefs as string[] | undefined) ?? [];
      return refs.includes(ref);
    });
    if (matchingSearch) {
      appendEvent({
        eventType: "select",
        ref,
        metadata: {
          query: matchingSearch.metadata?.query as string | undefined,
          searchTs: matchingSearch.ts,
          rankPosition: ((matchingSearch.metadata?.resultRefs as string[] | undefined) ?? []).indexOf(ref),
        },
      });
    }
  } catch {
    /* fire-and-forget — select is best-effort */
  }

  try {
    withIndexDb((db) => {
      insertUsageEvent(db, {
        event_type: "show",
        entry_ref: ref,
        entry_id: findEntryIdByRef(db, ref),
        source: eventSource,
      });
    });
  } catch (err) {
    rethrowIfTestIsolationError(err);
    /* fire-and-forget */
  }
}

/** @internal Use akmShowUnified() for all external callers. */
export async function showLocal(input: {
  ref: string;
  view?: KnowledgeView;
  detail?: ShowDetailLevel;
  stashDir?: string;
}): Promise<ShowResponse> {
  const parsed = parseAssetRef(input.ref);
  const displayType = parsed.type;
  const config = loadConfig();
  const allSources = resolveSourceEntries(input.stashDir);
  const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);

  const allSourceDirs = searchSources.map((s) => s.path);

  let assetPath: string | undefined;
  const matchedSource =
    parsed.type === "wiki" ? searchSources.find((source) => parsed.name.startsWith(`${source.wikiName}/`)) : undefined;
  let lastError: Error | undefined;
  if (parsed.type === "wiki" && matchedSource?.wikiName) {
    try {
      assetPath = resolveRegisteredWikiAssetPath(matchedSource.path, matchedSource.wikiName, parsed.name);
    } catch (err) {
      lastError = err instanceof Error ? err : new Error(String(err));
    }
  }
  if (!assetPath) {
    const resolvedAssetPath = await resolveAssetPath(parsed, {
      stashDir: input.stashDir,
      mode: "index-first",
    });
    assetPath = resolvedAssetPath ?? undefined;
  }

  if (!assetPath && parsed.origin && searchSources.length === 0) {
    const installCmd = `akm add ${parsed.origin}`;
    throw new NotFoundError(
      `Stash asset not found for ref: ${displayType}:${parsed.name}. ` +
        `Stash "${parsed.origin}" is not installed. Run: ${installCmd}`,
    );
  }

  if (!assetPath) {
    throw (
      lastError ??
      new NotFoundError(
        `Stash asset not found for ref: ${displayType}:${parsed.name}. ` +
          "Check the name with `akm search` or verify the asset exists in your stash.",
      )
    );
  }

  const source = matchedSource ?? findSourceForPath(assetPath, allSources);
  const sourceStashDir = source?.path ?? allSourceDirs[0];

  if (!sourceStashDir) {
    throw new UsageError(
      `Could not determine stash root for asset: ${displayType}:${parsed.name}. ` +
        "Run `akm init` to create the stash directory, or check `akm stash list` for configured paths.",
    );
  }

  const fileCtx = buildFileContext(sourceStashDir, assetPath);
  const forcedWikiMatch =
    parsed.type === "wiki" && source?.wikiName && parsed.name.startsWith(`${source.wikiName}/`)
      ? { type: "wiki", specificity: 20, renderer: "wiki-md", meta: {} }
      : undefined;
  const match = forcedWikiMatch ?? (await runMatchers(fileCtx));
  if (!match) {
    throw new UsageError(
      `Could not display asset "${displayType}:${parsed.name}" — unsupported file type or unrecognized layout`,
    );
  }

  match.meta = { ...match.meta, name: parsed.name, view: input.view };
  const renderer = await getRenderer(match.renderer);
  if (!renderer) {
    throw new UsageError(`Renderer "${match.renderer}" not found for asset: ${displayType}:${parsed.name}`);
  }

  const renderCtx = buildRenderContext(fileCtx, match, allSourceDirs, source?.registryId);
  const response = renderer.buildShowResponse(renderCtx);
  const editable = isEditable(assetPath, config);
  const fullResponse: ShowResponse = {
    ...response,
    origin: source?.registryId ?? null,
    editable,
    ...(!editable ? { editHint: buildEditHint(assetPath, parsed.type, parsed.name, source?.registryId) } : {}),
    related: (() => {
      try {
        return withIndexDb((db) => {
          const related = listRelatedPathsForFile(sourceStashDir, assetPath, 5, db);
          return { total: related.length, hits: related };
        });
      } catch (err) {
        rethrowIfTestIsolationError(err);
        return { total: 0, hits: [] };
      }
    })(),
  };

  const activeRun = await getActiveWorkflowRun(getCurrentWorkflowScopeKey());
  if (activeRun) {
    (fullResponse as unknown as Record<string, unknown>).activeRun = activeRun;
  }

  if (input.detail === "brief") {
    return buildBriefResponse(fullResponse, assetPath);
  }

  if (input.detail === "summary") {
    return buildSummaryResponse(fullResponse, assetPath);
  }

  return fullResponse;
}

/**
 * Minimal `show`: ref → indexer lookup → file contents. Used by callers that
 * just need the raw file (e.g. clone, write-source) and don't want the full
 * renderer graph. Spec §6.2's literal flow.
 */
export async function showByRef(ref: string): Promise<{ filePath: string; body: string }> {
  const parsed = parseAssetRef(ref);
  const entry = await lookup(parsed);
  if (!entry) {
    throw new NotFoundError(`Asset not found for ref: ${parsed.type}:${parsed.name}`);
  }
  const body = await fs.promises.readFile(entry.filePath, "utf8");
  return { filePath: entry.filePath, body };
}

/**
 * Build a reduced brief response from a full ShowResponse.
 *
 * Keeps routing/identification fields while omitting content/template/prompt.
 */
function buildBriefResponse(full: ShowResponse, assetPath?: string): ShowResponse {
  const summary = buildSummaryResponse(full, assetPath);
  return {
    type: summary.type,
    name: summary.name,
    path: summary.path,
    ...(summary.description ? { description: summary.description } : {}),
    ...(summary.action ? { action: summary.action } : {}),
    ...(summary.run ? { run: summary.run } : {}),
    ...(summary.origin !== undefined ? { origin: summary.origin } : {}),
    ...(full.editable !== undefined ? { editable: full.editable } : {}),
  };
}

/**
 * Build a compact summary response from a full ShowResponse.
 *
 * Strips content/template/prompt and returns only metadata fields:
 * type, name, path, description, tags, parameters, action.
 * Enriches description and tags from rendered content when available.
 *
 * The resulting JSON should be under 200 tokens.
 */
function buildSummaryResponse(full: ShowResponse, assetPath?: string): ShowResponse {
  let description = full.description;
  const tags = full.tags;

  if (assetPath) {
    const textContent = full.content ?? full.template ?? full.prompt;
    if (textContent && !description) {
      const parsed = parseFrontmatter(textContent);
      description = asNonEmptyString(parsed.data.description);
    }
  }

  const summary: ShowResponse = {
    type: full.type,
    name: full.name,
    path: full.path,
    ...(description ? { description } : {}),
    ...(tags && tags.length > 0 ? { tags } : {}),
    ...(full.parameters ? { parameters: full.parameters } : {}),
    ...(full.workflowTitle ? { workflowTitle: full.workflowTitle } : {}),
    ...(full.action ? { action: full.action } : {}),
    ...(full.run ? { run: full.run } : {}),
    ...(full.origin !== undefined ? { origin: full.origin } : {}),
  };

  return summary;
}

// ── argv normalisation ───────────────────────────────────────────────────────

const SHOW_VIEW_MODES = new Set(["toc", "frontmatter", "full", "section", "lines"]);

/**
 * Normalize argv so positional view-mode arguments after the asset ref
 * are rewritten into internal flags that citty can parse.
 *
 * Converts:
 *   akm show knowledge:guide.md toc          → akm show knowledge:guide.md --akmView toc
 *   akm show knowledge:guide.md section Auth → akm show knowledge:guide.md --akmView section --akmHeading Auth
 *   akm show knowledge:guide.md lines 1 50   → akm show knowledge:guide.md --akmView lines --akmStart 1 --akmEnd 50
 *
 * Legacy `--view` is intentionally unsupported.
 * Returns a new array; the input is never modified.
 */
export function normalizeShowArgv(argv: string[]): string[] {
  // argv[0]=bun argv[1]=script argv[2]=subcommand argv[3]=ref argv[4..]=rest
  if (argv[2] !== "show") return argv;
  if (argv.includes("--view") || argv.includes("--heading") || argv.includes("--start") || argv.includes("--end")) {
    throw new UsageError(
      'Legacy show flags are no longer supported. Use positional syntax like `akm show knowledge:guide toc` or `akm show knowledge:guide section "Auth"`.',
    );
  }

  // Separate global flags from positional/show-specific args
  const prefix = argv.slice(0, 3); // [bun, script, show]
  const rest = argv.slice(3);

  const globalFlags: string[] = [];
  const showArgs: string[] = [];

  for (let i = 0; i < rest.length; i++) {
    const arg = rest[i];
    if (arg === "--quiet" || arg === "-q" || arg === "--verbose") {
      globalFlags.push(arg);
      continue;
    }
    if (arg.startsWith("--format=") || arg.startsWith("--detail=") || arg.startsWith("--shape=")) {
      globalFlags.push(arg);
      continue;
    }
    if (arg === "--format" || arg === "--detail" || arg === "--shape") {
      globalFlags.push(arg);
      if (rest[i + 1] !== undefined) {
        globalFlags.push(rest[i + 1]);
        i++;
      }
      continue;
    }
    showArgs.push(arg);
  }

  // showArgs[0] = ref, showArgs[1] = potential view mode, showArgs[2..] = view params
  const ref = showArgs[0];
  const viewMode = showArgs[1];

  if (!ref || !viewMode || !SHOW_VIEW_MODES.has(viewMode)) {
    return argv;
  }

  const result = [...prefix, ref, "--akmView", viewMode];

  if (viewMode === "section") {
    // Next arg is the heading name; pass empty string when missing so the
    // show handler can produce a clear "section not found" error.
    const heading = showArgs[2] ?? "";
    result.push("--akmHeading", heading);
  } else if (viewMode === "lines") {
    // Next two args are start and end
    const start = showArgs[2];
    const end = showArgs[3];
    if (start) result.push("--akmStart", start);
    if (end) result.push("--akmEnd", end);
  }

  result.push(...globalFlags);
  return result;
}
