import { createHash, randomBytes } from 'crypto';
import type { Page, PageInput, PageType, Chunk, SearchResult } from './types.ts';
import type { Take, TakeKind } from './engine.ts';

/**
 * SHA-256 hash a token/secret for storage. Never store plaintext tokens.
 */
export function hashToken(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}

/**
 * Generate a cryptographically random token with a prefix.
 */
export function generateToken(prefix: string): string {
  return `${prefix}${randomBytes(32).toString('hex')}`;
}

/**
 * Validate and normalize a slug. Slugs are lowercased repo-relative paths.
 * Rejects empty slugs, path traversal (..), and leading /.
 */
export function validateSlug(slug: string): string {
  if (!slug || /(^|\/)\.\.($|\/)/.test(slug) || /^\//.test(slug)) {
    throw new Error(`Invalid slug: "${slug}". Slugs cannot be empty, start with /, or contain path traversal.`);
  }
  return slug.toLowerCase();
}

/**
 * SHA-256 hash of page content, used for import idempotency.
 * Hashes all PageInput fields to match importFromContent's hash algorithm.
 */
export function contentHash(page: PageInput): string {
  return createHash('sha256')
    .update(JSON.stringify({
      title: page.title,
      type: page.type,
      compiled_truth: page.compiled_truth,
      timeline: page.timeline || '',
      frontmatter: page.frontmatter || {},
    }))
    .digest('hex');
}

function readOptionalDate(raw: unknown): Date | null | undefined {
  // Three-state read for columns that may or may not be in the SELECT
  // projection: undefined (not selected), null (selected, NULL value),
  // Date (selected, populated). Mirrors the v0.26.5 deleted_at pattern.
  if (raw === undefined) return undefined;
  if (raw === null) return null;
  return new Date(raw as string);
}

export function rowToPage(row: Record<string, unknown>): Page {
  const deletedAt = readOptionalDate(row.deleted_at);
  const effectiveDate = readOptionalDate(row.effective_date);
  const salienceTouchedAt = readOptionalDate(row.salience_touched_at);
  const effectiveDateSource = row.effective_date_source as Page['effective_date_source'] | undefined;
  const importFilename = row.import_filename as string | null | undefined;
  return {
    id: row.id as number,
    slug: row.slug as string,
    type: row.type as PageType,
    title: row.title as string,
    compiled_truth: row.compiled_truth as string,
    timeline: row.timeline as string,
    frontmatter: (typeof row.frontmatter === 'string' ? JSON.parse(row.frontmatter) : row.frontmatter) as Record<string, unknown>,
    content_hash: row.content_hash as string | undefined,
    // v0.29 (column added in migration v40). Old brains pre-migration return undefined.
    emotional_weight: row.emotional_weight == null ? undefined : Number(row.emotional_weight),
    created_at: new Date(row.created_at as string),
    updated_at: new Date(row.updated_at as string),
    ...(deletedAt !== undefined && { deleted_at: deletedAt }),
    // v0.29.1 (columns added in migration v41). Optional in SELECT projection.
    ...(effectiveDate !== undefined && { effective_date: effectiveDate }),
    ...(effectiveDateSource !== undefined && { effective_date_source: effectiveDateSource }),
    ...(importFilename !== undefined && { import_filename: importFilename }),
    ...(salienceTouchedAt !== undefined && { salience_touched_at: salienceTouchedAt }),
  };
}

/**
 * Normalize an embedding value into a Float32Array.
 *
 * pgvector returns embeddings in different shapes depending on driver/path:
 *   - postgres.js (Postgres): often a string like `"[0.1,0.2,...]"`
 *   - pglite: typically a numeric array or Float32Array
 *   - pgvector node binding: numeric array
 *   - Some queries that JSON-aggregate embeddings: JSON-string array
 *
 * Without normalization, downstream cosine math sees a string and produces
 * NaN scores silently. This helper guarantees a Float32Array or throws
 * loudly on malformed input — never returns NaN.
 */
export function parseEmbedding(value: unknown): Float32Array | null {
  if (value === null || value === undefined) return null;
  if (value instanceof Float32Array) return value;
  if (Array.isArray(value)) {
    if (value.length === 0) return new Float32Array(0);
    if (typeof value[0] !== 'number') {
      throw new Error(`parseEmbedding: array contains non-numeric element (${typeof value[0]})`);
    }
    return Float32Array.from(value as number[]);
  }
  if (typeof value === 'string') {
    const trimmed = value.trim();
    // Plain non-vector strings: treat as "no embedding here", return null.
    // Strings that LOOK like vector literals but contain garbage: throw,
    // because that's a real corruption signal worth surfacing loudly.
    if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null;
    const inner = trimmed.slice(1, -1).trim();
    if (inner.length === 0) return new Float32Array(0);
    const parts = inner.split(',');
    const out = new Float32Array(parts.length);
    for (let i = 0; i < parts.length; i++) {
      const n = Number(parts[i].trim());
      if (!Number.isFinite(n)) {
        throw new Error(`parseEmbedding: non-finite value at index ${i}: ${parts[i]}`);
      }
      out[i] = n;
    }
    return out;
  }
  return null;
}

/**
 * Detect a Postgres "undefined column" error (SQLSTATE 42703) without depending
 * on the postgres.js driver-specific error class.
 *
 * Used for forward-compat probes — code that does `SELECT foo FROM bar` against
 * schemas where `foo` may not exist yet on legacy installs (column was added in
 * a later migration). Bare `try { ... } catch {}` swallows EVERY error
 * (network blips, lock timeouts, auth failures) which masks real bugs as
 * "column missing." This predicate keeps the probe narrow.
 *
 * Matches on either:
 *   - SQLSTATE code `42703` (postgres.js sets this on the error)
 *   - the column name appearing in the message alongside a "does not exist" /
 *     "no such column" / "undefined column" clause (PGLite + various driver
 *     wraps)
 *
 * Anything else falls through and the caller MUST re-throw.
 */
export function isUndefinedColumnError(error: unknown, column: string): boolean {
  const code = typeof error === 'object' && error !== null && 'code' in error
    ? String((error as { code?: unknown }).code)
    : '';
  if (code === '42703') return true;
  const message = error instanceof Error ? error.message : String(error);
  return message.includes(column) && /does not exist|no such column|undefined column/i.test(message);
}

let _tryParseEmbeddingWarned = false;

/**
 * Availability-path sibling of parseEmbedding(). Returns null + warns once
 * on any shape parseEmbedding would throw on. Use this on read/rescore paths
 * where one corrupt row should degrade ranking, not kill the whole query.
 * Use parseEmbedding() (throws) on ingest/migrate paths where silent skips
 * would be data loss.
 */
export function tryParseEmbedding(value: unknown): Float32Array | null {
  try {
    return parseEmbedding(value);
  } catch (err) {
    if (!_tryParseEmbeddingWarned) {
      _tryParseEmbeddingWarned = true;
      const msg = err instanceof Error ? err.message : String(err);
      console.warn(`tryParseEmbedding: skipping corrupt embedding row (${msg}). Further warnings suppressed this session.`);
    }
    return null;
  }
}

export function rowToChunk(row: Record<string, unknown>, includeEmbedding = false): Chunk {
  return {
    id: row.id as number,
    page_id: row.page_id as number,
    chunk_index: row.chunk_index as number,
    chunk_text: row.chunk_text as string,
    chunk_source: row.chunk_source as 'compiled_truth' | 'timeline' | 'fenced_code',
    embedding: includeEmbedding ? parseEmbedding(row.embedding) : null,
    model: row.model as string,
    token_count: row.token_count as number | null,
    embedded_at: row.embedded_at ? new Date(row.embedded_at as string) : null,
    // v0.19.0 code-chunk metadata (nullable for markdown chunks).
    language: (row.language as string | null | undefined) ?? null,
    symbol_name: (row.symbol_name as string | null | undefined) ?? null,
    symbol_type: (row.symbol_type as string | null | undefined) ?? null,
    start_line: (row.start_line as number | null | undefined) ?? null,
    end_line: (row.end_line as number | null | undefined) ?? null,
    // v0.20.0 Cathedral II Layer 1 additions (nullable for markdown chunks).
    parent_symbol_path: (row.parent_symbol_path as string[] | null | undefined) ?? null,
    doc_comment: (row.doc_comment as string | null | undefined) ?? null,
    symbol_name_qualified: (row.symbol_name_qualified as string | null | undefined) ?? null,
  };
}

export function rowToSearchResult(row: Record<string, unknown>): SearchResult {
  const result: SearchResult = {
    slug: row.slug as string,
    page_id: row.page_id as number,
    title: row.title as string,
    type: row.type as PageType,
    chunk_text: row.chunk_text as string,
    chunk_source: row.chunk_source as 'compiled_truth' | 'timeline',
    chunk_id: row.chunk_id as number,
    chunk_index: row.chunk_index as number,
    score: Number(row.score),
    stale: Boolean(row.stale),
  };
  // v0.17.0: source_id comes from the p.source_id column in search
  // SELECTs. Keep the field optional so pre-v0.17 engines that didn't
  // join sources don't crash on the absent column — rowToSearchResult
  // is shared by both paths.
  if (typeof row.source_id === 'string') {
    result.source_id = row.source_id;
  }
  return result;
}

/**
 * Convert a takes-table SQL row (joined with pages.slug AS page_slug) to the
 * `Take` shape. Handles Date → ISO string conversion for timestamp/date columns.
 */
export function takeRowToTake(row: Record<string, unknown>): Take {
  const isoOrNull = (v: unknown): string | null => {
    if (v == null) return null;
    if (v instanceof Date) return v.toISOString();
    return String(v);
  };
  // since/until_date are TEXT (since v0.28 — DATE was too restrictive for
  // partial dates like '2017-01' that the spec uses).
  const dateOrNull = (v: unknown): string | null => {
    if (v == null) return null;
    if (v instanceof Date) return v.toISOString().slice(0, 10);
    return String(v);
  };
  return {
    id: Number(row.id),
    page_id: Number(row.page_id),
    page_slug: String(row.page_slug ?? ''),
    row_num: Number(row.row_num),
    claim: String(row.claim),
    kind: row.kind as TakeKind,
    holder: String(row.holder),
    weight: Number(row.weight),
    since_date: dateOrNull(row.since_date),
    until_date: dateOrNull(row.until_date),
    source: row.source == null ? null : String(row.source),
    superseded_by: row.superseded_by == null ? null : Number(row.superseded_by),
    active: Boolean(row.active),
    resolved_at: isoOrNull(row.resolved_at),
    resolved_outcome: row.resolved_outcome == null ? null : Boolean(row.resolved_outcome),
    resolved_quality: row.resolved_quality == null
      ? null
      : (String(row.resolved_quality) as 'correct' | 'incorrect' | 'partial'),
    resolved_value: row.resolved_value == null ? null : Number(row.resolved_value),
    resolved_unit: row.resolved_unit == null ? null : String(row.resolved_unit),
    resolved_source: row.resolved_source == null ? null : String(row.resolved_source),
    resolved_by: row.resolved_by == null ? null : String(row.resolved_by),
    created_at: isoOrNull(row.created_at) ?? '',
    updated_at: isoOrNull(row.updated_at) ?? '',
  };
}
