/**
 * Property-Based Tests: Resolution-date helper
 *
 * Feature: compliance-metric-estimated-resolution-date
 *
 * Exercises the pure helper `formatResolutionDate(raw)` from
 * `frontend/src/utils/resolutionDate.js`, which classifies a raw per-metric
 * `resolution_date` value into a discriminated union:
 *   { state: 'set', value } | { state: 'none' } | { state: 'invalid' }
 *
 * Library: fast-check (v4) with Jest (react-scripts test). Generators are built
 * from fast-check arbitraries only — none are hand-rolled. Each property runs a
 * minimum of 100 iterations.
 *
 * Validates: Requirements 1.1, 1.3, 1.4, 1.6, 2.1, 2.2, 3.2, 3.3, 4.5
 */

import * as fc from 'fast-check';
import { formatResolutionDate } from '../resolutionDate';

const NUM_RUNS = 200;
const VALID_STATES = ['set', 'none', 'invalid'];
const SHARED_SENTINEL = 'Multiple values';

// --- Independent oracle (NOT the function under test) ----------------------
// Used only to filter generated inputs so we never assert the wrong class.

function daysInMonthOracle(year, month) {
  const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
  const lengths = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
  return lengths[month - 1];
}

// True iff `s` is a strict YYYY-MM-DD string that names a real calendar date.
function isValidCalendarYmd(s) {
  if (typeof s !== 'string') return false;
  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
  const year = Number(s.slice(0, 4));
  const month = Number(s.slice(5, 7));
  const day = Number(s.slice(8, 10));
  if (month < 1 || month > 12) return false;
  if (day < 1 || day > daysInMonthOracle(year, month)) return false;
  return true;
}

// True iff `s` is an ISO datetime string whose date prefix is a valid calendar date.
// e.g. "2026-07-01T00:00:00.000Z" → true (the helper now extracts the date prefix).
function isIsoDateTimeWithValidDate(s) {
  if (typeof s !== 'string') return false;
  if (!/^\d{4}-\d{2}-\d{2}T/.test(s)) return false;
  return isValidCalendarYmd(s.slice(0, 10));
}

// --- Shared arbitraries -----------------------------------------------------

// Four-digit zero-padded year string (0000–9999) — always matches \d{4}.
const year4Arb = fc.integer({ min: 0, max: 9999 }).map(y => String(y).padStart(4, '0'));

// Valid calendar dates spanning years, all months, month-length boundaries
// (28/29/30/31), and leap days. The `dim` boundary value guarantees the true
// last day of each month is exercised, including Feb 29 in leap years.
const validDateStringArb = year4Arb.chain(y =>
  fc.integer({ min: 1, max: 12 }).chain(m => {
    const dim = daysInMonthOracle(Number(y), m);
    const dayArb = fc.oneof(
      fc.constant(1),
      fc.constant(dim),
      fc.integer({ min: 1, max: dim })
    );
    return dayArb.map(
      d => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
    );
  })
);

// null / undefined / empty / whitespace-only (spaces, tabs, newlines).
const whitespaceChar = fc.constantFrom(' ', '\t', '\n');
const whitespaceStringArb = fc
  .array(whitespaceChar, { minLength: 1, maxLength: 20 })
  .map(chars => chars.join(''));
const absentArb = fc.oneof(
  fc.constantFrom(null, undefined, ''),
  whitespaceStringArb
);

// Non-empty, non-whitespace-only strings that are NOT valid YYYY-MM-DD dates.
// Built from several invalid-by-construction families, then defensively
// filtered against the oracle to drop any accidentally-valid value.
const twoDigitArb = fc.integer({ min: 0, max: 99 }).map(n => String(n).padStart(2, '0'));
const nonLeapYear4Arb = fc
  .integer({ min: 0, max: 9999 })
  .filter(y => !((y % 4 === 0 && y % 100 !== 0) || y % 400 === 0))
  .map(y => String(y).padStart(4, '0'));

// Valid shape but month out of range (00 or 13–99).
const badMonthArb = fc
  .tuple(
    year4Arb,
    fc.oneof(fc.constant('00'), fc.integer({ min: 13, max: 99 }).map(n => String(n).padStart(2, '0'))),
    fc.integer({ min: 1, max: 28 }).map(n => String(n).padStart(2, '0'))
  )
  .map(([y, m, d]) => `${y}-${m}-${d}`);

// Valid shape but day out of range (00 or 32–99).
const badDayArb = fc
  .tuple(
    year4Arb,
    fc.integer({ min: 1, max: 12 }).map(n => String(n).padStart(2, '0')),
    fc.oneof(fc.constant('00'), fc.integer({ min: 32, max: 99 }).map(n => String(n).padStart(2, '0')))
  )
  .map(([y, m, d]) => `${y}-${m}-${d}`);

// Valid shape but impossible calendar day (Feb 30/31, 31 in 30-day months,
// Feb 29 in a non-leap year).
const impossibleDayArb = fc.oneof(
  fc.tuple(year4Arb, fc.constant('02'), fc.constantFrom('30', '31')).map(([y, m, d]) => `${y}-${m}-${d}`),
  fc.tuple(year4Arb, fc.constantFrom('04', '06', '09', '11'), fc.constant('31')).map(([y, m, d]) => `${y}-${m}-${d}`),
  nonLeapYear4Arb.map(y => `${y}-02-29`)
);

// Wrong shapes (not matching ^\d{4}-\d{2}-\d{2}$).
const wrongShapeArb = fc.oneof(
  fc.constantFrom(
    '2026-7-1',
    '2026-7-01',
    '2026-07-1',
    '07/01/2026',
    '2026/07/01',
    '20260701',
    '2026-07',
    '2026-07-01T00:00:00',
    '2026-07-01 ', // trailing handled by trim, still wrong below
    'not-a-date',
    'July 1 2026'
  ),
  fc.string({ minLength: 1, maxLength: 30 })
);

const invalidStringArb = fc
  .oneof(wrongShapeArb, badMonthArb, badDayArb, impossibleDayArb, twoDigitArb)
  .filter(s => typeof s === 'string' && s.trim() !== '' && !isValidCalendarYmd(s.trim()) && !isIsoDateTimeWithValidDate(s.trim()));

// Any input category (used for totality / independence properties).
const anyInputArb = fc.oneof(
  validDateStringArb,
  absentArb,
  invalidStringArb
);

// --- Property 1 -------------------------------------------------------------

// Feature: compliance-metric-estimated-resolution-date, Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD
describe('Property 1: Valid calendar dates classify as "set" and format as YYYY-MM-DD', () => {
  /**
   * **Validates: Requirements 1.1, 1.4**
   *
   * For any valid calendar date in YYYY-MM-DD form, formatResolutionDate
   * returns { state: 'set', value } where value matches ^\d{4}-\d{2}-\d{2}$
   * and equals the canonical normalized form (the input itself).
   */
  test('valid calendar dates are classified set and normalized to YYYY-MM-DD', () => {
    fc.assert(
      fc.property(validDateStringArb, dateStr => {
        const result = formatResolutionDate(dateStr);
        expect(result.state).toBe('set');
        expect(result.value).toMatch(/^\d{4}-\d{2}-\d{2}$/);
        expect(result.value).toBe(dateStr);
      }),
      { numRuns: NUM_RUNS }
    );
  });
});

// --- Property 2 -------------------------------------------------------------

// Feature: compliance-metric-estimated-resolution-date, Property 2: Absent values classify as "none"
describe('Property 2: Absent values classify as "none"', () => {
  /**
   * **Validates: Requirements 2.1, 4.5**
   *
   * For any input that is null, undefined, the empty string, or a string
   * composed entirely of whitespace, formatResolutionDate returns
   * { state: 'none' }.
   */
  test('null, undefined, empty, and whitespace-only inputs classify as none', () => {
    fc.assert(
      fc.property(absentArb, raw => {
        const result = formatResolutionDate(raw);
        expect(result.state).toBe('none');
      }),
      { numRuns: NUM_RUNS }
    );
  });
});

// --- Property 3 -------------------------------------------------------------

// Feature: compliance-metric-estimated-resolution-date, Property 3: Non-empty non-calendar-date values classify as "invalid"
describe('Property 3: Non-empty non-calendar-date values classify as "invalid"', () => {
  /**
   * **Validates: Requirements 1.6**
   *
   * For any non-empty, non-whitespace-only string that is not a valid
   * YYYY-MM-DD calendar date — wrong shapes, out-of-range months/days,
   * impossible days such as 2026-02-30, and arbitrary text —
   * formatResolutionDate returns { state: 'invalid' }.
   */
  test('non-empty non-calendar-date strings classify as invalid', () => {
    fc.assert(
      fc.property(invalidStringArb, raw => {
        const result = formatResolutionDate(raw);
        expect(result.state).toBe('invalid');
      }),
      { numRuns: NUM_RUNS }
    );
  });
});

// --- Property 4 -------------------------------------------------------------

// Feature: compliance-metric-estimated-resolution-date, Property 4: Classification is total over any metric list
describe('Property 4: Classification is total over any metric list', () => {
  /**
   * **Validates: Requirements 2.2**
   *
   * For any array of resolution_date values drawn from all categories,
   * formatResolutionDate never throws, every result's state is one of
   * { 'set', 'none', 'invalid' }, and the number of classified results
   * equals the number of inputs.
   */
  test('classification is total: no throw, valid state, one result per input', () => {
    fc.assert(
      fc.property(fc.array(anyInputArb, { maxLength: 30 }), inputs => {
        const results = inputs.map(raw => formatResolutionDate(raw));
        expect(results).toHaveLength(inputs.length);
        results.forEach(result => {
          expect(VALID_STATES).toContain(result.state);
        });
      }),
      { numRuns: NUM_RUNS }
    );
  });
});

// --- Property 5 -------------------------------------------------------------

// Metric-like object with an independently chosen resolution_date.
const metricArb = fc.record({
  metric_id: fc.string({ minLength: 1, maxLength: 8 }),
  resolution_date: anyInputArb,
});

// General metric arrays plus arrays forced to contain two differing dates.
const differingMetricsArb = fc
  .tuple(validDateStringArb, validDateStringArb)
  .filter(([a, b]) => a !== b)
  .chain(([a, b]) =>
    fc.array(metricArb, { maxLength: 5 }).map(rest => [
      { metric_id: 'm-a', resolution_date: a },
      { metric_id: 'm-b', resolution_date: b },
      ...rest,
    ])
  );
const metricsArrayArb = fc.oneof(
  fc.array(metricArb, { maxLength: 30 }),
  differingMetricsArb
);

// Feature: compliance-metric-estimated-resolution-date, Property 5: Each metric's display derives only from its own field (no collapsing)
describe('Property 5: Each metric\'s display derives only from its own field (no collapsing)', () => {
  /**
   * **Validates: Requirements 1.3, 3.2, 3.3**
   *
   * For any array of metrics — including arrays where metrics carry different
   * resolution_date values — the derived display for each metric equals
   * formatResolutionDate applied to that same metric's own field in isolation,
   * independent of every sibling, and no result collapses to a shared
   * "Multiple values" sentinel.
   */
  test('each metric maps to its own field with no shared/collapsed value', () => {
    fc.assert(
      fc.property(metricsArrayArb, metrics => {
        const displayed = metrics.map(m => formatResolutionDate(m.resolution_date));
        expect(displayed).toHaveLength(metrics.length);

        metrics.forEach((metric, index) => {
          // Computed in isolation from this metric's own field only.
          const isolated = formatResolutionDate(metric.resolution_date);
          expect(displayed[index]).toEqual(isolated);

          // No collapsing to a shared "Multiple values" sentinel.
          expect(displayed[index].state).not.toBe(SHARED_SENTINEL);
          expect(displayed[index].value).not.toBe(SHARED_SENTINEL);
          expect(VALID_STATES).toContain(displayed[index].state);
        });
      }),
      { numRuns: NUM_RUNS }
    );
  });
});
