import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import * as ts from 'typescript';
import { generate } from '../orchestrator.js';
import { sha256, HASHES_DIR, HASHES_FILE } from '../regen-safety.js';

let projectRoot: string;

beforeEach(() => {
  projectRoot = mkdtempSync(join(tmpdir(), 'react-starter-orch-'));
  // Suppress the orchestrator's console.log summary during tests.
  vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
  rmSync(projectRoot, { recursive: true, force: true });
  vi.restoreAllMocks();
});

function makeSpec() {
  return {
    metadata: { name: 'My App' },
    models: {
      Post: {
        name: 'Post',
        attributes: {
          id: { type: 'UUID', auto: 'uuid4' },
          title: { type: 'String', required: true },
          body: { type: 'Text' },
        },
      },
    },
    views: {},
  };
}

function assertValidTsx(source: string, label: string): void {
  const result = ts.transpileModule(source, {
    compilerOptions: {
      jsx: ts.JsxEmit.Preserve,
      target: ts.ScriptTarget.ES2022,
      module: ts.ModuleKind.ESNext,
      moduleResolution: ts.ModuleResolutionKind.Bundler,
      strict: false,
    },
    reportDiagnostics: true,
  });
  const errors = result.diagnostics?.filter(d => d.category === ts.DiagnosticCategory.Error) ?? [];
  if (errors.length > 0) {
    const msg = errors.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')).join('\n');
    throw new Error(`${label} failed to parse:\n${msg}`);
  }
}

/**
 * Drive the orchestrator with the per-test tmp dir as the effective
 * frontend root. `frontendDir='.'` collapses the
 * `${outputDir}/${frontendDir}` join so files land directly under
 * `projectRoot`, which keeps test assertions terse.
 */
async function runGenerate(spec = makeSpec()) {
  return generate({ spec, outputDir: projectRoot, frontendDir: '.' });
}

/** List every file the orchestrator wrote under projectRoot. */
function walk(dir: string, prefix = ''): string[] {
  const { readdirSync, statSync } = require('fs');
  const out: string[] = [];
  for (const entry of readdirSync(dir)) {
    const abs = join(dir, entry);
    const rel = prefix ? `${prefix}/${entry}` : entry;
    if (statSync(abs).isDirectory()) out.push(...walk(abs, rel));
    else out.push(rel);
  }
  return out.sort();
}

describe('orchestrator — first-run (empty project)', () => {
  it('writes the full set of files: views + helpers + App.tsx + package.json + hash manifest', async () => {
    const ret = await runGenerate();
    // Contract: returns '' (realize's single-file writeOutput skips it).
    expect(ret).toBe('');

    const written = walk(projectRoot);
    expect(written).toEqual([
      `${HASHES_DIR}/${HASHES_FILE}`,
      'package.json',
      'src/App.tsx',
      'src/hooks/useAppEvents.tsx',
      'src/hooks/useResizableSidebar.ts',
      'src/lib/entity-display.ts',
      'src/views/PostDashboardView.tsx',
      'src/views/PostDetailView.tsx',
      'src/views/PostFormView.tsx',
      'src/views/PostListView.tsx',
    ]);
  });

  it('every emitted .tsx parses as valid TSX', async () => {
    await runGenerate();
    for (const path of walk(projectRoot)) {
      if (path.endsWith('.tsx')) {
        const source = readFileSync(join(projectRoot, path), 'utf8');
        assertValidTsx(source, path);
      }
    }
  });

  it('package.json has no @specverse/runtime dependency', async () => {
    await runGenerate();
    const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
    const deps = { ...pkg.dependencies, ...pkg.devDependencies };
    expect(deps['@specverse/runtime']).toBeUndefined();
  });

  it('the emitted hash manifest records every approved file', async () => {
    await runGenerate();
    const manifestPath = join(projectRoot, HASHES_DIR, HASHES_FILE);
    const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
    for (const path of walk(projectRoot)) {
      if (path === `${HASHES_DIR}/${HASHES_FILE}`) continue;
      const content = readFileSync(join(projectRoot, path), 'utf8');
      expect(manifest[path]).toBe(sha256(content));
    }
  });
});

describe('orchestrator — regeneration safety', () => {
  it('re-approves pristine files on a second run', async () => {
    await runGenerate();

    // Record the first-run hash of one of the view files.
    const listPath = 'src/views/PostListView.tsx';
    const listAbs = join(projectRoot, listPath);
    const firstContent = readFileSync(listAbs, 'utf8');

    // Second run. Pristine files should be rewritten (the orchestrator
    // writes them, possibly with identical bytes).
    await runGenerate();
    expect(existsSync(listAbs)).toBe(true);
    expect(readFileSync(listAbs, 'utf8')).toBe(firstContent);
  });

  it('preserves a user-edited file across regeneration', async () => {
    await runGenerate();

    const editedPath = 'src/views/PostListView.tsx';
    const editedAbs = join(projectRoot, editedPath);
    const userEdit = '/* user edit */';
    writeFileSync(editedAbs, userEdit, 'utf8');

    await runGenerate();

    // File on disk is unchanged — user edit survived.
    expect(readFileSync(editedAbs, 'utf8')).toBe(userEdit);
    // Manifest still records the ORIGINAL (pre-edit) hash so a future
    // un-edit can re-sync cleanly.
    const manifest = JSON.parse(
      readFileSync(join(projectRoot, HASHES_DIR, HASHES_FILE), 'utf8')
    );
    expect(manifest[editedPath]).toBeDefined();
    expect(manifest[editedPath]).not.toBe(sha256(userEdit));
  });

  it('is cautious when the user deletes the hash manifest', async () => {
    await runGenerate();

    // User deletes the hash manifest but keeps the view files.
    rmSync(join(projectRoot, HASHES_DIR), { recursive: true, force: true });

    // Record the existing file contents (may be identical to what the
    // orchestrator would emit, but we're treating them as unknown origin).
    const listPath = join(projectRoot, 'src/views/PostListView.tsx');
    const before = readFileSync(listPath, 'utf8');

    await runGenerate();

    // No hash manifest means we can't tell user edits from originals, so
    // existing files are skipped. Content is unchanged.
    expect(readFileSync(listPath, 'utf8')).toBe(before);
    // A fresh hash manifest was written.
    expect(existsSync(join(projectRoot, HASHES_DIR, HASHES_FILE))).toBe(true);
  });
});
