/**
 * @title Getting started with SQL
 *
 * Define a schema-backed domain model, run migrations against a SQLite
 * database, and expose a derived repository through a service.
 */
import { NodeRuntime } from "@effect/platform-node"
import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Model } from "effect/schema"
import { SqlClient, SqlModel, SqlSchema } from "effect/sql"

// Use a branded string for the group id, so it cannot be mixed up with other
// string ids in the application.
export const GroupId = Schema.String.pipe(Schema.brand("GroupId"))
export type GroupId = typeof GroupId.Type

// `Model.Class` defines a domain model with variants for the database and JSON
// boundaries. The single field declaration is the source of truth, and each
// variant only contains the fields that make sense for that operation:
//
// - `Group` / `Group.insert` / `Group.update` for the database
// - `Group.json` / `Group.jsonCreate` / `Group.jsonUpdate` for JSON APIs
export class Group extends Model.Class<Group>("Group")({
  // A UUID v4 primary key that is generated by the application on insert
  id: Model.UuidV4Insert(GroupId),
  name: Schema.NonEmptyString,
  // `Model.FieldExcept` removes a field from the given variants. The slug is
  // chosen when the group is created and is immutable afterwards, so it is
  // removed from the update variants.
  slug: Schema.NonEmptyString.pipe(Model.FieldExcept(["update", "jsonUpdate"])),
  // `Model.FieldOnly` keeps a field in only the given variants. Internal notes
  // live in the database and are never exposed through the JSON variants.
  notes: Schema.NullOr(Schema.String).pipe(Model.FieldOnly(["select", "insert"])),
  // `Model.Field` gives full control over the individual variants. The member
  // count is maintained by the database, so the application can read it but
  // never writes it.
  memberCount: Model.Field({
    select: Schema.Int,
    json: Schema.Int
  }),
  // `createdAt` is set to the current time on insert, and `updatedAt` is
  // refreshed on every update. Both are stored as strings, which suits SQLite.
  createdAt: Model.DateTimeInsert,
  updatedAt: Model.DateTimeUpdate
}) {}

export class GroupNotFound extends Schema.TaggedError<GroupNotFound>()("GroupNotFound", {
  id: GroupId
}) {}

// The SqlClient layer determines which database you are talking to. Swap this
// layer for `@effect/sql-pg`, `@effect/sql-mysql2` etc. to target another
// database without changing the rest of the code.
const SqlLayer = SqliteClient.layer({ filename: ":memory:" })

// Migrations are effects keyed by `<id>_<name>` that run once, in id order. A
// real application would keep each migration in its own file and load them
// with `SqliteMigrator.fromFileSystem` instead of an inline record.
const MigratorLayer = SqliteMigrator.layer({
  loader: SqliteMigrator.fromRecord({
    "0001_create_groups": Effect.gen(function*() {
      const sql = yield* SqlClient.SqlClient
      yield* sql`
        CREATE TABLE groups (
          id TEXT PRIMARY KEY,
          name TEXT NOT NULL,
          slug TEXT NOT NULL,
          notes TEXT,
          memberCount INTEGER NOT NULL DEFAULT 0,
          createdAt TEXT NOT NULL,
          updatedAt TEXT NOT NULL
        )
      `
    })
  })
})

// Combine the database client with the migrations, so anything built on top of
// `SqlLive` sees a fully migrated database.
const SqlLive = MigratorLayer.pipe(Layer.provideMerge(SqlLayer))

// Wrap data access in a service, so the rest of the application depends on
// `Groups` instead of the database directly.
export class Groups extends Context.Service<Groups, {
  create(name: string, slug: string): Effect.Effect<Group>
  rename(id: GroupId, name: string): Effect.Effect<Group, GroupNotFound>
  findById(id: GroupId): Effect.Effect<Group, GroupNotFound>
  readonly list: Effect.Effect<Array<Group>>
}>()("app/Groups") {
  static readonly layer = Layer.effect(
    Groups,
    Effect.gen(function*() {
      const sql = yield* SqlClient.SqlClient

      // `SqlModel.makeRepository` derives insert / update / findById / delete
      // operations from the model, using the matching variant schema for each
      // operation.
      const repo = yield* SqlModel.makeRepository(Group, {
        tableName: "groups",
        spanPrefix: "Groups",
        idColumn: "id"
      })

      // For queries the repository does not cover, combine the `sql` tag with
      // `SqlSchema` to decode the rows using the model schema.
      const listAll = SqlSchema.findAll({
        Request: Schema.Void,
        Result: Group,
        execute: () => sql`SELECT * FROM groups ORDER BY createdAt`
      })

      // Use `Effect.fn` to give each method a named span for observability.
      const create = Effect.fn("Groups.create")((name: string, slug: string) =>
        // `Group.insert.makeEffect` fills in the generated id and timestamps
        // using the Effect clock, so tests can control them with `TestClock`.
        Group.insert.makeEffect({ name, slug, notes: null }).pipe(
          Effect.flatMap(repo.insert),
          // Database and encoding failures are unexpected here, so treat
          // them as defects to keep the service interface focused on domain
          // errors.
          Effect.orDie
        )
      )

      const rename = Effect.fn("Groups.rename")((id: GroupId, name: string) =>
        Group.update.makeEffect({ id, name }).pipe(
          Effect.flatMap(repo.update),
          Effect.orDie
        )
      )

      const findById = Effect.fn("Groups.findById")((id: GroupId) =>
        repo.findById(id).pipe(
          Effect.catchTags({
            NoSuchElementError: () => new GroupNotFound({ id }),
            SchemaError: Effect.die,
            SqlError: Effect.die
          })
        )
      )

      const list = listAll().pipe(
        Effect.orDie,
        Effect.withSpan("Groups.list")
      )

      return Groups.of({ create, rename, findById, list })
    })
  ).pipe(
    // Provide the layers locally, so lots of messy wiring doesn't need to
    // happen in the "main" entrypoint of the application.
    Layer.provide(SqlLive)
  )
}

const program = Effect.gen(function*() {
  const groups = yield* Groups

  const engineering = yield* groups.create("Engineering", "engineering")
  const design = yield* groups.create("Design", "design")

  yield* groups.rename(design.id, "Product Design")

  const found = yield* groups.findById(engineering.id)
  yield* Effect.log("found group", found)

  const all = yield* groups.list
  yield* Effect.log(`total groups: ${all.length}`)
})

program.pipe(
  Effect.provide(Groups.layer),
  NodeRuntime.runMain
)
