/**
 * US-15 — redteam explores and selects techniques via the MITRE ATT&CK matrix modal.
 * Covers AC-15.1 → AC-15.5.
 */
import { test, expect } from '@playwright/test';
import {
  adminToken,
  createEngagement,
  deleteEngagement,
  deleteUserByUsername,
  ensureUser,
  login,
  makeClient,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';

const REDTEAM_USER = 'us15-redteam';
const PASS = 'us15-pass-strong';

interface Simulation {
  id: number;
  [key: string]: unknown;
}

async function createSimulation(
  token: string,
  engagementId: number,
  name = 'US-15 sim',
): Promise<Simulation> {
  const client = makeClient(token);
  const r = await client.post(`/engagements/${engagementId}/simulations`, { name });
  if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`);
  return r.data as Simulation;
}

async function deleteSimulation(token: string, simId: number): Promise<void> {
  await makeClient(token).delete(`/simulations/${simId}`);
}

test.describe('US-15 — MITRE matrix modal', () => {
  let redteamToken: string;
  let engagementId: number;

  test.beforeAll(async () => {
    await ensureUser(REDTEAM_USER, PASS, 'redteam');
    redteamToken = (await login(REDTEAM_USER, PASS)).token;
    const eng = await createEngagement(redteamToken, {
      name: 'US-15 Engagement',
      start_date: '2026-01-01',
    });
    engagementId = eng.id;
  });

  test.afterAll(async () => {
    try {
      const tok = await adminToken();
      await deleteEngagement(tok, engagementId);
      await deleteUserByUsername(tok, REDTEAM_USER);
    } catch {
      /* noop */
    }
  });

  test('AC-15.1 — GET /api/mitre/matrix returns tactic tree with correct structure', async () => {
    const client = makeClient(redteamToken);
    const r = await client.get('/mitre/matrix');
    expect(r.status).toBe(200);
    expect(Array.isArray(r.data)).toBe(true);

    // At least the 12 canonical MITRE Enterprise tactics
    expect(r.data.length).toBeGreaterThanOrEqual(12);

    const first = r.data[0];
    expect(first).toHaveProperty('tactic_id');
    expect(first).toHaveProperty('tactic_name');
    expect(Array.isArray(first.techniques)).toBe(true);

    // First tactic must be "Initial Access" (canonical order)
    expect(first.tactic_name).toBe('Initial Access');

    // Each technique has id, name, subtechniques array
    const tech = first.techniques[0];
    expect(tech).toHaveProperty('id');
    expect(tech).toHaveProperty('name');
    expect(Array.isArray(tech.subtechniques)).toBe(true);

    // A technique with known sub-techniques: T1059 is in Execution
    const execTactic = (r.data as { tactic_name: string; techniques: { id: string; subtechniques: { id: string; name: string }[] }[] }[]).find(
      (t) => t.tactic_name === 'Execution',
    );
    expect(execTactic).toBeTruthy();
    const t1059 = execTactic!.techniques.find((t) => t.id === 'T1059');
    expect(t1059).toBeTruthy();
    expect(t1059!.subtechniques.length).toBeGreaterThan(0);
    // T1059.001 should be a known sub-technique
    const sub = t1059!.subtechniques.find((s) => s.id === 'T1059.001');
    expect(sub).toBeTruthy();
    expect(sub!.name).toBeTruthy();
  });

  test('AC-15.1 — tactic canonical order is correct (Initial Access first, Impact last)', async () => {
    const client = makeClient(redteamToken);
    const r = await client.get('/mitre/matrix');
    expect(r.status).toBe(200);

    const tacticNames = (r.data as { tactic_name: string }[]).map((t) => t.tactic_name);
    expect(tacticNames[0]).toBe('Initial Access');
    expect(tacticNames[tacticNames.length - 1]).toBe('Impact');

    // Verify Exfiltration appears before Impact
    const exfilIdx = tacticNames.indexOf('Exfiltration');
    const impactIdx = tacticNames.indexOf('Impact');
    expect(exfilIdx).toBeGreaterThan(-1);
    expect(exfilIdx).toBeLessThan(impactIdx);
  });

  test('AC-15.2 — modal layout: columns per tactic, tactic header, technique cells', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 layout');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    // Open the matrix modal via "Add technique"
    await page.getByLabel(/open mitre matrix/i).click();

    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // Modal title
    await expect(dialog.getByRole('heading', { name: /mitre att&?ck matrix/i })).toBeVisible();

    // Search / filter input present and focused
    const searchInput = dialog.getByLabel(/filter techniques/i);
    await expect(searchInput).toBeVisible();

    // At least one tactic column visible — check for "Initial Access" and "Execution"
    await expect(dialog).toContainText('Initial Access');
    await expect(dialog).toContainText('Execution');

    // T1059 technique cell visible in Execution column
    await expect(dialog).toContainText('T1059');

    // Cancel button present
    await expect(dialog.getByRole('button', { name: /^cancel$/i })).toBeVisible();

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.2 — selecting technique updates Apply button counter', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 select');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // Use search to isolate T1059 so there's only the label button visible
    // The chevron has aria-label "Expand T1059"; we use filter to get the label button
    const searchInput = dialog.getByLabel(/filter techniques/i);
    await searchInput.fill('T1059');
    // Wait for filter to apply — only T1059 and its subtechniques should be visible
    await expect(dialog).toContainText('Command and Scripting Interpreter');

    // The label button (selection) is the one containing the technique name text
    // Filter explicitly excludes the chevron (aria-label="Expand T1059")
    const techLabelBtn = dialog
      .getByRole('button', { name: /command and scripting interpreter/i })
      .first();
    await techLabelBtn.click();

    // Apply button should now show count = 1
    await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();

    // Click again to deselect
    await techLabelBtn.click();

    // When 0 selected and no initial selection: footer shows disabled "Clear all"
    await expect(dialog.getByRole('button', { name: /clear all/i })).toBeVisible();
    await expect(dialog.getByRole('button', { name: /apply \d+ item/i })).not.toBeVisible();

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.2 — subtechnique expand/collapse via chevron', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 expand');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // Expand T1059 via chevron button (▸ Expand T1059)
    const expandBtn = dialog.getByRole('button', { name: /expand T1059/i });
    await expect(expandBtn).toBeVisible();
    await expandBtn.click();

    // Sub-technique T1059.001 should now be visible
    await expect(dialog).toContainText('T1059.001');

    // Collapse it
    const collapseBtn = dialog.getByRole('button', { name: /collapse T1059/i });
    await collapseBtn.click();
    await expect(dialog).not.toContainText('T1059.001');

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.2 — search filters techniques, auto-expands parent when sub matches', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 search');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    const searchInput = dialog.getByLabel(/filter techniques/i);

    // Search by sub-technique ID — parent should auto-expand
    await searchInput.fill('T1059.001');
    await expect(dialog).toContainText('T1059.001');

    // Search by name (case-insensitive)
    await searchInput.fill('powershell');
    await expect(dialog).toContainText('PowerShell');

    // Clear search — techniques come back
    await searchInput.fill('');
    await expect(dialog).toContainText('T1059');

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.2 — tactic header shows selected count when techniques selected', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 counter');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // Initially no "selected" counter visible
    // Sprint 4: tactic header shows truncated "N sel." due to tight column width
    await expect(dialog).not.toContainText('1 sel');

    // Use search to isolate T1059 so we can click the label button, not the chevron
    const searchInput = dialog.getByLabel(/filter techniques/i);
    await searchInput.fill('T1059');
    await expect(dialog).toContainText('Command and Scripting Interpreter');

    // The label button contains the technique name; the chevron has aria-label="Expand T1059"
    await dialog
      .getByRole('button', { name: /command and scripting interpreter/i })
      .first()
      .click();

    // Sprint 4: tactic header shows "1 sel." (truncated) due to tight column width
    await expect(dialog).toContainText('1 sel');

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.3 — Apply auto-saves techniques and closes modal', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 apply');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    const searchInput = dialog.getByLabel(/filter techniques/i);

    // Select T1059 via label button (not chevron) — filter to isolate
    await searchInput.fill('T1059');
    await expect(dialog).toContainText('Command and Scripting Interpreter');
    await dialog
      .getByRole('button', { name: /command and scripting interpreter/i })
      .first()
      .click();

    // Select T1566 (Phishing) — no subtechniques, so only one button
    await searchInput.fill('T1566');
    await expect(dialog).toContainText('T1566');
    await dialog.getByRole('button', { name: /phishing/i }).first().click();

    // Apply (2 techniques selected)
    const applyBtn = dialog.getByRole('button', { name: /apply \d+ item/i });
    await expect(applyBtn).toBeVisible();
    await applyBtn.click();

    // Modal closes
    await expect(dialog).not.toBeVisible({ timeout: 5_000 });

    // Auto-save toast appears
    await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });

    // Tags appear in the tag list
    const tagList = page.getByTestId('techniques-tag-list');
    await expect(tagList).toContainText('T1059');
    await expect(tagList).toContainText('T1566');

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.3 — modal receives current selection as initial state', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 initial');

    // Seed T1059 via API before opening the UI
    await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
      technique_ids: ['T1059'],
    });

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // Apply button should already show 1 technique (from initial selection)
    await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();

    // Cancel to discard
    await dialog.getByRole('button', { name: /^cancel$/i }).click();
    await expect(dialog).not.toBeVisible();

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.3 — Cancel discards local changes (no PATCH fired)', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 cancel');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // Select a technique via label button (filter to avoid hitting chevron)
    const searchInput = dialog.getByLabel(/filter techniques/i);
    await searchInput.fill('T1059');
    await expect(dialog).toContainText('Command and Scripting Interpreter');
    await dialog
      .getByRole('button', { name: /command and scripting interpreter/i })
      .first()
      .click();
    await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();

    // Cancel instead of Apply
    await dialog.getByRole('button', { name: /^cancel$/i }).click();
    await expect(dialog).not.toBeVisible();

    // No toast, no PATCH fired — empty state message still visible (0 techniques)
    await expect(page.getByText(/techniques updated/i)).not.toBeVisible();
    await expect(page.getByText(/no techniques selected/i)).toBeVisible();

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.4 — Escape key closes modal (Cancel behaviour)', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 escape');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // Select something to confirm Cancel semantics on Escape
    const searchInput = dialog.getByLabel(/filter techniques/i);
    await searchInput.fill('T1059');
    await expect(dialog).toContainText('Command and Scripting Interpreter');
    await dialog
      .getByRole('button', { name: /command and scripting interpreter/i })
      .first()
      .click();
    await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();

    await page.keyboard.press('Escape');
    await expect(dialog).not.toBeVisible({ timeout: 3_000 });

    // No PATCH fired — empty state message still visible (no techniques added)
    await expect(page.getByText(/no techniques selected/i)).toBeVisible();

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.4 — backdrop click closes modal (Cancel behaviour)', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 backdrop');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // Click outside the modal container (top-left corner of viewport, which is the backdrop)
    await page.mouse.click(5, 5);
    await expect(dialog).not.toBeVisible({ timeout: 3_000 });

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.5 — a11y: role=dialog + aria-labelledby, search input focused on open', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 a11y');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // role="dialog" is set (getByRole('dialog') already asserts this)
    // aria-modal attribute
    const ariaModal = await dialog.getAttribute('aria-modal');
    expect(ariaModal).toBe('true');

    // aria-labelledby points to the modal title
    const labelledBy = await dialog.getAttribute('aria-labelledby');
    expect(labelledBy).toBeTruthy();
    const titleEl = page.locator(`#${labelledBy}`);
    await expect(titleEl).toContainText(/mitre att&?ck matrix/i);

    // Search input is focused immediately after open
    const searchInput = dialog.getByLabel(/filter techniques/i);
    await expect(searchInput).toBeFocused({ timeout: 2_000 });

    await deleteSimulation(redteamToken, sim.id);
  });

  test('AC-15.5 — a11y: Tab wraps within modal (focus trap)', async ({
    page,
    context,
  }) => {
    const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 focus-trap');

    await seedTokenInStorage(context, redteamToken);
    await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);

    await page.getByLabel(/open mitre matrix/i).click();
    const dialog = page.getByRole('dialog');
    await expect(dialog).toBeVisible({ timeout: 10_000 });

    // Tab through enough elements to hit the wrap point
    // (we don't know exact count, but Shift+Tab from the first focused element
    //  should stay inside the modal — not land outside)
    const searchInput = dialog.getByLabel(/filter techniques/i);
    await expect(searchInput).toBeFocused({ timeout: 2_000 });

    // Shift+Tab from the first element (search) should wrap to Cancel or Apply
    await page.keyboard.press('Shift+Tab');
    // The focused element must still be inside the dialog
    const focusedOutsideDialog = await page.evaluate(() => {
      const dialog = document.querySelector('[role="dialog"]');
      return dialog ? !dialog.contains(document.activeElement) : true;
    });
    expect(focusedOutsideDialog).toBe(false);

    await deleteSimulation(redteamToken, sim.id);
  });
});
