import { promises as fs } from 'node:fs';
import path from 'node:path';

import yaml from 'js-yaml';

import { createPrd, listPrds, parsePrdDocument, prdDirectory, savePrd } from './prd.js';
import type {
  PrdCreateInput,
  PrdDocument,
  PrdExportInput,
  PrdExportResult,
  PrdImportInput,
  PrdImportResult,
  PrdLinkMissionInput,
  PrdMissionLinkage,
  PrdPlanForMissionInput,
  PrdServiceOptions,
  PrdUpdateInput,
} from './types.js';

/**
 * PrdService is the SINGLE authority surface for PRD documents.
 *
 * Every mutation path (CLI wizard, `mosaic mission --plan`, import) routes
 * through this service; the YAML store under `docs/prdy/` is the authority and
 * exported Markdown is a generated view that no code path reads back.
 */

// ── Typed errors ───────────────────────────────────────────────────────────────

export class PrdError extends Error {
  constructor(
    message: string,
    readonly code: string,
  ) {
    super(message);
    this.name = 'PrdError';
  }
}

export class PrdNotFoundError extends PrdError {
  constructor(message: string) {
    super(message, 'PRD_NOT_FOUND');
    this.name = 'PrdNotFoundError';
  }
}

export class PrdUpdateError extends PrdError {
  constructor(message: string) {
    super(message, 'PRD_UPDATE_INVALID');
    this.name = 'PrdUpdateError';
  }
}

/** Structural refusal: the import payload failed schema validation. Nothing is written. */
export class PrdImportInvalidError extends PrdError {
  constructor(
    message: string,
    readonly issues?: string,
  ) {
    super(message, 'PRD_IMPORT_INVALID');
    this.name = 'PrdImportInvalidError';
  }
}

/**
 * Conflict refusal: an existing PRD shares the imported id but the content
 * diverges. Carries a PROPOSED successor (existing version + 1) that is only
 * persisted via an explicit {@link PrdService.acceptSuccessor} call — import
 * never overwrites and never merges.
 */
export class PrdImportConflictError extends PrdError {
  constructor(
    message: string,
    readonly existing: PrdDocument,
    readonly proposal: PrdDocument,
  ) {
    super(message, 'PRD_IMPORT_CONFLICT');
    this.name = 'PrdImportConflictError';
  }
}

// ── Service ────────────────────────────────────────────────────────────────────

/** The generated-view label carried by every Markdown export. */
export const PRD_GENERATED_VIEW_LABEL = 'generated view — do not edit';

export class PrdService {
  private readonly projectPath: string;

  constructor(options: PrdServiceOptions) {
    this.projectPath = options.projectPath;
  }

  /** Create a new PRD (version 1, draft) in the authority store. */
  async create(input: PrdCreateInput): Promise<PrdDocument> {
    return createPrd({
      name: input.name,
      projectPath: this.projectPath,
      template: input.template,
      interactive: false,
    });
  }

  /** Read a PRD by id, or the most recently updated one. */
  async get(id?: string): Promise<PrdDocument> {
    const documents = await listPrds(this.projectPath);

    if (id === undefined) {
      const latest = documents[0];
      if (latest === undefined) {
        throw new PrdNotFoundError(`No PRD documents found under docs/prdy/ for this project`);
      }
      return latest;
    }

    const match = documents.find((doc) => doc.id === id);
    if (match === undefined) {
      throw new PrdNotFoundError(`PRD id not found: ${id}`);
    }
    return match;
  }

  /** List all PRDs in the authority store (most recently updated first). */
  async list(): Promise<PrdDocument[]> {
    return listPrds(this.projectPath);
  }

  /**
   * Apply section field patches and bump the content version.
   * Linkage entries are preserved; linkage writes do NOT bump the version.
   */
  async update(input: PrdUpdateInput): Promise<PrdDocument> {
    const doc = await this.get(input.id);

    for (const patch of input.sections) {
      const section = doc.sections.find((candidate) => candidate.id === patch.id);
      if (section === undefined) {
        throw new PrdUpdateError(`Unknown section id: ${patch.id}`);
      }
      for (const [field, value] of Object.entries(patch.fields)) {
        if (!(field in section.fields)) {
          throw new PrdUpdateError(`Unknown field "${field}" on section "${patch.id}"`);
        }
        section.fields[field] = value;
      }
    }

    doc.version += 1;
    doc.updatedAt = new Date().toISOString();
    await savePrd(doc);
    return doc;
  }

  /**
   * Record (or refresh) a mission ↔ PRD linkage on the PRD document.
   * Persisted in the YAML authority, so it survives restarts.
   */
  async linkMission(input: PrdLinkMissionInput): Promise<PrdDocument> {
    const doc = await this.get(input.prdId);
    return this.applyLinkage(doc, input);
  }

  /** Read back the mission linkages recorded on a PRD. */
  async listMissionLinks(prdId?: string): Promise<PrdMissionLinkage[]> {
    const doc = await this.get(prdId);
    return doc.missions;
  }

  /**
   * Mission planning path: create a PRD for a mission AND persist the
   * mission↔PRD linkage in a single authority write.
   */
  async planForMission(input: PrdPlanForMissionInput): Promise<PrdDocument> {
    const doc = await this.create({ name: input.name, template: input.template });
    return this.applyLinkage(doc, {
      prdId: doc.id,
      missionId: input.missionId,
      missionVersion: input.missionVersion,
      requirementIds: input.requirementIds,
    });
  }

  /**
   * Render the PRD to a Markdown GENERATED VIEW.
   *
   * The output carries source identity (PRD id + version + generated-view
   * label). It is written under `docs/prdy/<id>.md` and is NEVER read back:
   * the authority store only loads `.yaml`/`.yml` files, and no code path in
   * this package parses the exported Markdown.
   */
  async exportMarkdown(input?: PrdExportInput): Promise<PrdExportResult> {
    const doc = await this.get(input?.id);
    const content = renderMarkdown(doc);
    const filePath = input?.outPath ?? path.join(prdDirectory(doc.projectPath), `${doc.id}.md`);

    await fs.mkdir(path.dirname(filePath), { recursive: true });
    await fs.writeFile(filePath, content, 'utf8');
    return { filePath, content };
  }

  /**
   * Import a YAML PRD document.
   *
   * Structural validation (zod) happens BEFORE anything is proposed or
   * written. A structurally-valid import is persisted as `draft` — validity is
   * NOT approval. If an existing PRD shares the id with divergent content, a
   * typed {@link PrdImportConflictError} is thrown carrying a proposed
   * successor; the original authority document is left byte-identical on disk.
   */
  async importDocument(input: PrdImportInput): Promise<PrdImportResult> {
    const incoming = await this.readImportFile(input.filePath);

    const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
    if (existing === undefined) {
      const document = this.buildImportedDocument(incoming);
      await savePrd(document);
      return { kind: 'created', document };
    }

    if (canonicalCore(existing) === canonicalCore(incoming)) {
      return { kind: 'identical', document: existing };
    }

    throw new PrdImportConflictError(
      `PRD id "${incoming.id}" already exists with divergent content — refusing to overwrite. ` +
        `Proposed successor: version ${existing.version + 1} (draft). ` +
        `Accept explicitly with acceptSuccessor().`,
      existing,
      this.buildSuccessor(existing, incoming),
    );
  }

  /**
   * Explicitly accept a conflicted import as a successor version of the
   * existing PRD. Re-validates the source file before writing; the successor
   * is persisted with status `draft` (acceptance of the import is not approval
   * of the PRD) and the existing mission linkages are carried forward.
   */
  async acceptSuccessor(input: PrdImportInput): Promise<PrdDocument> {
    const incoming = await this.readImportFile(input.filePath);

    const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
    if (existing === undefined) {
      throw new PrdNotFoundError(
        `No existing PRD with id "${incoming.id}" — use importDocument to create it`,
      );
    }

    const successor = this.buildSuccessor(existing, incoming);
    await savePrd(successor);
    return successor;
  }

  // ── internals ──────────────────────────────────────────────────────────────

  private async applyLinkage(doc: PrdDocument, input: PrdLinkMissionInput): Promise<PrdDocument> {
    const entry: PrdMissionLinkage = {
      missionId: input.missionId,
      missionVersion: input.missionVersion,
      prdVersion: doc.version,
      requirementIds: input.requirementIds ?? [],
      linkedAt: new Date().toISOString(),
    };

    // One entry per mission: refresh in place if the mission is already linked.
    const index = doc.missions.findIndex((m) => m.missionId === entry.missionId);
    if (index === -1) {
      doc.missions.push(entry);
    } else {
      doc.missions[index] = entry;
    }

    // Linkage is mission-side metadata, not a content revision: bump the
    // timestamp only so ids/versions stay stable for consumers.
    doc.updatedAt = new Date().toISOString();
    await savePrd(doc);
    return doc;
  }

  private async readImportFile(filePath: string): Promise<PrdDocument> {
    let raw: string;
    try {
      raw = await fs.readFile(filePath, 'utf8');
    } catch (error) {
      throw new PrdImportInvalidError(`Cannot read import file ${filePath}: ${String(error)}`);
    }

    let parsed: unknown;
    try {
      parsed = yaml.load(raw);
    } catch (error) {
      throw new PrdImportInvalidError(`Import file is not valid YAML: ${String(error)}`);
    }

    try {
      return parsePrdDocument(parsed);
    } catch (error) {
      throw new PrdImportInvalidError(
        `Import file failed PRD schema validation: ${filePath}`,
        error instanceof Error ? error.message : String(error),
      );
    }
  }

  private buildImportedDocument(incoming: PrdDocument): PrdDocument {
    const now = new Date().toISOString();
    return {
      ...incoming,
      // The import lands in THIS project's authority store.
      projectPath: this.projectPath,
      // A structurally-valid import is not thereby approved.
      status: 'draft',
      version: 1,
      missions: [],
      createdAt: now,
      updatedAt: now,
    };
  }

  private buildSuccessor(existing: PrdDocument, incoming: PrdDocument): PrdDocument {
    return {
      ...incoming,
      id: existing.id,
      projectPath: existing.projectPath,
      status: 'draft',
      version: existing.version + 1,
      missions: existing.missions,
      createdAt: existing.createdAt,
      updatedAt: new Date().toISOString(),
    };
  }
}

// ── Markdown rendering (generated view) ───────────────────────────────────────

function canonicalCore(doc: PrdDocument): string {
  return JSON.stringify([doc.title, doc.template, doc.sections]);
}

function renderMarkdown(doc: PrdDocument): string {
  const lines: string[] = [
    '<!--',
    `${PRD_GENERATED_VIEW_LABEL}`,
    `source-of-truth: docs/prdy/${doc.id}.yaml (YAML authority)`,
    `prd-id: ${doc.id}`,
    `prd-version: ${doc.version}`,
    `generated-at: ${new Date().toISOString()}`,
    '-->',
    '',
    `# ${doc.title}`,
    '',
    `**Status:** ${doc.status} · **Version:** ${doc.version} · **Template:** ${doc.template}`,
    '',
  ];

  if (doc.missions.length > 0) {
    lines.push('## Mission Linkage', '');
    for (const mission of doc.missions) {
      const requirements =
        mission.requirementIds.length > 0 ? mission.requirementIds.join(', ') : 'none selected';
      lines.push(
        `- mission \`${mission.missionId}\` @ version \`${mission.missionVersion}\`` +
          ` (linked at PRD v${mission.prdVersion}) — requirements: ${requirements}`,
      );
    }
    lines.push('');
  }

  for (const section of doc.sections) {
    lines.push(`## ${section.title}`, '');
    for (const [field, value] of Object.entries(section.fields)) {
      lines.push(`### ${field}`, '', value.trim().length > 0 ? value : '_Not set_.', '');
    }
  }

  lines.push('---', '', `_End of generated view for ${doc.id} v${doc.version}._`, '');
  return lines.join('\n');
}
