/**
 * Step-handlers для full-cycle сценария. Каждая функция получает
 *   { ctx, step, report }
 * и обновляет step.checks и report.bugs / report.uxNotes / report.gap.
 *
 * ctx — общий накопитель ID'ов созданных сущностей и активных сессий
 * между steps. Структура шире чем строго необходима — для удобства
 * логирования в отчёте.
 */
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
import { Report as _R } from '../lib/report.js'  // type-only ниже
import { countRows, psql } from '../lib/db.js'
import { generateEan13 } from '../lib/barcode.js'

type Ctx = {
  apiOnly: boolean
  superAdminToken?: string
  organization?: { id: string; name: string }
  adminEmail?: string
  adminTempPassword?: string
  adminToken?: string
  storekeeperEmail?: string
  cashierEmail?: string
  cashierTempPassword?: string
  storekeeperTempPassword?: string
  counterpartyId?: string
  storeId?: string
  retailPointId?: string
  productGroupId?: string
  retailPriceTypeId?: string
  currencyId?: string
  supplyId?: string
  supplyLines?: { productId: string; productName: string; quantity: number; price: number }[]
  retailSaleId?: string
  saleLines?: { productId: string; quantity: number; unitPrice: number }[]
  stockBefore?: Record<string, number>
  stockAfterSupply?: Record<string, number>
}

interface StepCtx { ctx: Ctx; step: Step; report: Report }

const TIMESTAMP = Date.now()

function check(step: Step, c: CheckResult) {
  step.checks.push(c)
}

function asString(x: unknown): string {
  if (x == null) return ''
  if (typeof x === 'string') return x
  return JSON.stringify(x)
}

async function ensureSuperAdminToken(ctx: Ctx): Promise<string> {
  if (!ctx.superAdminToken) {
    const sa = await login('admin@food-market.local', 'Admin12345!')
    ctx.superAdminToken = sa.accessToken
  }
  return ctx.superAdminToken
}

// ---------------------------------------------------------------------------

export async function step01_create_organization({ ctx, step, report }: StepCtx) {
  const token = await ensureSuperAdminToken(ctx)
  const api = makeClient(token)
  const orgName = `Test Shop ${TIMESTAMP}`
  const orgEmail = `e2e-${TIMESTAMP}@example.kz`

  const res = await api.post('/api/super-admin/organizations', {
    org: {
      name: orgName,
      countryCode: 'KZ',
      bin: '123456789012',
      address: 'Алматы, ул. Тестовая 1',
      phone: '+77001234567',
      email: orgEmail,
      defaultCurrencyId: null,
      accountOwnerUserId: null,
    },
    adminLastName: 'Тестов',
    adminFirstName: 'Админ',
    adminEmail: `admin-${TIMESTAMP}@example.kz`,
    adminPosition: 'Директор',
  })

  check(step, {
    kind: 'api',
    description: `POST /api/super-admin/organizations → 200`,
    ok: res.status === 200,
    detail: res.status === 200 ? `org=${res.data?.organization?.name}` : asString(res.data),
  })
  if (res.status !== 200) {
    report.bug({
      step: '01',
      severity: 'critical',
      title: 'Не удаётся создать организацию через API SuperAdmin',
      detail: `POST /api/super-admin/organizations вернул ${res.status} ${asString(res.data)}`,
      fix: 'Проверь что таблицы tenant-bootstrap (employee_roles системные) сохранены при reset_db.',
    })
    return
  }
  ctx.organization = { id: res.data.organization.id, name: res.data.organization.name }
  ctx.adminEmail = res.data.adminEmail
  ctx.adminTempPassword = res.data.adminTempPassword

  // List ↦ должен возвращать новую org
  const list = await api.get('/api/super-admin/organizations?archived=false&pageSize=200')
  const found = list.data?.items?.some((o: { id: string }) => o.id === ctx.organization!.id)
  check(step, {
    kind: 'api',
    description: 'GET /api/super-admin/organizations включает созданную org',
    ok: !!found,
    detail: found ? '' : `total=${list.data?.total}`,
  })

  // ФЛК телефона: проверим что орг с невалидным KZ-телефоном (например +71234567890)
  // отвергается сервером. Делаем pure-API smoke без UI — это проверка backend-validation.
  const bad = await api.post('/api/super-admin/organizations', {
    org: {
      name: `Bad Phone ${TIMESTAMP}`,
      countryCode: 'KZ', bin: null, address: null, phone: 'abc', email: null,
      defaultCurrencyId: null, accountOwnerUserId: null,
    },
    adminLastName: 'X', adminFirstName: 'Y',
    adminEmail: `bad-${TIMESTAMP}@example.kz`, adminPosition: null,
  })
  // Здесь backend может НЕ валидировать phone (это поле опциональное и без regex'а
  // на уровне домена). Поэтому статус 200 — это logic gap, отметим:
  if (bad.status === 200) {
    report.gap('SuperAdmin /organizations принимает любой текст в поле phone — серверной валидации ФЛК нет (только в /api/auth/signup для самозаполнения).')
  } else {
    check(step, {
      kind: 'api',
      description: 'Невалидный phone отвергается',
      ok: bad.status >= 400 && bad.status < 500,
      detail: `${bad.status}`,
    })
  }
}

// ---------------------------------------------------------------------------

export async function step02_create_first_admin({ ctx, step, report }: StepCtx) {
  // SuperAdmin при создании org уже создаёт первого Admin'a (см. step01).
  // Проверяем что temp password выдан и в БД появился Employee + Identity-Admin.
  if (!ctx.organization || !ctx.adminEmail) {
    step.status = 'skip'; step.notes.push('Нет organization из step 01 — пропускаем.')
    return
  }

  check(step, {
    kind: 'api',
    description: 'Temp password возвращён CreateOrgResult',
    ok: !!ctx.adminTempPassword && ctx.adminTempPassword.length >= 8,
    detail: ctx.adminTempPassword ? `len=${ctx.adminTempPassword.length}` : 'empty',
  })

  const employeesCount = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`)
  check(step, {
    kind: 'db',
    description: `employees содержит ровно 1 запись для новой org`,
    ok: employeesCount === 1,
    detail: `count=${employeesCount}`,
  })

  // Проверим что AspNetUserRoles содержит role=Admin для нового user-id.
  const userIdRows = psql(
    `SELECT u."Id" FROM users u WHERE u."Email" = '${ctx.adminEmail}';`).trim()
  if (!userIdRows) {
    check(step, { kind: 'db', description: 'AppUser существует', ok: false, detail: 'не найден' })
    return
  }
  const userId = userIdRows
  const roleRows = psql(
    `SELECT r."Name" FROM "AspNetUserRoles" ur
     JOIN roles r ON r."Id" = ur."RoleId"
     WHERE ur."UserId" = '${userId}';`).trim().split('\n')
  const hasAdmin = roleRows.includes('Admin')
  check(step, {
    kind: 'db',
    description: 'AspNetUserRoles содержит role=Admin для нового user',
    ok: hasAdmin,
    detail: roleRows.join(','),
  })
  if (!hasAdmin) {
    report.bug({
      step: '02',
      severity: 'high',
      title: 'Identity-роль Admin не присвоена при создании организации',
      detail: 'CreateOrg в SuperAdminOrganizationsController должен вызывать UserManager.AddToRoleAsync(user, "Admin")',
    })
  }
}

// ---------------------------------------------------------------------------

export async function step03_login_as_admin({ ctx, step, report }: StepCtx) {
  if (!ctx.adminEmail || !ctx.adminTempPassword) {
    step.status = 'skip'; step.notes.push('Нет creds admin'); return
  }
  try {
    const sess = await login(ctx.adminEmail, ctx.adminTempPassword)
    ctx.adminToken = sess.accessToken
    check(step, {
      kind: 'api',
      description: '/connect/token password-grant выдал токен',
      ok: !!sess.accessToken,
    })
    check(step, {
      kind: 'api',
      description: '/api/me содержит role=Admin',
      ok: sess.roles.includes('Admin'),
      detail: sess.roles.join(','),
    })
    check(step, {
      kind: 'api',
      description: '/api/me содержит правильный orgId',
      ok: sess.orgId === ctx.organization?.id,
      detail: sess.orgId ?? 'null',
    })
  } catch (e) {
    check(step, { kind: 'api', description: 'login admin', ok: false, detail: (e as Error).message })
    report.bug({
      step: '03',
      severity: 'critical',
      title: 'Свежесозданный Admin не может залогиниться с temp password',
      detail: (e as Error).message,
    })
  }
}

// ---------------------------------------------------------------------------

export async function step04_create_storekeeper_and_cashier({ ctx, step, report }: StepCtx) {
  if (!ctx.adminToken || !ctx.organization) { step.status = 'skip'; return }
  const api = makeClient(ctx.adminToken)

  // Получаем list ролей; нужны Storekeeper (системная) и Кассир (системная).
  const rolesRes = await api.get('/api/organization/employee-roles?pageSize=200')
  check(step, {
    kind: 'api', description: 'employee-roles list', ok: rolesRes.status === 200,
    detail: `${rolesRes.status}, total=${rolesRes.data?.total}`,
  })
  const roles = (rolesRes.data?.items ?? []) as { id: string; name: string; isSystem: boolean }[]
  const keeperRole = roles.find((r) => r.name === 'Кладовщик')
  const cashierRole = roles.find((r) => r.name === 'Кассир')
  check(step, {
    kind: 'api', description: 'Системная роль «Кладовщик» существует', ok: !!keeperRole,
  })
  check(step, {
    kind: 'api', description: 'Системная роль «Кассир» существует', ok: !!cashierRole,
  })
  if (!keeperRole || !cashierRole) {
    report.bug({
      step: '04', severity: 'high',
      title: 'Не сидируются системные роли при создании org',
      detail: `roles list = ${roles.map((r) => r.name).join(', ')}`,
      fix: 'DevDataSeeder.SeedEmployeeRolesAsync должен вызываться после CreateOrg.',
    })
    return
  }

  ctx.storekeeperEmail = `keeper-${TIMESTAMP}@example.kz`
  ctx.cashierEmail = `cashier-${TIMESTAMP}@example.kz`

  for (const [emailField, roleId, lastName, firstName, position] of [
    [ctx.storekeeperEmail, keeperRole.id, 'Кладовщиков', 'Иван', 'Кладовщик'],
    [ctx.cashierEmail, cashierRole.id, 'Кассиров', 'Пётр', 'Кассир'],
  ] as const) {
    const res = await api.post('/api/organization/employees', {
      lastName, firstName, middleName: null, position,
      email: emailField, phone: '+77002223344',
      salary: null, taxNumber: null, description: null, imageUrl: null,
      roleId, isActive: true, retailPointIds: [], createAccount: true,
    })
    check(step, {
      kind: 'api', description: `POST /api/organization/employees (${position})`,
      ok: res.status === 200, detail: `${res.status} ${res.status !== 200 ? asString(res.data) : ''}`,
    })
    if (res.status === 200) {
      const tempPwd = res.data?.generatedPassword as string | undefined
      if (position === 'Кладовщик') ctx.storekeeperTempPassword = tempPwd
      else ctx.cashierTempPassword = tempPwd
    }
  }

  const total = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`)
  check(step, {
    kind: 'db', description: 'employees total = 3 (admin + keeper + cashier)',
    ok: total === 3, detail: `count=${total}`,
  })

  // Валидация: невалидный email должен быть отвергнут.
  const bad = await api.post('/api/organization/employees', {
    lastName: 'Bad', firstName: 'Email', middleName: null, position: null,
    email: 'not-an-email', phone: null, salary: null, taxNumber: null,
    description: null, imageUrl: null, roleId: cashierRole.id, isActive: true,
    retailPointIds: [], createAccount: true,
  })
  if (bad.status === 200) {
    report.gap('POST /api/organization/employees принимает невалидный email при createAccount=true (Identity создаёт юзера с UserName=not-an-email).')
  } else {
    check(step, {
      kind: 'api', description: 'Невалидный email отвергается при createAccount',
      ok: bad.status >= 400, detail: `${bad.status}`,
    })
  }
}

// ---------------------------------------------------------------------------

export async function step05_login_as_cashier({ ctx, step, report }: StepCtx) {
  if (!ctx.cashierEmail || !ctx.cashierTempPassword) { step.status = 'skip'; return }
  let cashier
  try {
    cashier = await login(ctx.cashierEmail, ctx.cashierTempPassword)
  } catch (e) {
    check(step, { kind: 'api', description: 'login Cashier', ok: false, detail: (e as Error).message })
    report.bug({
      step: '05', severity: 'critical',
      title: 'Cashier не может залогиниться с выданным паролем',
      detail: (e as Error).message,
    })
    return
  }
  check(step, {
    kind: 'api', description: '/api/me содержит роль соответствующую системной Cashier',
    ok: cashier.roles.length > 0,  // Identity-роль может отсутствовать (используются org-роли),
    detail: cashier.roles.join(',') || 'no Identity roles',
  })
  // Identity-роль для Cashier — "Cashier"? Проверим. Если она НЕ присвоена,
  // это значит role-guard на сервере основан только на Identity-роли Admin
  // (см. Authorize(Roles="Admin")). Cashier тогда всегда 403 на admin-ресурсах,
  // но и на /sales/retail может не пройти если там стоит [Authorize(Roles="Cashier")].
  if (!cashier.roles.includes('Cashier') && !cashier.roles.includes('Admin')) {
    report.gap('Cashier у созданного через POST /employees не получает Identity-роль "Cashier" — серверная авторизация на /api/sales/retail требует роли Admin или Cashier; нужно либо сидить Identity-роль из org-роли, либо переделать [Authorize] на permissions.')
  }

  const apiCashier = makeClient(cashier.accessToken)

  // /settings/employees должен быть 403 для Cashier.
  const empRes = await apiCashier.get('/api/organization/employees')
  check(step, {
    kind: 'api', description: 'Cashier → GET /api/organization/employees → 403',
    ok: empRes.status === 403,
    detail: `${empRes.status}`,
  })
  if (empRes.status !== 403) {
    if (empRes.status === 200) {
      report.bug({
        step: '05', severity: 'critical',
        title: 'Cashier ВИДИТ список сотрудников через API',
        detail: 'GET /api/organization/employees вернул 200 для Cashier; ожидается 403 (нет [Authorize(Roles="Admin")] на List? или Cashier имеет Identity-Admin)',
        fix: 'Поставить [Authorize(Roles="Admin")] на List + проверить что AddToRoleAsync(...,"Cashier") а не "Admin")',
      })
    }
  }

  const salesRes = await apiCashier.get('/api/sales/retail?pageSize=10')
  check(step, {
    kind: 'api', description: 'Cashier → GET /api/sales/retail — доступен',
    ok: salesRes.status === 200 || salesRes.status === 404,
    detail: `${salesRes.status}`,
  })
}

// ---------------------------------------------------------------------------

export async function step06_create_counterparty({ ctx, step, report }: StepCtx) {
  if (!ctx.adminToken) { step.status = 'skip'; return }
  const api = makeClient(ctx.adminToken)

  // Получим страну KZ
  const countries = await api.get('/api/catalog/countries?pageSize=200')
  const kz = (countries.data?.items as { id: string; code: string }[] | undefined)
    ?.find((c) => c.code === 'KZ')
  if (!kz) {
    report.bug({ step: '06', severity: 'high', title: 'Страна KZ отсутствует в справочнике', detail: '' })
  }

  const res = await api.post('/api/catalog/counterparties', {
    name: 'ТОО Тест Поставщик',
    legalName: 'Товарищество с ограниченной ответственностью «Тест Поставщик»',
    type: 0,  // LegalEntity
    bin: '987654321098', iin: null, taxNumber: null,
    countryId: kz?.id ?? null,
    address: 'Алматы, ул. Поставщиков 1',
    phone: '+77003332211', email: 'supplier@example.kz',
    bankName: null, bankAccount: null, bik: null,
    contactPerson: 'Иванов И.И.', notes: null,
  })
  check(step, {
    kind: 'api', description: 'POST /api/catalog/counterparties',
    ok: res.status === 200 || res.status === 201,
    detail: `${res.status} ${res.status >= 400 ? asString(res.data) : ''}`,
  })
  if (res.status === 200 || res.status === 201) {
    ctx.counterpartyId = res.data?.id
  } else {
    report.bug({
      step: '06', severity: 'critical',
      title: 'Создание контрагента падает',
      detail: `${res.status} ${asString(res.data)}`,
    })
  }
}

// ---------------------------------------------------------------------------

export async function step07_ensure_main_store({ ctx, step, report }: StepCtx) {
  if (!ctx.adminToken) { step.status = 'skip'; return }
  const api = makeClient(ctx.adminToken)
  const list = await api.get('/api/catalog/stores?pageSize=200')
  check(step, {
    kind: 'api', description: 'GET /api/catalog/stores',
    ok: list.status === 200, detail: `${list.status}`,
  })
  const stores = (list.data?.items ?? []) as { id: string; name: string; isMain: boolean }[]
  let main = stores.find((s) => s.isMain) ?? stores[0]
  if (!main) {
    const create = await api.post('/api/catalog/stores', {
      name: 'Main', code: 'MAIN', address: null, phone: null,
      managerName: null, isMain: true, isActive: true,
    })
    check(step, {
      kind: 'api', description: 'Main store создан вручную',
      ok: create.status === 200 || create.status === 201,
      detail: `${create.status}`,
    })
    if (create.status >= 400) {
      report.bug({
        step: '07', severity: 'high',
        title: 'Main store не сидируется автоматически и не создаётся вручную',
        detail: asString(create.data),
      })
      return
    }
    main = create.data
  } else {
    check(step, {
      kind: 'db', description: 'Main store существует (от bootstrap)',
      ok: true, detail: main.name,
    })
  }
  ctx.storeId = main!.id
}

// ---------------------------------------------------------------------------

export async function step08_create_supply({ ctx, step, report }: StepCtx) {
  if (!ctx.adminToken || !ctx.counterpartyId || !ctx.storeId) { step.status = 'skip'; return }
  const api = makeClient(ctx.adminToken)

  // 1) Подготовим справочники: unit, product group, retail price type, currency.
  const unitsRes = await api.get('/api/catalog/units-of-measure?pageSize=10')
  const unit = (unitsRes.data?.items ?? [])[0] as { id: string; name: string } | undefined
  if (!unit) {
    report.bug({
      step: '08', severity: 'critical',
      title: 'Для нового tenant нет ни одной единицы измерения',
      detail: 'GET /api/catalog/units-of-measure?pageSize=10 → пустой items',
      fix: 'Phase5c должен auto-enable все active globals в org_units_of_measure при создании org.',
    })
    return
  }

  const groupsRes = await api.get('/api/catalog/product-groups?pageSize=200')
  let groupId = (groupsRes.data?.items ?? [])[0]?.id as string | undefined
  if (!groupId) {
    const cg = await api.post('/api/catalog/product-groups', {
      name: 'e2e Группа', parentId: null, sortOrder: 0,
    })
    if (cg.status >= 400) {
      report.bug({
        step: '08', severity: 'high',
        title: 'Не удалось создать ProductGroup для нового tenant',
        detail: `${cg.status} ${asString(cg.data).slice(0, 200)}`,
        fix: 'Bootstrap должен сидить дефолтную группу «Продукты питания» при создании org.',
      })
      return
    }
    groupId = cg.data?.id
    report.gap('Bootstrap новой org не создаёт дефолтную ProductGroup, поэтому ProductsController.Create падает с 400 «ProductGroupId required» пока юзер вручную не заведёт группу.')
  }
  ctx.productGroupId = groupId!

  const ptRes = await api.get('/api/catalog/price-types?pageSize=200')
  const retailPt = ((ptRes.data?.items ?? []) as { id: string; isRetail?: boolean; name: string }[])
    .find((p) => p.isRetail) ?? (ptRes.data?.items ?? [])[0]
  if (!retailPt) {
    report.bug({
      step: '08', severity: 'critical',
      title: 'Нет ни одного PriceType для нового tenant',
      detail: '',
    })
    return
  }
  ctx.retailPriceTypeId = retailPt.id

  const curRes = await api.get('/api/catalog/currencies?pageSize=50')
  const kzt = ((curRes.data?.items ?? []) as { id: string; code: string }[]).find((c) => c.code === 'KZT')
  if (!kzt) {
    report.bug({ step: '08', severity: 'high', title: 'Нет валюты KZT в справочнике', detail: '' })
    return
  }
  ctx.currencyId = kzt.id

  // 2) Создаём 3 product'а с валидным ProductInput.
  const created: { id: string; name: string; price: number }[] = []
  for (let i = 0; i < 3; i++) {
    const retailPrice = 100 + i * 50
    const cr = await api.post('/api/catalog/products', {
      name: `e2e Product ${i + 1} ${TIMESTAMP}`,
      article: `E2E-${TIMESTAMP}-${i + 1}`,
      description: null,
      unitOfMeasureId: unit.id,
      vat: 12,
      vatEnabled: true,
      productGroupId: ctx.productGroupId,
      defaultSupplierId: null,
      countryOfOriginId: null,
      isService: false,
      packaging: 0,
      isMarked: false,
      minStock: null, maxStock: null,
      referencePrice: retailPrice * 0.7,
      purchaseCurrencyId: ctx.currencyId,
      imageUrl: null,
      prices: [
        { priceTypeId: ctx.retailPriceTypeId, amount: retailPrice, currencyId: ctx.currencyId },
      ],
      barcodes: [
        { code: generateEan13(i + 1), type: 0, isPrimary: true },
      ],
    })
    if (cr.status >= 400) {
      report.bug({
        step: '08', severity: 'high',
        title: `Не удалось создать product №${i + 1}`,
        detail: `${cr.status} ${asString(cr.data).slice(0, 200)}`,
      })
      return
    }
    created.push({ id: cr.data.id ?? cr.data.productId, name: cr.data.name, price: retailPrice })
  }
  check(step, {
    kind: 'api', description: 'Создано 3 product (валидный barcode + price + group)',
    ok: created.length === 3, detail: created.map((p) => p.name).join(', '),
  })

  const lines = created.map((p, i) => ({
    productId: p.id, productName: p.name, quantity: 10 + i * 5, price: 70 + i * 30,
  }))
  ctx.supplyLines = lines

  // Stock snapshot до приёмки (должен быть 0 на новой орге).
  const stockBefore: Record<string, number> = {}
  for (const ln of lines) stockBefore[ln.productId] = await stockOf(api, ctx.storeId, ln.productId)
  ctx.stockBefore = stockBefore

  // 3) Bug-hunt: попытаться создать Supply без supplierId / без lines.
  const noSupplier = await api.post('/api/purchases/supplies', {
    date: new Date().toISOString(),
    storeId: ctx.storeId,
    currencyId: ctx.currencyId,
    notes: 'no supplier',
    lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price })),
  })
  check(step, {
    kind: 'api', description: 'Supply без supplierId → 400/409',
    ok: noSupplier.status >= 400 && noSupplier.status < 500,
    detail: `${noSupplier.status} ${asString(noSupplier.data).slice(0, 100)}`,
  })
  if (noSupplier.status === 200 || noSupplier.status === 201) {
    report.bug({
      step: '08', severity: 'high',
      title: 'Supply создаётся без supplierId',
      detail: 'POST /api/purchases/supplies без поля supplierId прошёл валидацию и вернул 200',
      fix: 'SupplyInput.SupplierId должен быть [Required] и проверяться в Create.',
    })
  } else if (noSupplier.status >= 500) {
    report.bug({
      step: '08', severity: 'medium',
      title: 'Supply без supplierId → 500 вместо 400',
      detail: `Сервер бросил исключение (${noSupplier.status}) на отсутствующее required-поле supplierId. UI получит generic error вместо понятного field-level сообщения.`,
      fix: 'SupplyInput.SupplierId — [Required] / model validation; ловить FK-violation перед SaveChanges и возвращать 400 с описанием.',
    })
  }

  const noLines = await api.post('/api/purchases/supplies', {
    date: new Date().toISOString(),
    supplierId: ctx.counterpartyId, storeId: ctx.storeId, currencyId: ctx.currencyId,
    notes: 'no lines', lines: [],
  })
  check(step, {
    kind: 'api', description: 'Supply с пустым lines[] → 400',
    ok: noLines.status >= 400 && noLines.status < 500,
    detail: `${noLines.status}`,
  })

  // 4) Создаём настоящий draft Supply.
  const draft = await api.post('/api/purchases/supplies', {
    date: new Date().toISOString(),
    supplierId: ctx.counterpartyId,
    storeId: ctx.storeId,
    currencyId: ctx.currencyId,
    notes: 'e2e draft',
    lines: lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.price })),
  })
  check(step, {
    kind: 'api', description: 'POST /api/purchases/supplies (Draft)',
    ok: draft.status === 200 || draft.status === 201,
    detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`,
  })
  if (draft.status >= 400) {
    report.bug({
      step: '08', severity: 'critical',
      title: 'Не удаётся создать Draft Supply',
      detail: asString(draft.data),
    })
    return
  }
  ctx.supplyId = draft.data?.id ?? draft.data?.supplyId
  if (!ctx.supplyId) {
    report.bug({
      step: '08', severity: 'high',
      title: 'POST /supplies не возвращает id созданного документа',
      detail: asString(draft.data).slice(0, 200),
    })
    return
  }

  const post = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`, {})
  check(step, {
    kind: 'api', description: 'POST /api/purchases/supplies/{id}/post (Draft → Posted)',
    ok: post.status === 200 || post.status === 204,
    detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
  })
  if (post.status >= 400) {
    report.bug({
      step: '08', severity: 'critical',
      title: 'Posted-переход Supply падает',
      detail: asString(post.data),
    })
    return
  }

  // 5) Bug-hunt: повторный post → 409 (idempotency contract).
  const dblPost = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`, {})
  check(step, {
    kind: 'api', description: 'Повторный post Supply → 409 (idempotency)',
    ok: dblPost.status === 409,
    detail: `${dblPost.status} ${asString(dblPost.data).slice(0, 100)}`,
  })
  if (dblPost.status !== 409) {
    report.bug({
      step: '08', severity: 'medium',
      title: 'Повторный post Supply возвращает не 409',
      detail: `Получили ${dblPost.status}; ожидаем 409 чтобы UI мог показать «Уже проведено».`,
    })
  }

  // 6) Bug-hunt: stock_movements должны содержать одну запись на каждую строку.
  const orgId = ctx.organization!.id
  const movsRows = psql(`SELECT count(*) FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.supplyId}'`)
    .trim()
  const movs = parseInt(movsRows, 10) || 0
  check(step, {
    kind: 'db', description: 'stock_movements содержат запись на каждую строку Supply',
    ok: movs === lines.length,
    detail: `count=${movs}, expected=${lines.length}`,
  })
  if (movs !== lines.length) {
    report.bug({
      step: '08', severity: 'high',
      title: 'stock_movements не записаны после Posted Supply',
      detail: `expected ${lines.length}, got ${movs}`,
      fix: 'Проверить SupplyController.Post: ApplyMovementAsync вызывается для каждой строки.',
    })
  }
}

// ---------------------------------------------------------------------------

export async function step09_check_stock_after_supply({ ctx, step, report }: StepCtx) {
  if (!ctx.adminToken || !ctx.supplyLines || !ctx.storeId) { step.status = 'skip'; return }
  const api = makeClient(ctx.adminToken)
  const after: Record<string, number> = {}
  for (const ln of ctx.supplyLines) {
    after[ln.productId] = await stockOf(api, ctx.storeId, ln.productId)
  }
  ctx.stockAfterSupply = after

  for (const ln of ctx.supplyLines) {
    const before = ctx.stockBefore![ln.productId] ?? 0
    const now = after[ln.productId] ?? 0
    const delta = now - before
    check(step, {
      kind: 'api',
      description: `stock(${ln.productName}) +${ln.quantity} (было ${before}, стало ${now})`,
      ok: delta === ln.quantity,
      detail: `delta=${delta}, expected=${ln.quantity}`,
    })
    if (delta !== ln.quantity) {
      report.bug({
        step: '09', severity: 'high',
        title: 'Остатки не увеличились после Posted Supply',
        detail: `product=${ln.productName} expected delta=${ln.quantity} actual=${delta}`,
        fix: 'Проверь что SupplyPostHandler / hostedService применяет stock_movements при /post',
      })
    }
  }

  // GET /stock без storeId — должен агрегировать или возвращать строку на склад.
  const aggRes = await api.get(`/api/inventory/stock?productId=${ctx.supplyLines[0].productId}&pageSize=200`)
  const aggItems = (aggRes.data?.items ?? []) as { storeId: string; quantity: number }[]
  check(step, {
    kind: 'api',
    description: 'GET /api/inventory/stock без storeId возвращает строки на каждый склад',
    ok: aggItems.length >= 1,
    detail: `rows=${aggItems.length}, stores=${aggItems.map((i) => i.storeId.slice(0, 8)).join(',')}`,
  })
  const hasTotal = 'totalQuantity' in (aggRes.data ?? {})
  if (!hasTotal && aggItems.length > 1) {
    report.gap('Stock endpoint не агрегирует totalQuantity по нескольким складам — UI должен суммировать сам. Если в орге будет 5+ складов, это будет неудобно для дашборда «общие остатки товара».')
  }

  // Согласованность Stocks.Quantity с суммой stock_movements.Quantity.
  const orgId = ctx.organization!.id
  for (const ln of ctx.supplyLines) {
    const sumStr = psql(`SELECT COALESCE(SUM("Quantity"), 0)::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "ProductId"='${ln.productId}' AND "StoreId"='${ctx.storeId}'`).trim()
    const sum = parseFloat(sumStr) || 0
    const stockNow = after[ln.productId] ?? 0
    check(step, {
      kind: 'db',
      description: `stocks.Quantity == SUM(stock_movements.Quantity) для ${ln.productName.slice(0, 30)}…`,
      ok: Math.abs(sum - stockNow) < 0.0001,
      detail: `sum_movements=${sum} stocks.Quantity=${stockNow}`,
    })
    if (Math.abs(sum - stockNow) >= 0.0001) {
      report.bug({
        step: '09', severity: 'high',
        title: 'Stocks.Quantity рассинхронизированы с SUM(stock_movements.Quantity)',
        detail: `product=${ln.productName} sum=${sum} stocks=${stockNow}`,
      })
    }
  }
}

// ---------------------------------------------------------------------------

export async function step10_ensure_retail_point({ ctx, step, report }: StepCtx) {
  if (!ctx.adminToken) { step.status = 'skip'; return }
  const api = makeClient(ctx.adminToken)
  const list = await api.get('/api/catalog/retail-points?pageSize=200')
  const items = (list.data?.items ?? []) as { id: string; name: string; storeId?: string }[]
  if (items.length === 0) {
    const create = await api.post('/api/catalog/retail-points', {
      name: 'Касса 1', code: 'C1', storeId: ctx.storeId, isActive: true,
    })
    check(step, {
      kind: 'api', description: 'Создана касса',
      ok: create.status === 200 || create.status === 201, detail: `${create.status}`,
    })
    ctx.retailPointId = create.data?.id
  } else {
    ctx.retailPointId = items[0].id
    check(step, { kind: 'api', description: 'RetailPoint существует', ok: true, detail: items[0].name })
  }
  if (!ctx.retailPointId) {
    report.bug({
      step: '10', severity: 'high',
      title: 'Не получилось гарантировать наличие розничной точки',
      detail: '',
    })
    return
  }

  // Bug-hunt: касса должна быть привязана к существующему складу.
  const fakeStore = '00000000-0000-0000-0000-000000000001'
  const badStore = await api.post('/api/catalog/retail-points', {
    name: `e2e-bad-${TIMESTAMP}`, code: `BS-${TIMESTAMP}`, storeId: fakeStore, isActive: true,
  })
  check(step, {
    kind: 'api', description: 'RetailPoint с несуществующим storeId → 400/404',
    ok: badStore.status >= 400 && badStore.status < 500,
    detail: `${badStore.status}`,
  })
  if (badStore.status === 200 || badStore.status === 201) {
    report.bug({
      step: '10', severity: 'medium',
      title: 'RetailPoint создаётся с несуществующим storeId',
      detail: 'POST /api/catalog/retail-points c storeId=00000000-0000-0000-0000-000000000001 прошёл валидацию.',
      fix: 'Добавить проверку EXISTS Store в RetailPointsController.Create.',
    })
  }
}

// ---------------------------------------------------------------------------

export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) {
  if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines || !ctx.storeId || !ctx.currencyId) {
    step.status = 'skip'; return
  }
  const api = makeClient(ctx.adminToken)
  // Берём 2 первых product, продаём по 2 шт каждого. Цена = supply.price*2 (наценка).
  const lines = ctx.supplyLines.slice(0, 2).map((l) => ({
    productId: l.productId,
    quantity: 2,
    unitPrice: l.price * 2,
    discount: 0,
    vatPercent: 12,
  }))
  ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice }))

  // Bug-hunt: пустой чек → 400 на /post (после Draft); тут проверяем create с пустыми lines.
  const empty = await api.post('/api/sales/retail', {
    date: new Date().toISOString(),
    storeId: ctx.storeId,
    retailPointId: ctx.retailPointId,
    customerId: null,
    currencyId: ctx.currencyId,
    payment: 0, paidCash: 0, paidCard: 0,
    notes: 'empty', lines: [],
  })
  if (empty.status === 200 || empty.status === 201) {
    report.gap('RetailSale Draft создаётся даже с пустым lines[]; ошибка появится только на /post — UX мог бы блокировать раньше.')
  }

  // Bug-hunt: продажа qty больше остатка должна давать предупреждение или ошибку.
  const overSell = await api.post('/api/sales/retail', {
    date: new Date().toISOString(),
    storeId: ctx.storeId,
    retailPointId: ctx.retailPointId,
    customerId: null,
    currencyId: ctx.currencyId,
    payment: 0, paidCash: 999999, paidCard: 0,
    notes: 'over-sell',
    lines: [{ productId: ctx.supplyLines[0].productId, quantity: 99999, unitPrice: 100, discount: 0, vatPercent: 0 }],
  })
  let overSellId: string | undefined
  if (overSell.status === 200 || overSell.status === 201) {
    overSellId = overSell.data?.id ?? overSell.data?.saleId
    const overPost = await api.post(`/api/sales/retail/${overSellId}/post`, {})
    check(step, {
      kind: 'api', description: 'Продажа qty>остатка → /post должен 4xx',
      ok: overPost.status >= 400 && overPost.status < 500,
      detail: `${overPost.status}`,
    })
    if (overPost.status === 200 || overPost.status === 204) {
      report.bug({
        step: '11', severity: 'high',
        title: 'Розничная продажа провёл количество больше остатка',
        detail: `qty=99999 при остатке 10-15 — /post вернул ${overPost.status} вместо 409/400.`,
        fix: 'RetailSalesController.Post должен валидировать sum(line.qty) ≤ stocks.Available.',
      })
      // Откатим, чтобы не сбить остатки для step12.
      await api.post(`/api/sales/retail/${overSellId}/unpost`, {})
    }
    // Удалим draft чтобы не мусорил.
    await api.delete(`/api/sales/retail/${overSellId}`)
  } else {
    check(step, {
      kind: 'api', description: 'POST /api/sales/retail с qty>>остатка отклонено',
      ok: overSell.status >= 400 && overSell.status < 500,
      detail: `${overSell.status}`,
    })
  }

  // Bug-hunt: отрицательная цена/количество должны быть отклонены.
  const negative = await api.post('/api/sales/retail', {
    date: new Date().toISOString(),
    storeId: ctx.storeId,
    retailPointId: ctx.retailPointId,
    customerId: null,
    currencyId: ctx.currencyId,
    payment: 0, paidCash: 0, paidCard: 0,
    notes: 'negative',
    lines: [{ productId: ctx.supplyLines[0].productId, quantity: -1, unitPrice: -100, discount: 0, vatPercent: 0 }],
  })
  check(step, {
    kind: 'api', description: 'Продажа с отрицательным qty/price → 400',
    ok: negative.status === 400,
    detail: `${negative.status}`,
  })
  if (negative.status === 200 || negative.status === 201) {
    report.bug({
      step: '11', severity: 'high',
      title: 'RetailSale принимает отрицательные quantity / unitPrice',
      detail: 'POST /api/sales/retail с qty=-1, unitPrice=-100 → 200. [Range(0,..)] не валидирует отрицательное?',
      fix: 'Проверить RetailSaleLineInput атрибуты Range и [ApiController] валидацию.',
    })
  }

  // Bug-hunt: продажа с discount на позиции (lineTotal должен учитывать).
  const withDiscount = await api.post('/api/sales/retail', {
    date: new Date().toISOString(),
    storeId: ctx.storeId,
    retailPointId: ctx.retailPointId,
    customerId: null,
    currencyId: ctx.currencyId,
    payment: 0, paidCash: 90, paidCard: 0,
    notes: 'discount-test',
    lines: [{ productId: ctx.supplyLines[0].productId, quantity: 1, unitPrice: 100, discount: 10, vatPercent: 0 }],
  })
  if (withDiscount.status === 200 || withDiscount.status === 201) {
    const lt = withDiscount.data?.lines?.[0]?.lineTotal as number | undefined
    check(step, {
      kind: 'api', description: 'discount=10 на line(price=100,qty=1) → lineTotal=90',
      ok: lt === 90,
      detail: `lineTotal=${lt}`,
    })
    if (lt !== 90) {
      report.bug({
        step: '11', severity: 'medium',
        title: 'Discount на позиции не применяется к lineTotal',
        detail: `Передали unitPrice=100, qty=1, discount=10; ожидаем lineTotal=90 — получили ${lt}.`,
      })
    }
    if (withDiscount.data?.id) await api.delete(`/api/sales/retail/${withDiscount.data.id}`)
  }

  // Реальная продажа (positive).
  const draft = await api.post('/api/sales/retail', {
    date: new Date().toISOString(),
    storeId: ctx.storeId,
    retailPointId: ctx.retailPointId,
    customerId: null,
    currencyId: ctx.currencyId,
    payment: 0,
    paidCash: lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0),
    paidCard: 0,
    notes: 'e2e sale',
    lines,
  })
  check(step, {
    kind: 'api', description: 'POST /api/sales/retail (Draft)',
    ok: draft.status === 200 || draft.status === 201,
    detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`,
  })
  if (draft.status >= 400) {
    report.bug({
      step: '11', severity: 'critical',
      title: 'Не удаётся создать Draft RetailSale',
      detail: asString(draft.data),
    })
    return
  }
  ctx.retailSaleId = draft.data?.id ?? draft.data?.saleId
  if (!ctx.retailSaleId) {
    report.bug({
      step: '11', severity: 'high',
      title: 'POST /retail не возвращает id',
      detail: asString(draft.data).slice(0, 200),
    })
    return
  }

  const post = await api.post(`/api/sales/retail/${ctx.retailSaleId}/post`, {})
  check(step, {
    kind: 'api', description: 'POST /retail/{id}/post',
    ok: post.status === 200 || post.status === 204,
    detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
  })

  // Bug-hunt: повторный post проведённого → 409.
  const dblPost = await api.post(`/api/sales/retail/${ctx.retailSaleId}/post`, {})
  check(step, {
    kind: 'api', description: 'Повторный post RetailSale → 409',
    ok: dblPost.status === 409,
    detail: `${dblPost.status}`,
  })
}

// ---------------------------------------------------------------------------

export async function step12_check_stock_after_sale({ ctx, step, report }: StepCtx) {
  if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply || !ctx.retailSaleId) {
    step.status = 'skip'; return
  }
  const api = makeClient(ctx.adminToken)
  const orgId = ctx.organization!.id

  for (const ln of ctx.saleLines) {
    const before = ctx.stockAfterSupply[ln.productId] ?? 0
    const now = await stockOf(api, ctx.storeId, ln.productId)
    const delta = before - now
    check(step, {
      kind: 'api',
      description: `stock product=${ln.productId.slice(0, 8)}… −${ln.quantity} (было ${before}, стало ${now})`,
      ok: delta === ln.quantity,
      detail: `delta=${delta}, expected=${ln.quantity}`,
    })
    if (delta !== ln.quantity) {
      report.bug({
        step: '12', severity: 'high',
        title: 'Остатки не уменьшились после Posted RetailSale',
        detail: `expected delta=${ln.quantity} actual=${delta}`,
      })
    }
  }

  // stock_movements: записи на каждую sale-строку с типом RetailSale и qty<0.
  for (const ln of ctx.saleLines) {
    const row = psql(
      `SELECT count(*), COALESCE(SUM("Quantity"),0)::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.retailSaleId}' AND "ProductId"='${ln.productId}'`
    ).trim().split('|')
    const cnt = parseInt(row[0], 10) || 0
    const sumQty = parseFloat(row[1]) || 0
    check(step, {
      kind: 'db',
      description: `stock_movements запись на sale-line ${ln.productId.slice(0, 8)}…`,
      ok: cnt === 1 && Math.abs(sumQty + ln.quantity) < 0.0001,
      detail: `count=${cnt}, sum=${sumQty} (expected sum=${-ln.quantity})`,
    })
    if (cnt !== 1 || Math.abs(sumQty + ln.quantity) >= 0.0001) {
      report.bug({
        step: '12', severity: 'high',
        title: 'stock_movements не записаны после Posted RetailSale',
        detail: `productId=${ln.productId} count=${cnt} sumQty=${sumQty} (ожидаем 1 запись с qty=${-ln.quantity})`,
      })
    }
  }

  // Тип движения должен быть RetailSale (enum=2 в Domain.Inventory.MovementType).
  const typesRaw = psql(
    `SELECT DISTINCT "Type"::text FROM stock_movements WHERE "OrganizationId"='${orgId}' AND "DocumentId"='${ctx.retailSaleId}'`
  ).trim()
  check(step, {
    kind: 'db', description: 'stock_movements.Type = RetailSale (2) для sale документа',
    ok: typesRaw === '2' || typesRaw.toLowerCase() === 'retailsale',
    detail: `types=${typesRaw}`,
  })
}

// ---------------------------------------------------------------------------

async function stockOf(api: ReturnType<typeof makeClient>, storeId: string, productId: string): Promise<number> {
  const res = await api.get(`/api/inventory/stock?productId=${productId}&pageSize=200`)
  if (res.status !== 200) return 0
  const items = (res.data?.items ?? []) as { storeId?: string; quantity: number; productId: string }[]
  // Fallback: если нет фильтра по storeId на endpoint'е, фильтруем сами.
  const row = items.find((i) => i.storeId === storeId && i.productId === productId)
  return row?.quantity ?? 0
}
