/**
 * Phase 4c — Resource repo generator.
 *
 * Given a `ParsedResource` with `options.persistence` set, emit the full
 * TypeScript source of `.mandu/generated/server/repos/{name}.repo.ts` — a
 * typed CRUD bundle (`findById`, `findMany`, `create`, `update`, `delete`)
 * sitting on top of `@mandujs/core/db`.
 *
 * # Invariants
 *
 *   1. PURE FUNCTION. No I/O, no filesystem, no `Bun.write`. The orchestrator
 *      in `generator.ts` handles writes.
 *   2. The Row type is emitted INLINE as a TypeScript interface matching
 *      the resource's fields. We do NOT import the Zod schema from the
 *      contract file — the existing contract generator emits `XSchema`
 *      as a module-private const, not a named export, and Appendix B TC-3
 *      prohibits changing the contract's public surface in this phase.
 *   3. Column names in SQL are `snake_case` — sourced from
 *      `snapshotFromResources` normalization. We import `toSnakeCase` from
 *      `ddl/snapshot.ts` (DRY; single canonical transform).
 *   4. Dialect branching is RESOLVED AT GENERATION TIME from
 *      `persistence.provider`. The emitted file never runtime-switches on
 *      provider — there's one code path per file.
 *   5. All identifiers emitted into SQL strings flow through `quoteIdent`
 *      (imported from `ddl/emit.ts`). Table/column names come from the
 *      (author-validated) resource definition; `quoteIdent` adds defense
 *      in depth.
 *   6. `// @generated by Mandu — do not edit.` banner at the top so
 *      humans + CI tooling recognize the file as derived.
 *
 * # Emit shape
 *
 *   export interface User { id: string; email: string; ... }
 *   export function createUsersRepo(db: Db) {
 *     return {
 *       async findById(id): User | null { ... },
 *       async findMany(limit = 100, offset = 0): User[] { ... },
 *       async create(row): User { ... },   // PG/SQLite: RETURNING *; MySQL: INSERT + re-select
 *       async update(id, patch): User | null { ... },
 *       async delete(id): boolean { ... },
 *     };
 *   }
 *
 * The repo name `createUsersRepo` uses the table's plural form (what
 * `snapshotFromResources` resolves to) rather than the singular
 * `resource.name`. This matches what developers naturally type when
 * calling it: `createUsersRepo`, `createPostsRepo`, etc.
 *
 * # Non-goals
 *
 *   - Implicit transaction context (Appendix D.3 — v2).
 *   - JOIN helpers / multi-table queries — use raw `db` directly in slots.
 *   - Partial column selection — always `SELECT *` for type stability.
 *   - Custom error types — thrown errors bubble; users handle them.
 *
 * # References
 *
 *   - docs/rfcs/0001-db-resource-layer.md §D1 (5th artifact)
 *   - docs/rfcs/0001-db-resource-layer.md §D2 (Zod-inferred row type)
 *   - docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence)
 *   - DNA/drizzle-orm/drizzle-kit/src/sqlgenerator.ts (reference templates)
 */

import type { ParsedResource } from "./parser";
import type { ResourceField } from "./schema";
import { quoteIdent } from "./ddl/emit";
import { toSnakeCase } from "./ddl/snapshot";
import { asPersistence, type ExtendedResourcePersistence, type FieldOverride } from "./ddl/persistence-types";
import type { SqlProvider } from "./ddl/types";

// ============================================
// Public API
// ============================================

/**
 * Options controlling `generateRepoSource` behavior.
 *
 * All fields are optional. Defaults are chosen so the generator "just works"
 * when called from `generateResourceArtifacts` with no explicit options.
 */
export interface RepoGenerationOptions {
  /**
   * When `false`, return `null` for non-persistent resources instead of
   * throwing. Defaults to `true` (throw on non-persistent) — the
   * orchestrator in `generator.ts` guards against this path by checking
   * `options.persistence` before calling us. Advanced callers can pass
   * `enable: false` to treat missing persistence as a no-op.
   */
  enable?: boolean;
  /**
   * Import specifier for `Db` type. Default `@mandujs/core/db`. Useful in
   * monorepo tests that need to point at a local build.
   */
  dbImport?: string;
  /**
   * Override the contract import path. When not set, the generator uses
   * `../contracts/{resource.name}.contract` which matches the output of
   * `resolveGeneratedPaths()`. Pass this only when you're generating the
   * repo into a non-standard location.
   */
  contractImport?: string;
}

/**
 * Return the full TypeScript source of a resource's repo module.
 *
 * Pure — no filesystem, no side effects. Caller writes the result.
 *
 * @throws TypeError when the resource has no `options.persistence` and
 *   `options.enable` is not explicitly `false`.
 */
export function generateRepoSource(
  resource: ParsedResource,
  options: RepoGenerationOptions = {},
): string | null {
  const enable = options.enable ?? true;
  const rawPersistence = (
    resource.definition.options as Record<string, unknown> | undefined
  )?.persistence;
  const persistence = asPersistence(rawPersistence);

  if (!persistence) {
    if (enable === false) return null;
    throw new TypeError(
      `generateRepoSource: resource "${resource.resourceName}" has no options.persistence. ` +
        `Either add a persistence block or pass { enable: false } to skip repo generation.`,
    );
  }

  const dbImport = options.dbImport ?? "@mandujs/core/db";
  const contractImport =
    options.contractImport ?? `../contracts/${resource.definition.name}.contract`;

  return renderRepoFile(resource, persistence, dbImport, contractImport);
}

/**
 * Back-compat predicate exported for `generator.ts` to decide whether to
 * emit a repo without duplicating the `asPersistence` narrowing logic.
 */
export function shouldEmitRepo(resource: ParsedResource): boolean {
  const rawPersistence = (
    resource.definition.options as Record<string, unknown> | undefined
  )?.persistence;
  return asPersistence(rawPersistence) !== undefined;
}

// ============================================
// Renderer — top-level composition
// ============================================

function renderRepoFile(
  resource: ParsedResource,
  persistence: ExtendedResourcePersistence,
  dbImport: string,
  contractImport: string,
): string {
  const { definition } = resource;
  const pascalName = toPascalCase(definition.name);
  // Plural name from persistence.tableName > auto-plural. Matches what
  // `snapshotFromResources` resolves to — keeps the repo name + table
  // name aligned so stack traces are readable.
  const table = resolveTableName(resource, persistence);
  const repoFactoryName = `create${toPascalCase(table)}Repo`;
  const provider = persistence.provider;

  // Canonical column list — keys are the author's field names
  // (e.g., `passwordHash`), columns are the normalized SQL names
  // (`password_hash`). Column overrides from `fieldOverrides` take
  // precedence, matching `snapshot.ts` behavior.
  const columns = resolveColumns(resource, persistence);
  const primaryKeyColumn = columns.find((c) => c.primary)?.column ?? "id";
  const primaryKeyField = columns.find((c) => c.primary)?.field ?? "id";
  const insertColumns = columns.filter((c) => !c.omitOnInsert);
  const createInputType = createMethodInputType(pascalName, columns);

  // Quoted identifiers — precomputed so each builder reads cleanly.
  const q = (name: string) => escapeSqlTemplateText(quoteIdent(name, provider));
  const qTable = q(table);

  // Column list for SELECT: we don't use `*` because aliasing
  // snake_case → camelCase is needed to return the Row type the contract
  // expects. The `AS` alias is emitted only when column != field (avoid
  // `name AS name` noise).
  const selectList = columns
    .map((c) =>
      c.column === c.field ? q(c.column) : `${q(c.column)} AS ${q(c.field)}`,
    )
    .join(", ");

  const header = renderHeader(definition.name, repoFactoryName, pascalName);
  const imports = renderImports(dbImport, contractImport, pascalName);
  const rowTypeBlock = renderRowType(pascalName, Object.entries(definition.fields));

  const body = [
    findByIdMethod(pascalName, qTable, selectList, q, primaryKeyColumn, primaryKeyField),
    findManyMethod(pascalName, qTable, selectList),
    createMethod(pascalName, table, qTable, selectList, insertColumns, createInputType, primaryKeyColumn, primaryKeyField, q, provider),
    updateMethod(pascalName, table, qTable, selectList, columns, primaryKeyColumn, primaryKeyField, q, provider),
    deleteMethod(pascalName, qTable, q, primaryKeyColumn, primaryKeyField, provider),
  ].join(",\n\n");

  return `${header}${imports}
${rowTypeBlock}

export function ${repoFactoryName}(db: Db) {
  return {
${body},
  };
}
`;
}

// ============================================
// Headers / imports / row type
// ============================================

function renderHeader(
  resourceName: string,
  repoFactoryName: string,
  pascalName: string,
): string {
  return `// @generated by Mandu — do not edit.
// Regenerate with \`mandu generate\` or \`mandu db plan\`.
//
// Resource: ${resourceName}
// Factory:  ${repoFactoryName}(db) → typed CRUD bundle
// Row type: ${pascalName} (inline interface mirroring the contract schema)
//
// IMPLEMENTATION NOTES
// - Column names in SQL are snake_case; the Row type keeps camelCase by
//   aliasing in the SELECT list (\`AS "fieldName"\`).
// - The repo never constructs a Db itself — callers (slots, scripts) pass
//   in either \`ctx.deps.db\` or a module-level singleton.
// - Provider-specific SQL (INSERT ... RETURNING * on PG/SQLite vs
//   INSERT + re-select on MySQL) is resolved at generation
//   time; the generated file has one code path per provider.
`;
}

function renderImports(dbImport: string, _contractImport: string, _pascalName: string): string {
  // The repo's Row type is emitted INLINE (as a TypeScript interface)
  // rather than inferred from the contract's Zod schema.
  //
  // Why not `z.infer<typeof XSchema>`? The existing contract generator
  // (`generators/contract.ts:77`) emits `XSchema` as a module-private
  // `const`, not a named export. Touching the contract emitter to export
  // it is a cross-artifact API change that Appendix B TC-3 is explicitly
  // protecting against. Inlining the Row type keeps the repo artifact
  // self-contained and preserves the contract's existing public surface.
  //
  // The inline interface mirrors Mandu's `ResourceField` → TS mapping
  // exactly; correctness is guarded by the repo's unit tests.
  return `
import type { Db } from "${dbImport}";
`;
}

function renderRowType(pascalName: string, fields: Array<[string, ResourceField]>): string {
  const lines = fields.map(([name, field]) => {
    const tsType = fieldToTsType(field);
    // Fields are REQUIRED only when explicitly set. Matches the contract
    // generator's behavior (`generators/contract.ts:144`: appends
    // `.optional()` to the Zod schema when `required` is falsy).
    const optional = field.required === true ? "" : "?";
    return `  ${name}${optional}: ${tsType};`;
  });
  // Index signature satisfies `Db`'s `Row = Record<string, unknown>`
  // constraint so the generated repo type-checks when the callers pass
  // `${pascalName}` as the row type param to `db<${pascalName}>` / `db.one<${pascalName}>`.
  // Concrete fields above retain their precise types — the signature
  // only widens unknown keys.
  return `/**
 * Row shape for the \`${pascalName}\` resource. Mirrors the contract's
 * Zod schema — if you add or remove fields in the resource definition,
 * regenerate to pick up the change.
 */
export interface ${pascalName} {
${lines.join("\n")}
  [key: string]: unknown;
}`;
}

/**
 * Map a Mandu `ResourceField` to its TypeScript row type.
 *
 * Keep in sync with `generators/contract.ts:generateZodSchema` — the two
 * must agree or the Row type will diverge from the contract. Primitive
 * types only; complex shapes fall back to `unknown` which is explicit
 * and easy to spot at call sites.
 */
function fieldToTsType(field: ResourceField): string {
  switch (field.type) {
    case "string":
    case "uuid":
    case "email":
    case "url":
    case "date":
      return "string";
    case "number":
      return "number";
    case "boolean":
      return "boolean";
    case "array":
      return "unknown[]";
    case "json":
    case "object":
      return "Record<string, unknown>";
    default:
      return "unknown";
  }
}

// ============================================
// Method emitters — each returns the method text
// ============================================

function findByIdMethod(
  pascalName: string,
  qTable: string,
  selectList: string,
  q: (n: string) => string,
  pkColumn: string,
  pkField: string,
): string {
  return `    /**
     * Look up a single row by primary key.
     * Returns \`null\` when no row matches — never throws for "not found".
     */
    async findById(${pkField}: ${pascalName}["${pkField}"]): Promise<${pascalName} | null> {
      return db.one<${pascalName}>\`SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}\`;
    }`;
}

function findManyMethod(pascalName: string, qTable: string, selectList: string): string {
  return `    /**
     * List rows with basic pagination. Default: 100 rows, no offset.
     * Ordering is \`<primary-key> ASC\` — callers that need custom ordering
     * should drop to raw \`db\` in the slot.
     */
    async findMany(limit: number = 100, offset: number = 0): Promise<${pascalName}[]> {
      return db<${pascalName}>\`SELECT ${selectList} FROM ${qTable} LIMIT \${limit} OFFSET \${offset}\`;
    }`;
}

function createMethod(
  pascalName: string,
  tableName: string,
  qTable: string,
  selectList: string,
  insertColumns: ResolvedColumn[],
  createInputType: string,
  pkColumn: string,
  pkField: string,
  q: (n: string) => string,
  provider: SqlProvider,
): string {
  // Build INSERT column list + VALUES placeholder list. Each value is
  // sourced from the `input` object and bound via Bun.SQL's tagged-template
  // parameter mechanism — never concatenated into the SQL string.
  const columnList = insertColumns.map((c) => q(c.column)).join(", ");
  const valuePlaceholders = insertColumns
    .map((c) => `\${input.${c.field}}`)
    .join(", ");

  if (provider === "postgres" || provider === "sqlite") {
    // RETURNING * is native on both dialects (SQLite >= 3.35 — Bun's bundled
    // SQLite is modern enough per Agent A's emit.ts note). We reuse the
    // precomputed `selectList` on RETURNING so newly-generated DB-side
    // defaults come back with the inserted row and snake_case → camelCase
    // aliasing is consistent with SELECT.
    return `    /**
     * Insert a new row and return the inserted record. Errors from
     * constraint violations (UNIQUE, NOT NULL, FK) bubble unchanged.
     */
    async create(input: ${createInputType}): Promise<${pascalName}> {
      const row = await db.one<${pascalName}>\`
        INSERT INTO ${qTable} (${columnList})
        VALUES (${valuePlaceholders})
        RETURNING ${selectList}
      \`;
      if (!row) throw new Error("${tableName} create: INSERT RETURNING produced no row");
      return row;
    }`;
  }

  const mysqlLookup = insertColumns.some((c) => c.field === pkField)
    ? `${q(pkColumn)} = \${input.${pkField}}`
    : `${q(pkColumn)} = LAST_INSERT_ID()`;

  // MySQL: no RETURNING. INSERT then SELECT by the supplied primary key when
  // the caller provides it; if the primary key is DB-generated, fall back to
  // LAST_INSERT_ID().
  return `    /**
     * Insert a new row and return the inserted record via a follow-up SELECT.
     * MySQL lacks RETURNING; the generator uses LAST_INSERT_ID() when the
     * primary key is server-generated, otherwise it re-selects by the
     * provided primary key value.
     */
    async create(input: ${createInputType}): Promise<${pascalName}> {
      await db\`
        INSERT INTO ${qTable} (${columnList})
        VALUES (${valuePlaceholders})
      \`;
      const row = await db.one<${pascalName}>\`
        SELECT ${selectList}
        FROM ${qTable}
        WHERE ${mysqlLookup}
      \`;
      if (!row) throw new Error("${tableName} create: follow-up SELECT returned no row");
      return row;
    }`;
}

function updateMethod(
  pascalName: string,
  _tableName: string,
  qTable: string,
  selectList: string,
  allColumns: ResolvedColumn[],
  pkColumn: string,
  pkField: string,
  q: (n: string) => string,
  _provider: SqlProvider,
): string {
  // Strategy: per-column UPDATE statements inside a transaction so the SQL
  // remains a well-formed tagged template at all times (no dynamic SET
  // string-building). Trade-off: up to N round-trips for N patched fields,
  // but portable across all three dialects with no string concatenation.
  // Repo users who need a hot write path drop to raw `db` in the slot.
  //
  // We emit a per-column UPDATE whose table + column identifiers are
  // already baked into the generated source as string literals inside
  // the tagged template. Fields in `fieldToColumn` drive the runtime
  // iteration; the actual UPDATE statement for each column is a static
  // fragment selected by a switch on the field name. This keeps every
  // SQL string a true tagged template with no interpolated identifiers.
  const updateCases = allColumns
    .filter((c) => !c.primary)
    .map(
      (c) => `          case "${c.field}":
            await tx\`UPDATE ${qTable} SET ${q(c.column)} = \${value} WHERE ${q(pkColumn)} = \${${pkField}}\`;
            break;`,
    )
    .join("\n");

  return `    /**
     * Update an existing row by primary key. Fields left \`undefined\`
     * in \`patch\` are not modified. Returns the updated row, or
     * \`null\` when no row with the given primary key exists.
     *
     * Implementation detail: the dynamic SET is emitted as a sequence of
     * single-column UPDATEs within a transaction. This keeps the SQL a
     * well-formed tagged template across all three dialects at the cost
     * of N round-trips for N patched fields. For hot write paths, drop
     * to raw \`db\` in your slot.
     */
    async update(
      ${pkField}: ${pascalName}["${pkField}"],
      patch: Partial<Omit<${pascalName}, "${pkField}">>,
    ): Promise<${pascalName} | null> {
      return db.transaction(async (tx) => {
        const existing = await tx.one<${pascalName}>\`
          SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}
        \`;
        if (!existing) return null;
        for (const [field, value] of Object.entries(patch as Record<string, unknown>)) {
          if (value === undefined) continue;
          switch (field) {
${updateCases}
            default:
              // Unknown key — silently ignore. Contract validation at the
              // slot layer should prevent this from reaching the repo.
              break;
          }
        }
        return tx.one<${pascalName}>\`
          SELECT ${selectList} FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}
        \`;
      });
    }`;
}

function deleteMethod(
  pascalName: string,
  qTable: string,
  q: (n: string) => string,
  pkColumn: string,
  pkField: string,
  _provider: SqlProvider,
): string {
  // All three providers support DELETE; we return true iff at least one
  // row was removed. Rather than rely on driver-specific affected-row
  // reporting (`count` on PG, `changes()` on SQLite, `affectedRows` on
  // MySQL) we do a cheap SELECT-then-DELETE so behavior is identical
  // across dialects. Same correctness-over-perf trade-off as `update`:
  // drop to raw `db` in a slot for hot delete paths.
  return `    /**
     * Delete a row by primary key. Returns \`true\` if a row was deleted,
     * \`false\` if no row matched.
     */
    async delete(${pkField}: ${pascalName}["${pkField}"]): Promise<boolean> {
      const existing = await db.one\`SELECT 1 AS ok FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}} LIMIT 1\`;
      if (!existing) return false;
      await db\`DELETE FROM ${qTable} WHERE ${q(pkColumn)} = \${${pkField}}\`;
      return true;
    }`;
}

// ============================================
// Column + table resolution
// ============================================

interface ResolvedColumn {
  /** Field key the author wrote in `definition.fields` (e.g. `passwordHash`). */
  field: string;
  /** Column name in SQL (e.g. `password_hash`). */
  column: string;
  primary: boolean;
  /** PK auto-generated by the DB → skip from INSERT column list. */
  omitOnInsert: boolean;
}

function resolveColumns(
  resource: ParsedResource,
  persistence: ExtendedResourcePersistence,
): ResolvedColumn[] {
  const { definition } = resource;
  const overrides = persistence.fieldOverrides ?? {};
  const declaredPk = resolveDeclaredPrimaryKey(persistence.primaryKey);

  const columns: ResolvedColumn[] = [];
  let pkCount = 0;

  for (const [fieldKey, field] of Object.entries(definition.fields)) {
    const override = overrides[fieldKey];
    const column = override?.columnName ?? toSnakeCase(fieldKey);
    // `primary` resolution mirrors `snapshot.ts`:
    //   - named by persistence.primaryKey, OR
    //   - field-level `primary: true` (via cast — Mandu's public
    //     ResourceField type predates this flag).
    const declaredPkMatch = declaredPk !== undefined && declaredPk === fieldKey;
    const fieldLevelPk = Boolean(
      (field as { primary?: boolean }).primary,
    );
    const primary = declaredPkMatch || fieldLevelPk;
    if (primary) pkCount++;

    const omitOnInsert = primary && hasDbDefault(field, override);

    columns.push({ field: fieldKey, column, primary, omitOnInsert });
  }

  if (pkCount === 0) {
    throw new TypeError(
      `Resource "${resource.resourceName}" has no primary-key field. Mark one with \`primary: true\` (via fieldOverrides) or declare \`options.persistence.primaryKey\`.`,
    );
  }
  if (pkCount > 1) {
    throw new TypeError(
      `Resource "${resource.resourceName}" has ${pkCount} primary-key fields. Composite primary keys are not supported in v1.`,
    );
  }

  return columns;
}

function resolveDeclaredPrimaryKey(
  declared: ExtendedResourcePersistence["primaryKey"],
): string | undefined {
  if (declared === undefined) return undefined;
  if (typeof declared === "string") return declared;
  return declared[0];
}

function createMethodInputType(
  pascalName: string,
  columns: ResolvedColumn[],
): string {
  const omittedFields = columns
    .filter((c) => c.omitOnInsert)
    .map((c) => JSON.stringify(c.field));
  if (omittedFields.length === 0) return pascalName;
  return `Omit<${pascalName}, ${omittedFields.join(" | ")}>`;
}

function hasDbDefault(
  field: ResourceField,
  override: FieldOverride | undefined,
): boolean {
  return override?.default !== undefined || field.default !== undefined;
}

function escapeSqlTemplateText(text: string): string {
  return text.replace(/`/g, "\\`");
}

function resolveTableName(
  resource: ParsedResource,
  persistence: ExtendedResourcePersistence,
): string {
  if (persistence.tableName) return persistence.tableName;
  const options = resource.definition.options;
  if (options?.pluralName) return options.pluralName;
  if (options?.autoPlural === false) return resource.definition.name;
  return pluralize(resource.definition.name);
}

/**
 * Shared pluralizer — MUST match `snapshot.ts`. If the two diverge the
 * repo's table name won't match the actual table → runtime SQL errors.
 * Kept as a private copy (vs import) because `snapshot.ts` doesn't export
 * its pluralizer; duplication is guarded by a test that compares both.
 */
function pluralize(singular: string): string {
  if (/[^aeiou]y$/i.test(singular)) {
    return singular.slice(0, -1) + "ies";
  }
  if (/(?:s|x|z|ch|sh)$/i.test(singular)) {
    return singular + "es";
  }
  return singular + "s";
}

function toPascalCase(str: string): string {
  return str
    .split(/[-_\s]/)
    .map((part) => (part.length === 0 ? "" : part[0]!.toUpperCase() + part.slice(1)))
    .join("");
}
