import { sql } from "drizzle-orm";
import {
  pgSchema,
  text,
  timestamp,
  integer,
  real,
  jsonb,
  boolean,
  customType,
  uniqueIndex,
  index,
  check,
} from "drizzle-orm/pg-core";

const bytea = customType<{ data: Buffer }>({
  dataType() {
    return "bytea";
  },
});

const lineString = customType<{ data: string }>({
  dataType() {
    return "geometry(LineString, 4326)";
  },
});

export const journalSchema = pgSchema("journal");

export type ProfileVisibility = "public" | "private";

export const users = journalSchema.table("users", {
  id: text("id").primaryKey(),
  email: text("email").notNull().unique(),
  username: text("username").notNull().unique(),
  displayName: text("display_name"),
  bio: text("bio"),
  domain: text("domain").notNull(),
  // Profile visibility / lock setting. `public` means anyone can view
  // the profile and follows auto-accept. `private` is Mastodon-style
  // locked: the profile renders a stub for non-followers, and follows
  // require manual approval (Pending → Accepted via the Requests tab on
  // /notifications).
  // New users default to `private` to match trails.cool's privacy-first
  // content defaults; existing users were backfilled to `public` by an
  // earlier migration so behavior didn't change for them.
  profileVisibility: text("profile_visibility").$type<ProfileVisibility>().notNull().default("private"),
  // Federation signing keypair (spec: social-federation). The public key
  // is a JWK JSON string embedded in the actor object; the private key is
  // a JWK encrypted at rest with FEDERATION_KEY_ENCRYPTION_KEY (see
  // apps/journal app/lib/federation-keys.server.ts). NULL until generated —
  // at registration for new users, or by the one-shot
  // `backfill-user-keypairs` job for users predating federation.
  publicKey: text("public_key"),
  privateKeyEncrypted: text("private_key_encrypted"),
  termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }),
  termsVersion: text("terms_version"),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

export const credentials = journalSchema.table("credentials", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  credentialId: bytea("credential_id").notNull(),
  publicKey: bytea("public_key").notNull(),
  counter: integer("counter").notNull().default(0),
  deviceType: text("device_type"),
  transports: jsonb("transports").$type<string[]>(),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

export const magicTokens = journalSchema.table("magic_tokens", {
  id: text("id").primaryKey(),
  email: text("email").notNull(),
  token: text("token").notNull().unique(),
  code: text("code"),
  purpose: text("purpose").notNull().default("login"),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
  usedAt: timestamp("used_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

/**
 * Visibility for routes and activities. Stored as a plain text column so we
 * can extend without a migration. See spec `public-content-visibility`.
 *  - private: only the owner can view
 *  - unlisted: anyone with the direct URL can view; excluded from listings
 *  - public: anyone can view; appears in listings and profiles
 */
export type Visibility = "private" | "unlisted" | "public";

export const routes = journalSchema.table("routes", {
  id: text("id").primaryKey(),
  ownerId: text("owner_id")
    .notNull()
    .references(() => users.id),
  name: text("name").notNull(),
  description: text("description").default(""),
  gpx: text("gpx"),
  geom: lineString("geom"),
  routingProfile: text("routing_profile"),
  distance: real("distance"),
  elevationGain: real("elevation_gain"),
  elevationLoss: real("elevation_loss"),
  dayBreaks: jsonb("day_breaks").$type<number[]>(),
  tags: jsonb("tags").$type<string[]>(),
  plannerState: bytea("planner_state"),
  visibility: text("visibility").$type<Visibility>().notNull().default("private"),
  /**
   * Content generated by the demo-activity-bot. Set to true only on insert;
   * never surfaced in user-facing update flows. Lets us exclude demo content
   * from analytics and wipe all bot output with a single DELETE.
   */
  synthetic: boolean("synthetic").notNull().default(false),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  ownerUpdatedIdx: index("routes_owner_updated_idx").on(t.ownerId, t.updatedAt.desc()),
}));

export const routeVersions = journalSchema.table("route_versions", {
  id: text("id").primaryKey(),
  routeId: text("route_id")
    .notNull()
    .references(() => routes.id, { onDelete: "cascade" }),
  version: integer("version").notNull(),
  gpx: text("gpx").notNull(),
  createdBy: text("created_by").references(() => users.id),
  changeDescription: text("change_description"),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

/**
 * Audience of an activity cached from a remote actor's outbox (spec:
 * social-federation, "Audience-aware feed filtering"). Local activities
 * are always 'public' here — their actual visibility lives in
 * `visibility`; this column only matters for remote rows, where
 * 'followers-only' content must reach only the local viewer whose
 * accepted follow brought it in.
 */
export type Audience = "public" | "followers-only";

export const activities = journalSchema.table("activities", {
  id: text("id").primaryKey(),
  // Local author. NULL for activities ingested from a remote trails
  // actor's outbox, where `remoteActorIri` identifies the author —
  // exactly one of the two is set (check constraint below; same
  // pattern as follows.follower_id / follower_actor_iri). Resolves
  // the owner_id open question in social-federation design.md.
  ownerId: text("owner_id").references(() => users.id),
  routeId: text("route_id").references(() => routes.id),
  name: text("name").notNull(),
  description: text("description").default(""),
  gpx: text("gpx"),
  geom: lineString("geom"),
  startedAt: timestamp("started_at", { withTimezone: true }),
  duration: integer("duration"),
  distance: real("distance"),
  elevationGain: real("elevation_gain"),
  elevationLoss: real("elevation_loss"),
  photos: jsonb("photos").$type<string[]>(),
  participants: jsonb("participants").$type<string[]>(),
  visibility: text("visibility").$type<Visibility>().notNull().default("private"),
  synthetic: boolean("synthetic").notNull().default(false),
  // Federation provenance (spec: social-federation). NULL for local
  // activities. For rows ingested from a remote trails actor's outbox:
  // `remoteOriginIri` is the activity's IRI on the origin instance
  // (unique → replay-safe `ON CONFLICT DO NOTHING` ingestion),
  // `remoteActorIri` keys into `remote_actors`, and
  // `remotePublishedAt` carries the origin's publish time (feed sort
  // uses COALESCE(remote_published_at, created_at)).
  remoteOriginIri: text("remote_origin_iri").unique(),
  remoteActorIri: text("remote_actor_iri"),
  remotePublishedAt: timestamp("remote_published_at", { withTimezone: true }),
  audience: text("audience").$type<Audience>().notNull().default("public"),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  // Hot paths: listActivities (sort by started_at) and listPublicActivitiesForOwner.
  ownerStartedIdx: index("activities_owner_started_idx").on(t.ownerId, t.startedAt.desc()),
  ownerCreatedIdx: index("activities_owner_created_idx").on(t.ownerId, t.createdAt.desc()),
  // Feed join for remote rows (spec §8).
  remoteActorIdx: index("activities_remote_actor_idx").on(t.remoteActorIri),
  hasAuthorCheck: check(
    "activities_has_author_check",
    sql`(${t.ownerId} IS NOT NULL) <> (${t.remoteActorIri} IS NOT NULL)`,
  ),
}));

// --- OAuth2 PKCE (mobile app auth) ---

export const oauthClients = journalSchema.table("oauth_clients", {
  clientId: text("client_id").primaryKey(),
  redirectUri: text("redirect_uri").notNull(),
  trusted: integer("trusted").notNull().default(0),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

export const oauthCodes = journalSchema.table("oauth_codes", {
  id: text("id").primaryKey(),
  code: text("code").notNull().unique(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  clientId: text("client_id")
    .notNull()
    .references(() => oauthClients.clientId, { onDelete: "cascade" }),
  codeChallenge: text("code_challenge").notNull(),
  codeChallengeMethod: text("code_challenge_method").notNull().default("S256"),
  redirectUri: text("redirect_uri").notNull(),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
  usedAt: timestamp("used_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

export const oauthTokens = journalSchema.table("oauth_tokens", {
  id: text("id").primaryKey(),
  accessToken: text("access_token").notNull().unique(),
  refreshToken: text("refresh_token").notNull().unique(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  clientId: text("client_id")
    .notNull()
    .references(() => oauthClients.clientId, { onDelete: "cascade" }),
  deviceName: text("device_name"),
  lastActiveAt: timestamp("last_active_at", { withTimezone: true }),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
  revokedAt: timestamp("revoked_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

// External-service connections (OAuth, web-login, mobile-paired devices).
// `credential_kind` discriminates the shape of `credentials` JSONB:
//   - 'oauth':     { access_token, refresh_token, expires_at }
//   - 'web-login': { email, encrypted_password, session_jar }   (Komoot, future)
//   - 'device':    {}                                            (Apple Health, future)
//   - 'public':    {}  credential-less public-profile connection (Komoot public mode)
// Keep this list in sync with the CHECK constraint in
// packages/db/migrations/0002_connected_services.sql.
// See docs/adr/0001 and CONTEXT.md (Connected Services).
export const connectedServices = journalSchema.table(
  "connected_services",
  {
    id: text("id").primaryKey(),
    userId: text("user_id")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    provider: text("provider").notNull(),
    credentialKind: text("credential_kind").notNull(),
    credentials: jsonb("credentials").notNull(),
    // 'active' | 'needs_relink' | 'revoked'. Manager flips to 'needs_relink'
    // when CredentialAdapter.refresh returns a permanent failure.
    status: text("status").notNull().default("active"),
    providerUserId: text("provider_user_id"),
    // OAuth-only. Promoted out of the credentials JSONB because feature gates
    // (e.g. routes_write check on push) read this on every call.
    grantedScopes: text("granted_scopes")
      .array()
      .notNull()
      .default(sql`ARRAY[]::text[]`),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => ({
    userProviderUnique: uniqueIndex("connected_services_user_provider_unique").on(
      t.userId,
      t.provider,
    ),
  }),
);


export const syncImports = journalSchema.table("sync_imports", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  provider: text("provider").notNull(),
  externalWorkoutId: text("external_workout_id").notNull(),
  activityId: text("activity_id").references(() => activities.id, { onDelete: "set null" }),
  importedAt: timestamp("imported_at", { withTimezone: true }).notNull().defaultNow(),
});

// Tracks outbound route pushes to external providers (Wahoo today). One row
// per (user, route, provider) — a logical Wahoo route is per-route, not
// per-version. `lastPushedVersion` records which local version is currently
// reflected on the remote side; `remoteId` lets subsequent pushes PUT in
// place. A row with `error` set and no `pushedAt` is a failed attempt that
// can be retried (POST if `remoteId` is null, PUT otherwise).
export const syncPushes = journalSchema.table(
  "sync_pushes",
  {
    id: text("id").primaryKey(),
    userId: text("user_id")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    routeId: text("route_id")
      .notNull()
      .references(() => routes.id, { onDelete: "cascade" }),
    provider: text("provider").notNull(),
    externalId: text("external_id").notNull(),
    remoteId: text("remote_id"),
    lastPushedVersion: integer("last_pushed_version"),
    pushedAt: timestamp("pushed_at", { withTimezone: true }),
    error: text("error"),
    createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
  },
  (t) => ({
    userRouteProviderUnique: uniqueIndex("sync_pushes_user_route_provider_unique").on(
      t.userId,
      t.routeId,
      t.provider,
    ),
  }),
);

// Social follow relation. Always originates from a local user (`followerId`).
// The followed side is keyed by an actor IRI for federation forward-compat —
// today every IRI is local (`https://{DOMAIN}/users/{username}`); future
// `social-federation` change extends this to remote IRIs without migration.
// `followedUserId` is denormalized for fast local joins; populated for every
// row in this change. `acceptedAt` is always set today (auto-accept for
// public local profiles); the column stays nullable so federation's Pending
// state lands cleanly.
export type ImportBatchStatus = "pending" | "running" | "completed" | "failed";

export const importBatches = journalSchema.table("import_batches", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  connectionId: text("connection_id")
    .notNull()
    .references(() => connectedServices.id, { onDelete: "cascade" }),
  provider: text("provider").notNull(),
  status: text("status").$type<ImportBatchStatus>().notNull().default("pending"),
  totalFound: integer("total_found").notNull().default(0),
  importedCount: integer("imported_count").notNull().default(0),
  duplicateCount: integer("duplicate_count").notNull().default(0),
  errorMessage: text("error_message"),
  // Ranged backfill requests (Garmin): the activity time window this
  // batch asked the provider to re-deliver. NULL for pick-list style
  // imports (komoot bulk) that aren't range-shaped.
  rangeStart: timestamp("range_start", { withTimezone: true }),
  rangeEnd: timestamp("range_end", { withTimezone: true }),
  startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
  completedAt: timestamp("completed_at", { withTimezone: true }),
});

export const follows = journalSchema.table("follows", {
  id: text("id").primaryKey(),
  // Local follower. NULL for inbound federated follows (a remote actor
  // following a local user), where `followerActorIri` identifies the
  // follower instead. Exactly one of the two is set (check constraint).
  followerId: text("follower_id").references(() => users.id, { onDelete: "cascade" }),
  // Remote follower's actor IRI (spec: social-federation, inbound
  // Follow). NULL for local-origin follows.
  followerActorIri: text("follower_actor_iri"),
  followedActorIri: text("followed_actor_iri").notNull(),
  followedUserId: text("followed_user_id").references(() => users.id, { onDelete: "cascade" }),
  acceptedAt: timestamp("accepted_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  followerActorUnique: uniqueIndex("follows_follower_actor_unique").on(t.followerId, t.followedActorIri),
  // Dedupe inbound remote follows: one row per (remote follower, local
  // followed user). Partial — local-origin rows have a NULL IRI here.
  remoteFollowerUnique: uniqueIndex("follows_remote_follower_unique")
    .on(t.followerActorIri, t.followedUserId)
    .where(sql`${t.followerActorIri} IS NOT NULL`),
  followerCreatedIdx: index("follows_follower_created_idx").on(t.followerId, t.createdAt.desc()),
  followedActorIdx: index("follows_followed_actor_idx").on(t.followedActorIri),
  followedUserIdx: index("follows_followed_user_idx").on(t.followedUserId),
  hasFollowerCheck: check(
    "follows_has_follower_check",
    sql`(${t.followerId} IS NOT NULL) <> (${t.followerActorIri} IS NOT NULL)`,
  ),
}));

// Fedify KvStore backing table (inbox replay protection, remote
// document / key caches). Keys are JSON-serialized KvKey arrays;
// values arbitrary JSON. Expired rows are filtered at read time and
// swept by the `federation-kv-sweep` job.
export const federationKv = journalSchema.table("federation_kv", {
  key: text("key").primaryKey(),
  value: jsonb("value").notNull(),
  expiresAt: timestamp("expires_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  expiresAtIdx: index("federation_kv_expires_at_idx").on(t.expiresAt),
}));

// Cache of remote ActivityPub actors we interact with (spec:
// social-federation). One row per actor IRI: display fields for feed
// cards, inbox/outbox URLs for delivery and polling, the public key for
// inbound HTTP-Signature verification, and `software` (the discovery
// field) for the trails-to-trails outbound check. Refreshed during
// outbox polls so feed renders never re-fetch the actor document.
export const remoteActors = journalSchema.table("remote_actors", {
  actorIri: text("actor_iri").primaryKey(),
  displayName: text("display_name"),
  username: text("username"),
  domain: text("domain"),
  avatarUrl: text("avatar_url"),
  inboxUrl: text("inbox_url"),
  outboxUrl: text("outbox_url"),
  publicKey: text("public_key"),
  software: text("software"),
  lastPolledAt: timestamp("last_polled_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

// Notifications. Each row is a single event the recipient should be
// informed about. v1 types: follow_request_received, follow_request_approved,
// follow_received, activity_published. The `payload` JSONB snapshots the
// renderer-friendly fields at create time so future mobile push / email
// renderers can render without a fresh DB lookup; `payloadVersion` lets
// us evolve per-type payload shapes additively. See spec: notifications.
export type NotificationType =
  | "follow_request_received"
  | "follow_request_approved"
  | "follow_received"
  | "activity_published";

export const notifications = journalSchema.table("notifications", {
  id: text("id").primaryKey(),
  recipientUserId: text("recipient_user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  type: text("type").$type<NotificationType>().notNull(),
  actorUserId: text("actor_user_id").references(() => users.id, { onDelete: "set null" }),
  subjectId: text("subject_id"),
  payload: jsonb("payload").$type<Record<string, unknown>>(),
  payloadVersion: integer("payload_version").notNull().default(1),
  readAt: timestamp("read_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  // Listing query: every load of /notifications joins on recipient + sorts by created_at desc.
  recipientCreatedIdx: index("notifications_recipient_created_idx").on(t.recipientUserId, t.createdAt.desc()),
  // Hot path: countUnread for the navbar badge runs on every page nav.
  // Partial index keeps it tiny.
  recipientUnreadIdx: index("notifications_recipient_unread_idx")
    .on(t.recipientUserId, t.createdAt.desc())
    .where(sql`${t.readAt} IS NULL`),
  // Fan-out idempotency: prevent double-insert of activity_published rows
  // when a pg-boss job retries. Soft uniqueness across (recipient, type, subject).
  recipientTypeSubjectUnique: uniqueIndex("notifications_recipient_type_subject_unique")
    .on(t.recipientUserId, t.type, t.subjectId)
    .where(sql`${t.subjectId} IS NOT NULL`),
}));

// Single-use JWT enforcement for callback tokens (planner → journal
// save flow). Each token carries a `jti` claim; the verifier inserts
// into this table on first use. A second attempt hits the PK conflict
// and the verifier rejects the token. Rows are swept after `expires_at`.
// See spec / planner-audit #2 Phase B.
export const consumedJwtJti = journalSchema.table("consumed_jwt_jti", {
  jti: text("jti").primaryKey(),
  consumedAt: timestamp("consumed_at", { withTimezone: true }).notNull().defaultNow(),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
}, (t) => ({
  // Sweep runs `DELETE WHERE expires_at < now()` on a daily schedule.
  expiresAtIdx: index("consumed_jwt_jti_expires_at_idx").on(t.expiresAt),
}));
