/**
 * Preservation / Regression Property Tests:
 *   Compliance List Stale After Sidebar Edit
 *
 * Spec:  .kiro/specs/compliance-list-stale-after-sidebar-edit/ (bugfix)
 * Issue: #23 — "[Bug] Update Res Date/Remed Plan in list after updating in sidebar"
 *        http://steam-gitlab.charterlab.com/steam/cve-dashboard/-/issues/23
 *
 * Property 2 (Preservation): the behaviors below must hold AFTER the fix. Following
 * observation-first methodology, the preservation properties (note-add refresh, failed
 * save surfaces an error without falsely updating the list, close-without-change clears
 * selection, other row fields render) were observed to hold on UNFIXED code and must
 * keep holding. The "regression guard" (the list row reflects a saved metadata value)
 * is the one behavior that flips from failing (pre-fix) to passing (post-fix); it is
 * kept here as a standing guard against re-introducing the bug.
 *
 * Mirrors the tagging convention of
 *   backend/__tests__/compliance-remediation-display-fix.preservation.property.test.js
 *
 * Properties tested:
 *   P2.1 — Regression guard: after a successful sidebar save the list row shows the
 *          saved Resolution Date with no manual refresh           (bugfix.md 2.1, 2.2)
 *   P2.2 — Note-add still triggers a list re-fetch via onNoteAdded (bugfix.md 3.1)
 *   P2.3 — Failed metadata save surfaces metaError and does NOT
 *          falsely update the list row                            (bugfix.md 3.3)
 *   P2.4 — Close-without-change clears the selection, no PATCH     (bugfix.md 3.4)
 *   P2.5 — Other row fields (hostname, IP, type, failing metrics,
 *          seen count) render unchanged                           (bugfix.md 3.5)
 *
 * **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6**
 */
import React from 'react';
import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/react';
import fc from 'fast-check';

// --- Mocks (hoisted by babel-jest above the CompliancePage import) ---

// Stub auth so CompliancePage renders the STEAM team with write access.
jest.mock('../../../contexts/AuthContext', () => ({
  useAuth: () => ({
    canWrite: () => true,
    isAdmin: () => false,
    getAvailableTeams: () => ['STEAM'],
    adminScope: null,
  }),
}));

// The historical charts panel is irrelevant to the list-staleness bug and pulls
// in recharts; stub it so the tests stay focused and fast across property runs.
jest.mock('../ComplianceChartsPanel', () => () => null);

import CompliancePage from '../CompliancePage';

const HOSTNAME = 'HOST-001';
const METRIC_ID = '2.3.6i';

// --- Fixtures --------------------------------------------------------------

function jsonResponse(body, ok = true) {
  return Promise.resolve({ ok, json: async () => body });
}

function makeListDevice({ resolution_date, remediation_plan }) {
  return {
    hostname: HOSTNAME,
    ip_address: '10.0.0.1',
    device_type: 'Switch',
    failing_metrics: [{ metric_id: METRIC_ID, category: 'Vulnerability Management' }],
    resolution_date,
    remediation_plan,
    seen_count: 3,
    has_notes: false,
  };
}

function makeDetail() {
  return {
    hostname: HOSTNAME,
    ip_address: '10.0.0.1',
    device_type: 'Switch',
    team: 'STEAM',
    metrics: [
      {
        metric_id: METRIC_ID,
        category: 'Vulnerability Management',
        status: 'active',
        metric_desc: 'Outbound encryption required on all endpoints',
        resolution_date: null,
        remediation_plan: null,
        seen_count: 3,
        first_seen: '2025-01-01',
        resolved_on: null,
        extra: {},
      },
    ],
    history: [],
    notes: [],
  };
}

/**
 * Install a URL-routing global.fetch shared by every property.
 *
 * Options:
 *   savedResolutionDate / savedRemediationPlan — values the list endpoint returns
 *     AFTER a successful metadata PATCH (models the backend persisting the new value).
 *   patchOk  — whether PATCH /compliance/items/:hostname/metadata succeeds (default true).
 *   noteOk   — whether POST /compliance/notes succeeds (default true).
 *   fixedDevice — when provided, the list always returns this device unchanged (used by
 *     the row-field rendering property which never edits metadata).
 *
 * The list device starts stale (resolution_date / remediation_plan = null) and only
 * returns the saved values once a SUCCESSFUL patch has occurred, so a post-save
 * re-fetch is observable in the rendered row.
 */
function installFetchMock(opts = {}) {
  const {
    savedResolutionDate = '2027-06-15',
    savedRemediationPlan = 'Patch firmware',
    patchOk = true,
    noteOk = true,
    fixedDevice = null,
  } = opts;

  const state = {
    patchOccurred: false,
    patchCalls: 0,
    noteOccurred: false,
    noteCalls: 0,
    listCalls: 0,
    listCallsAfterPatch: 0,
    listCallsAfterNote: 0,
  };

  global.fetch = jest.fn((url, options = {}) => {
    const method = (options.method || 'GET').toUpperCase();

    // POST /compliance/notes → success / failure
    if (method === 'POST' && url.includes('/compliance/notes')) {
      state.noteCalls++;
      if (noteOk) {
        state.noteOccurred = true;
        return jsonResponse({ ok: true, id: 1 });
      }
      return jsonResponse({ error: 'Failed to save note' }, false);
    }

    // PATCH /compliance/items/:hostname/metadata → success / failure
    if (method === 'PATCH' && url.includes('/compliance/items/') && url.includes('/metadata')) {
      state.patchCalls++;
      if (patchOk) {
        state.patchOccurred = true;
        return jsonResponse({ ok: true });
      }
      return jsonResponse({ error: 'Failed to save metadata' }, false);
    }

    // GET /compliance/summary?team=STEAM → minimal valid summary
    if (url.includes('/compliance/summary')) {
      return jsonResponse({ entries: [], overall_scores: {}, upload: { report_date: '2025-01-01' } });
    }

    // GET /compliance/items?team=STEAM&status=active → device list (query string)
    if (url.includes('/compliance/items?')) {
      state.listCalls++;
      if (state.patchOccurred) state.listCallsAfterPatch++;
      if (state.noteOccurred) state.listCallsAfterNote++;
      let device;
      if (fixedDevice) {
        device = fixedDevice;
      } else if (state.patchOccurred) {
        device = makeListDevice({ resolution_date: savedResolutionDate, remediation_plan: savedRemediationPlan });
      } else {
        device = makeListDevice({ resolution_date: null, remediation_plan: null });
      }
      return jsonResponse({ devices: [device] });
    }

    // GET /compliance/items/:hostname → detail (one active metric)
    if (url.includes('/compliance/items/')) {
      return jsonResponse(makeDetail());
    }

    // Any other URL (charts panel is mocked out) → safe empty payload
    return jsonResponse({});
  });

  return state;
}

// --- Generators ------------------------------------------------------------

/**
 * YYYY-MM-DD strings built from integer tuples. Do NOT call toISOString on shrunk
 * values (mirrors the predecessor exploration test's date-generator pattern).
 */
const arbResolutionDate = fc
  .tuple(
    fc.integer({ min: 2026, max: 2030 }),
    fc.integer({ min: 1, max: 12 }),
    fc.integer({ min: 1, max: 28 })
  )
  .map(([y, m, d]) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`);

/** Non-empty, trimmed remediation plan strings, length-bounded 1–200. */
const arbRemediationPlan = fc
  .string({ minLength: 1, maxLength: 200 })
  .filter((s) => s.trim().length > 0);

/** Non-empty, trimmed note text, length-bounded 1–200. */
const arbNoteText = fc
  .string({ minLength: 1, maxLength: 200 })
  .filter((s) => s.trim().length > 0);

/** Hostnames like HOST-0001 — unique, regex-safe, distinct from IP/type cells. */
const arbHostname = fc
  .integer({ min: 1, max: 9999 })
  .map((n) => `HOST-${String(n).padStart(4, '0')}`);

/** IPv4 dotted-quad strings built from integer tuples. */
const arbIp = fc
  .tuple(
    fc.integer({ min: 1, max: 254 }),
    fc.integer({ min: 0, max: 255 }),
    fc.integer({ min: 0, max: 255 }),
    fc.integer({ min: 1, max: 254 })
  )
  .map(([a, b, c, d]) => `${a}.${b}.${c}.${d}`);

const arbDeviceType = fc.constantFrom('Switch', 'Router', 'Firewall', 'Server', 'Workstation');

/** metric_id like "7.1.3", built from integer tuples. */
const arbMetricId = fc
  .tuple(fc.integer({ min: 1, max: 9 }), fc.integer({ min: 1, max: 9 }), fc.integer({ min: 1, max: 9 }))
  .map(([a, b, c]) => `${a}.${b}.${c}`);

/** 1–3 unique metric ids (unique to avoid duplicate React keys / ambiguous queries). */
const arbMetricIds = fc.uniqueArray(arbMetricId, { minLength: 1, maxLength: 3 });

const arbSeenCount = fc.integer({ min: 1, max: 20 });

// --- Helpers ---------------------------------------------------------------

/** Render CompliancePage, wait for the device row, return { container, row }. */
async function renderAndGetRow(hostname = HOSTNAME) {
  const utils = render(<CompliancePage onNavigate={() => {}} />);
  const hostCell = await screen.findByText(hostname);
  return { ...utils, row: hostCell.parentElement };
}

/** Open the sidebar by clicking the row, wait for the editable date input. */
async function openPanel(row, container) {
  fireEvent.click(row);
  const dateInput = await waitFor(() => {
    const el = container.querySelector('input[type="date"]');
    if (!el) throw new Error('resolution date input not ready');
    return el;
  });
  return dateInput;
}

// --- Setup / teardown ------------------------------------------------------

beforeEach(() => {
  process.env.REACT_APP_API_BASE = 'http://localhost:3001/api';
});

afterEach(() => {
  cleanup();
  jest.restoreAllMocks();
});

// ===========================================================================
// P2.1 — Regression guard: list row reflects a saved Resolution Date
//        after a successful sidebar save, with no manual refresh.
//        (bugfix.md 2.1, 2.2 — kept as a standing regression guard)
// ===========================================================================
describe('P2.1 — Regression guard: list row reflects saved metadata after a successful save', () => {
  it('for any saved metadata, the parent row shows the saved Resolution Date without a manual refresh', async () => {
    await fc.assert(
      fc.asyncProperty(arbResolutionDate, arbRemediationPlan, async (savedResolutionDate, savedRemediationPlan) => {
        const state = installFetchMock({ savedResolutionDate, savedRemediationPlan });
        try {
          const { container, row } = await renderAndGetRow();

          // Pre-condition: the stale row does not yet show the to-be-saved date.
          expect(within(row).queryByText(savedResolutionDate)).toBeNull();

          const dateInput = await openPanel(row, container);
          const planInput = screen.getByPlaceholderText(/Describe the remediation plan/i);

          fireEvent.change(dateInput, { target: { value: savedResolutionDate } });
          fireEvent.change(planInput, { target: { value: savedRemediationPlan } });
          fireEvent.click(screen.getByRole('button', { name: /save/i }));

          await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0));

          // Regression guard: the row reflects the saved value (the fix re-fetches).
          await waitFor(
            () => {
              expect(within(row).getByText(savedResolutionDate)).toBeInTheDocument();
            },
            { timeout: 2000 }
          );
          expect(state.listCallsAfterPatch).toBeGreaterThan(0);
        } finally {
          cleanup();
        }
      }),
      { numRuns: 6, endOnFailure: true }
    );
  }, 60000);
});

// ===========================================================================
// P2.2 — Note-add refresh still works: adding a note re-issues the list GET
//        via the existing onNoteAdded callback. (bugfix.md 3.1)
// ===========================================================================
describe('P2.2 — Note-add still triggers a list re-fetch (onNoteAdded preserved)', () => {
  it('for any note text, a successful note add re-issues GET /compliance/items', async () => {
    await fc.assert(
      fc.asyncProperty(arbNoteText, async (noteText) => {
        const state = installFetchMock({ noteOk: true });
        try {
          const { container, row } = await renderAndGetRow();
          await openPanel(row, container);

          const noteInput = await screen.findByPlaceholderText(/Add a note/i);
          fireEvent.change(noteInput, { target: { value: noteText } });

          const addButton = container.querySelector('.lucide-send').closest('button');
          fireEvent.click(addButton);

          await waitFor(() => expect(state.noteCalls).toBeGreaterThan(0));
          // Preservation: the list is re-fetched after the note add.
          await waitFor(() => expect(state.listCallsAfterNote).toBeGreaterThan(0), { timeout: 2000 });
        } finally {
          cleanup();
        }
      }),
      { numRuns: 6, endOnFailure: true }
    );
  }, 60000);
});

// ===========================================================================
// P2.3 — Failed metadata save surfaces metaError and does NOT falsely
//        update the list row. (bugfix.md 3.3)
// ===========================================================================
describe('P2.3 — Failed save shows an error and does not falsely update the list', () => {
  it('for any attempted value, a non-OK PATCH surfaces metaError and the row stays stale', async () => {
    await fc.assert(
      fc.asyncProperty(arbResolutionDate, async (attemptedResolutionDate) => {
        const state = installFetchMock({ savedResolutionDate: attemptedResolutionDate, patchOk: false });
        try {
          const { container, row } = await renderAndGetRow();
          const dateInput = await openPanel(row, container);

          fireEvent.change(dateInput, { target: { value: attemptedResolutionDate } });
          fireEvent.click(screen.getByRole('button', { name: /save/i }));

          await waitFor(() => expect(state.patchCalls).toBeGreaterThan(0));

          // The panel surfaces the error from the failed save.
          await waitFor(() => expect(screen.getByText('Failed to save metadata')).toBeInTheDocument());

          // The list row was NOT updated to a value that never persisted.
          expect(within(row).queryByText(attemptedResolutionDate)).toBeNull();
          expect(state.listCallsAfterPatch).toBe(0);
        } finally {
          cleanup();
        }
      }),
      { numRuns: 6, endOnFailure: true }
    );
  }, 60000);
});

// ===========================================================================
// P2.4 — Close-without-change clears the selection and issues no PATCH.
//        (bugfix.md 3.4) — example-style assertion (no value in varying inputs).
// ===========================================================================
describe('P2.4 — Close without change clears selection and saves nothing', () => {
  it('clicking the close (X) removes the panel and triggers no metadata save', async () => {
    const state = installFetchMock();
    const { container, row } = await renderAndGetRow();

    // Open the panel and confirm the editable date input rendered.
    await openPanel(row, container);

    // Click the close (X) control without saving anything.
    const closeButton = container.querySelector('.lucide-x').closest('button');
    fireEvent.click(closeButton);

    // The panel is gone (selection cleared) — its date input no longer exists.
    await waitFor(() => expect(container.querySelector('input[type="date"]')).toBeNull());

    // The row remains and no metadata save was attempted.
    expect(screen.getByText(HOSTNAME)).toBeInTheDocument();
    expect(state.patchCalls).toBe(0);
  }, 30000);

  it('clicking the backdrop also clears selection and triggers no metadata save', async () => {
    const state = installFetchMock();
    const { container, row } = await renderAndGetRow();

    await openPanel(row, container);

    // The first fixed/inset overlay is the backdrop (onClick={onClose}).
    const backdrop = container.querySelector('div[style*="position: fixed"]');
    fireEvent.click(backdrop);

    await waitFor(() => expect(container.querySelector('input[type="date"]')).toBeNull());
    expect(screen.getByText(HOSTNAME)).toBeInTheDocument();
    expect(state.patchCalls).toBe(0);
  }, 30000);
});

// ===========================================================================
// P2.5 — Other row fields render unchanged: hostname, IP, device type,
//        failing metrics, seen count. (bugfix.md 3.5)
// ===========================================================================
describe('P2.5 — Other row fields render correctly and unchanged', () => {
  it('for any generated device, the row displays hostname, IP, type, metrics, and seen count', async () => {
    await fc.assert(
      fc.asyncProperty(
        arbHostname,
        arbIp,
        arbDeviceType,
        arbMetricIds,
        arbSeenCount,
        async (hostname, ip, deviceType, metricIds, seenCount) => {
          const device = {
            hostname,
            ip_address: ip,
            device_type: deviceType,
            failing_metrics: metricIds.map((id) => ({ metric_id: id, category: 'Vulnerability Management' })),
            resolution_date: null,
            remediation_plan: null,
            seen_count: seenCount,
            has_notes: false,
          };
          installFetchMock({ fixedDevice: device });
          try {
            const { row } = await renderAndGetRow(hostname);

            // Hostname, IP, and device type render verbatim.
            expect(within(row).getByText(hostname)).toBeInTheDocument();
            expect(within(row).getByText(ip)).toBeInTheDocument();
            expect(within(row).getByText(deviceType)).toBeInTheDocument();

            // Every failing-metric badge renders its metric_id.
            for (const id of metricIds) {
              expect(within(row).getByText(id)).toBeInTheDocument();
            }

            // The seen-count badge renders "<count>×".
            expect(within(row).getByText(`${seenCount}\u00D7`)).toBeInTheDocument();
          } finally {
            cleanup();
          }
        }
      ),
      { numRuns: 8, endOnFailure: true }
    );
  }, 60000);
});
