import { describe, it, expect } from 'vitest'
import { generate } from '../generate.js'
import type { ScaffoldControllerInput, ControllerCustomAction } from '../types.js'

function fixture(overrides: Partial<ScaffoldControllerInput> = {}): ScaffoldControllerInput {
  return {
    name: 'Budget',
    pluralName: 'Budgets',
    module: 'budgets',
    section: 'budgets',
    appCode: 'TestV2',
    namespace: 'TestV2',
    navRoute: 'budgets.budgets',
    permissionPrefix: 'budgets.budgets',
    actions: ['read', 'create', 'update', 'delete'],
    customActions: [],
    fields: [
      { name: 'code', type: 'string', required: true },
      { name: 'label', type: 'string', required: true },
    ],
    projectPath: '/tmp/project',
    ...overrides,
  }
}

describe('scaffold-controller / generate — integration strata (Swagger + route prefix)', () => {
  it('tags the controller with [ApiExplorerSettings(GroupName = "integration")]', () => {
    const files = generate(fixture())
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[ApiExplorerSettings\(GroupName\s*=\s*"integration"\)\]/)
  })

  it('routes the controller under api/v1/integration/{plural-kebab} (NOT /api/[controller])', () => {
    const files = generate(fixture())
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    // Wave 1 of the URL-convention alignment (2026-05-27): the explicit kebab
    // plural replaces ASP.NET's [controller] token so the route MATCHES the
    // frontend api-client's path one-to-one (kebab-case on both sides — was
    // diverging on multi-word entities). buildIntegrationRoute('budgets') →
    // '/api/v1/integration/budgets' → '[Route("api/v1/integration/budgets")]'.
    expect(ctrl.content).toMatch(/\[Route\("api\/v1\/integration\/budgets"\)\]/)
    // Belt-and-suspenders: the legacy [controller] token must be gone.
    expect(ctrl.content).not.toMatch(/\[Route\("api\/\[controller\]"\)\]/)
    expect(ctrl.content).not.toMatch(/\[controller\]/)
  })

  it('uses kebab-case plural for multi-word entities (OrderLines → order-lines)', () => {
    // Regression guard for the historical multi-word divergence: the backend
    // controller and the frontend api-client now both derive the URL segment
    // via `pluralSegment` from lib/url-conventions.ts, so they always agree.
    const files = generate(fixture({ name: 'OrderLine', pluralName: 'OrderLines' }))
    const ctrl = files.find((f) => f.path.endsWith('OrderLinesController.cs'))!
    expect(ctrl.content).toMatch(/\[Route\("api\/v1\/integration\/order-lines"\)\]/)
  })

  it('explains in a doc comment that the screen-driven API lives elsewhere', () => {
    const files = generate(fixture())
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/scaffold-screen-controller/)
    expect(ctrl.content).toMatch(/machine-to-machine/)
  })
})

describe('scaffold-controller / generate — standard CRUD', () => {
  it('emits GET /api/budgets and GET /api/budgets/:id always', () => {
    const files = generate(fixture())
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpGet\]\s+\[RequirePermission\(BudgetsPermissions\.Budgets\.Read\)\]/)
    expect(ctrl.content).toMatch(/\[HttpGet\("\{id:guid\}"\)\]/)
  })

  it('emits POST/PUT/DELETE conditionally on actions[]', () => {
    const files = generate(fixture({ actions: ['read', 'create'] }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpPost\]/)
    expect(ctrl.content).not.toMatch(/\[HttpPut\("\{id:guid\}"\)\]/)
    expect(ctrl.content).not.toMatch(/\[HttpDelete\("\{id:guid\}"\)\]/)
  })

  it('emits the permissions class with one constant per action', () => {
    const files = generate(fixture())
    const perm = files.find((f) => f.path.includes('Permissions'))!
    expect(perm.content).toMatch(/public const string Read\s*=\s*"budgets\.budgets\.read"/)
    expect(perm.content).toMatch(/public const string Create\s*=\s*"budgets\.budgets\.create"/)
    expect(perm.content).toMatch(/public const string Update\s*=\s*"budgets\.budgets\.update"/)
    expect(perm.content).toMatch(/public const string Delete\s*=\s*"budgets\.budgets\.delete"/)
  })
})

describe('scaffold-controller / generate — custom actions (per-page mode)', () => {
  function archive(): ControllerCustomAction {
    return {
      code: 'archive',
      scope: 'row',
      payloadDto: null,
      responseDto: 'NoContent',
      permissionAction: 'update',
    }
  }

  function duplicate(): ControllerCustomAction {
    return {
      code: 'duplicate',
      scope: 'row',
      payloadDto: null,
      responseDto: 'BudgetDto',
      permissionAction: 'create',
    }
  }

  it('emits a row-scope custom action as POST /{id:guid}/{code}', () => {
    const files = generate(fixture({ customActions: [archive()] }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpPost\("\{id:guid\}\/archive"\)\]/)
    // Method binds id parameter and calls _service.ArchiveAsync(id, ct)
    expect(ctrl.content).toMatch(/public async Task<ActionResult> Archive\(Guid id, CancellationToken/)
    expect(ctrl.content).toMatch(/await _service\.ArchiveAsync\(id, ct\)/)
    expect(ctrl.content).toMatch(/return NoContent\(\)/)
  })

  it('binds custom action permission to the {permissionAction} segment of the perm prefix', () => {
    const files = generate(fixture({ customActions: [archive()] }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    // archive uses permissionAction='update' → BudgetsPermissions.Budgets.Update
    expect(ctrl.content).toMatch(/\[RequirePermission\(BudgetsPermissions\.Budgets\.Update\)\]/)
  })

  it('emits ActionResult<T> when responseDto is set (not NoContent)', () => {
    const files = generate(fixture({ customActions: [duplicate()] }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/public async Task<ActionResult<BudgetDto>> Duplicate\(/)
    expect(ctrl.content).toMatch(/var result = await _service\.DuplicateAsync\(id, ct\)/)
    expect(ctrl.content).toMatch(/return Ok\(result\)/)
  })

  it('emits a bulk-scope custom action as POST /bulk/{code} (no id)', () => {
    const files = generate(fixture({
      customActions: [{
        code: 'archive',
        scope: 'bulk',
        payloadDto: 'BulkArchiveRequest',
        responseDto: 'NoContent',
        permissionAction: 'update',
      }],
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpPost\("bulk\/archive"\)\]/)
    expect(ctrl.content).toMatch(/public async Task<ActionResult> Archive\(\[FromBody\] BulkArchiveRequest dto, CancellationToken/)
    // No `Guid id` parameter on bulk methods
    expect(ctrl.content).not.toMatch(/Archive\(Guid id, \[FromBody\] BulkArchiveRequest/)
    expect(ctrl.content).toMatch(/await _service\.ArchiveAsync\(dto, ct\)/)
  })

  it('emits a header-scope custom action as POST /{code} (no id, optional payload)', () => {
    const files = generate(fixture({
      customActions: [{
        code: 'export',
        scope: 'header',
        payloadDto: null,
        responseDto: 'FileContentResult',
        permissionAction: 'read',
      }],
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpPost\("export"\)\]/)
    expect(ctrl.content).toMatch(/public async Task<ActionResult<FileContentResult>> Export\(CancellationToken/)
    expect(ctrl.content).toMatch(/\[RequirePermission\(BudgetsPermissions\.Budgets\.Read\)\]/)
  })

  it('pascalizes kebab-case codes for the C# method name', () => {
    const files = generate(fixture({
      customActions: [{
        code: 'submit-for-review',
        scope: 'row',
        payloadDto: null,
        responseDto: 'NoContent',
        permissionAction: 'update',
      }],
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/public async Task<ActionResult> SubmitForReview\(Guid id, /)
    expect(ctrl.content).toMatch(/await _service\.SubmitForReviewAsync\(id, ct\)/)
    // URL keeps the kebab-case
    expect(ctrl.content).toMatch(/\[HttpPost\("\{id:guid\}\/submit-for-review"\)\]/)
  })

  it('coexists peacefully with standard CRUD actions in one controller', () => {
    const files = generate(fixture({
      actions: ['read', 'create', 'update', 'delete'],
      customActions: [{
        code: 'archive',
        scope: 'row',
        payloadDto: null,
        responseDto: 'NoContent',
        permissionAction: 'update',
      }],
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    // Standard CRUD methods present
    expect(ctrl.content).toMatch(/public async Task<ActionResult<Guid>> Create/)
    expect(ctrl.content).toMatch(/public async Task<ActionResult> Update/)
    expect(ctrl.content).toMatch(/public async Task<ActionResult> Delete/)
    // Custom action also present
    expect(ctrl.content).toMatch(/public async Task<ActionResult> Archive/)
  })

  it('emits NO custom action methods when customActions is empty (legacy default)', () => {
    const files = generate(fixture({ customActions: [] }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).not.toMatch(/Archive\(/)
    expect(ctrl.content).not.toMatch(/Duplicate\(/)
  })
})

describe('scaffold-controller / generate — custom actions httpMethod', () => {
  it('emits [HttpGet] for a header-scope GET action with no body', () => {
    const files = generate(fixture({
      customActions: [{
        code: 'analyze-impact',
        scope: 'header',
        httpMethod: 'GET',
        payloadDto: null,
        responseDto: 'ImpactReportDto',
        permissionAction: 'read',
      }],
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpGet\("analyze-impact"\)\]/)
    expect(ctrl.content).toMatch(/public async Task<ActionResult<ImpactReportDto>> AnalyzeImpact\(CancellationToken/)
    // GET must NOT carry [FromBody], even if some caller pre-fills payloadDto
    expect(ctrl.content).not.toMatch(/AnalyzeImpact\([^)]*FromBody/)
  })

  it('emits [HttpGet] for a row-scope GET action with Guid id and no body', () => {
    const files = generate(fixture({
      customActions: [{
        code: 'impact',
        scope: 'row',
        httpMethod: 'GET',
        payloadDto: null,
        responseDto: 'ImpactReportDto',
        permissionAction: 'read',
      }],
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpGet\("\{id:guid\}\/impact"\)\]/)
    expect(ctrl.content).toMatch(/public async Task<ActionResult<ImpactReportDto>> Impact\(Guid id, CancellationToken/)
    expect(ctrl.content).toMatch(/await _service\.ImpactAsync\(id, ct\)/)
  })

  it('emits [HttpPatch] for a row-scope PATCH action', () => {
    const files = generate(fixture({
      customActions: [{
        code: 'rename',
        scope: 'row',
        httpMethod: 'PATCH',
        payloadDto: 'RenameRequest',
        responseDto: 'NoContent',
        permissionAction: 'update',
      }],
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpPatch\("\{id:guid\}\/rename"\)\]/)
    expect(ctrl.content).toMatch(/Rename\(Guid id, \[FromBody\] RenameRequest dto, CancellationToken/)
  })

  it('emits [HttpDelete] for a header-scope DELETE action', () => {
    const files = generate(fixture({
      customActions: [{
        code: 'purge',
        scope: 'header',
        httpMethod: 'DELETE',
        payloadDto: null,
        responseDto: 'NoContent',
        permissionAction: 'delete',
      }],
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpDelete\("purge"\)\]/)
    expect(ctrl.content).toMatch(/\[RequirePermission\(BudgetsPermissions\.Budgets\.Delete\)\]/)
  })

  it('preserves POST when httpMethod is omitted (legacy default)', () => {
    const files = generate(fixture({
      customActions: [{
        code: 'archive',
        scope: 'row',
        payloadDto: null,
        responseDto: 'NoContent',
        permissionAction: 'update',
      } as never], // httpMethod omitted on purpose
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpPost\("\{id:guid\}\/archive"\)\]/)
  })

  it('emits a non-default endpoint (code != defaultEndpoint)', () => {
    // BA code is `syncFromPce` but the legacy backend route is
    // `sync-from-proconcept`. ba-develop passes the endpoint verbatim as
    // `code` to scaffold-controller (the URL segment is the single source of
    // truth). The method name is PascalCased from that — matches the service.
    const files = generate(fixture({
      customActions: [{
        code: 'sync-from-proconcept',
        scope: 'header',
        httpMethod: 'POST',
        payloadDto: null,
        responseDto: 'SyncResultDto',
        permissionAction: 'execute',
      }],
    }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpPost\("sync-from-proconcept"\)\]/)
    expect(ctrl.content).toMatch(/public async Task<ActionResult<SyncResultDto>> SyncFromProconcept\(CancellationToken/)
    expect(ctrl.content).toMatch(/await _service\.SyncFromProconceptAsync\(ct\)/)
  })
})

describe('scaffold-controller / generate — lookup endpoint (feeds <EntityLookup>)', () => {
  it('emits GET /lookup with the Read permission and the right shape', () => {
    const files = generate(fixture())
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpGet\("lookup"\)\]/)
    expect(ctrl.content).toMatch(/\[RequirePermission\(BudgetsPermissions\.Budgets\.Read\)\]/)
    expect(ctrl.content).toMatch(/public async Task<ActionResult<PaginatedResult<BudgetRefDto>>> GetLookup/)
    expect(ctrl.content).toMatch(/await _service\.GetLookupAsync\(new GetBudgetsLookupQuery\(search, page, pageSize\), ct\)/)
  })

  it('always emits the lookup endpoint, even when create/update/delete are disabled', () => {
    const files = generate(fixture({ actions: ['read'] }))
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    expect(ctrl.content).toMatch(/\[HttpGet\("lookup"\)\]/)
    expect(ctrl.content).not.toMatch(/\[HttpPost\]/)
  })

  it('places the lookup endpoint before the Create POST in the file (ASP.NET route ordering)', () => {
    const files = generate(fixture())
    const ctrl = files.find((f) => f.path.endsWith('BudgetsController.cs'))!
    const lookupIdx = ctrl.content.indexOf('[HttpGet("lookup")]')
    const createIdx = ctrl.content.indexOf('[HttpPost]')
    expect(lookupIdx).toBeGreaterThan(0)
    expect(createIdx).toBeGreaterThan(0)
    expect(lookupIdx).toBeLessThan(createIdx)
  })
})
