import bcryptjs from 'bcryptjs'
import { randomBytes } from 'node:crypto'
import { sdk } from '../sdk'

/**
 * change-admin-credentials — StartOS Package Action.
 *
 * Two modes (one action):
 *
 *   - CREATE mode (no admin exists yet): inserts the User row +
 *     UserPreferences row + seeds the curated exercise library for
 *     them. Required on every fresh install — as of v1.0.0:4 the
 *     image ships with zero users so this is the only way to
 *     bootstrap login.
 *
 *   - UPDATE mode (an admin already exists): rotates email + password
 *     on the oldest existing admin, same as the v1.0.0:1-3 behavior.
 *
 * The action picks the mode by counting `User WHERE isAdmin = 1` in
 * the same subcontainer pass. Operators don't have to think about
 * which one applies — fresh install or rotation, same form, same
 * action.
 *
 * Design notes
 *
 * - allowedStatuses: 'only-stopped'. StartOS forces a Stop before the
 *   action runs, so there's zero risk of two writers (the running
 *   Next.js server + the action's sqlite3 INSERT/UPDATE) racing on
 *   /data/app.db. Side effect: any previously-issued session cookies
 *   are invalidated when the service restarts and re-reads the User
 *   row.
 *
 * - Bcrypt salt rounds = 10. MUST match
 *   proof-of-work/lib/auth.ts::hashPassword. If the app ever changes
 *   its rounds, change them here too — otherwise login will fail.
 *
 * - The plaintext password lives only in the action's JS runtime —
 *   we hash before the SQL ever touches the subcontainer. No
 *   plaintext in /proc, the SQL log, or anywhere persistent.
 *
 * - CREATE mode also runs ensureExerciseLibrary.cjs for the new
 *   admin so they don't have to wait for the next service start to
 *   see the curated 164-exercise library.
 *
 * - We assert the post-action User-row count matches expectation:
 *   create-mode expects to go from 0 admins to 1; update-mode keeps
 *   the count unchanged. Anything else, abort and surface the
 *   discrepancy.
 */

const EMAIL_PATTERN = '^[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}$'

const sqlQuote = (s: string): string => `'${s.replace(/'/g, "''")}'`

/** cuid-shaped id (25 chars, "c" prefix + 24 random hex). */
const newId = (): string => 'c' + randomBytes(12).toString('hex')

export const changeAdminCredentials = sdk.Action.withInput(
  'change-admin-credentials',
  // ---------------------------------------------------------------------------
  // metadata
  // ---------------------------------------------------------------------------
  async () => ({
    name: 'Set admin credentials',
    description:
      'Bootstrap the first admin (required after a fresh install before anyone can log in) OR rotate an existing admin\'s email + password. The service must be stopped first; you will log in with the new credentials after starting it again.',
    warning:
      'On a fresh install this is the ONLY way to create the first admin — the image ships with no default account on purpose. On an existing install this overwrites the current admin row; previously-issued browser sessions stop working as soon as the service restarts. Make sure you can receive email at the address you enter.',
    visibility: 'enabled',
    allowedStatuses: 'only-stopped',
    group: null,
  }),
  // ---------------------------------------------------------------------------
  // input form
  // ---------------------------------------------------------------------------
  sdk.InputSpec.of({
    email: sdk.Value.text({
      name: 'Admin email address',
      description: 'The email you will use to log in.',
      required: true,
      default: null,
      inputmode: 'email',
      placeholder: 'you@example.com',
      patterns: [
        {
          regex: EMAIL_PATTERN,
          description: 'Must be a valid email address (e.g. you@example.com).',
        },
      ],
    }),
    password: sdk.Value.text({
      name: 'Admin password',
      description: 'Minimum 8 characters. No other complexity rules.',
      required: true,
      default: null,
      masked: true,
      minLength: 8,
      placeholder: 'At least 8 characters',
    }),
    passwordConfirm: sdk.Value.text({
      name: 'Confirm password',
      description: 'Retype the password to guard against typos.',
      required: true,
      default: null,
      masked: true,
      minLength: 8,
      placeholder: 'Retype password',
    }),
  }),
  // ---------------------------------------------------------------------------
  // getInput (prefill) — deliberately left empty.
  // ---------------------------------------------------------------------------
  async () => null,
  // ---------------------------------------------------------------------------
  // run
  // ---------------------------------------------------------------------------
  async ({ effects, input }) => {
    if (input.password !== input.passwordConfirm) {
      throw new Error(
        'Password and confirmation do not match. Re-enter both fields.',
      )
    }

    const passwordHash = await bcryptjs.hash(input.password, 10)
    const newUserId = newId()
    const newPrefsId = newId()

    type Mode = 'create' | 'update'
    let mode: Mode = 'update' as Mode

    await sdk.SubContainer.withTemp(
      effects,
      { imageId: 'main' },
      sdk.Mounts.of().mountVolume({
        volumeId: 'main',
        subpath: null,
        mountpoint: '/data',
        readonly: false,
      }),
      'change-admin-credentials',
      async (sc) => {
        // Decide create vs update by counting admins.
        const countRes = await sc.execFail(
          ['sqlite3', '/data/app.db'],
          { input: 'SELECT COUNT(*) FROM User WHERE isAdmin = 1;' },
          30_000,
        )
        const adminCount = parseInt(
          countRes.stdout.toString().trim() || '0',
          10,
        )

        if (adminCount === 0) {
          mode = 'create'
          // Single transaction: insert User, insert UserPreferences,
          // verify exactly one admin exists at the end.
          const sql = [
            'BEGIN IMMEDIATE;',
            `INSERT INTO User (id, email, passwordHash, isAdmin, createdAt, updatedAt) VALUES (${sqlQuote(newUserId)}, ${sqlQuote(input.email)}, ${sqlQuote(passwordHash)}, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);`,
            `INSERT INTO UserPreferences (id, userId, theme, defaultWeightUnit, defaultRestSeconds, enableClaudeAI, createdAt, updatedAt) VALUES (${sqlQuote(newPrefsId)}, ${sqlQuote(newUserId)}, 'system', 'lbs', 90, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);`,
            'COMMIT;',
            'SELECT COUNT(*) FROM User WHERE isAdmin = 1;',
          ].join('\n')

          const res = await sc.execFail(
            ['sqlite3', '/data/app.db'],
            { input: sql },
            30_000,
          )
          const finalCount = parseInt(
            res.stdout.toString().trim() || '0',
            10,
          )
          if (finalCount !== 1) {
            throw new Error(
              `Aborting: expected exactly 1 admin after CREATE, got ${finalCount}. /data/app.db may be in an unexpected state.`,
            )
          }

          // Seed the curated exercise library for this brand-new admin.
          // Failure here is non-fatal — the next service boot's
          // ensureExerciseLibrary will catch it. We log loudly and keep
          // going so the operator can still use the new credentials.
          try {
            await sc.execFail(
              [
                'node',
                '/app/prisma/ensureExerciseLibrary.cjs',
                '--db',
                '/data/app.db',
                '--json',
                '/app/prisma/exercises.seed.json',
              ],
              undefined,
              60_000,
            )
          } catch (e) {
            console.error(
              '[change-admin-credentials] WARN: ensureExerciseLibrary failed for the new admin; the next service boot will retry.',
              e,
            )
          }
        } else {
          // UPDATE path: existing rotation behavior. Targets oldest admin.
          const sql = [
            'BEGIN IMMEDIATE;',
            `UPDATE User`,
            `SET email = ${sqlQuote(input.email)},`,
            `    passwordHash = ${sqlQuote(passwordHash)},`,
            `    updatedAt = (strftime('%s','now') * 1000)`,
            `WHERE id = (SELECT id FROM User WHERE isAdmin = 1 ORDER BY createdAt ASC LIMIT 1);`,
            'SELECT changes();',
            'COMMIT;',
          ].join('\n')

          const res = await sc.execFail(
            ['sqlite3', '/data/app.db'],
            { input: sql },
            30_000,
          )
          const changes = parseInt(
            res.stdout
              .toString()
              .split('\n')
              .map((s) => s.trim())
              .filter(Boolean)
              .pop() ?? '0',
            10,
          )
          if (changes !== 1) {
            throw new Error(
              `Aborting: expected exactly 1 user row updated, got changes()=${changes}. The User table may be empty or contain unexpected rows. Inspect /data/app.db before retrying.`,
            )
          }
        }
      },
    )

    return {
      version: '1',
      title: mode === 'create' ? 'Admin created' : 'Credentials updated',
      message:
        mode === 'create'
          ? 'The admin account is set up and the curated exercise library is seeded for it. Start the service and log in with these credentials.'
          : 'The admin email and password have been rotated. Start the service and log in with the new credentials.',
      result: {
        type: 'group',
        value: [
          {
            type: 'single',
            name: 'Login email',
            description: 'What you will enter on the Proof of Work login page.',
            value: input.email,
            copyable: true,
            qr: false,
            masked: false,
          },
          {
            type: 'single',
            name: 'Password',
            description:
              'Stored as a bcrypt hash (salt rounds 10). Not displayed.',
            value: '••••••••',
            copyable: false,
            qr: false,
            masked: true,
          },
          {
            type: 'single',
            name: 'Mode',
            description:
              mode === 'create'
                ? 'Created the first admin (no admin existed before this run).'
                : 'Updated the existing admin row.',
            value: mode,
            copyable: false,
            qr: false,
            masked: false,
          },
        ],
      },
    }
  },
)
