import type {
  AudioResponse,
  ChaosConfig,
  EmbeddingFixtureOpts,
  Fixture,
  FixtureFileEntry,
  FixtureFileResponse,
  FixtureMatch,
  FixtureOpts,
  FixtureResponse,
  ImageResponse,
  MockServerOptions,
  Mountable,
  RecordConfig,
  ResponseFactory,
  TranscriptionResponse,
  VideoResponse,
} from "./types.js";
import { createServer, type ServerInstance } from "./server.js";
import {
  loadFixtureFile,
  loadFixturesFromDir,
  entryToFixture,
  normalizeResponse,
  validateFixtures,
} from "./fixture-loader.js";
import { Journal } from "./journal.js";
import type { SearchFixture, SearchResult } from "./search.js";
import type { RerankFixture, RerankResult } from "./rerank.js";
import type { ModerationFixture, ModerationResult } from "./moderation.js";
import { falJobs } from "./fal-audio.js";
import { falQueueStates, imageResponseToFalJson, videoResponseToFalJson } from "./fal.js";

export class LLMock {
  private fixtures: Fixture[] = [];
  private searchFixtures: SearchFixture[] = [];
  private rerankFixtures: RerankFixture[] = [];
  private moderationFixtures: ModerationFixture[] = [];
  private mounts: Array<{ path: string; handler: Mountable }> = [];
  private serverInstance: ServerInstance | null = null;
  private options: MockServerOptions;

  constructor(options?: MockServerOptions) {
    this.options = options ?? {};
  }

  // ---- Fixture management ----

  addFixture(fixture: Fixture): this {
    this.fixtures.push(fixture);
    return this;
  }

  addFixtures(fixtures: Fixture[]): this {
    this.fixtures.push(...fixtures);
    return this;
  }

  prependFixture(fixture: Fixture): this {
    this.fixtures.unshift(fixture);
    return this;
  }

  getFixtures(): readonly Fixture[] {
    return this.fixtures;
  }

  loadFixtureFile(filePath: string): this {
    this.fixtures.push(...loadFixtureFile(filePath));
    return this;
  }

  loadFixtureDir(dirPath: string): this {
    this.fixtures.push(...loadFixturesFromDir(dirPath));
    return this;
  }

  /**
   * Add fixtures from a JSON string or pre-parsed array of fixture entries.
   * Validates all fixtures and throws if any have severity "error".
   */
  addFixturesFromJSON(input: string | FixtureFileEntry[]): this {
    const entries: FixtureFileEntry[] = typeof input === "string" ? JSON.parse(input) : input;
    const converted = entries.map((e) => entryToFixture(e));
    const issues = validateFixtures(converted);
    const errors = issues.filter((i) => i.severity === "error");
    if (errors.length > 0) {
      throw new Error(`Fixture validation failed: ${JSON.stringify(errors)}`);
    }
    this.fixtures.push(...converted);
    return this;
  }

  // Uses length = 0 to preserve array reference identity — the running
  // server reads this same array on every request.
  clearFixtures(): this {
    this.fixtures.length = 0;
    return this;
  }

  // ---- Convenience ----

  on(
    match: FixtureMatch,
    response: FixtureFileResponse | ResponseFactory,
    opts?: FixtureOpts,
  ): this {
    return this.addFixture({
      match,
      response: typeof response === "function" ? response : normalizeResponse(response),
      ...opts,
    });
  }

  onMessage(
    pattern: string | RegExp,
    response: FixtureFileResponse | ResponseFactory,
    opts?: FixtureOpts,
  ): this {
    return this.on({ userMessage: pattern }, response, opts);
  }

  onEmbedding(
    pattern: string | RegExp,
    response: FixtureFileResponse | ResponseFactory,
    opts?: EmbeddingFixtureOpts,
  ): this {
    return this.on({ inputText: pattern }, response, opts);
  }

  onJsonOutput(pattern: string | RegExp, jsonContent: object | string, opts?: FixtureOpts): this {
    const content = typeof jsonContent === "string" ? jsonContent : JSON.stringify(jsonContent);
    return this.on({ userMessage: pattern, responseFormat: "json_object" }, { content }, opts);
  }

  onToolCall(
    name: string,
    response: FixtureFileResponse | ResponseFactory,
    opts?: FixtureOpts,
  ): this {
    return this.on({ toolName: name }, response, opts);
  }

  onToolResult(
    id: string,
    response: FixtureFileResponse | ResponseFactory,
    opts?: FixtureOpts,
  ): this {
    return this.on({ toolCallId: id }, response, opts);
  }

  onTurn(
    turn: number,
    pattern: string | RegExp,
    response: FixtureFileResponse | ResponseFactory,
    opts?: FixtureOpts,
  ): this {
    return this.on({ userMessage: pattern, turnIndex: turn }, response, opts);
  }

  onImage(prompt: string | RegExp, response: ImageResponse): this {
    return this.addFixture({
      match: { userMessage: prompt, endpoint: "image" },
      response,
    });
  }

  onSpeech(input: string | RegExp, response: AudioResponse): this {
    return this.addFixture({
      match: { userMessage: input, endpoint: "speech" },
      response,
    });
  }

  onTranscription(response: TranscriptionResponse): this {
    return this.addFixture({
      match: { endpoint: "transcription" },
      response,
    });
  }

  onTranslation(response: TranscriptionResponse): this {
    return this.addFixture({
      match: { endpoint: "translation" },
      response,
    });
  }

  onVideo(prompt: string | RegExp, response: VideoResponse): this {
    return this.addFixture({
      match: { userMessage: prompt, endpoint: "video" },
      response,
    });
  }

  onAudio(input: string | RegExp, response: AudioResponse): this {
    return this.addFixture({ match: { userMessage: input }, response });
  }

  onSoundEffect(text: string | RegExp, response: AudioResponse): this {
    return this.addFixture({
      match: { userMessage: text, endpoint: "audio-gen" },
      response,
    });
  }

  onMusic(prompt: string | RegExp, response: AudioResponse): this {
    return this.addFixture({
      match: { userMessage: prompt, endpoint: "audio-gen" },
      response,
    });
  }

  onElevenLabsTTS(text: string | RegExp, response: AudioResponse): this {
    return this.addFixture({
      match: { userMessage: text, endpoint: "elevenlabs-tts" },
      response,
    });
  }

  onFalAudio(prompt: string | RegExp, response: AudioResponse, model?: string): this {
    return this.addFixture({
      match: { userMessage: prompt, endpoint: "fal-audio", ...(model ? { model } : {}) },
      response,
    });
  }

  // fal.queue.* is the dominant client API; onFalRun is a sync alias.
  onFalQueue(modelOrPrompt: string | RegExp, response: unknown, opts?: FixtureOpts): this {
    return this.addFixture({
      match: { model: modelOrPrompt, endpoint: "fal" },
      response: { json: response },
      ...opts,
    });
  }

  onFalRun(modelOrPrompt: string | RegExp, response: unknown, opts?: FixtureOpts): this {
    return this.onFalQueue(modelOrPrompt, response, opts);
  }

  /**
   * Register a fal.ai image fixture. Wraps an `ImageResponse` (the shape used
   * by `onImage` and OpenAI/Azure image fixtures) into fal's image envelope
   * before storing it as a `RawJSONResponse`. Defaults `width`/`height` to
   * 1024 when the fixture's `ImageItem` doesn't carry them.
   */
  onFalImage(modelOrPrompt: string | RegExp, response: ImageResponse, opts?: FixtureOpts): this {
    return this.onFalQueue(modelOrPrompt, imageResponseToFalJson(response), opts);
  }

  /**
   * Register a fal.ai video fixture. Wraps a `VideoResponse` into fal's video
   * envelope (`{ video: { url, content_type, file_name, file_size }, seed }`)
   * before storing it as a `RawJSONResponse`.
   */
  onFalVideo(modelOrPrompt: string | RegExp, response: VideoResponse, opts?: FixtureOpts): this {
    return this.onFalQueue(modelOrPrompt, videoResponseToFalJson(response), opts);
  }

  // ---- Service mock convenience methods ----

  onSearch(pattern: string | RegExp, results: SearchResult[]): this {
    this.searchFixtures.push({ match: pattern, results });
    return this;
  }

  onRerank(pattern: string | RegExp, results: RerankResult[]): this {
    this.rerankFixtures.push({ match: pattern, results });
    return this;
  }

  onModerate(pattern: string | RegExp, result: ModerationResult): this {
    this.moderationFixtures.push({ match: pattern, result });
    return this;
  }

  /**
   * Queue a one-shot error that will be returned for the next matching
   * request, then automatically removed. Implemented as an internal fixture
   * with a `predicate` that always matches (so it fires first) and spliced
   * at the front of the fixture list.
   */
  nextRequestError(
    status: number,
    errorBody?: { message?: string; type?: string; code?: string },
  ): this {
    const errorResponse: FixtureResponse = {
      error: {
        message: errorBody?.message ?? "Injected error",
        type: errorBody?.type ?? "server_error",
        code: errorBody?.code,
      },
      status,
    };
    const fixture: Fixture = {
      match: { predicate: () => true },
      response: errorResponse,
    };
    // Insert at front so it matches before everything else
    this.fixtures.unshift(fixture);
    // Remove after first match — the journal records it so tests can assert
    const original = fixture.match.predicate!;
    fixture.match.predicate = (req) => {
      const result = original(req);
      if (result) {
        // Remove synchronously on first match to prevent race conditions
        const idx = this.fixtures.indexOf(fixture);
        if (idx !== -1) this.fixtures.splice(idx, 1);
      }
      return result;
    };
    return this;
  }

  // ---- Mounts ----

  mount(path: string, handler: Mountable): this {
    this.mounts.push({ path, handler });

    // If server is already running, wire up journal, registry, and baseUrl immediately
    // so late mounts behave identically to pre-start mounts.
    if (this.serverInstance) {
      if (handler.setJournal) handler.setJournal(this.serverInstance.journal);
      if (handler.setBaseUrl) handler.setBaseUrl(this.serverInstance.url + path);
      const registry = this.serverInstance.defaults.registry;
      if (registry && handler.setRegistry) handler.setRegistry(registry);
    }

    return this;
  }

  // ---- Journal proxies ----

  getRequests(): import("./types.js").JournalEntry[] {
    return this.journal.getAll();
  }

  getLastRequest(): import("./types.js").JournalEntry | null {
    return this.journal.getLast();
  }

  clearRequests(): void {
    this.journal.clear();
  }

  resetMatchCounts(testId?: string): this {
    if (this.serverInstance) {
      this.serverInstance.journal.clearMatchCounts(testId);
    }
    return this;
  }

  // ---- Chaos ----

  setChaos(config: ChaosConfig): this {
    this.options.chaos = config;
    return this;
  }

  clearChaos(): this {
    delete this.options.chaos;
    return this;
  }

  // ---- Recording ----

  enableRecording(config: RecordConfig): this {
    this.options.record = config;
    return this;
  }

  disableRecording(): this {
    delete this.options.record;
    return this;
  }

  // ---- Reset ----

  reset(): this {
    this.clearFixtures();
    this.searchFixtures.length = 0;
    this.rerankFixtures.length = 0;
    this.moderationFixtures.length = 0;
    falJobs.clear();
    falQueueStates.clear();
    if (this.serverInstance) {
      this.serverInstance.journal.clear();
      this.serverInstance.videoStates.clear();
    }
    return this;
  }

  // ---- Server lifecycle ----

  async start(): Promise<string> {
    if (this.serverInstance) {
      throw new Error("Server already started");
    }
    this.serverInstance = await createServer(this.fixtures, this.options, this.mounts, {
      search: this.searchFixtures,
      rerank: this.rerankFixtures,
      moderation: this.moderationFixtures,
    });
    return this.serverInstance.url;
  }

  async stop(): Promise<void> {
    if (!this.serverInstance) {
      throw new Error("Server not started");
    }
    const { server } = this.serverInstance;
    await new Promise<void>((resolve, reject) => {
      server.close((err: Error | undefined) => (err ? reject(err) : resolve()));
    });
    this.serverInstance = null;
  }

  // ---- Accessors ----

  get journal(): Journal {
    if (!this.serverInstance) {
      throw new Error("Server not started");
    }
    return this.serverInstance.journal;
  }

  get url(): string {
    if (!this.serverInstance) {
      throw new Error("Server not started");
    }
    return this.serverInstance.url;
  }

  get baseUrl(): string {
    return this.url;
  }

  get port(): number {
    const parsed = new URL(this.url); // this.url throws if not started
    if (!parsed.port) {
      throw new Error(`Server URL has no explicit port: ${this.url}`);
    }
    return parseInt(parsed.port, 10);
  }

  // ---- Static factory ----

  static async create(options?: MockServerOptions): Promise<LLMock> {
    const instance = new LLMock(options);
    await instance.start();
    return instance;
  }
}
