import { describe, expect, it } from 'vitest';
import type { Task } from '@/lib/types';
import {
  acceptSnapshot,
  assertMutable,
  canMutate,
  combineFreshness,
  computeDigest,
  computeFreshness,
  DEFAULT_FRESHNESS_POLICY,
  formatAge,
  type FreshSnapshot,
  invalidationReasonLabels,
  StaleMutationError,
  UNKNOWN_VERDICT,
  verdictValue,
} from './model';
import { validateProjectCollection, validateTaskCollection } from './validators';

const NOW = 1_800_000_000_000;

const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };

const taskPayload: Task[] = [
  {
    id: 'task-1',
    title: 'T1',
    description: null,
    status: 'not-started',
    priority: 'high',
    projectId: 'project-1',
    missionId: null,
    assignee: null,
    tags: null,
    dueDate: null,
    metadata: null,
    createdAt: '2026-08-01T00:00:00.000Z',
    updatedAt: '2026-08-01T00:00:00.000Z',
  },
];

function acceptedTaskSnapshot(
  overrides: Partial<FreshSnapshot<typeof taskPayload>> = {},
): FreshSnapshot<typeof taskPayload> {
  const result = acceptSnapshot({
    value: taskPayload,
    validate: validateTaskCollection,
    previous: null,
    policy,
    source: 'gateway:/api/tasks',
    now: NOW,
  });
  if (result.outcome !== 'accepted') {
    throw new Error(`fixture setup failed: ${result.reason}`);
  }
  return { ...result.snapshot, ...overrides };
}

describe('computeFreshness', () => {
  it('treats a missing snapshot as unavailable, never as an empty healthy collection', () => {
    expect(computeFreshness({ snapshot: null, policy, now: NOW })).toBe('unavailable');
  });

  it('returns current for a fresh verified snapshot regardless of data emptiness', () => {
    const empty = acceptSnapshot({
      value: [],
      validate: validateTaskCollection,
      previous: null,
      policy,
      source: 'gateway:/api/tasks',
      now: NOW,
    });
    if (empty.outcome !== 'accepted') throw new Error('expected acceptance');
    expect(computeFreshness({ snapshot: empty.snapshot, policy, now: NOW })).toBe('current');
  });

  it('degrades to stale once the snapshot ages past staleAfterMs', () => {
    const snapshot = acceptedTaskSnapshot();
    expect(computeFreshness({ snapshot, policy, now: NOW + 60_001 })).toBe('stale');
    expect(computeFreshness({ snapshot, policy, now: NOW + 59_999 })).toBe('current');
  });

  it('degrades to stale when the latest revalidation failed', () => {
    const snapshot = acceptedTaskSnapshot();
    expect(computeFreshness({ snapshot, policy, now: NOW, degraded: true })).toBe('stale');
  });
});

describe('mutation guard', () => {
  it('permits mutations only on current data', () => {
    expect(canMutate('current')).toBe(true);
    for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
      expect(canMutate(state)).toBe(false);
    }
  });

  it('refuses mutations on non-current data via assertMutable', () => {
    expect(() => assertMutable('current')).not.toThrow();
    for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
      let thrown: unknown;
      try {
        assertMutable(state);
      } catch (caught) {
        thrown = caught;
      }
      expect(thrown).toBeInstanceOf(StaleMutationError);
      expect(thrown).toBeInstanceOf(Error);
      if (thrown instanceof StaleMutationError) {
        expect(thrown.name).toBe('StaleMutationError');
        expect(thrown.freshness).toBe(state);
        expect(thrown.message).toContain(state);
        expect(thrown.message).toContain('revalidat');
      }
    }
  });
});

describe('acceptSnapshot', () => {
  it('accepts a valid payload with provenance', () => {
    const result = acceptSnapshot({
      value: taskPayload,
      validate: validateTaskCollection,
      previous: null,
      policy,
      source: 'gateway:/api/tasks',
      now: NOW,
    });
    expect(result.outcome).toBe('accepted');
    if (result.outcome !== 'accepted') return;
    expect(result.snapshot.source).toBe('gateway:/api/tasks');
    expect(result.snapshot.version).toBe(1);
    expect(result.snapshot.fetchedAt).toBe(NOW);
    expect(result.snapshot.data).toEqual(taskPayload);
  });

  it('invalidates a schema-mismatched payload instead of rendering it', () => {
    const result = acceptSnapshot({
      value: { not: 'an array' },
      validate: validateTaskCollection,
      previous: acceptedTaskSnapshot(),
      policy,
      source: 'gateway:/api/tasks',
      now: NOW,
    });
    expect(result).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
    expect(invalidationReasonLabels['schema-mismatch']).toContain('schema');
  });

  it('invalidates cross-workspace payloads', () => {
    const userOne = acceptSnapshot({
      value: [
        {
          id: 'p1',
          name: 'P1',
          description: null,
          status: 'active',
          userId: 'user-1',
          metadata: null,
          createdAt: '2026-08-01T00:00:00.000Z',
          updatedAt: '2026-08-01T00:00:00.000Z',
        },
      ],
      validate: validateProjectCollection,
      previous: null,
      policy,
      source: 'gateway:/api/projects',
      now: NOW,
    });
    if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');

    const switched = acceptSnapshot({
      value: [
        {
          id: 'p9',
          name: 'P9',
          description: null,
          status: 'active',
          userId: 'user-2',
          metadata: null,
          createdAt: '2026-08-01T00:00:00.000Z',
          updatedAt: '2026-08-01T00:00:00.000Z',
        },
      ],
      validate: validateProjectCollection,
      previous: userOne.snapshot,
      policy,
      source: 'gateway:/api/projects',
      now: NOW,
    });
    expect(switched).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
  });

  it('keeps the previous workspace for collections with no intrinsic identity', () => {
    const userOne = acceptSnapshot({
      value: [
        {
          id: 'p1',
          name: 'P1',
          description: null,
          status: 'active',
          userId: 'user-1',
          metadata: null,
          createdAt: '2026-08-01T00:00:00.000Z',
          updatedAt: '2026-08-01T00:00:00.000Z',
        },
      ],
      validate: validateProjectCollection,
      previous: null,
      policy,
      source: 'gateway:/api/projects',
      now: NOW,
    });
    if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');

    // Empty list after the user deleted every project: no identity to check,
    // so the verified scope is retained and the empty state stays healthy.
    const emptied = acceptSnapshot({
      value: [],
      validate: validateProjectCollection,
      previous: userOne.snapshot,
      policy,
      source: 'gateway:/api/projects',
      now: NOW,
    });
    expect(emptied.outcome).toBe('accepted');
    if (emptied.outcome === 'accepted') {
      expect(emptied.snapshot.data).toEqual([]);
      expect(emptied.snapshot.workspace).toBe('user-1');
    }
  });

  it('invalidates version regressions', () => {
    const previous = acceptedTaskSnapshot({ version: 7 });
    const regressed = acceptSnapshot({
      value: taskPayload,
      validate: validateTaskCollection,
      previous,
      policy,
      source: 'gateway:/api/tasks',
      now: NOW,
      incomingVersion: 3,
    });
    expect(regressed).toEqual({ outcome: 'invalidated', reason: 'version-regression' });

    const newerSchema = acceptedTaskSnapshot({ schemaVersion: 4 });
    const downgradedClient = acceptSnapshot({
      value: taskPayload,
      validate: validateTaskCollection,
      previous: newerSchema,
      policy: { ...policy, schemaVersion: 2 },
      source: 'gateway:/api/tasks',
      now: NOW,
    });
    expect(downgradedClient).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
  });

  it('increments the version monotonically across accepted snapshots', () => {
    const first = acceptedTaskSnapshot();
    const second = acceptSnapshot({
      value: taskPayload,
      validate: validateTaskCollection,
      previous: first,
      policy,
      source: 'gateway:/api/tasks',
      now: NOW,
    });
    expect(second.outcome).toBe('accepted');
    if (second.outcome === 'accepted') {
      expect(second.snapshot.version).toBe(first.version + 1);
    }
  });
});

describe('combineFreshness', () => {
  it('gates the surface on the primary collection', () => {
    expect(combineFreshness('unavailable', ['current'])).toBe('unavailable');
    expect(combineFreshness('unknown', ['current'])).toBe('unknown');
    expect(combineFreshness('current', [])).toBe('current');
  });

  it('degrades to partial when a secondary is unavailable', () => {
    expect(combineFreshness('current', ['current', 'unavailable'])).toBe('partial');
  });

  it('degrades to unknown while a secondary is still loading', () => {
    expect(combineFreshness('current', ['unknown'])).toBe('unknown');
  });

  it('degrades to stale when any collection is stale', () => {
    expect(combineFreshness('current', ['stale'])).toBe('stale');
    expect(combineFreshness('stale', ['current'])).toBe('stale');
  });

  it('propagates partial secondaries', () => {
    expect(combineFreshness('current', ['partial'])).toBe('partial');
  });
});

describe('computeDigest', () => {
  it('is stable across key order and changes with data', () => {
    const a = computeDigest({ x: 1, y: [1, 2] });
    const b = computeDigest({ y: [1, 2], x: 1 });
    expect(a).toBe(b);
    expect(computeDigest({ x: 1, y: [1, 3] })).not.toBe(a);
  });
});

describe('verdictValue', () => {
  it('returns the value only for verified inputs', () => {
    expect(verdictValue(true, '5')).toBe('5');
    expect(verdictValue(false, '5')).toBe(UNKNOWN_VERDICT);
    expect(verdictValue(false, '5')).not.toBe('5');
  });
});

describe('formatAge', () => {
  it('labels age in human terms', () => {
    expect(formatAge(NOW, NOW)).toBe('just now');
    expect(formatAge(NOW, NOW + 15_000)).toBe('under a minute ago');
    expect(formatAge(NOW, NOW + 120_000)).toBe('2m ago');
    expect(formatAge(NOW, NOW + 3 * 3_600_000)).toBe('3h ago');
    expect(formatAge(NOW, NOW + 2 * 86_400_000)).toBe('2d ago');
  });
});
