import type {
  ComfyApiEventDetailCached,
  ComfyApiEventDetailError,
  ComfyApiEventDetailExecuted,
  ComfyApiEventDetailExecuting,
  ComfyApiEventDetailExecutionStart,
  ComfyApiEventDetailProgress,
  ComfyApiEventDetailStatus,
  ComfyApiFormat,
  ComfyApiPrompt,
} from "typings/comfy.js";
import { api } from "scripts/api.js";
import type { LGraph as TLGraph, LGraphCanvas as TLGraphCanvas } from "typings/litegraph.js";
import { Resolver, getResolver } from "./shared_utils.js";

/**
 * Wraps general data of a prompt's execution.
 */
export class PromptExecution {
  id: string;
  promptApi: ComfyApiFormat | null = null;
  executedNodeIds: string[] = [];
  totalNodes: number = 0;
  currentlyExecuting: {
    nodeId: string;
    nodeLabel?: string;
    step?: number;
    maxSteps?: number;
    /** The current pass, for nodes with multiple progress passes. */
    pass: number;
    /**
     * The max num of passes. Can be calculated for some nodes, or set to -1 when known there will
     * be multiple passes, but the number cannot be calculated.
     */
    maxPasses?: number;
  } | null = null;
  errorDetails: any | null = null;

  apiPrompt: Resolver<null> = getResolver();

  constructor(id: string) {
    this.id = id;
  }

  /**
   * Sets the prompt and prompt-related data. This can technically come in lazily, like if the web
   * socket fires the 'execution-start' event before we actually get a response back from the
   * initial prompt call.
   */
  setPrompt(prompt: ComfyApiPrompt) {
    this.promptApi = prompt.output;
    this.totalNodes = Object.keys(this.promptApi).length;
    this.apiPrompt.resolve(null);
  }

  getApiNode(nodeId: string | number) {
    return this.promptApi?.[String(nodeId)] || null;
  }

  private getNodeLabel(nodeId: string | number) {
    const apiNode = this.getApiNode(nodeId);
    let label = apiNode?._meta?.title || apiNode?.class_type || undefined;
    if (!label) {
      const graphNode = this.maybeGetComfyGraph()?.getNodeById(Number(nodeId));
      label = graphNode?.title || graphNode?.type || undefined;
    }
    return label;
  }

  /**
   * Updates the execution data depending on the passed data, fed from api events.
   */
  executing(nodeId: string | null, step?: number, maxSteps?: number) {
    if (nodeId == null) {
      // We're done, any left over nodes must be skipped...
      this.currentlyExecuting = null;
      return;
    }
    if (this.currentlyExecuting?.nodeId !== nodeId) {
      if (this.currentlyExecuting != null) {
        this.executedNodeIds.push(nodeId);
      }
      this.currentlyExecuting = { nodeId, nodeLabel: this.getNodeLabel(nodeId), pass: 0 };
      // We'll see if we're known node for multiple passes, that will come in as generic 'progress'
      // updates from the api. If we're known to have multiple passes, then we'll pre-set data to
      // allow the progress bar to handle intial rendering. If we're not, that's OK, the data will
      // be shown with the second pass.
      this.apiPrompt.promise.then(() => {
        // If we execute with a null node id and clear the currently executing, then we can just
        // move on. This seems to only happen with a super-fast execution (like, just seed node
        // and display any for testing).
        if (this.currentlyExecuting == null) {
          return;
        }
        const apiNode = this.getApiNode(nodeId);
        if (!this.currentlyExecuting.nodeLabel) {
          this.currentlyExecuting.nodeLabel = this.getNodeLabel(nodeId);
        }
        if (apiNode?.class_type === "UltimateSDUpscale") {
          // From what I can tell, UltimateSDUpscale, does an initial pass that isn't actually a
          // tile. It seems to always be 4 steps... We'll start our pass at -1, so this prepass is
          // "0" and "1" will start with the first tile. This way, a user knows they have 4 tiles,
          // know this pass counter will go to 4 (and not 5). Also, we cannot calculate maxPasses
          // for 'UltimateSDUpscale' :(
          this.currentlyExecuting.pass--;
          this.currentlyExecuting.maxPasses = -1;
        } else if (apiNode?.class_type === "IterativeImageUpscale") {
          this.currentlyExecuting.maxPasses = (apiNode?.inputs["steps"] as number) ?? -1;
        }
      });
    }
    if (step != null) {
      // If we haven't had any stpes before, or the passes step is lower than the previous, then
      // increase the passes.
      if (!this.currentlyExecuting!.step || step < this.currentlyExecuting!.step) {
        this.currentlyExecuting!.pass!++;
      }
      this.currentlyExecuting!.step = step;
      this.currentlyExecuting!.maxSteps = maxSteps;
    }
  }

  /**
   * If there's an error, we add the details.
   */
  error(details: any) {
    this.errorDetails = details;
  }

  private maybeGetComfyGraph(): TLGraph | null {
    return ((window as any)?.app?.graph as TLGraph) || null;
  }
}

/**
 * A singleton service that wraps the Comfy API and simplifies the event data being fired.
 */
class PromptService extends EventTarget {
  promptsMap: Map<string, PromptExecution> = new Map();
  currentExecution: PromptExecution | null = null;
  lastQueueRemaining = 0;

  constructor(api: any) {
    super();
    const that = this;

    // Patch the queuePrompt method so we can capture new data going through.
    const queuePrompt = api.queuePrompt;
    api.queuePrompt = async function (num: number, prompt: ComfyApiPrompt) {
      let response;
      try {
        response = await queuePrompt.apply(api, [...arguments]);
      } catch (e) {
        const promptExecution = that.getOrMakePrompt("error");
        promptExecution.error({ exception_type: "Unknown." });
        // console.log("ERROR QUEUE PROMPT", response, arguments);
        throw e;
      }
      // console.log("QUEUE PROMPT", response, arguments);
      const promptExecution = that.getOrMakePrompt(response.prompt_id);
      promptExecution.setPrompt(prompt);
      if (!that.currentExecution) {
        that.currentExecution = promptExecution;
      }
      that.promptsMap.set(response.prompt_id, promptExecution);
      that.dispatchEvent(
        new CustomEvent("queue-prompt", {
          detail: {
            prompt: promptExecution,
          },
        }),
      );
      return response;
    };

    api.addEventListener("status", (e: CustomEvent<ComfyApiEventDetailStatus>) => {
      // console.log("status", JSON.stringify(e.detail));
      // Sometimes a status message is fired when the app loades w/o any details.
      if (!e.detail?.exec_info) return;
      this.lastQueueRemaining = e.detail.exec_info.queue_remaining;
      this.dispatchProgressUpdate();
    });

    api.addEventListener("execution_start", (e: CustomEvent<ComfyApiEventDetailExecutionStart>) => {
      // console.log("execution_start", JSON.stringify(e.detail));
      if (!this.promptsMap.has(e.detail.prompt_id)) {
        console.warn("'execution_start' fired before prompt was made.");
      }
      const prompt = this.getOrMakePrompt(e.detail.prompt_id);
      this.currentExecution = prompt;
      this.dispatchProgressUpdate();
    });

    api.addEventListener("executing", (e: CustomEvent<ComfyApiEventDetailExecuting>) => {
      // console.log("executing", JSON.stringify(e.detail));
      if (!this.currentExecution) {
        this.currentExecution = this.getOrMakePrompt("unknown");
        console.warn("'executing' fired before prompt was made.");
      }
      this.currentExecution.executing(e.detail);
      this.dispatchProgressUpdate();
      if (e.detail == null) {
        this.currentExecution = null;
      }
    });

    api.addEventListener("progress", (e: CustomEvent<ComfyApiEventDetailProgress>) => {
      // console.log("progress", JSON.stringify(e.detail));
      if (!this.currentExecution) {
        this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
        console.warn("'progress' fired before prompt was made.");
      }
      this.currentExecution.executing(e.detail.node, e.detail.value, e.detail.max);
      this.dispatchProgressUpdate();
    });

    api.addEventListener("execution_cached", (e: CustomEvent<ComfyApiEventDetailCached>) => {
      // console.log("execution_cached", JSON.stringify(e.detail));
      if (!this.currentExecution) {
        this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
        console.warn("'execution_cached' fired before prompt was made.");
      }
      for (const cached of e.detail.nodes) {
        this.currentExecution.executing(cached);
      }
      this.dispatchProgressUpdate();
    });

    api.addEventListener("executed", (e: CustomEvent<ComfyApiEventDetailExecuted>) => {
      // console.log("executed", JSON.stringify(e.detail));
      if (!this.currentExecution) {
        this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
        console.warn("'executed' fired before prompt was made.");
      }
    });

    api.addEventListener("execution_error", (e: CustomEvent<ComfyApiEventDetailError>) => {
      // console.log("execution_error", e.detail);
      if (!this.currentExecution) {
        this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
        console.warn("'execution_error' fired before prompt was made.");
      }
      this.currentExecution?.error(e.detail);
      this.dispatchProgressUpdate();
    });
  }

  /** A helper method, since we extend/override api.queuePrompt above anyway. */
  async queuePrompt(prompt: ComfyApiPrompt) {
    return await api.queuePrompt(-1, prompt);
  }

  dispatchProgressUpdate() {
    this.dispatchEvent(
      new CustomEvent("progress-update", {
        detail: {
          queue: this.lastQueueRemaining,
          prompt: this.currentExecution,
        },
      }),
    );
  }

  getOrMakePrompt(id: string) {
    let prompt = this.promptsMap.get(id);
    if (!prompt) {
      prompt = new PromptExecution(id);
      this.promptsMap.set(id, prompt);
    }
    return prompt;
  }
}

export const SERVICE = new PromptService(api);
