const {
  Notice,
  Plugin,
  PluginSettingTab,
  Setting,
  TFile,
} = require("obsidian");

const DEFAULT_SETTINGS = {
  codeBlockLanguage: "text",
  includeMetadataKey: true,
  skipExistingPromptBlocks: true,
};

const IMAGE_EMBED_RE = /!\[[^\]]*]\(([^)]+)\)|!\[\[([^|\]\n]+)(?:\|[^\]\n]*)?]]/g;
const PROMPT_BLOCK_MARKER = "<!-- exif-prompt-extractor -->";

module.exports = class ExifPromptPlugin extends Plugin {
  async onload() {
    this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());

    this.addRibbonIcon("image-plus", "Extract image prompt", async () => {
      await this.extractForActiveImage();
    });

    this.addCommand({
      id: "extract-prompt-for-image-at-cursor",
      name: "Extract prompt for image at cursor",
      icon: "image-plus",
      hotkeys: [{ modifiers: ["Mod", "Alt"], key: "p" }],
      editorCallback: async (editor, view) => {
        await this.extractForImageAtCursor(editor, view);
      },
    });

    this.addCommand({
      id: "extract-prompts-for-all-images-in-note",
      name: "Extract prompts for all images in note",
      icon: "images",
      editorCallback: async (editor, view) => {
        await this.extractAllInEditor(editor, view);
      },
    });

    this.addSettingTab(new ExifPromptSettingTab(this.app, this));
  }

  async saveSettings() {
    await this.saveData(this.settings);
  }

  async extractForActiveImage() {
    const view = this.app.workspace.getActiveViewOfType(require("obsidian").MarkdownView);
    if (!view) {
      new Notice("Open a Markdown note first.");
      return;
    }
    await this.extractForImageAtCursor(view.editor, view);
  }

  async extractForImageAtCursor(editor, view) {
    const lineNumber = editor.getCursor().line;
    const match = findNearestImageEmbed(editor, lineNumber);
    if (!match) {
      new Notice("No image embed found near the cursor.");
      return;
    }

    const result = await this.extractPromptFromLink(match.link, view.file);
    if (!result.prompt) {
      new Notice(result.message || "No prompt metadata found.");
      return;
    }

    if (this.settings.skipExistingPromptBlocks && hasPromptBlockAfter(editor, match.line)) {
      new Notice("Prompt block already exists below this image.");
      return;
    }

    const block = this.formatPromptBlock(result.prompt, result.label);
    editor.replaceRange("\n" + block, { line: match.line + 1, ch: 0 });
    new Notice("Prompt inserted below the image.");
  }

  async extractAllInEditor(editor, view) {
    const embeds = findAllImageEmbeds(editor.getValue());
    if (!embeds.length) {
      new Notice("No image embeds found in this note.");
      return;
    }

    let inserted = 0;
    for (let index = embeds.length - 1; index >= 0; index--) {
      const embed = embeds[index];
      if (this.settings.skipExistingPromptBlocks && hasPromptBlockAfter(editor, embed.line)) {
        continue;
      }

      const result = await this.extractPromptFromLink(embed.link, view.file);
      if (!result.prompt) {
        continue;
      }

      editor.replaceRange(
        "\n" + this.formatPromptBlock(result.prompt, result.label),
        { line: embed.line + 1, ch: 0 }
      );
      inserted++;
    }

    new Notice(inserted ? `Inserted ${inserted} prompt block(s).` : "No new prompt metadata found.");
  }

  async extractPromptFromLink(link, sourceFile) {
    const cleanLink = normalizeImageLink(link);
    const file = this.app.metadataCache.getFirstLinkpathDest(cleanLink, sourceFile ? sourceFile.path : "");

    if (!(file instanceof TFile)) {
      return { prompt: null, message: `Image not found: ${cleanLink}` };
    }

    const extension = file.extension.toLowerCase();
    if (!["png", "jpg", "jpeg", "webp"].includes(extension)) {
      return { prompt: null, message: `Unsupported image type: ${file.extension}` };
    }

    const buffer = await this.app.vault.readBinary(file);
    const bytes = new Uint8Array(buffer);
    const metadata = extractImageMetadata(bytes, extension);
    const picked = pickPrompt(metadata);

    return {
      prompt: picked ? picked.value : null,
      label: picked ? picked.key : null,
      message: picked ? null : `No prompt metadata found in ${file.name}.`,
    };
  }

  formatPromptBlock(prompt, label) {
    const language = this.settings.codeBlockLanguage.trim() || "text";
    const header = this.settings.includeMetadataKey && label ? `Prompt metadata: ${label}\n\n` : "";
    return `${PROMPT_BLOCK_MARKER}\n\`\`\`${language}\n${header}${prompt.trim()}\n\`\`\`\n`;
  }
};

class ExifPromptSettingTab extends PluginSettingTab {
  constructor(app, plugin) {
    super(app, plugin);
    this.plugin = plugin;
  }

  display() {
    const { containerEl } = this;
    containerEl.empty();

    new Setting(containerEl)
      .setName("Code block language")
      .setDesc("Language label for inserted fenced code blocks.")
      .addText((text) => text
        .setPlaceholder("text")
        .setValue(this.plugin.settings.codeBlockLanguage)
        .onChange(async (value) => {
          this.plugin.settings.codeBlockLanguage = value || "text";
          await this.plugin.saveSettings();
        }));

    new Setting(containerEl)
      .setName("Include metadata key")
      .setDesc("Add the metadata field name at the top of inserted prompt blocks.")
      .addToggle((toggle) => toggle
        .setValue(this.plugin.settings.includeMetadataKey)
        .onChange(async (value) => {
          this.plugin.settings.includeMetadataKey = value;
          await this.plugin.saveSettings();
        }));

    new Setting(containerEl)
      .setName("Skip existing prompt blocks")
      .setDesc("Avoid inserting another block when this plugin already added one below the image.")
      .addToggle((toggle) => toggle
        .setValue(this.plugin.settings.skipExistingPromptBlocks)
        .onChange(async (value) => {
          this.plugin.settings.skipExistingPromptBlocks = value;
          await this.plugin.saveSettings();
        }));
  }
}

function findNearestImageEmbed(editor, lineNumber) {
  const maxDistance = 8;
  for (let distance = 0; distance <= maxDistance; distance++) {
    for (const candidateLine of uniqueLineCandidates(lineNumber, distance, editor.lineCount())) {
      const line = editor.getLine(candidateLine);
      const matches = parseImageEmbedsFromLine(line, candidateLine);
      if (matches.length) {
        return matches[0];
      }
    }
  }
  return null;
}

function uniqueLineCandidates(baseLine, distance, lineCount) {
  const candidates = distance === 0 ? [baseLine] : [baseLine - distance, baseLine + distance];
  return [...new Set(candidates)].filter((line) => line >= 0 && line < lineCount);
}

function findAllImageEmbeds(markdown) {
  const lines = markdown.split("\n");
  return lines.flatMap((line, index) => parseImageEmbedsFromLine(line, index));
}

function parseImageEmbedsFromLine(line, lineNumber) {
  const matches = [];
  IMAGE_EMBED_RE.lastIndex = 0;
  let match;
  while ((match = IMAGE_EMBED_RE.exec(line)) !== null) {
    matches.push({
      line: lineNumber,
      link: match[1] || match[2],
      from: match.index,
      to: match.index + match[0].length,
    });
  }
  return matches;
}

function hasPromptBlockAfter(editor, imageLine) {
  const lookAhead = Math.min(editor.lineCount(), imageLine + 8);
  for (let line = imageLine + 1; line < lookAhead; line++) {
    const text = editor.getLine(line);
    if (text.includes(PROMPT_BLOCK_MARKER)) {
      return true;
    }
    if (parseImageEmbedsFromLine(text, line).length) {
      return false;
    }
  }
  return false;
}

function normalizeImageLink(link) {
  return decodeURIComponent(link)
    .split("#")[0]
    .split("?")[0]
    .trim();
}

function extractImageMetadata(bytes, extension) {
  if (extension === "png") {
    return extractPngMetadata(bytes);
  }
  return extractStringMetadata(bytes);
}

function extractPngMetadata(bytes) {
  const metadata = {};
  if (!hasPngSignature(bytes)) {
    return extractStringMetadata(bytes);
  }

  let offset = 8;
  while (offset + 12 <= bytes.length) {
    const length = readUInt32(bytes, offset);
    const type = ascii(bytes.subarray(offset + 4, offset + 8));
    const dataStart = offset + 8;
    const dataEnd = dataStart + length;
    if (dataEnd + 4 > bytes.length) {
      break;
    }

    if (type === "tEXt") {
      readTextChunk(bytes.subarray(dataStart, dataEnd), metadata);
    } else if (type === "iTXt") {
      readInternationalTextChunk(bytes.subarray(dataStart, dataEnd), metadata);
    } else if (type === "zTXt") {
      readCompressedTextChunk(bytes.subarray(dataStart, dataEnd), metadata);
    }

    offset = dataEnd + 4;
    if (type === "IEND") {
      break;
    }
  }

  return Object.keys(metadata).length ? metadata : extractStringMetadata(bytes);
}

function readTextChunk(data, metadata) {
  const separator = data.indexOf(0);
  if (separator < 0) {
    return;
  }
  const key = latin1(data.subarray(0, separator));
  const value = decodeText(data.subarray(separator + 1));
  if (key && value) {
    metadata[key] = value;
  }
}

function readInternationalTextChunk(data, metadata) {
  let cursor = 0;
  const keyEnd = data.indexOf(0, cursor);
  if (keyEnd < 0) return;
  const key = latin1(data.subarray(cursor, keyEnd));
  cursor = keyEnd + 1;
  const compressionFlag = data[cursor++];
  cursor++;

  const languageEnd = data.indexOf(0, cursor);
  if (languageEnd < 0) return;
  cursor = languageEnd + 1;

  const translatedEnd = data.indexOf(0, cursor);
  if (translatedEnd < 0) return;
  cursor = translatedEnd + 1;

  let textBytes = data.subarray(cursor);
  if (compressionFlag === 1) {
    textBytes = inflateBytes(textBytes);
  }

  const value = decodeText(textBytes);
  if (key && value) {
    metadata[key] = value;
  }
}

function readCompressedTextChunk(data, metadata) {
  const separator = data.indexOf(0);
  if (separator < 0 || separator + 2 >= data.length) {
    return;
  }
  const key = latin1(data.subarray(0, separator));
  const compressed = data.subarray(separator + 2);
  const value = decodeText(inflateBytes(compressed));
  if (key && value) {
    metadata[key] = value;
  }
}

function extractStringMetadata(bytes) {
  const text = decodeText(bytes)
    .replace(/\u0000/g, "\n")
    .replace(/[^\S\r\n]+/g, " ");

  const metadata = {};
  const patterns = [
    ["parameters", /parameters[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i],
    ["UserComment", /UserComment[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i],
    ["ImageDescription", /ImageDescription[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i],
    ["prompt", /prompt["'\s:=]+([\s\S]{20,8000}?)(?:negative_prompt|Negative prompt|Steps:|Sampler:|Seed:|$)/i],
  ];

  for (const [key, pattern] of patterns) {
    const match = text.match(pattern);
    if (match && match[1]) {
      metadata[key] = cleanExtractedText(match[1]);
    }
  }

  return metadata;
}

function pickPrompt(metadata) {
  const preferredKeys = [
    "parameters",
    "prompt",
    "Prompt",
    "UserComment",
    "ImageDescription",
    "Description",
    "Comment",
    "workflow",
  ];

  for (const key of preferredKeys) {
    if (metadata[key]) {
      return { key, value: normalizePromptValue(metadata[key]) };
    }
  }

  const fallbackKey = Object.keys(metadata).find((key) => looksLikePrompt(metadata[key]));
  return fallbackKey ? { key: fallbackKey, value: normalizePromptValue(metadata[fallbackKey]) } : null;
}

function normalizePromptValue(value) {
  const trimmed = cleanExtractedText(value);
  const jsonPrompt = extractPromptFromJson(trimmed);
  return jsonPrompt || trimmed;
}

function extractPromptFromJson(value) {
  if (!value.trim().startsWith("{") && !value.trim().startsWith("[")) {
    return null;
  }

  try {
    const parsed = JSON.parse(value);
    const collected = [];
    collectPromptStrings(parsed, collected);
    return collected.length ? collected.join("\n\n") : null;
  } catch (_) {
    return null;
  }
}

function collectPromptStrings(node, collected) {
  if (!node || collected.length >= 12) {
    return;
  }

  if (typeof node === "string") {
    if (looksLikePrompt(node)) {
      collected.push(node.trim());
    }
    return;
  }

  if (Array.isArray(node)) {
    node.forEach((item) => collectPromptStrings(item, collected));
    return;
  }

  if (typeof node === "object") {
    for (const [key, value] of Object.entries(node)) {
      const lower = key.toLowerCase();
      if (typeof value === "string" && ["text", "prompt", "positive", "inputs"].some((part) => lower.includes(part))) {
        collectPromptStrings(value, collected);
      } else if (typeof value === "object") {
        collectPromptStrings(value, collected);
      }
    }
  }
}

function looksLikePrompt(value) {
  if (!value || value.length < 20) {
    return false;
  }
  return /(negative prompt|steps:|sampler:|cfg scale|seed:|masterpiece|cinematic|portrait|photo|prompt)/i.test(value);
}

function cleanExtractedText(value) {
  return value
    .replace(/\r\n/g, "\n")
    .replace(/\r/g, "\n")
    .replace(/[^\x09\x0A\x0D\x20-\x7E\u0400-\u04FF\u2010-\u2027]+/g, " ")
    .replace(/[ \t]+\n/g, "\n")
    .replace(/\n{4,}/g, "\n\n\n")
    .trim();
}

function inflateBytes(bytes) {
  try {
    const zlib = require("zlib");
    return new Uint8Array(zlib.inflateSync(Buffer.from(bytes)));
  } catch (_) {
    return new Uint8Array();
  }
}

function hasPngSignature(bytes) {
  return bytes.length > 8
    && bytes[0] === 0x89
    && bytes[1] === 0x50
    && bytes[2] === 0x4e
    && bytes[3] === 0x47
    && bytes[4] === 0x0d
    && bytes[5] === 0x0a
    && bytes[6] === 0x1a
    && bytes[7] === 0x0a;
}

function readUInt32(bytes, offset) {
  return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
}

function ascii(bytes) {
  return Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
}

function latin1(bytes) {
  return new TextDecoder("latin1").decode(bytes).trim();
}

function decodeText(bytes) {
  const utf8 = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
  if (utf8.includes("\u0000")) {
    const utf16 = tryDecodeUtf16(bytes);
    if (utf16 && countPrintable(utf16) > countPrintable(utf8)) {
      return utf16;
    }
  }
  return utf8;
}

function tryDecodeUtf16(bytes) {
  try {
    return new TextDecoder("utf-16le", { fatal: false }).decode(bytes);
  } catch (_) {
    return "";
  }
}

function countPrintable(text) {
  return (text.match(/[\wа-яА-Я.,:;!?()[\]{}'" -]/g) || []).length;
}
