import { existsSync } from 'node:fs';
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import yaml from 'js-yaml';
import { beforeEach, describe, expect, it } from 'vitest';

import {
  PRD_GENERATED_VIEW_LABEL,
  PrdImportConflictError,
  PrdImportInvalidError,
  PrdNotFoundError,
  PrdService,
  PrdUpdateError,
} from './index.js';
import type { PrdDocument } from './index.js';

// ── Helpers ──────────────────────────────────────────────────────────────────

let projectDir: string;

async function makeProject(): Promise<string> {
  return mkdtemp(path.join(os.tmpdir(), 'prdy-service-'));
}

function service(): PrdService {
  return new PrdService({ projectPath: projectDir });
}

function storeDir(): string {
  return path.join(projectDir, 'docs', 'prdy');
}

/** Handcraft a full, schema-valid PRD document for import scenarios. */
function importFixture(overrides: Partial<PrdDocument> = {}): PrdDocument {
  return {
    id: 'imported-prd-20260101-000000',
    title: 'Imported PRD',
    status: 'draft',
    projectPath: '/tmp/elsewhere',
    template: 'software',
    version: 1,
    sections: [
      { id: 'introduction', title: 'Introduction', fields: { context: '', objective: '' } },
      {
        id: 'scope-non-goals',
        title: 'Scope / Non-Goals',
        fields: { inScope: '', outOfScope: '' },
      },
    ],
    missions: [],
    createdAt: '2026-01-01T00:00:00.000Z',
    updatedAt: '2026-01-01T00:00:00.000Z',
    ...overrides,
  };
}

async function writeImportFile(doc: PrdDocument): Promise<string> {
  const filePath = path.join(projectDir, `${doc.id}.import.yaml`);
  await writeFile(filePath, yaml.dump(doc), 'utf8');
  return filePath;
}

beforeEach(async () => {
  projectDir = await makeProject();
});

// ── Single authority store (AC: prdy path and mission path resolve to the
//    SAME store under docs/prdy/ with stable ids/versions) ────────────────────

describe('PrdService single authority store', () => {
  it('persists PRDs from the prdy path and the mission path into the same docs/prdy store', async () => {
    const direct = await service().create({ name: 'Direct PRD' });
    const viaMission = await service().planForMission({
      name: 'Mission PRD',
      missionId: 'mission-1',
      missionVersion: '2026-01-01T00:00:00.000Z',
    });

    const files = await readdir(storeDir());
    expect(files).toContain(`${direct.id}.yaml`);
    expect(files).toContain(`${viaMission.id}.yaml`);

    // A fresh service instance (new process equivalent) resolves both.
    const all = await service().list();
    expect(all.map((doc) => doc.id).sort()).toEqual([direct.id, viaMission.id].sort());

    // Stable versions: creation is v1; linkage writes do not bump content version.
    expect((await service().get(direct.id)).version).toBe(1);
    expect((await service().get(viaMission.id)).version).toBe(1);
  });

  it('round-trips documents through the store with identity intact', async () => {
    const created = await service().create({ name: 'Round Trip', template: 'feature' });
    const fresh = await service().get(created.id);

    expect(fresh).toEqual(created);
    expect(fresh.id).toBe(created.id);
    expect(fresh.template).toBe('feature');
    expect(fresh.status).toBe('draft');
  });

  it('throws a typed error for unknown ids and empty stores', async () => {
    await expect(service().get('nope')).rejects.toBeInstanceOf(PrdNotFoundError);
    await expect(service().get()).rejects.toBeInstanceOf(PrdNotFoundError);
  });
});

// ── Mission linkage persistence (AC: linkage survives restart via fresh
//    service instances) ────────────────────────────────────────────────────────

describe('PrdService mission linkage', () => {
  it('persists linkage and reads it back from a fresh service instance', async () => {
    const created = await service().planForMission({
      name: 'Linked PRD',
      missionId: 'mission-42',
      missionVersion: '2026-02-03T04:05:06.000Z',
      requirementIds: ['FR-1', 'FR-2'],
    });

    // Fresh instance — nothing in memory from the creating call.
    const links = await service().listMissionLinks(created.id);
    expect(links).toHaveLength(1);
    expect(links[0]).toMatchObject({
      missionId: 'mission-42',
      missionVersion: '2026-02-03T04:05:06.000Z',
      prdVersion: 1,
      requirementIds: ['FR-1', 'FR-2'],
    });

    // Linkage is carried in the YAML authority file itself.
    const raw = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
    const persisted = yaml.load(raw) as PrdDocument;
    expect(persisted.missions[0]?.missionId).toBe('mission-42');
    expect(persisted.missions[0]?.requirementIds).toEqual(['FR-1', 'FR-2']);
  });

  it('refreshes an existing linkage entry in place instead of duplicating', async () => {
    const created = await service().planForMission({
      name: 'Relink PRD',
      missionId: 'mission-7',
      missionVersion: 'v1',
    });

    await service().update({
      id: created.id,
      sections: [{ id: 'introduction', fields: { objective: 'Ship it' } }],
    });

    const relinked = await service().linkMission({
      prdId: created.id,
      missionId: 'mission-7',
      missionVersion: 'v2',
      requirementIds: ['NFR-1'],
    });

    expect(relinked.missions).toHaveLength(1);
    expect(relinked.missions[0]).toMatchObject({ missionVersion: 'v2', prdVersion: 2 });
  });

  it('does not bump the content version when writing linkage', async () => {
    const created = await service().create({ name: 'Stable Version' });
    const linked = await service().linkMission({
      prdId: created.id,
      missionId: 'm',
      missionVersion: 'v1',
    });
    expect(linked.version).toBe(1);
  });
});

// ── Update semantics ──────────────────────────────────────────────────────────

describe('PrdService update', () => {
  it('applies section patches and bumps the content version', async () => {
    const created = await service().create({ name: 'Updatable' });
    const updated = await service().update({
      id: created.id,
      sections: [{ id: 'introduction', fields: { context: 'Some context', objective: 'Goal' } }],
    });

    expect(updated.version).toBe(2);
    expect(updated.sections[0]?.fields).toMatchObject({
      context: 'Some context',
      objective: 'Goal',
    });
    expect((await service().get(created.id)).version).toBe(2);
  });

  it('refuses unknown section ids with a typed error', async () => {
    const created = await service().create({ name: 'Strict' });
    await expect(
      service().update({ id: created.id, sections: [{ id: 'nope', fields: {} }] }),
    ).rejects.toBeInstanceOf(PrdUpdateError);
  });
});

// ── Markdown export is a labeled generated view, never authority ──────────────

describe('PrdService exportMarkdown', () => {
  it('writes a generated view carrying the label and source identity', async () => {
    const created = await service().create({ name: 'Exported PRD' });
    const result = await service().exportMarkdown({ id: created.id });

    expect(result.filePath).toBe(path.join(storeDir(), `${created.id}.md`));
    expect(result.content).toContain(PRD_GENERATED_VIEW_LABEL);
    expect(result.content).toContain(`prd-id: ${created.id}`);
    expect(result.content).toContain('prd-version: 1');
    expect(result.content).toContain(`source-of-truth: docs/prdy/${created.id}.yaml`);
  });

  it('reflects the current version after updates', async () => {
    const created = await service().create({ name: 'Versioned Export' });
    await service().update({
      id: created.id,
      sections: [{ id: 'introduction', fields: { objective: 'v2 goal' } }],
    });
    const result = await service().exportMarkdown({ id: created.id });
    expect(result.content).toContain('prd-version: 2');
  });

  it('NEGATIVE CONTROL: mutating the exported Markdown cannot change the authority', async () => {
    const created = await service().create({ name: 'Guarded PRD' });
    const before = structuredClone(await service().get(created.id));

    const result = await service().exportMarkdown({ id: created.id });
    await writeFile(
      result.filePath,
      `<!-- ${PRD_GENERATED_VIEW_LABEL} -->\n# FAKE\nprd-id: fake-id\nprd-version: 99\n`,
      'utf8',
    );

    const after = await service().get(created.id);
    expect(after).toEqual(before);
    expect(after.version).toBe(1);
    expect(after.title).toBe(before.title);
  });

  it('never parses Markdown files that sit in the store directory', async () => {
    const created = await service().create({ name: 'Decoy Guard' });

    // A decoy .md file with invalid YAML must be invisible to the store.
    await writeFile(path.join(storeDir(), 'decoy.md'), 'not: [valid: yaml', 'utf8');
    // And a decoy .yaml-named Markdown body must not silently validate either.
    await service().exportMarkdown({ id: created.id });

    const listed = await service().list();
    expect(listed.map((doc) => doc.id)).toEqual([created.id]);
    await expect(service().get(created.id)).resolves.toBeTruthy();
  });
});

// ── Import: validated, conflict-aware, never silently merging ─────────────────

describe('PrdService importDocument', () => {
  it('creates a valid import through the service, as draft — validity is not approval', async () => {
    const filePath = await writeImportFile(importFixture({ status: 'approved' }));

    const result = await service().importDocument({ filePath });

    expect(result.kind).toBe('created');
    expect(result.document.id).toBe('imported-prd-20260101-000000');
    expect(result.document.status).toBe('draft'); // structural validity ≠ approval
    expect(result.document.version).toBe(1);

    const persisted = await service().get('imported-prd-20260101-000000');
    expect(persisted.status).toBe('draft');

    const files = await readdir(storeDir());
    expect(files).toContain('imported-prd-20260101-000000.yaml');
  });

  it('reports identical content as a no-op without writing', async () => {
    const created = await service().create({ name: 'Existing PRD' });
    const before = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');

    const filePath = await writeImportFile(importFixture({ ...created }));
    const result = await service().importDocument({ filePath });

    expect(result.kind).toBe('identical');
    const after = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
    expect(after).toBe(before);
  });

  it('refuses a conflicting import with a typed error, a proposed successor, and untouched bytes', async () => {
    const existing = await service().create({ name: 'Authority PRD' });
    await service().linkMission({
      prdId: existing.id,
      missionId: 'mission-keep',
      missionVersion: 'v1',
      requirementIds: ['FR-0'],
    });
    const beforeBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');

    const divergent = importFixture({
      ...existing,
      title: 'Divergent Title',
      sections: [
        {
          id: 'introduction',
          title: 'Introduction',
          fields: { context: 'changed', objective: '' },
        },
      ],
    });
    const filePath = await writeImportFile(divergent);

    const attempt = service().importDocument({ filePath });
    let caught: unknown;
    try {
      await attempt;
    } catch (error) {
      caught = error;
    }
    expect(caught).toBeInstanceOf(PrdImportConflictError);

    const error = caught as PrdImportConflictError;
    expect(error.code).toBe('PRD_IMPORT_CONFLICT');
    expect(error.existing.id).toBe(existing.id);
    expect(error.proposal.version).toBe(existing.version + 1); // successor proposal
    expect(error.proposal.status).toBe('draft');

    // Original authority content untouched on disk.
    const afterBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
    expect(afterBytes).toBe(beforeBytes);
  });

  it('acceptSuccessor persists the proposal explicitly, carrying linkages forward', async () => {
    const existing = await service().create({ name: 'Successor Base' });
    await service().linkMission({
      prdId: existing.id,
      missionId: 'mission-keep',
      missionVersion: 'v1',
    });

    const divergent = importFixture({
      ...existing,
      title: 'Accepted Successor Title',
    });
    const filePath = await writeImportFile(divergent);

    const successor = await service().acceptSuccessor({ filePath });
    expect(successor.id).toBe(existing.id);
    expect(successor.version).toBe(existing.version + 1);
    expect(successor.title).toBe('Accepted Successor Title');
    expect(successor.status).toBe('draft');
    expect(successor.missions.map((m) => m.missionId)).toEqual(['mission-keep']);

    // Persisted for a fresh reader.
    const fresh = await service().get(existing.id);
    expect(fresh.version).toBe(2);
    expect(fresh.title).toBe('Accepted Successor Title');
  });

  it('refuses structurally-invalid imports with a typed error and creates nothing', async () => {
    const cases: Array<{ name: string; body: string }> = [
      { name: 'missing-title.yaml', body: yaml.dump({ id: 'x', status: 'draft' }) },
      {
        name: 'bad-status.yaml',
        body: yaml.dump(importFixture({ status: 'not-a-status' as PrdDocument['status'] })),
      },
      {
        name: 'bad-version.yaml',
        body: yaml.dump(importFixture({ version: 0 })),
      },
      { name: 'not-yaml.yaml', body: '::: not yaml [\n  - {' },
    ];

    for (const fixture of cases) {
      const filePath = path.join(projectDir, fixture.name);
      await writeFile(filePath, fixture.body, 'utf8');

      await expect(service().importDocument({ filePath })).rejects.toBeInstanceOf(
        PrdImportInvalidError,
      );
    }

    // Nothing was created: the authority store does not even exist yet.
    await expect(readdir(storeDir())).rejects.toMatchObject({ code: 'ENOENT' });
  });

  it('acceptSuccessor refuses when there is no existing document to succeed', async () => {
    const filePath = await writeImportFile(importFixture());
    await expect(service().acceptSuccessor({ filePath })).rejects.toBeInstanceOf(PrdNotFoundError);
  });
});

// ── No second writer: no code path reads exported Markdown back into authority ─

describe('no-second-writer invariant (source-level)', () => {
  // Resolve the package source dir whether vitest runs from the package root
  // (turbo/pnpm test) or from the worktree root.
  function resolveSrcDir(): string {
    const candidates = [path.resolve('src'), path.resolve('packages/prdy/src')];
    return candidates.find((dir) => existsSync(path.join(dir, 'service.ts'))) ?? candidates[0]!;
  }

  const srcDir = resolveSrcDir();
  const sourceFiles = [
    'cli.ts',
    'index.ts',
    'prd.ts',
    'service.ts',
    'templates.ts',
    'types.ts',
    'wizard.ts',
  ];

  it('no source file in @mosaicstack/prdy reads a .md file', async () => {
    for (const file of sourceFiles) {
      const text = await readFile(path.join(srcDir, file), 'utf8');
      const readLines = text
        .split('\n')
        .map((line) => line.trim())
        .filter((line) => /readFile|readFileSync|createReadStream/.test(line));

      for (const line of readLines) {
        expect(line.includes('.md'), `${file} reads a Markdown file: ${line}`).toBe(false);
      }
    }
  });

  it('the mosaic prdy/mission adapters never read a .md file', async () => {
    const adapterDir = path.resolve(srcDir, '..', '..', 'mosaic', 'src', 'commands');
    for (const file of ['prdy.ts', 'mission.ts']) {
      const text = await readFile(path.join(adapterDir, file), 'utf8');
      expect(text.includes("'.md'") || text.includes('.md`'), `${file} references a .md path`).toBe(
        false,
      );
    }
  });
});
