import { defineTable } from 'convex/server';
import { v } from 'convex/values';

import { lifecycleStatusValidator } from '../governance/soft_delete_validators';

export const fileMetadataTable = defineTable({
  organizationId: v.string(),
  storageId: v.id('_storage'),
  documentId: v.optional(v.id('documents')),
  // Open provenance string (no hard enum, so a new channel needs no schema
  // change — mirrors documents.sourceProvider). Reserved values:
  //   'user'       — member-uploaded via the UI
  //   'agent'      — model / automation-generated
  //   'video_link' — yt-dlp transcript; TRUST-DISTINCT, wrapped in
  //                  <untrusted_source> on RAG retrieval (R2 review)
  //   <connector>  — external import: the connector slug verbatim, e.g.
  //                  'confluence', 'google_drive', 'onedrive', 'sharepoint',
  //                  'webdav', or any integration slug. Set by
  //                  linkDocumentToFile from the linked document's
  //                  sourceProvider when a blob is promoted to a document.
  // Only 'user' and 'agent' participate in the temp-retention GC lanes; import
  // slugs (and undefined) are never reclaimed by the sweep.
  source: v.optional(v.string()),
  fileName: v.string(),
  contentType: v.string(),
  size: v.number(),
  pageCount: v.optional(v.number()),
  scannedPagesDetected: v.optional(v.number()),
  visionRequired: v.optional(v.boolean()),
  ocrApplied: v.optional(v.boolean()),
  ragStatus: v.optional(
    v.union(
      v.literal('queued'),
      v.literal('running'),
      v.literal('completed'),
      v.literal('failed'),
    ),
  ),
  ragError: v.optional(v.string()),
  ragProgress: v.optional(v.string()),
  // Timestamp (ms) when ragStatus was last set to 'queued'. Used by the
  // poll-timeout watchdog to give up on uploads that never reached RAG
  // (e.g. scheduled action silently failed before hitting the service).
  // Falls back to _creationTime when absent on older rows.
  ragQueuedAt: v.optional(v.number()),
  // Unix SECONDS when ragStatus most recently reached 'completed'. Canonical
  // replacement for the retired documents.ragInfo.indexedAt — stamped by
  // updateFileRagStatus on completion, read by getDocumentRagProjection. Seconds
  // (not ms) to match the legacy writer, the backfill, and the RagStatusBadge's
  // `new Date(indexedAt * 1000)` render.
  ragIndexedAt: v.optional(v.number()),
  // Audio transcription (populated when contentType starts with 'audio/').
  transcript: v.optional(v.string()),
  transcriptionStatus: v.optional(
    v.union(
      v.literal('queued'),
      v.literal('running'),
      v.literal('completed'),
      v.literal('failed'),
      v.literal('skipped'),
    ),
  ),
  transcriptionError: v.optional(v.string()),
  transcriptionDurationSec: v.optional(v.number()),
  // Human-readable progress hint while transcriptionStatus is 'running'
  // (e.g. "compressing", "transcribing chunk 2 of 4"). Cleared on completion.
  transcriptionProgress: v.optional(v.string()),
  // Single-flight lock for the `transcribeAudio` action. Set atomically
  // by `acquireTranscriptionLock` when status transitions queued/null →
  // running. Concurrent invocations on the same storageId (e.g. a
  // `retryTranscription` double-click) check the run id under a lease
  // window and short-circuit if another invocation is in flight.
  // Cleared on completion / final failure / lease expiry.
  transcriptionRunId: v.optional(v.string()),
  // Unix ms; transcribeAudio re-acquisition is gated until this point.
  // `recoverStuckTranscriptions` (watchdog) breaks the lock once the
  // lease expires AND there's been no progress.
  transcriptionLeaseExpiresAt: v.optional(v.number()),
  // Unix ms when transcriptionStatus most recently flipped to 'running'
  // (stamped by acquireTranscriptionLock). Used by the watchdog to
  // detect stuck transcriptions without confusing a freshly-retried row
  // — `_creationTime` alone caused fresh retries of old rows to be
  // killed within seconds. Falls back to `_creationTime` for legacy
  // rows that pre-date this field.
  transcriptionStartedAt: v.optional(v.number()),
  // RAG indexing of the transcript (separate from ragStatus above, which is
  // gated out at scheduling time for audio uploads — see mutations).
  transcriptRagStatus: v.optional(
    v.union(
      v.literal('queued'),
      v.literal('running'),
      v.literal('completed'),
      v.literal('failed'),
    ),
  ),
  transcriptRagError: v.optional(v.string()),
  // SHA-256 of the raw uploaded audio bytes. Used for dedup across uploads
  // of the same content (different storageIds, same hash) — transcribeAudio
  // short-circuits to the cached transcript when a prior row in the same
  // org has completed transcription of the same content.
  contentHash: v.optional(v.string()),
  // SHA-256 (hex) of the raw bytes for sandbox-harvested output files.
  // Set by `insertOutputFiles` from the spawner's harvest payload; used for
  // pre-stage attestation when the same file is later re-injected into
  // another run's `/workspace/output/`. Distinct from `contentHash` (audio
  // transcript dedup) — different write source, different purpose. Optional
  // because non-sandbox uploads (chat attachments, document imports) don't
  // compute it.
  sha256: v.optional(v.string()),
  uploadedBy: v.optional(v.string()),
  /**
   * For chat-uploaded files, the chat thread the file was attached to.
   *
   * Three classes of `fileMetadata` after this field landed:
   *  - Document Hub: `documentId` set, `threadId` unset → org-wide knowledge
   *  - Chat upload: `documentId` unset, `threadId` set → bound to thread chain
   *  - Legacy / integration: both unset → falls back to same-org check
   *
   * Drives:
   *  - `rag_search` access: chat-bound files require caller's `ctx.threadId`
   *    to be in the same chain (verified by
   *    `verifyStorageIdsInThreadScope` + a chain walk in action context)
   *  - Soft-delete cascade: trashing a thread also trashes its bound files
   *    (lifecycleStatus='trashed' + statusChangedAt sync); restoring the
   *    thread restores the same set; hard-delete cascades to `_storage`
   *    blob + RAG purge via `eraseDocumentBlobs` style helper
   */
  threadId: v.optional(v.string()),
  lifecycleStatus: v.optional(lifecycleStatusValidator),
  statusChangedAt: v.optional(v.number()),
  /**
   * Video-link provenance — LEGACY/FALLBACK ONLY.
   *
   * @deprecated New writes go to `videoLinkJobs` (which carries the same
   * fields as live ingest state). `start_agent_chat.ts` JOINs videoLinkJobs
   * by storageId and prefers values from there; these fields are kept only
   * so rows written by older orchestrator builds (which DID copy them
   * here) continue to render with provenance. Per the project's
   * "deprecate, don't delete" rule, they remain `v.optional` indefinitely.
   *
   * NO `videoThumbnailUrl` here: `services/platform/server.ts` CSP
   * (img-src 'self' data: blob:) hard-blocks remote <img>. Add via the
   * existing `/api/image-proxy` pattern when chip thumbnail is promoted.
   */
  sourceUrl: v.optional(v.string()),
  sourcePlatform: v.optional(v.string()),
  videoTitle: v.optional(v.string()),
  videoUploader: v.optional(v.string()),
  videoDurationSec: v.optional(v.number()),
})
  .index('by_organizationId', ['organizationId'])
  .index('by_organizationId_and_lifecycleStatus', [
    'organizationId',
    'lifecycleStatus',
  ])
  .index('by_storageId', ['storageId'])
  .index('by_organizationId_and_documentId', ['organizationId', 'documentId'])
  .index('by_organizationId_and_source_and_documentId', [
    'organizationId',
    'source',
    'documentId',
  ])
  .index('by_org_user', ['organizationId', 'uploadedBy'])
  .index('by_org_contentHash', ['organizationId', 'contentHash'])
  // Chat-upload cascade: trash/restore/erase a thread → enumerate the
  // thread's bound files in O(1) per thread. Same shape as the soft-delete
  // composite index for status-narrowed sweeps.
  .index('by_organizationId_and_threadId', ['organizationId', 'threadId'])
  // Canonical "list indexed Document-Hub files in this org" lookup — replaces
  // the documents-side by_organizationId_and_indexed index after RAG status
  // collapsed onto fileMetadata. Used by getAgentScopedFileIds +
  // listIndexedDocumentsForAgent, queried as
  // `.eq(org).eq('ragStatus','completed').gt('documentId', undefined)`: the
  // documentId third field lets the range bound SEEK past chat-upload /
  // transcript rows (documentId absent → ordered as `undefined`, the least
  // value), so the scan stays dense with Hub docs instead of being inflated by
  // the org's chat corpus. (Convex orders: undefined < null < … < string.)
  .index('by_organizationId_and_ragStatus_and_documentId', [
    'organizationId',
    'ragStatus',
    'documentId',
  ])
  // Watchdog sweep: the `recoverStuckTranscriptions` cron runs every 5
  // minutes and only cares about rows whose `transcriptionStatus` is
  // `'running'`. The vast majority of rows are `'completed'` /
  // `'skipped'` / unset and an unindexed scan was paying for those on
  // every tick (round-2 M2). Indexing on the status field plus
  // `_creationTime` lets the cron iterate the tiny live set directly.
  .index('by_transcriptionStatus', ['transcriptionStatus']);
