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?
  agentId    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)
  agent         Agent?              @relation(fields: [agentId], references: [id], onDelete: Cascade)
  personalities PersonalityPrompt[]

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

// ── 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?
  defaultPersonalityId String? // applied at chat time when no --personality flag
  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[]
  prompts            Prompt[]
  personalities      Personality[] @relation("AgentPersonalities")
  defaultPersonality Personality?  @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)

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

// ── Personalities (named overlay bundles of prompts on top of an Agent) ──
//
// VLAN-on-ethernet semantics: an Agent works without a Personality (today's
// flow). Selecting a Personality adds its bound Prompts to the system block
// after the Agent's own systemPrompt, agent-direct prompts, and project
// prompts — additive, never replacing.

model Personality {
  id          String   @id @default(cuid())
  name        String
  description String   @default("")
  agentId     String
  priority    Int      @default(5)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  agent           Agent               @relation("AgentPersonalities", fields: [agentId], references: [id], onDelete: Cascade)
  prompts         PersonalityPrompt[]
  defaultForAgent Agent[]             @relation("AgentDefaultPersonality")

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

// ── Personality ↔ Prompt join table ──
//
// A Prompt can be bound to many Personalities; a Personality can bind many
// Prompts. The `priority` here overrides the Prompt's own priority *within
// this Personality's overlay slice* (so the same prompt can be high-priority
// in one personality and low in another).

model PersonalityPrompt {
  id            String   @id @default(cuid())
  personalityId String
  promptId      String
  priority      Int      @default(5)
  createdAt     DateTime @default(now())

  personality Personality @relation(fields: [personalityId], references: [id], onDelete: Cascade)
  prompt      Prompt      @relation(fields: [promptId], references: [id], onDelete: Cascade)

  @@unique([personalityId, promptId])
  @@index([personalityId])
  @@index([promptId])
}

// ── 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])
}
