const { Notice, Plugin, PluginSettingTab, Setting } = require("obsidian");
const { spawn } = require("child_process");
const fs = require("fs/promises");
const nodeFs = require("fs");
const http = require("http");
const https = require("https");
const path = require("path");

const NODE_BIN = "/Users/neeldhara/.nvm/versions/node/v22.22.0/bin";
const NPM = `${NODE_BIN}/npm`;
const BLOGS_ROOT = "/Users/neeldhara/forgejo/blogs";
const ALLBLOGS_REPO = `${BLOGS_ROOT}/allblogs`;
const MICRO_REPO = `${BLOGS_ROOT}/micro`;
const HOME_REPO = `${BLOGS_ROOT}/home`;

const DEFAULT_TARGETS = [
  {
    id: "micro",
    label: "Micro",
    vaultFolder: "blogs/micro",
    repoFolder: MICRO_REPO,
    appFolder: MICRO_REPO,
    contentFolder: `${MICRO_REPO}/src/content/blog`,
    previewPathPrefix: "/blog",
    devServerUrl: "http://127.0.0.1:4321",
    devServerCommand: `${NPM} run dev -- --host 127.0.0.1 --port 4321 --strictPort`,
    publishBuildCommand: `${NPM} run build`,
    publishCommitMessage: "Publish microblog updates",
    stageAllPaths: ["src/content/blog", "public/_redirects"],
    extraCurrentStagePaths: ["public/_redirects"],
  },
  blogTarget("research", "Research", "01-research", 4331),
  blogTarget("vibes", "Vibes", "02-vibes", 4332),
  blogTarget("puzzles", "Puzzles", "03-puzzles", 4333),
  blogTarget("reflections", "Reflections", "04-reflections", 4334),
  blogTarget("poetry", "Poetry", "05-poetry", 4335),
  blogTarget("reviews", "Reviews", "06-reviews", 4336),
  blogTarget("art", "Art", "07-art", 4337),
];

const DEFAULT_SETTINGS = {
  syncDirection: "vault-to-repo",
  autoSyncOnChange: true,
  watchFilesystemChanges: true,
  pollForExternalChanges: true,
  pollIntervalMs: 3000,
  syncOnStartup: true,
  showStatusBar: true,
  ignoreDraftMarkdown: true,
  deleteOrphans: false,
  debounceMs: 1000,
  includeExtensions:
    "md,mdx,jpg,jpeg,png,gif,webp,svg,pdf,js,json,yml,yaml,css,html,txt",
  devServerShell: "/bin/zsh",
  commandPathPrepend: `${NODE_BIN}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`,
  devServerReadyTimeoutMs: 15000,
  openPreviewEvenIfServerStartFails: false,
  debugLogPath: ".obsidian/plugins/microblog-sync/debug.log",
  targets: DEFAULT_TARGETS,
};

const MARKDOWN_EXTENSIONS = new Set([".md", ".mdx"]);

module.exports = class BlogFamilySyncPlugin extends Plugin {
  async onload() {
    await this.loadSettings();
    this.isSyncing = false;
    this.debounceTimers = new Map();
    this.pollIntervalId = null;
    this.fileWatchers = [];
    this.devServers = new Map();
    this.lastVaultSnapshot = null;
    this.statusBar = this.addStatusBarItem();
    this.updateStatusBar("Blog sync: idle");

    this.addRibbonIcon("refresh-cw", "Sync all blog content", () => {
      this.syncWithNotice(this.settings.syncDirection);
    });

    this.addRibbonIcon(
      "external-link",
      "Sync and preview current blog note",
      () => {
        this.syncAndPreviewCurrentNote();
      },
    );

    this.addCommand({
      id: "blog-family-sync-all",
      name: "Sync all configured blogs",
      callback: () => this.syncWithNotice(this.settings.syncDirection),
    });

    this.addCommand({
      id: "blog-family-sync-current-vault-to-repo",
      name: "Sync current blog vault folder to repo",
      callback: () => this.syncCurrentTargetWithNotice("vault-to-repo"),
    });

    this.addCommand({
      id: "blog-family-sync-current-repo-to-vault",
      name: "Sync current blog repo folder to vault",
      callback: () => this.syncCurrentTargetWithNotice("repo-to-vault"),
    });

    this.addCommand({
      id: "blog-family-sync-current-two-way",
      name: "Two-way sync current blog",
      callback: () => this.syncCurrentTargetWithNotice("two-way"),
    });

    this.addCommand({
      id: "blog-family-preview-current",
      name: "Sync and preview current note",
      callback: () => this.syncAndPreviewCurrentNote(),
    });

    this.addCommand({
      id: "blog-family-publish-current-note",
      name: "Publish current note",
      callback: () => this.publishCurrentNote(),
    });

    this.addCommand({
      id: "blog-family-publish-current-blog",
      name: "Publish all changes for current blog",
      callback: () => this.publishCurrentBlog(),
    });

    this.registerEvent(
      this.app.vault.on("create", (file) => this.scheduleAutoSync(file)),
    );
    this.registerEvent(
      this.app.vault.on("modify", (file) => this.scheduleAutoSync(file)),
    );
    this.registerEvent(
      this.app.vault.on("delete", (file) => this.scheduleAutoSync(file)),
    );
    this.registerEvent(
      this.app.vault.on("rename", (file, oldPath) =>
        this.scheduleAutoSync(file, oldPath),
      ),
    );

    this.addSettingTab(new BlogFamilySyncSettingTab(this.app, this));

    await this.resetPollSnapshot();
    this.startPolling();
    this.startFileWatchers();

    if (this.settings.syncOnStartup) {
      await this.syncWithNotice(this.settings.syncDirection, true);
      await this.resetPollSnapshot();
    }
  }

  onunload() {
    for (const timer of this.debounceTimers.values()) {
      window.clearTimeout(timer);
    }
    this.debounceTimers.clear();

    if (this.pollIntervalId) {
      window.clearInterval(this.pollIntervalId);
    }

    for (const watcher of this.fileWatchers) {
      watcher.close();
    }
    this.fileWatchers = [];

    for (const state of this.devServers.values()) {
      if (state.process && !state.process.killed) {
        state.process.kill();
      }
    }
    this.devServers.clear();
  }

  async loadSettings() {
    const saved = await this.loadData();
    this.settings = Object.assign({}, DEFAULT_SETTINGS, saved || {});

    if (
      !Array.isArray(this.settings.targets) ||
      !this.settings.targets.length
    ) {
      this.settings.targets = DEFAULT_TARGETS;
    }

    if (
      !this.settings.includeExtensions ||
      this.settings.includeExtensions === "md,mdx,jpg,jpeg,png,gif,webp,svg"
    ) {
      this.settings.includeExtensions = DEFAULT_SETTINGS.includeExtensions;
    }

    this.settings.syncDirection = normalizeDirection(
      this.settings.syncDirection,
    );
  }

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

  getTargets() {
    return this.settings.targets.map((target) => normalizeTarget(target));
  }

  getTargetById(targetId) {
    return this.getTargets().find((target) => target.id === targetId);
  }

  getCurrentTarget() {
    const activePath = this.getActiveFilePath();
    const target = this.getTargetForVaultPath(activePath);
    if (!target) {
      throw new Error(
        "Open a Markdown note under one of the configured blog folders first.",
      );
    }

    return target;
  }

  getTargetForVaultPath(filePath) {
    if (!filePath) {
      return null;
    }

    const normalized = normalizeRelativePath(filePath);
    return (
      this.getTargets().find((target) => {
        const folder = normalizeRelativePath(target.vaultFolder);
        return normalized === folder || normalized.startsWith(`${folder}/`);
      }) || null
    );
  }

  startPolling() {
    if (this.pollIntervalId) {
      window.clearInterval(this.pollIntervalId);
      this.pollIntervalId = null;
    }

    if (
      !this.settings.autoSyncOnChange ||
      !this.settings.pollForExternalChanges
    ) {
      return;
    }

    const intervalMs = Math.max(
      1000,
      Number(this.settings.pollIntervalMs) || DEFAULT_SETTINGS.pollIntervalMs,
    );
    this.pollIntervalId = window.setInterval(() => {
      this.pollVaultFolders();
    }, intervalMs);
    this.registerInterval(this.pollIntervalId);
  }

  startFileWatchers() {
    for (const watcher of this.fileWatchers) {
      watcher.close();
    }
    this.fileWatchers = [];

    if (
      !this.settings.autoSyncOnChange ||
      !this.settings.watchFilesystemChanges
    ) {
      return;
    }

    const vaultRoot = this.getVaultRoot();
    const extensions = getIncludedExtensions(this.settings.includeExtensions);

    for (const target of this.getTargets()) {
      try {
        const vaultFolder = path.resolve(vaultRoot, target.vaultFolder);
        const watcher = nodeFs.watch(
          vaultFolder,
          { recursive: true },
          (_eventType, filename) => {
            if (!filename) {
              this.scheduleSync(target.id, true);
              return;
            }

            const relativePath = toPosix(String(filename));
            if (isHiddenPath(relativePath)) {
              return;
            }

            const ext = path.extname(relativePath).toLowerCase();
            if (extensions.has(ext)) {
              this.scheduleSync(target.id, true);
            }
          },
        );

        watcher.on("error", (error) => {
          console.warn(
            `Blog sync filesystem watcher failed for ${target.id}; polling remains active.`,
            error,
          );
        });
        this.fileWatchers.push(watcher);
      } catch (error) {
        console.warn(
          `Blog sync could not start filesystem watcher for ${target.id}; polling remains active.`,
          error,
        );
      }
    }
  }

  async restartWatchers() {
    await this.resetPollSnapshot();
    this.startPolling();
    this.startFileWatchers();
  }

  async resetPollSnapshot() {
    try {
      this.lastVaultSnapshot = await this.buildVaultSnapshot();
    } catch (error) {
      console.warn("Blog sync could not initialize folder polling", error);
      this.lastVaultSnapshot = null;
    }
  }

  async pollVaultFolders() {
    if (
      this.isSyncing ||
      !this.settings.autoSyncOnChange ||
      !this.settings.pollForExternalChanges
    ) {
      return;
    }

    try {
      const snapshot = await this.buildVaultSnapshot();
      if (this.lastVaultSnapshot === null) {
        this.lastVaultSnapshot = snapshot;
        return;
      }

      if (snapshot !== this.lastVaultSnapshot) {
        this.lastVaultSnapshot = snapshot;
        await this.syncWithNotice(this.settings.syncDirection, true);
        this.lastVaultSnapshot = await this.buildVaultSnapshot();
      }
    } catch (error) {
      console.error("Blog sync polling failed", error);
    }
  }

  async buildVaultSnapshot() {
    const vaultRoot = this.getVaultRoot();
    const extensions = getIncludedExtensions(this.settings.includeExtensions);
    const parts = [];

    for (const target of this.getTargets()) {
      const vaultFolder = path.resolve(vaultRoot, target.vaultFolder);
      const entries = await collectFiles(
        vaultFolder,
        extensions,
        "vault",
        this.settings.ignoreDraftMarkdown,
      );
      const snapshot = [...entries.values()]
        .sort((left, right) =>
          left.logicalPath.localeCompare(right.logicalPath),
        )
        .map(
          (entry) =>
            `${target.id}:${entry.logicalPath}:${Math.round(entry.mtimeMs)}:${entry.size}`,
        )
        .join("\n");
      parts.push(snapshot);
    }

    return parts.join("\n");
  }

  scheduleAutoSync(file, oldPath) {
    if (!this.settings.autoSyncOnChange || this.isSyncing) {
      return;
    }

    const target =
      this.getTargetForVaultPath(file?.path) ||
      this.getTargetForVaultPath(oldPath);
    if (!target) {
      return;
    }

    this.scheduleSync(target.id, true);
  }

  scheduleSync(targetId, quiet = true) {
    if (!this.settings.autoSyncOnChange || this.isSyncing) {
      return;
    }

    if (this.debounceTimers.has(targetId)) {
      window.clearTimeout(this.debounceTimers.get(targetId));
    }

    const timer = window.setTimeout(() => {
      const target = this.getTargetById(targetId);
      if (target) {
        this.syncWithNotice(this.settings.syncDirection, quiet, target);
      }
      this.debounceTimers.delete(targetId);
    }, this.settings.debounceMs);

    this.debounceTimers.set(targetId, timer);
  }

  async syncCurrentTargetWithNotice(direction) {
    const target = this.getCurrentTarget();
    await this.syncWithNotice(direction, false, target);
  }

  async syncWithNotice(direction, quiet = false, target = null) {
    const normalizedDirection = normalizeDirection(direction);
    try {
      const label = target ? target.label : "all blogs";
      this.updateStatusBar(`Blog sync: syncing ${label}...`);
      const result = await this.sync(normalizedDirection, target);
      const message = `Blog sync complete (${label}): ${result.copied} copied, ${result.deleted} deleted, ${result.skipped} unchanged.`;
      console.info(message, result);
      this.updateStatusBar(
        `Blog sync: ${result.copied} copied, ${result.skipped} unchanged`,
      );
      await this.resetPollSnapshot();
      if (!quiet) {
        new Notice(message);
      }
    } catch (error) {
      console.error("Blog sync failed", error);
      this.updateStatusBar("Blog sync: failed");
      new Notice(`Blog sync failed: ${error.message}`);
    }
  }

  updateStatusBar(message) {
    if (!this.statusBar) {
      return;
    }

    if (!this.settings.showStatusBar) {
      this.statusBar.setText("");
      return;
    }

    this.statusBar.setText(message);
  }

  async syncAndPreviewCurrentNote() {
    try {
      const activePath = this.getActiveFilePath();
      const target = this.getCurrentTarget();
      const url = this.getPreviewUrl(activePath, target);
      await this.logDebug(
        `Preview requested for ${target.id}:${activePath} -> ${url}`,
      );
      this.updateStatusBar(`Blog sync: syncing ${target.label} for preview...`);
      await this.sync("vault-to-repo", target);
      await this.resetPollSnapshot();
      this.updateStatusBar(`Blog sync: starting ${target.label} dev server...`);
      try {
        await this.ensureDevServer(target);
      } catch (error) {
        await this.logDebug(
          `Dev server failed for ${target.id}: ${error.stack || error.message}`,
        );
        if (!this.settings.openPreviewEvenIfServerStartFails) {
          throw error;
        }
      }
      await openExternalUrl(url);
      await this.logDebug(`Opened preview URL: ${url}`);
      this.updateStatusBar("Blog sync: preview opened");
      new Notice(`Opened preview: ${url}`);
    } catch (error) {
      console.error("Blog preview failed", error);
      await this.logDebug(`Preview failed: ${error.stack || error.message}`);
      this.updateStatusBar("Blog sync: preview failed");
      new Notice(
        `Blog preview failed: ${error.message}. See ${this.getDebugLogPath()}`,
      );
    }
  }

  async publishCurrentNote() {
    try {
      const activePath = this.getActiveFilePath();
      const target = this.getCurrentTarget();
      const slug = this.getSlugForVaultPath(activePath, target);
      if (!slug) {
        throw new Error(
          "Open a Markdown note under one of the configured blog folders first.",
        );
      }

      const contentRelativePath = this.getContentRepoPath(activePath, target);
      const paths = [
        contentRelativePath,
        ...(target.extraCurrentStagePaths || []),
      ];
      const message = `${target.publishCommitMessage}: ${slug}`;
      await this.publish(target, paths, message);
    } catch (error) {
      console.error("Blog publish current note failed", error);
      this.updateStatusBar("Blog sync: publish failed");
      new Notice(`Blog publish failed: ${error.message}`);
    }
  }

  async publishCurrentBlog() {
    try {
      const target = this.getCurrentTarget();
      const paths = target.stageAllPaths || [
        toPosix(path.relative(target.repoFolder, target.contentFolder)),
      ];
      await this.publish(target, paths, target.publishCommitMessage);
    } catch (error) {
      console.error("Blog publish failed", error);
      this.updateStatusBar("Blog sync: publish failed");
      new Notice(`Blog publish failed: ${error.message}`);
    }
  }

  async publish(target, pathsToStage, commitMessage) {
    this.updateStatusBar(
      `Blog sync: syncing ${target.label} before publish...`,
    );
    await this.sync("vault-to-repo", target);
    await this.resetPollSnapshot();

    const repoFolder = path.resolve(target.repoFolder);
    const buildCommand = target.publishBuildCommand;
    this.updateStatusBar(`Blog sync: building ${target.label}...`);
    await this.runShellCommand(buildCommand, repoFolder);

    const addTargets = [...new Set(pathsToStage)].map(shellQuote).join(" ");
    const commit = shellQuote(commitMessage || target.publishCommitMessage);
    const publishCommand = [
      `git add ${addTargets}`,
      `if git diff --cached --quiet; then echo "No staged changes to publish."; else git commit -m ${commit}; fi`,
      "git push",
    ].join(" && ");

    this.updateStatusBar(`Blog sync: pushing ${target.label}...`);
    const output = await this.runShellCommand(publishCommand, repoFolder);

    let homeOutput = "";
    if (path.resolve(target.repoFolder) === path.resolve(ALLBLOGS_REPO)) {
      homeOutput = await this.publishHomeIndex();
    }

    this.updateStatusBar("Blog sync: published");
    new Notice(
      [
        output.includes("No staged changes")
          ? `No ${target.label} changes to publish.`
          : `${target.label} changes pushed.`,
        homeOutput
          ? homeOutput.includes("No homepage index changes")
            ? "Homepage index unchanged."
            : "Homepage index pushed."
          : "",
      ]
        .filter(Boolean)
        .join(" "),
    );
  }

  async publishHomeIndex() {
    this.updateStatusBar("Blog sync: refreshing homepage index...");
    const command = [
      `${shellQuote(NPM)} run refresh:index`,
      "git add content/blog-index.json",
      'if git diff --cached --quiet; then echo "No homepage index changes to publish."; else git commit -m "Refresh homepage blog index"; fi',
      "git push",
    ].join(" && ");

    return await this.runShellCommand(command, HOME_REPO);
  }

  async ensureDevServer(target) {
    const baseUrl = normalizeBaseUrl(target.devServerUrl);
    if (await urlResponds(baseUrl)) {
      return;
    }

    const existing = this.devServers.get(target.id);
    if (existing?.process && !existing.process.killed) {
      try {
        await waitForUrl(baseUrl, 3000, () => existing.output);
        return;
      } catch (_) {
        existing.process.kill();
        this.devServers.delete(target.id);
      }
    }

    const cwd = path.resolve(target.appFolder || target.repoFolder);
    const shell =
      this.settings.devServerShell || DEFAULT_SETTINGS.devServerShell;
    const command = target.devServerCommand;
    await this.logDebug(
      `Starting dev server for ${target.id} with shell=${shell}, cwd=${cwd}, command=${command}, PATH=${this.getCommandEnv().PATH}`,
    );
    const state = { process: null, output: "" };
    let exited = false;
    let exitCode = null;

    state.process = spawn(shell, ["-lc", command], {
      cwd,
      env: this.getCommandEnv(),
      stdio: ["ignore", "pipe", "pipe"],
    });
    this.devServers.set(target.id, state);

    state.process.stdout.on("data", (chunk) => {
      state.output = trimOutput(`${state.output}${chunk.toString()}`);
    });

    state.process.stderr.on("data", (chunk) => {
      state.output = trimOutput(`${state.output}${chunk.toString()}`);
    });

    state.process.on("exit", (code) => {
      exited = true;
      exitCode = code;
      if (code !== 0 && code !== null) {
        console.warn("Blog dev server exited", target.id, code, state.output);
        this.logDebug(
          `Dev server exited for ${target.id} with code ${code}: ${state.output}`,
        );
      }
      this.devServers.delete(target.id);
    });

    await waitForUrl(
      baseUrl,
      Number(this.settings.devServerReadyTimeoutMs) ||
        DEFAULT_SETTINGS.devServerReadyTimeoutMs,
      () => {
        if (exited) {
          return `Dev server exited with code ${exitCode}.\n${state.output}`;
        }

        return state.output;
      },
      () => exited,
    );
  }

  async runShellCommand(command, cwd) {
    const shell =
      this.settings.devServerShell || DEFAULT_SETTINGS.devServerShell;

    return await new Promise((resolve, reject) => {
      let output = "";
      const child = spawn(shell, ["-lc", command], {
        cwd,
        env: this.getCommandEnv(),
        stdio: ["ignore", "pipe", "pipe"],
      });

      child.stdout.on("data", (chunk) => {
        output = trimOutput(`${output}${chunk.toString()}`);
      });

      child.stderr.on("data", (chunk) => {
        output = trimOutput(`${output}${chunk.toString()}`);
      });

      child.on("error", reject);
      child.on("exit", (code) => {
        if (code === 0) {
          resolve(output);
          return;
        }

        reject(
          new Error(`${command} failed with exit code ${code}\n${output}`),
        );
      });
    });
  }

  getDebugLogPath() {
    const configuredPath =
      this.settings.debugLogPath || DEFAULT_SETTINGS.debugLogPath;
    if (path.isAbsolute(configuredPath)) {
      return configuredPath;
    }

    return path.join(this.getVaultRoot(), configuredPath);
  }

  getCommandEnv() {
    const pathPrepend =
      this.settings.commandPathPrepend || DEFAULT_SETTINGS.commandPathPrepend;
    const existingPath = process.env.PATH || "/usr/bin:/bin:/usr/sbin:/sbin";
    const commandPath = pathPrepend
      ? `${pathPrepend}:${existingPath}`
      : existingPath;
    return {
      ...process.env,
      PATH: commandPath,
    };
  }

  async logDebug(message) {
    try {
      const logPath = this.getDebugLogPath();
      await fs.mkdir(path.dirname(logPath), { recursive: true });
      await fs.appendFile(
        logPath,
        `[${new Date().toISOString()}] ${message}\n`,
      );
    } catch (error) {
      console.warn("Blog sync could not write debug log", error);
    }
  }

  getActiveFilePath() {
    const file = this.app.workspace.getActiveFile();
    if (!file) {
      throw new Error("Open a blog note first.");
    }

    return file.path;
  }

  getPreviewUrl(filePath, target) {
    const slug = this.getSlugForVaultPath(filePath, target);
    const baseUrl = normalizeBaseUrl(target.devServerUrl);
    if (!slug) {
      return `${baseUrl}/`;
    }

    const prefix = normalizeUrlPath(target.previewPathPrefix || "");
    return `${baseUrl}${prefix}/${slug}/`;
  }

  getContentRepoPath(filePath, target) {
    const folder = normalizeRelativePath(target.vaultFolder);
    const normalized = normalizeRelativePath(filePath);
    if (normalized !== folder && !normalized.startsWith(`${folder}/`)) {
      throw new Error(
        `Open a Markdown note under ${target.vaultFolder} first.`,
      );
    }

    const relativePath = normalized.slice(folder.length + 1);
    const logicalPath = vaultToLogicalPath(relativePath);
    const contentFolder = path.resolve(target.contentFolder);
    const repoFolder = path.resolve(target.repoFolder);
    return toPosix(
      path.relative(
        repoFolder,
        path.join(contentFolder, fromPosix(logicalPath)),
      ),
    );
  }

  getSlugForVaultPath(filePath, target) {
    const folder = normalizeRelativePath(target.vaultFolder);
    const normalized = normalizeRelativePath(filePath);
    if (normalized !== folder && !normalized.startsWith(`${folder}/`)) {
      return null;
    }

    const relativePath = normalized.slice(folder.length + 1);
    const ext = path.extname(relativePath).toLowerCase();
    if (!MARKDOWN_EXTENSIONS.has(ext)) {
      return null;
    }

    const withoutExt = toPosix(relativePath).slice(0, -ext.length);
    if (withoutExt.endsWith("/index")) {
      return withoutExt.slice(0, -"/index".length);
    }

    return withoutExt;
  }

  async sync(direction, target = null) {
    if (this.isSyncing) {
      return { copied: 0, deleted: 0, skipped: 0 };
    }

    this.isSyncing = true;
    try {
      const targets = target ? [target] : this.getTargets();
      const total = { copied: 0, deleted: 0, skipped: 0 };
      for (const currentTarget of targets) {
        const result = await this.syncTarget(direction, currentTarget);
        total.copied += result.copied;
        total.deleted += result.deleted;
        total.skipped += result.skipped;
      }
      return total;
    } finally {
      this.isSyncing = false;
    }
  }

  async syncTarget(direction, target) {
    const vaultRoot = this.getVaultRoot();
    const vaultFolder = path.resolve(vaultRoot, target.vaultFolder);
    const contentFolder = path.resolve(target.contentFolder);
    const extensions = getIncludedExtensions(this.settings.includeExtensions);

    await fs.mkdir(vaultFolder, { recursive: true });
    await fs.mkdir(contentFolder, { recursive: true });

    const vaultEntries = await collectFiles(
      vaultFolder,
      extensions,
      "vault",
      this.settings.ignoreDraftMarkdown,
    );
    const contentEntries = await collectFiles(
      contentFolder,
      extensions,
      "content",
      this.settings.ignoreDraftMarkdown,
    );

    if (direction === "repo-to-vault") {
      return await syncOneWay(
        contentEntries,
        vaultEntries,
        contentFolder,
        vaultFolder,
        this.settings.deleteOrphans,
      );
    }

    if (direction === "two-way") {
      return await syncTwoWay(
        vaultEntries,
        contentEntries,
        vaultFolder,
        contentFolder,
      );
    }

    return await syncOneWay(
      vaultEntries,
      contentEntries,
      vaultFolder,
      contentFolder,
      this.settings.deleteOrphans,
    );
  }

  getVaultRoot() {
    const adapter = this.app.vault.adapter;
    if (!adapter || typeof adapter.getBasePath !== "function") {
      throw new Error(
        "This plugin needs Obsidian's desktop filesystem adapter.",
      );
    }

    return adapter.getBasePath();
  }
};

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

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

    containerEl.createEl("h2", { text: "Blog Family Sync" });

    const targetList = containerEl.createEl("div");
    targetList.createEl("p", {
      text: "Configured blog folders:",
    });
    const ul = targetList.createEl("ul");
    for (const target of this.plugin.getTargets()) {
      ul.createEl("li", {
        text: `${target.label}: ${target.vaultFolder} -> ${target.contentFolder}`,
      });
    }

    new Setting(containerEl)
      .setName("Default sync direction")
      .setDesc("Use Vault -> repo if Obsidian is the source of truth.")
      .addDropdown((dropdown) =>
        dropdown
          .addOption("vault-to-repo", "Vault -> repo")
          .addOption("repo-to-vault", "Repo -> vault")
          .addOption("two-way", "Two-way, newest file wins")
          .setValue(this.plugin.settings.syncDirection)
          .onChange(async (value) => {
            this.plugin.settings.syncDirection = value;
            await this.plugin.saveSettings();
          }),
      );

    new Setting(containerEl)
      .setName("Auto-sync on vault changes")
      .setDesc(
        "When enabled, changes under any configured blog folder trigger sync for that blog.",
      )
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.autoSyncOnChange)
          .onChange(async (value) => {
            this.plugin.settings.autoSyncOnChange = value;
            await this.plugin.saveSettings();
            await this.plugin.restartWatchers();
          }),
      );

    new Setting(containerEl)
      .setName("Watch filesystem changes")
      .setDesc("Uses the desktop filesystem watcher to catch changes quickly.")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.watchFilesystemChanges)
          .onChange(async (value) => {
            this.plugin.settings.watchFilesystemChanges = value;
            await this.plugin.saveSettings();
            await this.plugin.restartWatchers();
          }),
      );

    new Setting(containerEl)
      .setName("Poll for external file changes")
      .setDesc("Catches edits made outside Obsidian.")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.pollForExternalChanges)
          .onChange(async (value) => {
            this.plugin.settings.pollForExternalChanges = value;
            await this.plugin.saveSettings();
            await this.plugin.restartWatchers();
          }),
      );

    new Setting(containerEl)
      .setName("Polling interval")
      .setDesc("Milliseconds between scans. Minimum is 1000.")
      .addText((text) =>
        text
          .setPlaceholder(String(DEFAULT_SETTINGS.pollIntervalMs))
          .setValue(String(this.plugin.settings.pollIntervalMs))
          .onChange(async (value) => {
            this.plugin.settings.pollIntervalMs =
              Number(value) || DEFAULT_SETTINGS.pollIntervalMs;
            await this.plugin.saveSettings();
            await this.plugin.restartWatchers();
          }),
      );

    new Setting(containerEl)
      .setName("Sync once when plugin loads")
      .setDesc("Useful when Obsidian starts after files changed on disk.")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.syncOnStartup)
          .onChange(async (value) => {
            this.plugin.settings.syncOnStartup = value;
            await this.plugin.saveSettings();
          }),
      );

    new Setting(containerEl)
      .setName("Show status bar item")
      .setDesc("Shows the latest sync status at the bottom of Obsidian.")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.showStatusBar)
          .onChange(async (value) => {
            this.plugin.settings.showStatusBar = value;
            await this.plugin.saveSettings();
            this.plugin.updateStatusBar("Blog sync: idle");
          }),
      );

    new Setting(containerEl)
      .setName("Ignore draft Markdown")
      .setDesc(
        "Skips empty Markdown files and Obsidian's default Untitled drafts.",
      )
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.ignoreDraftMarkdown)
          .onChange(async (value) => {
            this.plugin.settings.ignoreDraftMarkdown = value;
            await this.plugin.saveSettings();
            await this.plugin.restartWatchers();
          }),
      );

    new Setting(containerEl)
      .setName("Delete orphaned target files")
      .setDesc(
        "Only applies to one-way sync. Leave this off unless you want deletions mirrored.",
      )
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.deleteOrphans)
          .onChange(async (value) => {
            this.plugin.settings.deleteOrphans = value;
            await this.plugin.saveSettings();
          }),
      );

    new Setting(containerEl)
      .setName("Included extensions")
      .setDesc("Comma-separated extensions synced as content/assets.")
      .addText((text) =>
        text
          .setPlaceholder(DEFAULT_SETTINGS.includeExtensions)
          .setValue(this.plugin.settings.includeExtensions)
          .onChange(async (value) => {
            this.plugin.settings.includeExtensions =
              value || DEFAULT_SETTINGS.includeExtensions;
            await this.plugin.saveSettings();
          }),
      );

    containerEl.createEl("h3", { text: "Preview and Publish" });

    new Setting(containerEl)
      .setName("Dev server ready timeout")
      .setDesc(
        "Milliseconds to wait for local Astro before reporting captured startup output.",
      )
      .addText((text) =>
        text
          .setPlaceholder(String(DEFAULT_SETTINGS.devServerReadyTimeoutMs))
          .setValue(String(this.plugin.settings.devServerReadyTimeoutMs))
          .onChange(async (value) => {
            this.plugin.settings.devServerReadyTimeoutMs =
              Number(value) || DEFAULT_SETTINGS.devServerReadyTimeoutMs;
            await this.plugin.saveSettings();
          }),
      );

    new Setting(containerEl)
      .setName("Open preview even if server start fails")
      .setDesc("Useful only if you usually start the Astro server yourself.")
      .addToggle((toggle) =>
        toggle
          .setValue(this.plugin.settings.openPreviewEvenIfServerStartFails)
          .onChange(async (value) => {
            this.plugin.settings.openPreviewEvenIfServerStartFails = value;
            await this.plugin.saveSettings();
          }),
      );

    new Setting(containerEl)
      .setName("Debug log path")
      .setDesc("Relative to the vault unless absolute.")
      .addText((text) =>
        text
          .setPlaceholder(DEFAULT_SETTINGS.debugLogPath)
          .setValue(this.plugin.settings.debugLogPath)
          .onChange(async (value) => {
            this.plugin.settings.debugLogPath =
              value || DEFAULT_SETTINGS.debugLogPath;
            await this.plugin.saveSettings();
          }),
      );

    new Setting(containerEl)
      .setName("Shell")
      .setDesc("Shell used to run dev and publish commands.")
      .addText((text) =>
        text
          .setPlaceholder(DEFAULT_SETTINGS.devServerShell)
          .setValue(this.plugin.settings.devServerShell)
          .onChange(async (value) => {
            this.plugin.settings.devServerShell =
              value || DEFAULT_SETTINGS.devServerShell;
            await this.plugin.saveSettings();
          }),
      );

    new Setting(containerEl)
      .setName("Command PATH prefix")
      .setDesc("Prepended to PATH when running npm/git commands from Obsidian.")
      .addText((text) =>
        text
          .setPlaceholder(DEFAULT_SETTINGS.commandPathPrepend)
          .setValue(this.plugin.settings.commandPathPrepend)
          .onChange(async (value) => {
            this.plugin.settings.commandPathPrepend =
              value || DEFAULT_SETTINGS.commandPathPrepend;
            await this.plugin.saveSettings();
          }),
      );

    new Setting(containerEl)
      .setName("Sync all now")
      .setDesc("Run the configured sync direction for every configured blog.")
      .addButton((button) =>
        button
          .setButtonText("Sync all")
          .setCta()
          .onClick(() => {
            this.plugin.syncWithNotice(this.plugin.settings.syncDirection);
          }),
      );

    new Setting(containerEl)
      .setName("Preview current note")
      .setDesc(
        "Sync, start the matching local Astro dev server if needed, and open the current post.",
      )
      .addButton((button) =>
        button.setButtonText("Sync and preview").onClick(() => {
          this.plugin.syncAndPreviewCurrentNote();
        }),
      );

    new Setting(containerEl)
      .setName("Publish current note")
      .setDesc("Sync, build, commit the current post, and push.")
      .addButton((button) =>
        button.setButtonText("Publish current").onClick(() => {
          this.plugin.publishCurrentNote();
        }),
      );

    new Setting(containerEl)
      .setName("Publish current blog")
      .setDesc(
        "Sync, build, commit all content changes for the current blog, and push.",
      )
      .addButton((button) =>
        button.setButtonText("Publish blog").onClick(() => {
          this.plugin.publishCurrentBlog();
        }),
      );
  }
}

function blogTarget(id, label, vaultFolder, port) {
  return {
    id,
    label,
    vaultFolder: `blogs/${vaultFolder}`,
    repoFolder: ALLBLOGS_REPO,
    appFolder: `${ALLBLOGS_REPO}/sites/${id}`,
    contentFolder: `${ALLBLOGS_REPO}/sites/${id}/src/content/${id}`,
    previewPathPrefix: "",
    devServerUrl: `http://127.0.0.1:${port}`,
    devServerCommand: `${NPM} run dev -- --host 127.0.0.1 --port ${port} --strictPort`,
    publishBuildCommand: `${NPM} run build --prefix sites/${id}`,
    publishCommitMessage: `Publish ${label.toLowerCase()} blog updates`,
    stageAllPaths: [`sites/${id}/src/content/${id}`],
    extraCurrentStagePaths: [],
  };
}

function normalizeTarget(target) {
  const defaults =
    DEFAULT_TARGETS.find((candidate) => candidate.id === target.id) || {};
  return Object.assign({}, defaults, target);
}

async function collectFiles(root, extensions, side, ignoreDraftMarkdown) {
  const files = await walk(root);
  const entries = new Map();

  for (const absolutePath of files) {
    const relativePath = toPosix(path.relative(root, absolutePath));
    if (isHiddenPath(relativePath)) {
      continue;
    }

    const ext = path.extname(relativePath).toLowerCase();
    if (!extensions.has(ext)) {
      continue;
    }

    const stat = await fs.stat(absolutePath);
    if (
      ignoreDraftMarkdown &&
      MARKDOWN_EXTENSIONS.has(ext) &&
      shouldSkipDraftMarkdown(relativePath, stat)
    ) {
      continue;
    }

    const logicalPath =
      side === "vault"
        ? vaultToLogicalPath(relativePath)
        : toPosix(relativePath);
    entries.set(logicalPath, {
      absolutePath,
      relativePath,
      logicalPath,
      mtimeMs: stat.mtimeMs,
      size: stat.size,
      ext,
    });
  }

  return entries;
}

async function walk(root) {
  let dirents = [];
  try {
    dirents = await fs.readdir(root, { withFileTypes: true });
  } catch (error) {
    if (error.code === "ENOENT") {
      return [];
    }
    throw error;
  }

  const paths = [];
  for (const dirent of dirents) {
    const absolutePath = path.join(root, dirent.name);
    if (dirent.isDirectory()) {
      paths.push(...(await walk(absolutePath)));
    } else if (dirent.isFile()) {
      paths.push(absolutePath);
    }
  }

  return paths;
}

async function syncOneWay(
  sourceEntries,
  targetEntries,
  sourceRoot,
  targetRoot,
  deleteOrphans,
) {
  const result = { copied: 0, deleted: 0, skipped: 0 };

  for (const [logicalPath, source] of sourceEntries) {
    const targetRelativePath = targetPathForLogical(
      logicalPath,
      targetRoot,
      targetEntries,
    );
    const targetAbsolutePath = path.join(
      targetRoot,
      fromPosix(targetRelativePath),
    );
    const target = targetEntries.get(logicalPath);

    if (
      target &&
      (await sameFileContent(source.absolutePath, target.absolutePath))
    ) {
      result.skipped += 1;
      continue;
    }

    await copyFile(source.absolutePath, targetAbsolutePath, source.mtimeMs);
    result.copied += 1;
  }

  if (deleteOrphans) {
    for (const [logicalPath, target] of targetEntries) {
      if (!sourceEntries.has(logicalPath)) {
        await fs.rm(target.absolutePath, { force: true });
        await removeEmptyParents(path.dirname(target.absolutePath), targetRoot);
        result.deleted += 1;
      }
    }
  }

  return result;
}

async function syncTwoWay(
  vaultEntries,
  contentEntries,
  vaultRoot,
  contentRoot,
) {
  const result = { copied: 0, deleted: 0, skipped: 0 };
  const logicalPaths = new Set([
    ...vaultEntries.keys(),
    ...contentEntries.keys(),
  ]);

  for (const logicalPath of logicalPaths) {
    const vault = vaultEntries.get(logicalPath);
    const content = contentEntries.get(logicalPath);

    if (vault && content) {
      if (await sameFileContent(vault.absolutePath, content.absolutePath)) {
        result.skipped += 1;
        continue;
      }

      if (vault.mtimeMs >= content.mtimeMs) {
        await copyFile(
          vault.absolutePath,
          path.join(contentRoot, fromPosix(logicalPath)),
          vault.mtimeMs,
        );
      } else {
        const targetRelativePath = targetPathForLogical(
          logicalPath,
          vaultRoot,
          vaultEntries,
        );
        await copyFile(
          content.absolutePath,
          path.join(vaultRoot, fromPosix(targetRelativePath)),
          content.mtimeMs,
        );
      }
      result.copied += 1;
      continue;
    }

    if (vault) {
      await copyFile(
        vault.absolutePath,
        path.join(contentRoot, fromPosix(logicalPath)),
        vault.mtimeMs,
      );
      result.copied += 1;
      continue;
    }

    if (content) {
      const targetRelativePath = targetPathForLogical(
        logicalPath,
        vaultRoot,
        vaultEntries,
      );
      await copyFile(
        content.absolutePath,
        path.join(vaultRoot, fromPosix(targetRelativePath)),
        content.mtimeMs,
      );
      result.copied += 1;
    }
  }

  return result;
}

function vaultToLogicalPath(relativePath) {
  const normalized = toPosix(relativePath);
  const ext = path.extname(normalized).toLowerCase();

  if (!MARKDOWN_EXTENSIONS.has(ext)) {
    return normalized;
  }

  if (!normalized.includes("/")) {
    const slug = normalized.slice(0, -ext.length);
    return `${slug}/index${ext}`;
  }

  return normalized;
}

function targetPathForLogical(logicalPath, targetRoot, targetEntries) {
  const ext = path.extname(logicalPath).toLowerCase();
  if (!MARKDOWN_EXTENSIONS.has(ext)) {
    return logicalPath;
  }

  const match = logicalPath.match(/^([^/]+)\/index(\.mdx?)$/);
  if (!match) {
    return logicalPath;
  }

  const flatPath = `${match[1]}${match[2]}`;
  const existingFlat = path.join(targetRoot, fromPosix(flatPath));
  if (
    targetEntries.has(logicalPath) &&
    targetEntries.get(logicalPath).relativePath === flatPath
  ) {
    return flatPath;
  }

  try {
    const stat = nodeFs.statSync(existingFlat);
    if (stat.isFile()) {
      return flatPath;
    }
  } catch (_) {
    // Fall through to folder/index form.
  }

  return logicalPath;
}

async function copyFile(source, target, mtimeMs) {
  await fs.mkdir(path.dirname(target), { recursive: true });
  await fs.copyFile(source, target);
  const mtime = new Date(mtimeMs);
  await fs.utimes(target, mtime, mtime);
}

async function sameFileContent(left, right) {
  try {
    const [leftBuffer, rightBuffer] = await Promise.all([
      fs.readFile(left),
      fs.readFile(right),
    ]);
    return leftBuffer.equals(rightBuffer);
  } catch (error) {
    if (error.code === "ENOENT") {
      return false;
    }
    throw error;
  }
}

async function removeEmptyParents(start, stop) {
  let current = path.resolve(start);
  const stopAt = path.resolve(stop);

  while (current.startsWith(stopAt) && current !== stopAt) {
    try {
      await fs.rmdir(current);
    } catch (_) {
      return;
    }
    current = path.dirname(current);
  }
}

function getIncludedExtensions(value) {
  const extensions = new Set();
  for (const part of String(value || "").split(",")) {
    const cleaned = part.trim().toLowerCase();
    if (!cleaned) {
      continue;
    }
    extensions.add(cleaned.startsWith(".") ? cleaned : `.${cleaned}`);
  }
  return extensions;
}

async function openExternalUrl(url) {
  try {
    const electron = require("electron");
    if (electron?.shell?.openExternal) {
      await electron.shell.openExternal(url);
      return;
    }
  } catch (_) {
    // Fall back to macOS open below.
  }

  const child = spawn("/usr/bin/open", [url], {
    detached: true,
    stdio: "ignore",
  });
  child.unref();
}

async function waitForUrl(url, timeoutMs, getOutput, shouldAbort) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    if (await urlResponds(url)) {
      return;
    }

    if (typeof shouldAbort === "function" && shouldAbort()) {
      const output = typeof getOutput === "function" ? getOutput() : "";
      throw new Error(
        `Dev server stopped before ${url} was ready.${output ? `\n${output}` : ""}`,
      );
    }

    await delay(500);
  }

  const output = typeof getOutput === "function" ? getOutput() : "";
  throw new Error(
    `Timed out waiting for ${url}.${output ? `\n${output}` : ""}`,
  );
}

function urlResponds(url) {
  return new Promise((resolve) => {
    let parsed;
    try {
      parsed = new URL(url);
    } catch (_) {
      resolve(false);
      return;
    }

    const client = parsed.protocol === "https:" ? https : http;
    const request = client.request(
      {
        hostname: parsed.hostname,
        port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
        path: `${parsed.pathname}${parsed.search}`,
        method: "GET",
        timeout: 1200,
      },
      (response) => {
        response.resume();
        resolve(response.statusCode >= 200 && response.statusCode < 500);
      },
    );

    request.on("timeout", () => {
      request.destroy();
      resolve(false);
    });
    request.on("error", () => resolve(false));
    request.end();
  });
}

function delay(ms) {
  return new Promise((resolve) => window.setTimeout(resolve, ms));
}

function normalizeBaseUrl(value) {
  return String(value || "").replace(/\/+$/, "");
}

function normalizeUrlPath(value) {
  const cleaned = String(value || "").replace(/^\/+|\/+$/g, "");
  return cleaned ? `/${cleaned}` : "";
}

function normalizeDirection(value) {
  if (value === "vault-to-micro") {
    return "vault-to-repo";
  }
  if (value === "micro-to-vault") {
    return "repo-to-vault";
  }
  if (value === "repo-to-vault" || value === "two-way") {
    return value;
  }
  return "vault-to-repo";
}

function shellQuote(value) {
  return `'${String(value).replace(/'/g, "'\\''")}'`;
}

function trimOutput(value) {
  const maxLength = 20000;
  if (value.length <= maxLength) {
    return value;
  }

  return value.slice(value.length - maxLength);
}

function normalizeRelativePath(value) {
  return String(value || "")
    .replace(/\\/g, "/")
    .replace(/^\/+/, "")
    .replace(/\/+$/, "");
}

function isHiddenPath(relativePath) {
  return relativePath.startsWith(".") || relativePath.includes("/.");
}

function shouldSkipDraftMarkdown(relativePath, stat) {
  const normalized = toPosix(relativePath);
  if (stat.size === 0) {
    return true;
  }

  return /(^|\/)Untitled(\.mdx?|\/index\.mdx?)$/i.test(normalized);
}

function toPosix(value) {
  return value.split(path.sep).join("/");
}

function fromPosix(value) {
  return value.split("/").join(path.sep);
}
