generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// ── Users ──

model User {
  id           String   @id @default(cuid())
  email        String   @unique
  name         String?
  passwordHash String
  role         Role     @default(USER)
  provider     String?
  externalId   String?
  version      Int      @default(1)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  sessions           Session[]
  auditLogs          AuditLog[]
  ownedProjects      Project[]
  groupMemberships   GroupMember[]
  mcpTokens          McpToken[]
  ownedAgents        Agent[]
  chatThreads        ChatThread[]

  @@index([email])
}

enum Role {
  USER
  ADMIN
}

// ── Sessions ──

model Session {
  id        String   @id @default(cuid())
  token     String   @unique
  userId    String
  expiresAt DateTime
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([token])
  @@index([userId])
  @@index([expiresAt])
}

// ── MCP Servers ──

model McpServer {
  id            String          @id @default(cuid())
  name          String          @unique
  description   String          @default("")
  packageName   String?
  runtime       String?
  dockerImage   String?
  transport     Transport       @default(STDIO)
  repositoryUrl String?
  externalUrl   String?
  command       Json?
  containerPort Int?
  replicas      Int             @default(1)
  env           Json            @default("[]")
  healthCheck   Json?
  version       Int             @default(1)
  createdAt     DateTime        @default(now())
  updatedAt     DateTime        @updatedAt

  templateName    String?
  templateVersion String?

  instances McpInstance[]
  projects  ProjectServer[]

  @@index([name])
}

enum Transport {
  STDIO
  SSE
  STREAMABLE_HTTP
}

// ── MCP Templates ──

model McpTemplate {
  id            String    @id @default(cuid())
  name          String    @unique
  version       String    @default("1.0.0")
  description   String    @default("")
  packageName   String?
  runtime       String?
  dockerImage   String?
  transport     Transport @default(STDIO)
  repositoryUrl String?
  externalUrl   String?
  command       Json?
  containerPort Int?
  replicas      Int       @default(1)
  env           Json      @default("[]")
  healthCheck   Json?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  @@index([name])
}

// ── Secret Backends ──
//
// Pluggable storage for Secret.data. Default is `plaintext` (data stored in
// Secret.data JSON). Other drivers (e.g. `openbao`) store only a reference in
// Secret.externalRef and fetch actual values from the external system at read
// time. A `plaintext` row is seeded on first startup so the system always has
// a viable backend; additional backends are user-managed via
// `mcpctl create secretbackend`.

model SecretBackend {
  id          String   @id @default(cuid())
  name        String   @unique
  type        String                                  // plaintext | openbao | (future: vault, aws-sm, ...)
  config      Json     @default("{}")                 // type-specific: url, mount, namespace, tokenSecretRef
  // Runtime metadata for auto-rotating backend credentials (openbao token
  // auth). Fields: generatedAt, nextRenewalAt, validUntil, lastRotationAt,
  // lastRotationError, rotatable (true only for wizard-provisioned tokens).
  // Empty object for backends that don't use rotation (plaintext, kubernetes
  // auth, or static tokens). Managed entirely by the rotator service.
  tokenMeta   Json     @default("{}")
  isDefault   Boolean  @default(false)                // exactly one row has isDefault=true
  description String   @default("")
  version     Int      @default(1)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  secrets Secret[]

  @@index([name])
  @@index([isDefault])
}

// ── Secrets ──

model Secret {
  id          String   @id @default(cuid())
  name        String   @unique
  // FK to SecretBackend. Default empty string lets `prisma db push` add the
  // column to pre-existing rows without a data-loss reset; `bootstrapSecretBackends`
  // then points any empty-string values at the seeded `default` plaintext backend
  // on next mcpd startup. New rows written by SecretService always carry a
  // valid FK immediately.
  backendId   String   @default("")
  data        Json     @default("{}")                 // populated by plaintext backend only
  externalRef String   @default("")                   // populated by non-plaintext backends (e.g. "mount/path#v3")
  // Sorted list of the secret's data keys WITHOUT their values. Populated on
  // every create/update/migrate so list views and describe-without-reveal can
  // show "this secret has GRAFANA_URL + GRAFANA_TOKEN" without fetching the
  // backing data. For pre-existing rows the field is empty until the next
  // write or a lazy resolve in getById fills it in.
  keyNames    Json     @default("[]")
  version     Int      @default(1)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  backend SecretBackend @relation(fields: [backendId], references: [id])
  llms    Llm[]

  @@index([name])
  @@index([backendId])
}

// ── LLMs ──
//
// Server-managed LLM providers. Clients (agent, HTTP-mode mcplocal) send
// OpenAI-format requests to `mcpd /api/v1/llms/:name/infer` — mcpd attaches the
// provider API key server-side so credentials never leave the cluster.
// Credentials are stored by reference: `apiKeySecret` points at a Secret, and
// `apiKeySecretKey` names the key within that secret's data.

model Llm {
  id              String   @id @default(cuid())
  name            String   @unique
  type            String                                  // anthropic | openai | deepseek | vllm | ollama | gemini-cli
  model           String                                  // e.g. claude-3-5-sonnet-20241022
  url             String   @default("")                   // endpoint (empty for provider default)
  tier            String   @default("fast")               // fast | heavy
  description     String   @default("")
  apiKeySecretId  String?                                 // FK to Secret
  apiKeySecretKey String?                                 // key inside the Secret's data
  extraConfig     Json     @default("{}")                 // per-type extras
  version         Int      @default(1)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
  agents       Agent[]

  @@index([name])
  @@index([tier])
  @@index([apiKeySecretId])
}

// ── Groups ──

model Group {
  id          String   @id @default(cuid())
  name        String   @unique
  description String   @default("")
  version     Int      @default(1)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  members GroupMember[]

  @@index([name])
}

model GroupMember {
  id        String   @id @default(cuid())
  groupId   String
  userId    String
  createdAt DateTime @default(now())

  group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
  user  User  @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([groupId, userId])
  @@index([groupId])
  @@index([userId])
}

// ── RBAC Definitions ──

model RbacDefinition {
  id           String   @id @default(cuid())
  name         String   @unique
  subjects     Json     @default("[]")
  roleBindings Json     @default("[]")
  version      Int      @default(1)
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  @@index([name])
}

// ── Projects ──

model Project {
  id          String   @id @default(cuid())
  name        String   @unique
  description String   @default("")
  prompt      String   @default("")
  proxyModel  String   @default("")
  gated       Boolean  @default(true)
  llmProvider     String?
  llmModel        String?
  serverOverrides Json?
  ownerId         String
  version     Int      @default(1)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  owner          User            @relation(fields: [ownerId], references: [id], onDelete: Cascade)
  servers        ProjectServer[]
  prompts        Prompt[]
  promptRequests PromptRequest[]
  mcpTokens      McpToken[]
  agents         Agent[]

  @@index([name])
  @@index([ownerId])
}

model ProjectServer {
  id        String   @id @default(cuid())
  projectId String
  serverId  String
  createdAt DateTime @default(now())

  project Project   @relation(fields: [projectId], references: [id], onDelete: Cascade)
  server  McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)

  @@unique([projectId, serverId])
}

// ── MCP Tokens (bearer credentials for HTTP-mode mcplocal) ──
//
// Raw value format: `mcpctl_pat_<32 base62 chars>`. The raw value is shown
// exactly once at create time; only the SHA-256 hash is persisted. Tokens are
// scoped to exactly one project — they're only valid at
// `/projects/<that-project>/mcp`. Creator's RBAC is the ceiling; the service
// rejects bindings that exceed what the creator themselves can do.

model McpToken {
  id          String    @id @default(cuid())
  name        String
  projectId   String
  tokenHash   String    @unique
  tokenPrefix String
  ownerId     String
  description String    @default("")
  createdAt   DateTime  @default(now())
  expiresAt   DateTime?
  lastUsedAt  DateTime?
  revokedAt   DateTime?

  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
  owner   User    @relation(fields: [ownerId], references: [id], onDelete: Cascade)

  @@unique([name, projectId])
  @@index([tokenHash])
  @@index([projectId])
  @@index([ownerId])
}

// ── MCP Instances (running containers) ──

model McpInstance {
  id          String         @id @default(cuid())
  serverId    String
  containerId String?
  status      InstanceStatus @default(STOPPED)
  port        Int?
  metadata        Json           @default("{}")
  healthStatus    String?
  lastHealthCheck DateTime?
  events          Json           @default("[]")
  version         Int            @default(1)
  createdAt       DateTime       @default(now())
  updatedAt       DateTime       @updatedAt

  server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)

  @@index([serverId])
  @@index([status])
}

enum InstanceStatus {
  STARTING
  RUNNING
  STOPPING
  STOPPED
  ERROR
}

// ── Prompts (approved content resources) ──

model Prompt {
  id         String   @id @default(cuid())
  name       String
  content    String   @db.Text
  projectId  String?
  priority   Int      @default(5)
  summary    String?  @db.Text
  chapters   Json?
  linkTarget String?
  version    Int      @default(1)
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)

  @@unique([name, projectId])
  @@index([projectId])
}

// ── Prompt Requests (pending proposals from LLM sessions) ──

model PromptRequest {
  id               String   @id @default(cuid())
  name             String
  content          String   @db.Text
  projectId        String?
  priority         Int      @default(5)
  createdBySession String?
  createdByUserId  String?
  createdAt        DateTime @default(now())

  project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)

  @@unique([name, projectId])
  @@index([projectId])
  @@index([createdBySession])
}

// ── Audit Events (pipeline/gate/tool trace from mcplocal) ──

model AuditEvent {
  id             String   @id @default(cuid())
  timestamp      DateTime
  sessionId      String
  projectName    String
  eventKind      String
  source         String
  verified       Boolean  @default(false)
  serverName     String?
  correlationId  String?
  parentEventId  String?
  userName       String?
  tokenName      String?
  tokenSha       String?
  payload        Json
  createdAt      DateTime @default(now())

  @@index([sessionId])
  @@index([projectName])
  @@index([correlationId])
  @@index([timestamp])
  @@index([eventKind])
  @@index([userName])
  @@index([tokenSha])
}

// ── Backup Pending Queue ──

model BackupPending {
  id           String   @id @default(cuid())
  resourceKind String
  resourceName String
  action       String   // 'create' | 'update' | 'delete'
  userName     String
  yamlContent  String?  @db.Text
  createdAt    DateTime @default(now())

  @@index([createdAt])
}

// ── Agents (LLM personas pinned to a specific Llm) ──
//
// Agents combine a system prompt, a pinned LLM, and (optionally) a project to
// inherit Prompts from. Each Agent is also exposed by mcplocal as a virtual
// MCP server (`agent-<name>/chat`), so other clients can consult it as a tool.
// Per-call LiteLLM-style overrides stack on top of `defaultParams`.

model Agent {
  id             String   @id @default(cuid())
  name           String   @unique
  description    String   @default("")                  // shown in MCP tools/list
  systemPrompt   String   @default("") @db.Text          // agent persona
  llmId          String
  projectId      String?
  proxyModelName String?                                 // optional informational override
  defaultParams  Json     @default("{}")                 // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ...
  extras         Json     @default("{}")                 // future LoRA / tool-allowlist
  ownerId        String
  version        Int      @default(1)
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  llm     Llm          @relation(fields: [llmId], references: [id], onDelete: Restrict)
  project Project?     @relation(fields: [projectId], references: [id], onDelete: SetNull)
  owner   User         @relation(fields: [ownerId], references: [id], onDelete: Cascade)
  threads ChatThread[]

  @@index([name])
  @@index([llmId])
  @@index([projectId])
  @@index([ownerId])
}

// ── Chat Threads (persisted conversation per Agent) ──

model ChatThread {
  id         String   @id @default(cuid())
  agentId    String
  ownerId    String
  title      String   @default("")
  lastTurnAt DateTime @default(now())
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  agent    Agent         @relation(fields: [agentId], references: [id], onDelete: Cascade)
  owner    User          @relation(fields: [ownerId], references: [id], onDelete: Cascade)
  messages ChatMessage[]

  @@index([agentId, lastTurnAt(sort: Desc)])
  @@index([ownerId])
}

// ── Chat Messages ──
//
// `turnIndex` is monotonic per thread; the @@unique enforces ordering even
// under racing appends (callers retry on collision). `status` stays `pending`
// until the orchestrator confirms the turn completed successfully.

model ChatMessage {
  id         String   @id @default(cuid())
  threadId   String
  turnIndex  Int
  role       String                                      // 'system' | 'user' | 'assistant' | 'tool'
  content    String   @db.Text
  toolCalls  Json?                                       // assistant turn: [{id,name,arguments}]
  toolCallId String?                                     // tool turn: which call this answers
  status     String   @default("complete")               // 'pending' | 'complete' | 'error'
  createdAt  DateTime @default(now())

  thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade)

  @@unique([threadId, turnIndex])
  @@index([threadId, createdAt])
}

// ── Audit Logs ──

model AuditLog {
  id        String   @id @default(cuid())
  userId    String
  action    String
  resource  String
  resourceId String?
  details   Json     @default("{}")
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@index([action])
  @@index([resource])
  @@index([createdAt])
}
