import fs from "node:fs";
import AjvPkg from "ajv";
import { describe, expect, it, vi } from "vitest";
import {
  DEFAULT_DIFFS_PLUGIN_SECURITY,
  DEFAULT_DIFFS_TOOL_DEFAULTS,
  diffsPluginConfigSchema,
  resolveDiffImageRenderOptions,
  resolveDiffsPluginDefaults,
  resolveDiffsPluginSecurity,
  resolveDiffsPluginViewerBaseUrl,
} from "./config.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
import {
  getServedViewerAsset,
  resolveViewerRuntimeFileUrl,
  VIEWER_LOADER_PATH,
  VIEWER_RUNTIME_PATH,
} from "./viewer-assets.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";

const FULL_DEFAULTS = {
  fontFamily: "JetBrains Mono",
  fontSize: 17,
  lineSpacing: 1.8,
  layout: "split",
  showLineNumbers: false,
  diffIndicators: "classic",
  wordWrap: false,
  background: false,
  theme: "light",
  fileFormat: "pdf",
  fileQuality: "hq",
  fileScale: 2.6,
  fileMaxWidth: 1280,
  mode: "file",
} as const;

function compileManifestConfigSchema() {
  const manifest = JSON.parse(
    fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
  ) as { configSchema: Record<string, unknown> };
  const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
  const ajv = new Ajv({ allErrors: true, strict: false, useDefaults: true });
  return ajv.compile(manifest.configSchema);
}

describe("resolveDiffsPluginDefaults", () => {
  it("returns built-in defaults when config is missing", () => {
    expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
  });

  it("applies configured defaults from plugin config", () => {
    expect(
      resolveDiffsPluginDefaults({
        defaults: FULL_DEFAULTS,
      }),
    ).toEqual(FULL_DEFAULTS);
  });

  it("clamps and falls back for invalid line spacing and indicators", () => {
    expect(
      resolveDiffsPluginDefaults({
        defaults: {
          lineSpacing: -5,
          diffIndicators: "unknown",
        },
      }),
    ).toMatchObject({
      lineSpacing: 1,
      diffIndicators: "bars",
    });

    expect(
      resolveDiffsPluginDefaults({
        defaults: {
          lineSpacing: 9,
        },
      }),
    ).toMatchObject({
      lineSpacing: 3,
    });

    expect(
      resolveDiffsPluginDefaults({
        defaults: {
          lineSpacing: Number.NaN,
        },
      }),
    ).toMatchObject({
      lineSpacing: DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing,
    });
  });

  it("derives file defaults from quality preset and clamps explicit overrides", () => {
    expect(
      resolveDiffsPluginDefaults({
        defaults: {
          fileQuality: "print",
        },
      }),
    ).toMatchObject({
      fileQuality: "print",
      fileScale: 3,
      fileMaxWidth: 1400,
    });

    expect(
      resolveDiffsPluginDefaults({
        defaults: {
          fileQuality: "hq",
          fileScale: 99,
          fileMaxWidth: 99999,
        },
      }),
    ).toMatchObject({
      fileQuality: "hq",
      fileScale: 4,
      fileMaxWidth: 2400,
    });
  });

  it("falls back to png for invalid file format defaults", () => {
    expect(
      resolveDiffsPluginDefaults({
        defaults: {
          fileFormat: "invalid" as "png",
        },
      }),
    ).toMatchObject({
      fileFormat: "png",
    });
  });

  it("resolves file render format from defaults and explicit overrides", () => {
    const defaults = resolveDiffsPluginDefaults({
      defaults: {
        fileFormat: "pdf",
      },
    });

    expect(resolveDiffImageRenderOptions({ defaults }).format).toBe("pdf");
    expect(resolveDiffImageRenderOptions({ defaults, fileFormat: "png" }).format).toBe("png");
    expect(resolveDiffImageRenderOptions({ defaults, format: "png" }).format).toBe("png");
  });

  it("accepts format as a config alias for fileFormat", () => {
    expect(
      resolveDiffsPluginDefaults({
        defaults: {
          format: "pdf",
        },
      }),
    ).toMatchObject({
      fileFormat: "pdf",
    });
  });

  it("accepts image* config aliases for backward compatibility", () => {
    expect(
      resolveDiffsPluginDefaults({
        defaults: {
          imageFormat: "pdf",
          imageQuality: "hq",
          imageScale: 2.2,
          imageMaxWidth: 1024,
        },
      }),
    ).toMatchObject({
      fileFormat: "pdf",
      fileQuality: "hq",
      fileScale: 2.2,
      fileMaxWidth: 1024,
    });
  });

  it("keeps loader-applied schema defaults from shadowing aliases and quality-derived defaults", () => {
    const validate = compileManifestConfigSchema();

    const aliasOnly = {
      defaults: {
        format: "pdf",
        imageQuality: "hq",
      },
    };
    expect(validate(aliasOnly)).toBe(true);
    expect(resolveDiffsPluginDefaults(aliasOnly)).toMatchObject({
      fileFormat: "pdf",
      fileQuality: "hq",
      fileScale: 2.5,
      fileMaxWidth: 1200,
    });

    const qualityOnly = {
      defaults: {
        fileQuality: "hq",
      },
    };
    expect(validate(qualityOnly)).toBe(true);
    expect(resolveDiffsPluginDefaults(qualityOnly)).toMatchObject({
      fileQuality: "hq",
      fileScale: 2.5,
      fileMaxWidth: 1200,
    });
  });
});

describe("resolveDiffsPluginSecurity", () => {
  it("defaults to local-only viewer access", () => {
    expect(resolveDiffsPluginSecurity(undefined)).toEqual(DEFAULT_DIFFS_PLUGIN_SECURITY);
  });

  it("allows opt-in remote viewer access", () => {
    expect(resolveDiffsPluginSecurity({ security: { allowRemoteViewer: true } })).toEqual({
      allowRemoteViewer: true,
    });
  });
});

describe("resolveDiffsPluginViewerBaseUrl", () => {
  it("defaults to undefined when config is missing", () => {
    expect(resolveDiffsPluginViewerBaseUrl(undefined)).toBeUndefined();
  });

  it("normalizes configured viewer base URLs", () => {
    expect(
      resolveDiffsPluginViewerBaseUrl({
        viewerBaseUrl: "https://example.com/openclaw/",
      }),
    ).toBe("https://example.com/openclaw");
  });
});

describe("diffs plugin schema surfaces", () => {
  it("rejects invalid viewerBaseUrl values at manifest-validation time too", () => {
    const validate = compileManifestConfigSchema();

    expect(validate({ viewerBaseUrl: "javascript:alert(1)" })).toBe(false);
    expect(validate({ viewerBaseUrl: "https://example.com/openclaw?x=1" })).toBe(false);
    expect(validate({ viewerBaseUrl: "https://example.com/openclaw#frag" })).toBe(false);
    expect(validate({ viewerBaseUrl: "https://example.com/openclaw/" })).toBe(true);
  });

  it("preserves defaults and security for direct safeParse callers", () => {
    expect(
      diffsPluginConfigSchema.safeParse?.({
        viewerBaseUrl: "https://example.com/openclaw/",
        defaults: {
          theme: "light",
        },
        security: {
          allowRemoteViewer: true,
        },
      }),
    ).toMatchObject({
      success: true,
      data: {
        viewerBaseUrl: "https://example.com/openclaw",
        defaults: {
          fontFamily: "Fira Code",
          fontSize: 15,
          lineSpacing: 1.6,
          layout: "unified",
          showLineNumbers: true,
          diffIndicators: "bars",
          wordWrap: true,
          background: true,
          theme: "light",
          fileFormat: "png",
          fileQuality: "standard",
          fileScale: 2,
          fileMaxWidth: 960,
          mode: "both",
        },
        security: {
          allowRemoteViewer: true,
        },
      },
    });
  });

  it("canonicalizes alias-driven defaults for direct safeParse callers", () => {
    expect(
      diffsPluginConfigSchema.safeParse?.({
        defaults: {
          format: "pdf",
          imageQuality: "hq",
        },
      }),
    ).toMatchObject({
      success: true,
      data: {
        defaults: {
          fileFormat: "pdf",
          fileQuality: "hq",
          fileScale: 2.5,
          fileMaxWidth: 1200,
        },
      },
    });
  });

  it("rejects invalid viewerBaseUrl config values", () => {
    expect(
      diffsPluginConfigSchema.safeParse?.({
        viewerBaseUrl: "javascript:alert(1)",
      }),
    ).toMatchObject({
      success: false,
      error: {
        issues: [
          {
            path: ["viewerBaseUrl"],
            message: "viewerBaseUrl must use http or https: javascript:alert(1)",
          },
        ],
      },
    });
  });

  it("keeps the runtime json schema in sync with the manifest config schema", () => {
    const manifest = JSON.parse(
      fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
    ) as { configSchema?: unknown };

    expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema);
  });
});

describe("diffs viewer URL helpers", () => {
  it("defaults to loopback for lan/tailnet bind modes", () => {
    expect(
      buildViewerUrl({
        config: { gateway: { bind: "lan", port: 18789 } },
        viewerPath: "/plugins/diffs/view/id/token",
      }),
    ).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");

    expect(
      buildViewerUrl({
        config: { gateway: { bind: "tailnet", port: 24444 } },
        viewerPath: "/plugins/diffs/view/id/token",
      }),
    ).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
  });

  it("uses custom bind host when provided", () => {
    expect(
      buildViewerUrl({
        config: {
          gateway: {
            bind: "custom",
            customBindHost: "gateway.example.com",
            port: 443,
            tls: { enabled: true },
          },
        },
        viewerPath: "/plugins/diffs/view/id/token",
      }),
    ).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
  });

  it("joins viewer path under baseUrl pathname", () => {
    expect(
      buildViewerUrl({
        config: {},
        baseUrl: "https://example.com/openclaw",
        viewerPath: "/plugins/diffs/view/id/token",
      }),
    ).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
  });

  it("prefers normalized viewerBaseUrl strings too", () => {
    expect(
      buildViewerUrl({
        config: {},
        baseUrl: "https://example.com/openclaw/",
        viewerPath: "/plugins/diffs/view/id/token",
      }),
    ).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
  });

  it("rejects base URLs with query/hash", () => {
    expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
      "baseUrl must not include query/hash",
    );
    expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
      "baseUrl must not include query/hash",
    );
  });

  it("uses the configured field name in viewerBaseUrl validation errors", () => {
    expect(() => normalizeViewerBaseUrl("https://example.com?a=1", "viewerBaseUrl")).toThrow(
      "viewerBaseUrl must not include query/hash",
    );
  });
});

describe("viewer assets", () => {
  it("prefers the built plugin asset layout when present", async () => {
    const stat = vi.fn(async (path: string) => {
      if (path === "/repo/dist/extensions/diffs/assets/viewer-runtime.js") {
        return { mtimeMs: 1 };
      }
      const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" });
      throw error;
    });

    await expect(
      resolveViewerRuntimeFileUrl({
        baseUrl: "file:///repo/dist/extensions/diffs/index.js",
        stat,
      }),
    ).resolves.toMatchObject({
      pathname: "/repo/dist/extensions/diffs/assets/viewer-runtime.js",
    });
    expect(stat).toHaveBeenCalledTimes(1);
  });

  it("falls back to the source asset layout when the built artifact is absent", async () => {
    const stat = vi.fn(async (path: string) => {
      if (path === "/repo/extensions/diffs/assets/viewer-runtime.js") {
        return { mtimeMs: 1 };
      }
      const error = Object.assign(new Error(`missing: ${path}`), { code: "ENOENT" });
      throw error;
    });

    await expect(
      resolveViewerRuntimeFileUrl({
        baseUrl: "file:///repo/extensions/diffs/src/viewer-assets.js",
        stat,
      }),
    ).resolves.toMatchObject({
      pathname: "/repo/extensions/diffs/assets/viewer-runtime.js",
    });
    expect(stat).toHaveBeenNthCalledWith(1, "/repo/extensions/diffs/src/assets/viewer-runtime.js");
    expect(stat).toHaveBeenNthCalledWith(2, "/repo/extensions/diffs/assets/viewer-runtime.js");
  });

  it("serves a stable loader that points at the current runtime bundle", async () => {
    const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);

    expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
    expect(String(loader?.body)).toContain(`./viewer-runtime.js?v=`);
  });

  it("serves the runtime bundle body", async () => {
    const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);

    expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
    expect(String(runtime?.body)).toContain("openclawDiffsReady");
    expect(String(runtime?.body)).toContain('style.width="24px"');
    expect(String(runtime?.body)).toContain('style.gap="6px"');
  });

  it("returns null for unknown asset paths", async () => {
    await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
  });
});

describe("parseViewerPayloadJson", () => {
  function buildValidPayload(): Record<string, unknown> {
    return {
      prerenderedHTML: "<div>ok</div>",
      langs: ["text"],
      oldFile: {
        name: "README.md",
        contents: "before",
      },
      newFile: {
        name: "README.md",
        contents: "after",
      },
      options: {
        theme: {
          light: "pierre-light",
          dark: "pierre-dark",
        },
        diffStyle: "unified",
        diffIndicators: "bars",
        disableLineNumbers: false,
        expandUnchanged: false,
        themeType: "dark",
        backgroundEnabled: true,
        overflow: "wrap",
        unsafeCSS: ":host{}",
      },
    };
  }

  it("accepts valid payload JSON", () => {
    const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
    expect(parsed.options.diffStyle).toBe("unified");
    expect(parsed.options.diffIndicators).toBe("bars");
  });

  it("rejects payloads with invalid shape", () => {
    const broken = buildValidPayload();
    broken.options = {
      ...(broken.options as Record<string, unknown>),
      diffIndicators: "invalid",
    };

    expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
      "Diff payload has invalid shape.",
    );
  });

  it("rejects invalid JSON", () => {
    expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
  });
});
