import { beforeEach, describe, expect, it, vi } from "vitest";

const { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi } = vi.hoisted(() => {
  const state = {
    servicesYaml: null,
    dockerYaml: null,
    dockerContainers: [],
    dockerContainersByServer: {},
    dockerServicesByServer: {},
    kubeConfig: null,
    kubeServices: [],
    logger: {
      debug: vi.fn(),
      info: vi.fn(),
      warn: vi.fn(),
      error: vi.fn(),
    },
  };

  const fs = {
    readFile: vi.fn(async (filePath) => {
      if (String(filePath).endsWith("/services.yaml")) return "services";
      if (String(filePath).endsWith("/docker.yaml")) return "docker";
      return "";
    }),
  };

  const yaml = {
    load: vi.fn((contents) => {
      if (contents === "services") return state.servicesYaml;
      if (contents === "docker") return state.dockerYaml;
      return null;
    }),
  };

  const config = {
    CONF_DIR: "/conf",
    getSettings: vi.fn(() => ({ instanceName: undefined })),
    substituteEnvironmentVars: vi.fn((s) => s),
    default: vi.fn(),
  };

  const Docker = vi.fn(function Docker(conn) {
    return {
      listContainers: vi.fn(async () => state.dockerContainersByServer[conn?.serverName] ?? state.dockerContainers),
      listServices: vi.fn(async () => state.dockerServicesByServer[conn?.serverName] ?? state.dockerContainers),
    };
  });

  const dockerCfg = {
    default: vi.fn((serverName) => ({ conn: { serverName } })),
  };

  const kubeCfg = {
    getKubeConfig: vi.fn(() => state.kubeConfig),
  };

  const kubeApi = {
    listIngress: vi.fn(async () => []),
    listTraefikIngress: vi.fn(async () => []),
    listHttpRoute: vi.fn(async () => []),
    isDiscoverable: vi.fn(() => true),
    constructedServiceFromResource: vi.fn(async () => state.kubeServices.shift()),
  };

  return { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi };
});

vi.mock("fs", () => ({
  promises: fs,
}));

vi.mock("utils/config/yaml", () => ({ loadYaml: yaml.load }));

vi.mock("utils/config/config", () => config);
vi.mock("dockerode", () => ({ default: Docker }));
vi.mock("utils/config/docker", () => dockerCfg);
vi.mock("utils/config/kubernetes", () => kubeCfg);
vi.mock("utils/kubernetes/export", () => ({ default: kubeApi }));

vi.mock("utils/logger", () => ({
  // Keep a stable logger instance so tests don't depend on module re-imports.
  default: vi.fn(() => state.logger),
}));

describe("utils/config/service-helpers", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    state.servicesYaml = null;
    state.dockerYaml = null;
    state.dockerContainers = [];
    state.dockerContainersByServer = {};
    state.dockerServicesByServer = {};
    state.kubeConfig = null;
    state.kubeServices = [];
    config.getSettings.mockReturnValue({ instanceName: undefined });
  });

  it("servicesFromConfig returns [] when services.yaml is empty", async () => {
    state.servicesYaml = null;

    const mod = await import("./service-helpers");
    expect(await mod.servicesFromConfig()).toEqual([]);
  });

  it("servicesFromDocker returns [] when docker.yaml is empty", async () => {
    state.dockerYaml = null;

    const mod = await import("./service-helpers");
    expect(await mod.servicesFromDocker()).toEqual([]);
  });

  it("servicesFromDocker tolerates non-array container responses from Docker", async () => {
    state.dockerYaml = { "docker-local": {} };
    state.dockerContainersByServer["docker-local"] = Buffer.from("bad docker response");

    const mod = await import("./service-helpers");
    const discovered = await mod.servicesFromDocker();

    expect(discovered).toEqual([]);
  });

  it("servicesFromConfig parses nested groups, assigns default weights, and skips invalid entries", async () => {
    state.servicesYaml = [
      {
        Main: [
          {
            Child: [{ SvcA: { icon: "a" } }, { SvcB: { icon: "b", weight: 5 } }],
          },
          { SvcRoot: { icon: "r" } },
          { BadSvc: null },
        ],
      },
    ];

    const mod = await import("./service-helpers");
    const groups = await mod.servicesFromConfig();

    expect(groups).toHaveLength(1);
    expect(groups[0].name).toBe("Main");
    expect(groups[0].type).toBe("group");

    // Root services live on the group; child groups are nested.
    expect(groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([
      { name: "SvcRoot", weight: 100 },
    ]);
    expect(groups[0].groups).toHaveLength(1);
    expect(groups[0].groups[0].name).toBe("Child");
    expect(groups[0].groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([
      { name: "SvcA", weight: 100 },
      { name: "SvcB", weight: 5 },
    ]);

    expect(state.logger.warn).toHaveBeenCalled();
  });

  it("cleanServiceGroups normalizes weights, moves widget->widgets, and parses per-widget settings", async () => {
    const mod = await import("./service-helpers");
    const { cleanServiceGroups } = mod;

    const rawGroups = [
      {
        name: "Group",
        services: [
          {
            name: "svc",
            showStats: "true",
            weight: "not-a-number",
            widgets: [
              // Invalid fields/highlight should be dropped with a log message.
              { type: "iframe", fields: "{bad}", highlight: "{bad}", src: "https://example.com" },
              // Type-specific boolean parsing.
              { type: "portainer", kubernetes: "true" },
              { type: "deluge", enableLeechProgress: "true", enableLeechSize: "false" },
            ],
            // `widget` is appended after the `widgets` array.
            widget: {
              type: "glances",
              metric: "cpu",
              chart: false,
              version: "3",
              refreshInterval: 1500,
              pointsLimit: 10,
              diskUnits: "gb",
              fields: '["cpu"]',
              highlight: '{"level":"warning"}',
              hideErrors: true,
            },
          },
          {
            name: "svc2",
            weight: {},
            widget: { type: "openwrt", interfaceName: "eth0" },
          },
          {
            name: "svc3",
            weight: "7",
            widget: { type: "frigate", enableRecentEvents: true },
          },
        ],
        groups: [],
      },
    ];

    const cleaned = cleanServiceGroups(rawGroups);
    expect(cleaned).toHaveLength(1);
    expect(cleaned[0].type).toBe("group");
    expect(cleaned[0].services).toHaveLength(3);

    const svc = cleaned[0].services[0];
    expect(svc.showStats).toBe(true);
    expect(svc.weight).toBe(0);
    expect(svc.widgets).toHaveLength(4);

    // The last widget is the appended `widget` entry; it should carry service metadata.
    const glancesWidget = svc.widgets[3];
    expect(glancesWidget.type).toBe("glances");
    expect(glancesWidget.service_group).toBe("Group");
    expect(glancesWidget.service_name).toBe("svc");
    expect(glancesWidget.index).toBe(3);
    expect(glancesWidget.hide_errors).toBe(true);
    expect(glancesWidget.fields).toEqual(["cpu"]);
    expect(glancesWidget.highlight).toEqual({ level: "warning" });
    expect(glancesWidget.chart).toBe(false);
    expect(glancesWidget.version).toBe(3);

    // Type-specific parsing for other widgets.
    expect(svc.widgets[1].kubernetes).toBe(true);
    expect(svc.widgets[2].enableLeechProgress).toBe(true);
    expect(svc.widgets[2].enableLeechSize).toBe(false);

    const svc2 = cleaned[0].services[1];
    expect(svc2.weight).toBe(0);
    expect(svc2.widgets).toHaveLength(1);
    expect(svc2.widgets[0]).toEqual(
      expect.objectContaining({
        type: "openwrt",
        interfaceName: "eth0",
        service_group: "Group",
        service_name: "svc2",
        index: 0,
      }),
    );

    const svc3 = cleaned[0].services[2];
    expect(svc3.weight).toBe(7);
    expect(svc3.widgets[0]).toEqual(expect.objectContaining({ type: "frigate", enableRecentEvents: true }));

    expect(state.logger.error).toHaveBeenCalled();
  });

  it("cleanServiceGroups applies widget-type specific mappings for commonly used widgets", async () => {
    const mod = await import("./service-helpers");
    const { cleanServiceGroups } = mod;

    const rawGroups = [
      {
        name: "Core",
        services: [
          {
            name: "svc",
            weight: 100,
            widgets: [
              { type: "azuredevops", userEmail: "u@example.com", repositoryId: "r" },
              { type: "beszel", version: "2", systemId: "sys" },
              { type: "pulse", version: "2" },
              { type: "coinmarketcap", currency: "USD", symbols: "BTC", slugs: "bitcoin", defaultinterval: "1d" },
              { type: "crowdsec", limit24h: "true" },
              { type: "docker", server: "docker-local", container: "c1" },
              { type: "unifi", site: "Home" },
              { type: "proxmox", node: "pve" },
              { type: "proxmoxbackupserver", datastore: "ds" },
              { type: "komodo", showSummary: "true", showStacks: "false" },
              { type: "kubernetes", namespace: "default", app: "app", podSelector: "app=test" },
              {
                type: "iframe",
                src: "https://example.com",
                allowFullscreen: true,
                allowPolicy: "geolocation",
                allowScrolling: false,
                classes: "x",
                loadingStrategy: "lazy",
                referrerPolicy: "no-referrer",
                refreshInterval: 1000,
              },
              { type: "qbittorrent", enableLeechProgress: "true", enableLeechSize: "true" },
              { type: "opnsense", wan: "wan1" },
              { type: "emby", enableBlocks: "true", enableNowPlaying: "false", enableMediaControl: "true" },
              { type: "tautulli", expandOneStreamToTwoRows: "true", showEpisodeNumber: "true", enableUser: "true" },
              { type: "radarr", enableQueue: "true" },
              { type: "truenas", enablePools: "true", nasType: "scale" },
              { type: "qnap", volume: "vol1" },
              { type: "dispatcharr", enableActiveStreams: "true" },
              { type: "gamedig", gameToken: "t" },
              { type: "kopia", snapshotHost: "h", snapshotPath: "/p" },
              { type: "glances", version: "4", metric: "cpu", refreshInterval: 2000, pointsLimit: 5, diskUnits: "gb" },
              { type: "mjpeg", stream: "s", fit: "contain" },
              { type: "openmediavault", method: "foo.bar" },
              { type: "customapi", mappings: { x: 1 }, display: { y: 2 }, refreshInterval: 5000 },
              {
                type: "calendar",
                integrations: [],
                firstDayInWeek: "monday",
                view: "agenda",
                maxEvents: 10,
                previousDays: 2,
                showTime: true,
                timezone: "UTC",
              },
              { type: "dockhand", environment: "prod" },
              { type: "hdhomerun", tuner: 1 },
              { type: "healthchecks", uuid: "u" },
              { type: "speedtest", bitratePrecision: "3", version: "1" },
              { type: "stocks", watchlist: "AAPL", showUSMarketStatus: true },
              {
                type: "tracearr",
                expandOneStreamToTwoRows: "true",
                showEpisodeNumber: "true",
                enableUser: "true",
                view: "both",
              },
              { type: "wgeasy", threshold: "10", version: "1" },
              { type: "technitium", range: "24h" },
              { type: "lubelogger", vehicleID: "12" },
              { type: "vikunja", enableTaskList: true, version: "1" },
              { type: "prometheusmetric", metrics: [], refreshInterval: 2500 },
              { type: "spoolman", spoolIds: [1, 2] },
              { type: "jellystat", days: "7" },
              { type: "grafana", alerts: [] },
              { type: "unraid", pool1: "a", pool2: "b", pool3: "c", pool4: "d" },
              { type: "yourspotify", interval: "daily" },
            ],
          },
        ],
        groups: [],
      },
    ];

    const cleaned = cleanServiceGroups(rawGroups);
    const widgets = cleaned[0].services[0].widgets;

    expect(widgets.find((w) => w.type === "azuredevops")).toEqual(
      expect.objectContaining({ userEmail: "u@example.com", repositoryId: "r" }),
    );
    expect(widgets.find((w) => w.type === "beszel")).toEqual(expect.objectContaining({ version: 2, systemId: "sys" }));
    expect(widgets.find((w) => w.type === "pulse")).toEqual(expect.objectContaining({ version: 2 }));
    expect(widgets.find((w) => w.type === "crowdsec")).toEqual(expect.objectContaining({ limit24h: true }));
    expect(widgets.find((w) => w.type === "docker")).toEqual(
      expect.objectContaining({ server: "docker-local", container: "c1" }),
    );
    expect(widgets.find((w) => w.type === "komodo")).toEqual(
      expect.objectContaining({ showSummary: true, showStacks: false }),
    );
    expect(widgets.find((w) => w.type === "kubernetes")).toEqual(
      expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }),
    );
    expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" }));
    expect(widgets.find((w) => w.type === "speedtest")).toEqual(
      expect.objectContaining({ bitratePrecision: 3, version: 1 }),
    );
    expect(widgets.find((w) => w.type === "tracearr")).toEqual(
      expect.objectContaining({
        expandOneStreamToTwoRows: true,
        showEpisodeNumber: true,
        enableUser: true,
        view: "both",
      }),
    );
    expect(widgets.find((w) => w.type === "jellystat")).toEqual(expect.objectContaining({ days: 7 }));
    expect(widgets.find((w) => w.type === "lubelogger")).toEqual(expect.objectContaining({ vehicleID: 12 }));
  });

  it("cleanServiceGroups removes calendar integration urls from frontend widget payload", async () => {
    const mod = await import("./service-helpers");
    const { cleanServiceGroups } = mod;

    const rawGroups = [
      {
        name: "Core",
        services: [
          {
            name: "Calendar",
            weight: 100,
            widgets: [
              {
                type: "calendar",
                integrations: [
                  {
                    type: "ical",
                    name: "EPL Fixtures",
                    url: "https://calendar.google.com/calendar/ical/example/public/basic.ics",
                    color: "purple",
                  },
                ],
              },
            ],
          },
        ],
        groups: [],
      },
    ];

    const cleaned = cleanServiceGroups(rawGroups);
    const calendarWidget = cleaned[0].services[0].widgets[0];
    expect(calendarWidget.integrations).toEqual([
      {
        type: "ical",
        name: "EPL Fixtures",
        color: "purple",
      },
    ]);
  });

  it("findGroupByName deep-searches and annotates parent", async () => {
    const mod = await import("./service-helpers");
    const { findGroupByName } = mod;

    const groups = [
      {
        name: "Parent",
        groups: [{ name: "Child", services: [], groups: [] }],
        services: [],
      },
    ];

    const found = findGroupByName(groups, "Child");
    expect(found.name).toBe("Child");
    expect(found.parent).toBe("Parent");
  });

  it("getServiceItem prefers configured services over docker/kubernetes", async () => {
    // Service present in config -> should return early (no Docker init).
    state.servicesYaml = [{ G: [{ S: { icon: "x" } }] }];

    const mod = await import("./service-helpers");
    const serviceItem = await mod.getServiceItem("G", "S");

    expect(serviceItem).toEqual(expect.objectContaining({ name: "S", type: "service", icon: "x" }));
    expect(Docker).not.toHaveBeenCalled();
    expect(kubeCfg.getKubeConfig).not.toHaveBeenCalled();
  });

  it("getServiceItem falls back to docker then kubernetes", async () => {
    const mod = await import("./service-helpers");

    // Miss in config, hit in Docker.
    state.servicesYaml = [{ G: [{ Other: { icon: "nope" } }] }];
    state.dockerYaml = { "docker-local": {} };
    state.dockerContainers = [
      {
        Names: ["/c1"],
        Labels: {
          "homepage.group": "G",
          "homepage.name": "S",
        },
      },
    ];

    expect(await mod.getServiceItem("G", "S")).toEqual(
      expect.objectContaining({ name: "S", server: "docker-local", container: "c1" }),
    );

    // Miss in config, miss in Docker, hit in Kubernetes.
    vi.resetModules();
    state.servicesYaml = [{ G: [{ Other: { icon: "nope" } }] }];
    state.dockerYaml = { "docker-local": {} };
    state.dockerContainers = [];
    state.kubeConfig = {}; // truthy => proceed
    state.kubeServices = [{ name: "S", group: "G", type: "service" }];
    kubeApi.listIngress.mockResolvedValueOnce([{}]);

    const mod2 = await import("./service-helpers");
    expect(await mod2.getServiceItem("G", "S")).toEqual(expect.objectContaining({ name: "S", type: "service" }));
  });

  it("getServiceItem returns false when the service cannot be found anywhere", async () => {
    state.servicesYaml = null;
    state.dockerYaml = null;
    state.kubeConfig = null;

    const mod = await import("./service-helpers");
    expect(await mod.getServiceItem("MissingGroup", "MissingService")).toBe(false);
  });

  it("getServiceWidget returns false when the widget cannot be found", async () => {
    state.servicesYaml = null;
    state.dockerYaml = null;
    state.kubeConfig = null;

    const mod = await import("./service-helpers");
    expect(await mod.default("MissingGroup", "MissingService", 0)).toBe(false);
  });

  it("getServiceWidget returns widget or widgets[index]", async () => {
    state.servicesYaml = [
      {
        G: [
          {
            S: { widget: { id: "single" }, widgets: [{ id: "w0" }, { id: "w1" }] },
          },
        ],
      },
    ];

    const mod = await import("./service-helpers");

    expect(await mod.default("G", "S", -1)).toEqual({ id: "single" });
    expect(await mod.default("G", "S", "1")).toEqual({ id: "w1" });
  });

  it("servicesFromDocker maps homepage labels to groups, filters instance-scoped labels, and parses widget version", async () => {
    config.getSettings.mockReturnValue({ instanceName: "foo" });

    state.dockerYaml = {
      "docker-local": {},
      "docker-swarm": { swarm: true },
    };

    state.dockerContainersByServer["docker-local"] = [
      {
        Names: ["/c1"],
        Labels: {
          "homepage.group": "G",
          "homepage.name": "Svc",
          "homepage.href": "http://svc",
          "homepage.widget.version": "3",
          "homepage.instance.foo.description": "Desc",
          "homepage.instance.bar.description": "Ignore",
        },
      },
      // Missing required labels -> should be skipped with an error.
      {
        Names: ["/bad"],
        Labels: {
          "homepage.group": "G",
        },
      },
    ];

    state.dockerServicesByServer["docker-swarm"] = [
      // Swarm service label format.
      {
        Spec: {
          Name: "swarm1",
          Labels: {
            "homepage.group": "G2",
            "homepage.name": "SwarmSvc",
            "homepage.widgets[0].version": "2",
          },
        },
      },
    ];

    const mod = await import("./service-helpers");
    const discoveredGroups = await mod.servicesFromDocker();

    expect(discoveredGroups).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          name: "G",
          services: [
            expect.objectContaining({
              name: "Svc",
              server: "docker-local",
              container: "c1",
              href: "http://svc",
              description: "Desc",
              widget: { version: 3 },
            }),
          ],
        }),
        expect.objectContaining({
          name: "G2",
          services: [
            expect.objectContaining({
              name: "SwarmSvc",
              server: "docker-swarm",
              container: "swarm1",
              widgets: [{ version: 2 }],
            }),
          ],
        }),
      ]),
    );

    // The instance.bar.* labels should be ignored when instanceName=foo.
    expect(JSON.stringify(discoveredGroups)).not.toContain("Ignore");
    expect(state.logger.error).toHaveBeenCalled();
  });

  it("servicesFromDocker tolerates per-server failures and still returns other results", async () => {
    state.dockerYaml = { "docker-a": {}, "docker-b": {} };

    Docker.mockImplementationOnce(() => {
      throw new Error("boom");
    });

    state.dockerContainers = [{ Names: ["/c1"], Labels: { "homepage.group": "G", "homepage.name": "Svc" } }];

    const mod = await import("./service-helpers");
    const discoveredGroups = await mod.servicesFromDocker();

    expect(discoveredGroups).toEqual([
      { name: "G", services: [expect.objectContaining({ name: "Svc", container: "c1" })] },
    ]);
    expect(["docker-a", "docker-b"]).toContain(discoveredGroups[0].services[0].server);
    expect(state.logger.error).toHaveBeenCalled();
  });

  it("servicesFromKubernetes returns [] when kubernetes is not configured", async () => {
    state.kubeConfig = null;

    const mod = await import("./service-helpers");
    expect(await mod.servicesFromKubernetes()).toEqual([]);
  });

  it("servicesFromKubernetes maps discoverable resources into service groups", async () => {
    config.getSettings.mockReturnValue({ instanceName: "foo" });
    state.kubeConfig = {}; // truthy
    kubeApi.listIngress.mockResolvedValueOnce([{ kind: "Ingress" }]);
    kubeApi.isDiscoverable.mockReturnValueOnce(true);
    state.kubeServices = [{ name: "S", group: "G", type: "service", href: "http://k" }];

    const mod = await import("./service-helpers");
    const groups = await mod.servicesFromKubernetes();

    expect(groups).toEqual([
      {
        name: "G",
        services: [{ name: "S", type: "service", href: "http://k" }],
      },
    ]);
    expect(kubeApi.isDiscoverable).toHaveBeenCalledWith({ kind: "Ingress" }, "foo");
  });

  it("servicesFromKubernetes logs and rethrows unexpected errors", async () => {
    state.kubeConfig = {}; // truthy
    kubeApi.listIngress.mockRejectedValueOnce(new Error("boom"));

    const mod = await import("./service-helpers");
    await expect(mod.servicesFromKubernetes()).rejects.toThrow("boom");
    expect(state.logger.error).toHaveBeenCalled();
  });
});
