import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { spawnSync } from 'node:child_process'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import postgres from 'postgres'

/**
 * Stage 1 migration test for the pricing relational rollout.
 *
 * Scope (matches §"Tests" #1 in requirement/pricing-relational-migration-plan.md):
 *   1. Empty DB → backfill seeds the DEFAULT_* values from pricing.service.js.
 *   2. Pre-existing app_config JSON → backfill copies those values, not defaults.
 *   3. Re-running the migration → no duplicate rows.
 *
 * Isolation strategy
 * ------------------
 * Each test creates its OWN throwaway schema (`pricing_mig_test_<random>`),
 * runs migrate.js as a child process against that schema, inspects the
 * resulting rows via a one-off postgres client scoped to the schema, then
 * drops the schema in afterAll.
 *
 * Why a child process: migrate.js calls `sql.end()` at the bottom, which would
 * tear down the singleton sql client shared with the rest of the test process
 * if invoked in-process. Same trick setup.js uses for the global migration.
 *
 * Why a separate schema per test (vs. the shared `halobestie_test` schema):
 * the shared schema already has app_config seeded with Phase 4 defaults, which
 * would make the "empty app_config → fall back to DEFAULTS" path untestable.
 * Per-schema isolation lets us control the initial state precisely.
 */

const __dirname = dirname(fileURLToPath(import.meta.url))
const backendRoot = resolve(__dirname, '../..')
const migratePath = resolve(backendRoot, 'src/db/migrate.js')

// Mirrors DEFAULT_CHAT_TIERS / DEFAULT_CALL_TIERS / DEFAULT_DISCOUNT in
// src/services/pricing.service.js. Duplicated here so the test fails loudly
// if either side drifts — that's a feature, not duplication-tax.
const DEFAULT_CHAT_TIERS = [
  { minutes: 5,   price_idr: 5000,  tag: null },
  { minutes: 12,  price_idr: 12000, tag: 'paling pas' },
  { minutes: 30,  price_idr: 25000, tag: 'hemat' },
  { minutes: 60,  price_idr: 45000, tag: null },
  { minutes: 120, price_idr: 80000, tag: 'best deal' },
]
const DEFAULT_CALL_TIERS = [
  { minutes: 10, price_idr: 9000,  tag: null },
  { minutes: 20, price_idr: 17000, tag: 'paling pas' },
  { minutes: 45, price_idr: 35000, tag: null },
  { minutes: 60, price_idr: 45000, tag: 'hemat' },
]
const DEFAULT_DISCOUNT = {
  enabled: true,
  actual_price_idr: 2000,
  gimmick_price_idr: 12000,
  duration_minutes: 12,
  modes: ['chat'],
}

/**
 * Build a DATABASE_URL that pins search_path to the given schema. Postgres applies
 * the search_path on every new connection, so all CREATE TABLE / INSERT / SELECT
 * resolve to this schema without further qualification.
 */
const scopedUrl = (baseUrl, schema) => {
  const sep = baseUrl.includes('?') ? '&' : '?'
  return `${baseUrl}${sep}options=${encodeURIComponent(`-c search_path=${schema},public`)}`
}

/**
 * Spawn migrate.js as a child process against the given schema. Throws on non-zero
 * exit so the test fails with the migration's stdout/stderr.
 */
const runMigration = (schema) => {
  const result = spawnSync(
    process.execPath,
    [migratePath],
    {
      env: {
        ...process.env,
        DATABASE_URL: scopedUrl(process.env.TEST_DATABASE_URL, schema),
      },
      cwd: backendRoot,
      encoding: 'utf8',
    }
  )
  if (result.status !== 0) {
    throw new Error(
      `Migration failed (exit ${result.status}):\n` +
      `stdout: ${result.stdout}\nstderr: ${result.stderr}`
    )
  }
}

/**
 * Open a one-off postgres client scoped to the given schema. Caller MUST `.end()` it.
 * We intentionally do not reuse the singleton from src/db/client.js — that's scoped to
 * the shared test schema, not the throwaway one we built here.
 */
const openClient = (schema) => postgres(scopedUrl(process.env.TEST_DATABASE_URL, schema))

describe('pricing relational migration — Stage 1 backfill', () => {
  // Test schemas we create. Tracked so afterAll can drop them even if a test throws.
  const createdSchemas = []

  /**
   * Allocate a fresh schema, register it for teardown, return its name. Schemas are
   * suffixed with a random hex string + the test's nominal label so collisions across
   * parallel CI runs (or repeated local runs after a crash) are vanishingly unlikely.
   */
  const newSchema = async (label) => {
    const suffix = Math.random().toString(16).slice(2, 10)
    const name = `pricing_mig_test_${label}_${suffix}`
    const admin = postgres(process.env.TEST_DATABASE_URL)
    try {
      await admin`CREATE SCHEMA ${admin(name)}`
    } finally {
      await admin.end()
    }
    createdSchemas.push(name)
    return name
  }

  beforeAll(() => {
    if (!process.env.TEST_DATABASE_URL) {
      throw new Error('TEST_DATABASE_URL must be set (loaded by test/setup.js)')
    }
  })

  afterAll(async () => {
    // Drop every schema we created. CASCADE so we don't have to enumerate tables.
    const admin = postgres(process.env.TEST_DATABASE_URL)
    try {
      for (const schema of createdSchemas) {
        await admin`DROP SCHEMA IF EXISTS ${admin(schema)} CASCADE`
      }
    } finally {
      await admin.end()
    }
  })

  it('empty DB → backfill seeds DEFAULT_CHAT_TIERS / DEFAULT_CALL_TIERS / DEFAULT_DISCOUNT', async () => {
    const schema = await newSchema('empty')
    runMigration(schema)

    const sql = openClient(schema)
    try {
      // 1. Tiers: count + per-row equivalence with DEFAULT_* (sort_order = array index).
      const chatTiers = await sql`
        SELECT minutes, price_idr, tag, sort_order
        FROM pricing_tiers
        WHERE mode = 'chat'
        ORDER BY sort_order
      `
      expect(chatTiers).toHaveLength(DEFAULT_CHAT_TIERS.length)
      chatTiers.forEach((row, idx) => {
        expect(row.minutes).toBe(DEFAULT_CHAT_TIERS[idx].minutes)
        expect(row.price_idr).toBe(DEFAULT_CHAT_TIERS[idx].price_idr)
        expect(row.tag).toBe(DEFAULT_CHAT_TIERS[idx].tag)
        expect(row.sort_order).toBe(idx)
      })

      const callTiers = await sql`
        SELECT minutes, price_idr, tag, sort_order
        FROM pricing_tiers
        WHERE mode = 'call'
        ORDER BY sort_order
      `
      expect(callTiers).toHaveLength(DEFAULT_CALL_TIERS.length)
      callTiers.forEach((row, idx) => {
        expect(row.minutes).toBe(DEFAULT_CALL_TIERS[idx].minutes)
        expect(row.price_idr).toBe(DEFAULT_CALL_TIERS[idx].price_idr)
        expect(row.tag).toBe(DEFAULT_CALL_TIERS[idx].tag)
        expect(row.sort_order).toBe(idx)
      })

      // All freshly inserted rows must be active.
      const [{ inactive_count }] = await sql`
        SELECT COUNT(*)::int AS inactive_count FROM pricing_tiers WHERE is_active = false
      `
      expect(inactive_count).toBe(0)

      // 2. Promotions: single 'first_session' row matching DEFAULT_DISCOUNT.
      const promos = await sql`
        SELECT enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
        FROM pricing_promotions
      `
      expect(promos).toHaveLength(1)
      expect(promos[0]).toMatchObject({
        enabled: DEFAULT_DISCOUNT.enabled,
        eligibility: 'first_session',
        actual_price_idr: DEFAULT_DISCOUNT.actual_price_idr,
        gimmick_price_idr: DEFAULT_DISCOUNT.gimmick_price_idr,
        duration_minutes: DEFAULT_DISCOUNT.duration_minutes,
      })
      expect(promos[0].modes).toEqual(DEFAULT_DISCOUNT.modes)
    } finally {
      await sql.end()
    }
  })

  it('pre-existing app_config JSON → backfill copies those values, not defaults', async () => {
    const schema = await newSchema('preseed')

    // Custom values that DO NOT overlap with any DEFAULT_* entry — guarantees the
    // assertions can tell the two sources apart.
    const customChat = [
      { minutes: 7,   price_idr: 7700,  tag: 'custom-a' },
      { minutes: 22,  price_idr: 21000, tag: null },
      { minutes: 99,  price_idr: 75000, tag: 'custom-b' },
    ]
    const customCall = [
      { minutes: 11, price_idr: 9900,  tag: 'custom-c' },
      { minutes: 33, price_idr: 27500, tag: null },
    ]
    const customDiscount = {
      enabled: false,
      actual_price_idr: 1500,
      gimmick_price_idr: 11000,
      duration_minutes: 8,
      modes: ['chat', 'call'],
    }

    // Seed app_config BEFORE the pricing migration block runs. The earlier
    // migration sections create the app_config table itself; running the migration
    // once gives us the table + the default Phase 4 app_config rows. But that same
    // run also backfills pricing_tiers from those defaults — we don't want that.
    //
    // Workaround: bootstrap just enough of the migration prologue manually
    // (create app_config + insert our custom JSON), THEN run migrate.js. The
    // tier/promo CREATE TABLE IF NOT EXISTS is idempotent, and the backfill is
    // gated on "table empty", so it'll read our app_config rows on first pass.
    const bootstrap = openClient(schema)
    try {
      await bootstrap`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`
      await bootstrap`
        CREATE TABLE app_config (
          key VARCHAR(100) PRIMARY KEY,
          value JSONB NOT NULL,
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
        )
      `
      await bootstrap`
        INSERT INTO app_config (key, value) VALUES
          ('pricing_chat_tiers_json',                  ${bootstrap.json({ tiers: customChat })}),
          ('pricing_call_tiers_json',                  ${bootstrap.json({ tiers: customCall })}),
          ('first_session_discount_enabled',           ${bootstrap.json({ value: customDiscount.enabled })}),
          ('first_session_discount_actual_price_idr',  ${bootstrap.json({ value: customDiscount.actual_price_idr })}),
          ('first_session_discount_gimmick_price_idr', ${bootstrap.json({ value: customDiscount.gimmick_price_idr })}),
          ('first_session_discount_duration_minutes',  ${bootstrap.json({ value: customDiscount.duration_minutes })}),
          ('first_session_discount_modes',             ${bootstrap.json({ value: customDiscount.modes })})
      `
    } finally {
      await bootstrap.end()
    }

    // Now run the full migration. The pricing block sees pre-seeded app_config
    // and an empty pricing_tiers, so it should copy our custom values.
    runMigration(schema)

    const sql = openClient(schema)
    try {
      const chatTiers = await sql`
        SELECT minutes, price_idr, tag, sort_order
        FROM pricing_tiers WHERE mode = 'chat' ORDER BY sort_order
      `
      expect(chatTiers).toHaveLength(customChat.length)
      chatTiers.forEach((row, idx) => {
        expect(row.minutes).toBe(customChat[idx].minutes)
        expect(row.price_idr).toBe(customChat[idx].price_idr)
        expect(row.tag).toBe(customChat[idx].tag)
        expect(row.sort_order).toBe(idx)
      })

      // Spot-check that DEFAULTS are NOT present (e.g. 5/12/30/60/120 minute chat
      // tiers from DEFAULT_CHAT_TIERS shouldn't appear unless customChat includes them).
      const defaultsThatShouldNotBeHere = await sql`
        SELECT minutes FROM pricing_tiers
        WHERE mode = 'chat' AND minutes IN (5, 12, 30, 120)
      `
      expect(defaultsThatShouldNotBeHere).toHaveLength(0)

      const callTiers = await sql`
        SELECT minutes, price_idr, tag, sort_order
        FROM pricing_tiers WHERE mode = 'call' ORDER BY sort_order
      `
      expect(callTiers).toHaveLength(customCall.length)
      callTiers.forEach((row, idx) => {
        expect(row.minutes).toBe(customCall[idx].minutes)
        expect(row.price_idr).toBe(customCall[idx].price_idr)
        expect(row.tag).toBe(customCall[idx].tag)
        expect(row.sort_order).toBe(idx)
      })

      const [promo] = await sql`
        SELECT enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
        FROM pricing_promotions
      `
      expect(promo).toMatchObject({
        enabled: customDiscount.enabled,
        eligibility: 'first_session',
        actual_price_idr: customDiscount.actual_price_idr,
        gimmick_price_idr: customDiscount.gimmick_price_idr,
        duration_minutes: customDiscount.duration_minutes,
      })
      expect(promo.modes).toEqual(customDiscount.modes)
    } finally {
      await sql.end()
    }
  })

  it('Stage 5 cleanup: legacy pricing app_config rows are deleted after backfill, idempotent on re-run', async () => {
    const schema = await newSchema('stage5')

    // First migration pass: seeds the legacy app_config keys, backfills relational
    // tables, then the Stage 5 DELETE block removes the legacy keys.
    runMigration(schema)

    const sql = openClient(schema)
    try {
      // The seven legacy keys must be gone post-migration.
      const legacyKeys = [
        'first_session_discount_enabled',
        'first_session_discount_actual_price_idr',
        'first_session_discount_gimmick_price_idr',
        'first_session_discount_duration_minutes',
        'first_session_discount_modes',
        'pricing_chat_tiers_json',
        'pricing_call_tiers_json',
      ]
      const remaining = await sql`
        SELECT key FROM app_config WHERE key IN ${sql(legacyKeys)}
      `
      expect(remaining).toHaveLength(0)

      // Sanity: relational tables still hold the values that were copied off.
      const [{ n: tierCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers`
      expect(tierCount).toBeGreaterThan(0)
      const [{ n: promoCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_promotions`
      expect(promoCount).toBe(1)

      // Re-run the migration. The DELETE must remain a no-op (zero affected rows)
      // and not error — i.e. cleanup is idempotent.
      runMigration(schema)

      const remainingAfter = await sql`
        SELECT key FROM app_config WHERE key IN ${sql(legacyKeys)}
      `
      expect(remainingAfter).toHaveLength(0)
    } finally {
      await sql.end()
    }
  })

  it('Stage 5 cleanup: legacy keys present from a pre-Stage-5 DB are still deleted on first run', async () => {
    // Simulates a DB that was already migrated to Stage 1 (pricing_tiers/promotions
    // populated, legacy app_config keys still around) and is being upgraded to
    // Stage 5 for the first time. The DELETE should remove the legacy keys
    // without disturbing the relational data.
    const schema = await newSchema('stage5_existing')

    // Bootstrap: schema + app_config table + legacy keys with custom values, then
    // run the migration. Pre-seeding here is what differentiates this from the
    // empty-DB case — it proves the DELETE doesn't depend on the seeding INSERT.
    const bootstrap = openClient(schema)
    try {
      await bootstrap`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`
      await bootstrap`
        CREATE TABLE app_config (
          key VARCHAR(100) PRIMARY KEY,
          value JSONB NOT NULL,
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
        )
      `
      await bootstrap`
        INSERT INTO app_config (key, value) VALUES
          ('first_session_discount_enabled',           ${bootstrap.json({ value: false })}),
          ('first_session_discount_actual_price_idr',  ${bootstrap.json({ value: 1234 })}),
          ('first_session_discount_gimmick_price_idr', ${bootstrap.json({ value: 5678 })}),
          ('first_session_discount_duration_minutes', ${bootstrap.json({ value: 9 })}),
          ('first_session_discount_modes',             ${bootstrap.json({ value: ['chat', 'call'] })}),
          ('pricing_chat_tiers_json',                  ${bootstrap.json({ tiers: [{ minutes: 3, price_idr: 3000, tag: null }] })}),
          ('pricing_call_tiers_json',                  ${bootstrap.json({ tiers: [{ minutes: 4, price_idr: 4000, tag: null }] })})
      `
    } finally {
      await bootstrap.end()
    }

    runMigration(schema)

    const sql = openClient(schema)
    try {
      const remaining = await sql`
        SELECT key FROM app_config
        WHERE key IN (
          'first_session_discount_enabled',
          'first_session_discount_actual_price_idr',
          'first_session_discount_gimmick_price_idr',
          'first_session_discount_duration_minutes',
          'first_session_discount_modes',
          'pricing_chat_tiers_json',
          'pricing_call_tiers_json'
        )
      `
      expect(remaining).toHaveLength(0)

      // Backfill copied the pre-seeded values into the relational tables (Stage 1
      // semantics) and the Stage 5 DELETE didn't touch them.
      const [promo] = await sql`
        SELECT enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes
        FROM pricing_promotions WHERE eligibility = 'first_session'
      `
      expect(promo.enabled).toBe(false)
      expect(promo.actual_price_idr).toBe(1234)
      expect(promo.gimmick_price_idr).toBe(5678)
      expect(promo.duration_minutes).toBe(9)
      expect(promo.modes).toEqual(['chat', 'call'])
    } finally {
      await sql.end()
    }
  })

  it('re-running the migration → no duplicate rows, existing data preserved', async () => {
    const schema = await newSchema('rerun')

    // First migration pass: backfills defaults (app_config rows are seeded by the
    // earlier sections of the same migration, then pricing_tiers fills from them).
    runMigration(schema)

    // Capture state after pass 1 — ids included so we can prove rows weren't
    // re-inserted (UUIDs would change if they were).
    const snapshot = openClient(schema)
    let tiersBefore, promoBefore
    try {
      tiersBefore = await snapshot`
        SELECT id, mode, minutes, price_idr, tag, sort_order, is_active
        FROM pricing_tiers ORDER BY mode, sort_order
      `
      promoBefore = await snapshot`
        SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
               duration_minutes, modes
        FROM pricing_promotions
      `
      expect(tiersBefore.length).toBeGreaterThan(0)
      expect(promoBefore).toHaveLength(1)
    } finally {
      await snapshot.end()
    }

    // Second migration pass: must be a no-op for pricing data.
    runMigration(schema)

    const sql = openClient(schema)
    try {
      const tiersAfter = await sql`
        SELECT id, mode, minutes, price_idr, tag, sort_order, is_active
        FROM pricing_tiers ORDER BY mode, sort_order
      `
      const promoAfter = await sql`
        SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
               duration_minutes, modes
        FROM pricing_promotions
      `

      // Row count unchanged.
      expect(tiersAfter).toHaveLength(tiersBefore.length)
      expect(promoAfter).toHaveLength(1)

      // Every id from pass 1 still exists with identical values in pass 2.
      // UUIDs are stable across re-runs because the backfill is gated on "table empty".
      expect(tiersAfter.map((r) => r.id).sort()).toEqual(
        tiersBefore.map((r) => r.id).sort()
      )
      expect(promoAfter[0].id).toBe(promoBefore[0].id)

      // Defense against an "INSERT … ON CONFLICT DO UPDATE" regression: spot-check
      // that price_idr / tag for one tier wasn't silently clobbered.
      tiersAfter.forEach((after) => {
        const before = tiersBefore.find((b) => b.id === after.id)
        expect(after.price_idr).toBe(before.price_idr)
        expect(after.tag).toBe(before.tag)
        expect(after.sort_order).toBe(before.sort_order)
      })

      // UNIQUE (mode, minutes) on pricing_tiers should give us zero duplicate
      // (mode, minutes) pairs. Explicit query in case the constraint is ever
      // accidentally dropped in a future migration.
      const dupes = await sql`
        SELECT mode, minutes, COUNT(*)::int AS n
        FROM pricing_tiers
        GROUP BY mode, minutes
        HAVING COUNT(*) > 1
      `
      expect(dupes).toHaveLength(0)

      // UNIQUE (eligibility) on pricing_promotions: same check.
      const promoDupes = await sql`
        SELECT eligibility, COUNT(*)::int AS n
        FROM pricing_promotions
        GROUP BY eligibility
        HAVING COUNT(*) > 1
      `
      expect(promoDupes).toHaveLength(0)
    } finally {
      await sql.end()
    }
  })
})
