import { describe, expect, it } from 'vitest';
import {
  DEFAULT_RESOURCE_POLICY,
  MemoryDerivedCache,
  ResilientDerivedCache,
  createCacheKey,
  createResourcePolicy,
  estimatePlanResources,
  estimateOutputBytes,
  policyForDeviceMemory,
  preflightResources,
  readStorageQuota,
  stableStringify,
  type DerivedCache,
} from '../../src/storage';
import type { FFmpegCommandPlan } from '../../src/commands/command-plan';
import type { MediaProbe } from '../../src/media';

describe('cache identities', () => {
  it('canonicalizes settings and produces deterministic path-safe keys', () => {
    const one = createCacheKey({
      sourceFingerprint: 'source-123',
      operation: 'waveform',
      streamIndex: 1,
      settings: { zoom: 2, channels: ['left', 'right'] },
    });
    const two = createCacheKey({
      sourceFingerprint: 'source-123',
      operation: 'waveform',
      streamIndex: 1,
      settings: { channels: ['left', 'right'], zoom: 2 },
    });
    expect(one).toBe(two);
    expect(one).toMatch(/^waveform-[0-9a-f]{16}$/u);
    expect(stableStringify({ z: 1, a: { y: 2, x: 3 } })).toBe(
      '{"a":{"x":3,"y":2},"z":1}'
    );
    expect(() =>
      createCacheKey({
        sourceFingerprint: 'source',
        operation: '../outside',
      })
    ).toThrow(/safe operation/u);
  });
});

describe('resource preflight', () => {
  it('allows small jobs and labels estimates as non-guarantees', () => {
    const decision = preflightResources({
      inputBytes: [10 * 1024 * 1024],
      expectedOutputBytes: estimateOutputBytes(10, 2_000, 128),
      durationSeconds: 10,
      width: 1920,
      height: 1080,
      frameRate: 25,
      passes: 1,
    });
    expect(decision.status).toBe('allowed');
    expect(decision.totals.pixelWorkEstimate).toBe(518_400_000);
    expect(decision.estimateDisclaimer).toContain('not a guarantee');
  });

  it('blocks hard limits and provides actionable suggestions', () => {
    const decision = preflightResources({
      inputBytes: [DEFAULT_RESOURCE_POLICY.hardSingleInputBytes + 1],
      width: 9000,
      height: 9000,
      thumbnailCount: 501,
      contactSheetCells: 501,
      expectedOutputBytes: DEFAULT_RESOURCE_POLICY.hardOutputEstimateBytes + 1,
      timelineClips: DEFAULT_RESOURCE_POLICY.maxTimelineClips + 1,
      streamsPerInput: [DEFAULT_RESOURCE_POLICY.maxStreamsPerInput + 1],
    });
    expect(decision.status).toBe('blocked');
    expect(decision.issues.map((issue) => issue.code)).toEqual(
      expect.arrayContaining([
        'single-input-hard-limit',
        'total-input-hard-limit',
        'pixel-limit',
        'thumbnail-limit',
        'contact-sheet-limit',
        'output-hard-limit',
        'timeline-clip-limit',
        'stream-count-limit',
      ])
    );
    expect(decision.issues.every((issue) => issue.suggestions.length > 0)).toBe(
      true
    );
  });

  it('lowers policy for reliable low-memory signals', () => {
    const policy = createResourcePolicy();
    expect(policyForDeviceMemory(policy, 2).softInputBytes).toBeLessThan(
      policy.softInputBytes
    );
  });

  it('reserves collection and report ZIP capacity before running a batch', () => {
    const policy = createResourcePolicy();
    const decision = preflightResources(
      {
        inputBytes: [],
        generatedOutputs: 2,
        retainedOutputs: policy.maxGeneratedOutputs - 1,
      },
      policy
    );
    expect(decision.status).toBe('blocked');
    expect(decision.issues).toContainEqual(
      expect.objectContaining({
        code: 'generated-output-limit',
        message: expect.stringMatching(/ZIP with an optional report/iu),
      })
    );
  });

  it('rejects malformed generated-image estimates before comparing limits', () => {
    expect(() =>
      preflightResources({
        inputBytes: [],
        thumbnailCount: -1,
      })
    ).toThrow(/thumbnailCount/u);
    expect(() =>
      preflightResources({
        inputBytes: [],
        contactSheetCells: 1.5,
      })
    ).toThrow(/contactSheetCells/u);
  });

  it('derives output size, geometry, frame rate, and generated-image semantics from plans', () => {
    const contactEstimate = estimatePlanResources(
      plan({
        operation: 'contact-sheet',
        args: [
          '-vf',
          'fps=0.5,scale=320:-2:force_original_aspect_ratio=decrease,tile=3x2:padding=4:margin=4',
          '/work/contact.jpg',
        ],
        outputs: [
          {
            id: 'contact',
            path: '/work/contact.jpg',
            fileName: 'contact.jpg',
            mediaType: 'image/jpeg',
            role: 'contact-sheet',
          },
        ],
        expectedDurationSeconds: 12,
      }),
      [{ size: 2_000_000, probe: videoProbe() }]
    );
    expect(contactEstimate).toMatchObject({
      width: 976,
      height: 372,
      frameRate: 0.5,
      contactSheetCells: 6,
      generatedOutputs: 1,
    });

    const remuxEstimate = estimatePlanResources(
      plan({
        operation: 'remux',
        args: ['-i', '/input/source.mp4', '-c', 'copy', '/work/output.mkv'],
        outputs: [
          {
            id: 'media',
            path: '/work/output.mkv',
            fileName: 'output.mkv',
            mediaType: 'video/x-matroska',
            role: 'media',
          },
        ],
        expectedDurationSeconds: 10,
      }),
      [{ size: 2_000_000, probe: videoProbe() }]
    );
    expect(remuxEstimate.expectedOutputBytes).toBe(2_000_000);

    const bitrateEstimate = estimatePlanResources(
      plan({
        operation: 'convert',
        args: [
          '-c:v',
          'libx264',
          '-b:v',
          '500k',
          '-c:a',
          'aac',
          '-b:a',
          '128k',
          '/work/output.mp4',
        ],
        outputs: [
          {
            id: 'media',
            path: '/work/output.mp4',
            fileName: 'output.mp4',
            mediaType: 'video/mp4',
            role: 'media',
          },
        ],
        expectedDurationSeconds: 10,
      }),
      [{ size: 2_000_000, probe: audioVideoProbe() }]
    );
    expect(bitrateEstimate.expectedOutputBytes).toBe(
      estimateOutputBytes(10, 628)
    );

    const thumbnailEstimate = estimatePlanResources(
      plan({
        operation: 'thumbnail-series',
        args: ['-vf', 'scale=320:-2', '/work/thumb.png'],
        outputs: Array.from({ length: 4 }, (_, index) => ({
          id: `thumb-${index}`,
          path: `/work/thumb-${index}.png`,
          fileName: `thumb-${index}.png`,
          mediaType: 'image/png',
          role: 'thumbnail' as const,
        })),
        expectedDurationSeconds: 8,
      }),
      [{ size: 2_000_000, probe: videoProbe() }]
    );
    expect(thumbnailEstimate).toMatchObject({
      width: 320,
      height: 180,
      frameRate: 0.5,
      thumbnailCount: 4,
      generatedOutputs: 4,
    });
  });
});

describe('derived cache lifecycle', () => {
  it('lists sizes, expires entries, clears projects and clears all', async () => {
    let now = new Date('2026-01-01T00:00:00.000Z');
    const cache = new MemoryDerivedCache({
      maxBytes: 1_024,
      now: () => now,
    });
    await cache.put(
      {
        key: 'waveform-a',
        kind: 'waveform-peaks',
        projectId: 'project-a',
        mediaType: 'application/octet-stream',
        expiresAt: '2026-01-02T00:00:00.000Z',
      },
      new Uint8Array([1, 2, 3])
    );
    await cache.put(
      {
        key: 'thumb-b',
        kind: 'thumbnail',
        projectId: 'project-b',
        mediaType: 'image/png',
      },
      new Blob(['image'], { type: 'image/png' })
    );
    expect(await cache.sizeBytes()).toBe(8);
    expect((await cache.list()).map((entry) => entry.key)).toHaveLength(2);
    now = new Date('2026-01-03T00:00:00.000Z');
    expect(await cache.pruneExpired()).toBe(1);
    expect(await cache.get('waveform-a')).toBeUndefined();
    expect(await cache.clearProject('project-b')).toBe(1);
    expect(await cache.sizeBytes()).toBe(0);
    await cache.clear();
    expect(await cache.list()).toEqual([]);
  });

  it('evicts least recently used memory entries under the configured limit', async () => {
    let tick = 0;
    const cache = new MemoryDerivedCache({
      maxBytes: 6,
      now: () => new Date(1_000 + tick++),
    });
    await cache.put({ key: 'one', kind: 'project' }, new Blob(['123']));
    await cache.put({ key: 'two', kind: 'project' }, new Blob(['456']));
    await cache.get('one');
    await cache.put({ key: 'three', kind: 'project' }, new Blob(['789']));
    expect(await cache.get('two')).toBeUndefined();
    expect(await cache.get('one')).toBeDefined();
    expect(await cache.get('three')).toBeDefined();
  });

  it('switches explicitly to memory fallback when OPFS operations fail', async () => {
    const primary = new ThrowingCache();
    const fallback = new MemoryDerivedCache();
    const cache = new ResilientDerivedCache(primary, fallback);
    expect(cache.mode).toBe('opfs');
    await cache.put({ key: 'fallback', kind: 'project' }, new Blob(['ok']));
    expect(cache.mode).toBe('memory');
    expect(cache.fallbackReason).toContain('quota denied');
    expect(await cache.get('fallback')).toBeDefined();
    await cache.clear();
    expect(await cache.list()).toEqual([]);
  });

  it('reports quota and unavailable estimate failures', async () => {
    expect(
      await readStorageQuota({
        estimate: async () => ({ usage: 25, quota: 100 }),
        persisted: async () => true,
      })
    ).toEqual({
      available: true,
      usageBytes: 25,
      quotaBytes: 100,
      remainingBytes: 75,
      usageFraction: 0.25,
      persisted: true,
    });
    expect(
      await readStorageQuota({
        estimate: async () => {
          throw new Error('denied');
        },
      })
    ).toEqual({ available: false, error: 'denied' });
  });
});

class ThrowingCache implements DerivedCache {
  readonly mode = 'opfs' as const;
  async list(): Promise<never> {
    throw new Error('quota denied');
  }
  async get(): Promise<never> {
    throw new Error('quota denied');
  }
  async put(): Promise<never> {
    throw new Error('quota denied');
  }
  async delete(): Promise<never> {
    throw new Error('quota denied');
  }
  async clear(): Promise<never> {
    throw new Error('quota denied');
  }
  async clearProject(): Promise<never> {
    throw new Error('quota denied');
  }
  async pruneExpired(): Promise<never> {
    throw new Error('quota denied');
  }
  async sizeBytes(): Promise<never> {
    throw new Error('quota denied');
  }
}

function plan(
  options: Pick<
    FFmpegCommandPlan,
    'operation' | 'args' | 'outputs' | 'expectedDurationSeconds'
  >
): FFmpegCommandPlan {
  return {
    id: `job:${options.operation}`,
    operation: options.operation,
    inputs: [],
    temporaryFiles: [],
    args: options.args,
    outputs: options.outputs,
    expectedDurationSeconds: options.expectedDurationSeconds,
    requiredCapabilities: {},
    diagnostics: [],
  };
}

function videoProbe(): MediaProbe {
  return {
    durationSeconds: 10,
    bitRate: 800_000,
    formatNames: ['mov', 'mp4'],
    tags: {},
    chapters: [],
    warnings: [],
    streams: [
      {
        index: 0,
        type: 'video',
        width: 1920,
        height: 1080,
        frameRate: 25,
        disposition: {},
        tags: {},
      },
    ],
  };
}

function audioVideoProbe(): MediaProbe {
  const probe = videoProbe();
  return {
    ...probe,
    streams: [
      ...probe.streams,
      {
        index: 1,
        type: 'audio',
        disposition: {},
        tags: {},
      },
    ],
  };
}
