// Tests for the update channel (Task 1 of
// docs/superpowers/plans/2026-08-01-launch.md, per
// docs/superpowers/specs/2026-08-01-launch-design.md "Version + update
// channel"). This is local, offline-first software (CLAUDE.md /
// local-app design) — the binding rule under test throughout is that
// NOTHING here may ever make the app less reliable without internet
// access than it would be with this feature deleted:
//
//  - compareSemver: pure numeric-field comparison (no deps).
//  - fetchReleasesFeed: every failure mode (network error, timeout,
//    non-2xx, invalid JSON, wrong shape) collapses to `null`, never throws.
//  - checkForUpdate: UPDATE_FEED_URL unset -> "disabled" (no network call
//    attempted at all); a bad feed -> "unreachable"; a good feed -> "current"
//    or "update-available" depending on the semver comparison.
//  - GET /api/version, GET /api/update-status, POST /api/update-status/check
//    against a real buildApp() instance (fastify.inject, no bound port).
//  - maybeDailyUpdateCheck: deterministic, no real timers — mirrors
//    backup.ts's own maybeDailyBackup(), which backup.test.ts likewise never
//    drives with real wall-clock delays.

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { buildApp } from "../../server/src/app.js";
import {
  compareSemver,
  validateReleasesFeed,
  fetchReleasesFeed,
  checkForUpdate,
  getUpdateStatus,
  maybeDailyUpdateCheck,
  resetUpdateModuleStateForTests,
  type ReleasesFeed,
} from "../../server/src/updates.js";
import { getVersion } from "../../server/src/version.js";

beforeEach(() => {
  resetUpdateModuleStateForTests();
});

// ---------------------------------------------------------------------------
// compareSemver
// ---------------------------------------------------------------------------

describe("compareSemver", () => {
  it("compares numerically, not as strings — 1.9.0 sorts before 1.10.0", () => {
    expect(compareSemver("1.9.0", "1.10.0")).toBe(-1);
    expect(compareSemver("1.10.0", "1.9.0")).toBe(1);
  });

  it("treats a missing patch as 0 — 1.0.0 and 1.0 compare equal", () => {
    expect(compareSemver("1.0.0", "1.0")).toBe(0);
    expect(compareSemver("1.0", "1.0.0")).toBe(0);
  });

  it("reports equal versions as 0", () => {
    expect(compareSemver("1.2.3", "1.2.3")).toBe(0);
  });

  it("compares major, then minor, then patch, in that order", () => {
    expect(compareSemver("2.0.0", "1.9.9")).toBe(1);
    expect(compareSemver("1.9.9", "2.0.0")).toBe(-1);
    expect(compareSemver("1.3.0", "1.2.9")).toBe(1);
    expect(compareSemver("1.2.0", "1.2.1")).toBe(-1);
  });

  it("tolerates a leading 'v' and whitespace", () => {
    expect(compareSemver("v1.2.3", " 1.2.3 ")).toBe(0);
  });

  it("treats non-numeric/missing fields as 0 rather than throwing", () => {
    expect(compareSemver("1", "1.0.0")).toBe(0);
    expect(compareSemver("1.x.0", "1.0.0")).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// validateReleasesFeed — pure shape validation
// ---------------------------------------------------------------------------

describe("validateReleasesFeed", () => {
  const good: ReleasesFeed = {
    latest: "1.1.0",
    releases: [
      { version: "1.1.0", date: "2026-08-01", notes: "Bug fixes.", downloadUrl: "https://example.com/1.1.0.zip" },
      { version: "1.0.0", date: "2026-07-01", notes: "Initial release.", downloadUrl: "https://example.com/1.0.0.zip" },
    ],
  };

  it("accepts a well-formed feed", () => {
    expect(validateReleasesFeed(good)).toEqual(good);
  });

  it("rejects null/non-object input", () => {
    expect(validateReleasesFeed(null)).toBeNull();
    expect(validateReleasesFeed(undefined)).toBeNull();
    expect(validateReleasesFeed("a string")).toBeNull();
    expect(validateReleasesFeed(42)).toBeNull();
  });

  it("rejects a missing or non-string 'latest'", () => {
    expect(validateReleasesFeed({ releases: [] })).toBeNull();
    expect(validateReleasesFeed({ latest: 1, releases: [] })).toBeNull();
    expect(validateReleasesFeed({ latest: "", releases: [] })).toBeNull();
  });

  it("rejects a missing or non-array 'releases'", () => {
    expect(validateReleasesFeed({ latest: "1.0.0" })).toBeNull();
    expect(validateReleasesFeed({ latest: "1.0.0", releases: "not an array" })).toBeNull();
  });

  it("rejects a release entry missing a required field", () => {
    expect(
      validateReleasesFeed({
        latest: "1.0.0",
        releases: [{ version: "1.0.0", date: "2026-01-01", notes: "x" /* no downloadUrl */ }],
      }),
    ).toBeNull();
  });

  it("accepts an empty releases array (latest with no matching entry is a caller concern, not a shape error)", () => {
    expect(validateReleasesFeed({ latest: "1.0.0", releases: [] })).toEqual({ latest: "1.0.0", releases: [] });
  });

  // FIX 1 (security): a downloadUrl ends up directly in a browser-facing
  // "Download the update" link — a non-http(s) scheme from a bad/compromised
  // feed must never make it through validation.
  it("rejects a javascript: downloadUrl — the whole release entry (and feed) is invalid", () => {
    expect(
      validateReleasesFeed({
        latest: "1.1.0",
        releases: [{ version: "1.1.0", date: "2026-08-01", notes: "x", downloadUrl: "javascript:alert(1)" }],
      }),
    ).toBeNull();
  });

  it("rejects other non-http(s) schemes too (file:, data:)", () => {
    expect(
      validateReleasesFeed({
        latest: "1.1.0",
        releases: [{ version: "1.1.0", date: "2026-08-01", notes: "x", downloadUrl: "file:///etc/passwd" }],
      }),
    ).toBeNull();
    expect(
      validateReleasesFeed({
        latest: "1.1.0",
        releases: [{ version: "1.1.0", date: "2026-08-01", notes: "x", downloadUrl: "data:text/html,<script>1</script>" }],
      }),
    ).toBeNull();
  });

  it("rejects a downloadUrl that doesn't even parse as a URL", () => {
    expect(
      validateReleasesFeed({
        latest: "1.1.0",
        releases: [{ version: "1.1.0", date: "2026-08-01", notes: "x", downloadUrl: "not a url at all" }],
      }),
    ).toBeNull();
  });

  it("accepts plain http (not just https) downloadUrls", () => {
    const httpFeed = {
      latest: "1.1.0",
      releases: [{ version: "1.1.0", date: "2026-08-01", notes: "x", downloadUrl: "http://example.com/x.zip" }],
    };
    expect(validateReleasesFeed(httpFeed)).toEqual(httpFeed);
  });

  // FIX 2: prerelease/build-metadata suffixes are refused, not silently
  // truncated — compareSemver's lenient parser would otherwise treat
  // "2.0.0-beta" as equal to "2.0.0" and misreport "you're current" for a
  // version that hasn't actually shipped as a real release yet.
  it("rejects a prerelease-suffixed 'latest' (e.g. '1.0.0-beta')", () => {
    expect(validateReleasesFeed({ latest: "1.0.0-beta", releases: [] })).toBeNull();
  });

  it("rejects a prerelease-suffixed release version", () => {
    expect(
      validateReleasesFeed({
        latest: "1.0.0",
        releases: [{ version: "1.0.0-beta", date: "2026-01-01", notes: "x", downloadUrl: "https://example.com/x.zip" }],
      }),
    ).toBeNull();
  });

  it("rejects a build-metadata suffix ('+build.1') the same way", () => {
    expect(validateReleasesFeed({ latest: "1.0.0+build.1", releases: [] })).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// fetchReleasesFeed — network + parse, every failure mode -> null, never throws
// ---------------------------------------------------------------------------

describe("fetchReleasesFeed", () => {
  const feed: ReleasesFeed = {
    latest: "1.1.0",
    releases: [{ version: "1.1.0", date: "2026-08-01", notes: "Notes.", downloadUrl: "https://example.com/1.1.0.zip" }],
  };

  function fakeFetch(body: string, ok = true, status = 200): typeof fetch {
    return (async () =>
      ({
        ok,
        status,
        text: async () => body,
      }) as unknown as Response) as unknown as typeof fetch;
  }

  it("returns the parsed feed on a good response", async () => {
    const result = await fetchReleasesFeed("https://example.com/releases.json", {
      fetchImpl: fakeFetch(JSON.stringify(feed)),
    });
    expect(result).toEqual(feed);
  });

  it("returns null for a non-2xx response", async () => {
    const logged: string[] = [];
    const result = await fetchReleasesFeed("https://example.com/releases.json", {
      fetchImpl: fakeFetch("not found", false, 404),
      logger: { debug: (m) => logged.push(m) },
    });
    expect(result).toBeNull();
    expect(logged).toHaveLength(1);
    expect(logged[0]).toMatch(/unreachable/i);
  });

  it("returns null for malformed (invalid) JSON — logged at debug, not error", async () => {
    const logged: string[] = [];
    const result = await fetchReleasesFeed("https://example.com/releases.json", {
      fetchImpl: fakeFetch("{ this is not json"),
      logger: { debug: (m) => logged.push(m) },
    });
    expect(result).toBeNull();
    expect(logged).toHaveLength(1);
    expect(logged[0]).toMatch(/malformed/i);
  });

  it("returns null for valid JSON that doesn't match the expected shape", async () => {
    const result = await fetchReleasesFeed("https://example.com/releases.json", {
      fetchImpl: fakeFetch(JSON.stringify({ hello: "world" })),
    });
    expect(result).toBeNull();
  });

  it("returns null (never throws) when the fetch itself rejects (network error / offline)", async () => {
    const throwingFetch = (async () => {
      throw new Error("getaddrinfo ENOTFOUND example.com");
    }) as unknown as typeof fetch;
    await expect(
      fetchReleasesFeed("https://example.com/releases.json", { fetchImpl: throwingFetch }),
    ).resolves.toBeNull();
  });

  it("logs a feed issue only ONCE across repeated failures", async () => {
    const logged: string[] = [];
    const logger = { debug: (m: string) => logged.push(m) };
    await fetchReleasesFeed("https://example.com/releases.json", { fetchImpl: fakeFetch("bad", false, 500), logger });
    await fetchReleasesFeed("https://example.com/releases.json", { fetchImpl: fakeFetch("bad", false, 500), logger });
    await fetchReleasesFeed("https://example.com/releases.json", { fetchImpl: fakeFetch("bad", false, 500), logger });
    expect(logged).toHaveLength(1);
  });

  // FIX 1 (security), end to end through the real network path: a feed
  // whose downloadUrl uses a non-http(s) scheme must never come back as a
  // usable feed — validateReleasesFeed's own unit tests cover the pure
  // logic; this confirms fetchReleasesFeed actually routes through it.
  it("returns null when a release's downloadUrl uses a javascript: scheme, never letting it reach a caller", async () => {
    const maliciousFeed = {
      latest: "1.1.0",
      releases: [{ version: "1.1.0", date: "2026-08-01", notes: "x", downloadUrl: "javascript:alert(document.cookie)" }],
    };
    const result = await fetchReleasesFeed("https://example.com/releases.json", {
      fetchImpl: fakeFetch(JSON.stringify(maliciousFeed)),
    });
    expect(result).toBeNull();
  });

  // FIX 5: a feed that never responds must still resolve (never hang the
  // caller) once the timeout fires. Uses a REAL small timer (injected via
  // timeoutMs, not the production 5000ms default) so this stays a fast
  // test rather than one that waits out the real timeout — the fetchImpl
  // below only ever settles via the AbortController's signal, exactly like
  // a real unresponsive `fetch` would behave once aborted.
  it("resolves null (never hangs) once the injected timeout aborts an unresponsive fetch", async () => {
    const neverResolvingFetch = ((_url: string, init?: RequestInit) =>
      new Promise<Response>((_resolve, reject) => {
        init?.signal?.addEventListener("abort", () => {
          reject(new DOMException("The operation was aborted.", "AbortError"));
        });
      })) as unknown as typeof fetch;

    const start = Date.now();
    const result = await fetchReleasesFeed("https://example.com/releases.json", {
      fetchImpl: neverResolvingFetch,
      timeoutMs: 50,
    });
    const elapsedMs = Date.now() - start;

    expect(result).toBeNull();
    // Well under the production 5000ms default — proves the INJECTED
    // timeout fired, not that the test got lucky waiting on something else.
    expect(elapsedMs).toBeLessThan(1000);
  }, 2000);
});

// ---------------------------------------------------------------------------
// checkForUpdate — status derivation, via injected fetch
// ---------------------------------------------------------------------------

describe("checkForUpdate", () => {
  function fakeFetch(feed: ReleasesFeed): typeof fetch {
    return (async () =>
      ({ ok: true, status: 200, text: async () => JSON.stringify(feed) }) as unknown as Response) as unknown as typeof fetch;
  }

  it("is 'disabled' when UPDATE_FEED_URL is unset — no network call attempted", async () => {
    let called = false;
    const spyFetch = (async () => {
      called = true;
      throw new Error("should never be called");
    }) as unknown as typeof fetch;

    const result = await checkForUpdate({ feedUrl: undefined, fetchImpl: spyFetch });
    expect(result.status).toBe("disabled");
    expect(result.latest).toBeNull();
    expect(result.checkedAt).toBeNull();
    expect(result.current).toBe(getVersion());
    expect(called).toBe(false);
  });

  it("is 'disabled' when UPDATE_FEED_URL is set to an empty/blank string", async () => {
    const result = await checkForUpdate({ feedUrl: "   " });
    expect(result.status).toBe("disabled");
  });

  // FIX 2: a feed whose "latest" carries a prerelease suffix fails shape
  // validation (validateReleasesFeed) and is therefore treated exactly like
  // any other malformed/unreachable feed — never silently truncated into a
  // misleading "you're current" or "1.0.0 is available" comparison.
  it("is 'unreachable' when the feed's latest carries a prerelease suffix (e.g. '1.0.0-beta')", async () => {
    const prereleaseFeedBody = JSON.stringify({ latest: "1.0.0-beta", releases: [] });
    const prereleaseFetch = (async () =>
      ({ ok: true, status: 200, text: async () => prereleaseFeedBody }) as unknown as Response) as unknown as typeof fetch;

    const result = await checkForUpdate({ feedUrl: "https://example.com/releases.json", fetchImpl: prereleaseFetch });
    expect(result.status).toBe("unreachable");
    expect(result.latest).toBeNull();
  });

  it("is 'unreachable' when the feed can't be fetched or parsed", async () => {
    const badFetch = (async () => ({ ok: false, status: 500, text: async () => "" }) as unknown as Response) as unknown as typeof fetch;
    const now = new Date("2026-08-01T12:00:00.000Z");
    const result = await checkForUpdate({ feedUrl: "https://example.com/releases.json", fetchImpl: badFetch, now: () => now });
    expect(result.status).toBe("unreachable");
    expect(result.latest).toBeNull();
    expect(result.notes).toBeNull();
    expect(result.downloadUrl).toBeNull();
    expect(result.checkedAt).toBe(now.toISOString());
  });

  it("is 'current' when the feed's latest is not newer than this build", async () => {
    const current = getVersion();
    const feed: ReleasesFeed = {
      latest: current,
      releases: [{ version: current, date: "2026-01-01", notes: "Current.", downloadUrl: "https://example.com/x.zip" }],
    };
    const result = await checkForUpdate({ feedUrl: "https://example.com/releases.json", fetchImpl: fakeFetch(feed) });
    expect(result.status).toBe("current");
    expect(result.latest).toBe(current);
  });

  it("is 'update-available' with notes/downloadUrl when the feed's latest is newer", async () => {
    const feed: ReleasesFeed = {
      latest: "99.0.0",
      releases: [
        { version: "99.0.0", date: "2026-09-01", notes: "Big new stuff.", downloadUrl: "https://example.com/99.0.0.zip" },
        { version: "1.0.0", date: "2026-01-01", notes: "Old.", downloadUrl: "https://example.com/1.0.0.zip" },
      ],
    };
    const result = await checkForUpdate({ feedUrl: "https://example.com/releases.json", fetchImpl: fakeFetch(feed) });
    expect(result.status).toBe("update-available");
    expect(result.latest).toBe("99.0.0");
    expect(result.notes).toBe("Big new stuff.");
    expect(result.downloadUrl).toBe("https://example.com/99.0.0.zip");
    expect(result.current).toBe(getVersion());
  });

  it("caches the result — getUpdateStatus() reflects the most recent checkForUpdate() call", async () => {
    const feed: ReleasesFeed = {
      latest: "99.0.0",
      releases: [{ version: "99.0.0", date: "2026-09-01", notes: "n", downloadUrl: "https://example.com/x.zip" }],
    };
    await checkForUpdate({ feedUrl: "https://example.com/releases.json", fetchImpl: fakeFetch(feed) });
    expect(getUpdateStatus().status).toBe("update-available");
    expect(getUpdateStatus().latest).toBe("99.0.0");
  });

  it("update-available still resolves cleanly if the feed's latest has no matching release entry", async () => {
    const feed: ReleasesFeed = { latest: "99.0.0", releases: [] };
    const result = await checkForUpdate({ feedUrl: "https://example.com/releases.json", fetchImpl: fakeFetch(feed) });
    expect(result.status).toBe("update-available");
    expect(result.latest).toBe("99.0.0");
    expect(result.notes).toBeNull();
    expect(result.downloadUrl).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// maybeDailyUpdateCheck — deterministic, no real timers (mirrors
// backup.ts's maybeDailyBackup, which backup.test.ts also never drives via
// real wall-clock delays; the underlying checkForUpdate() above is what
// gets the thorough behavioral coverage).
// ---------------------------------------------------------------------------

describe("maybeDailyUpdateCheck", () => {
  const originalFeedUrl = process.env.UPDATE_FEED_URL;
  afterEach(() => {
    if (originalFeedUrl === undefined) delete process.env.UPDATE_FEED_URL;
    else process.env.UPDATE_FEED_URL = originalFeedUrl;
  });

  it("does nothing (and never throws) when UPDATE_FEED_URL is unset", async () => {
    delete process.env.UPDATE_FEED_URL;
    const infoLines: string[] = [];
    await maybeDailyUpdateCheck({ log: { info: (m) => infoLines.push(m), error: () => {} } });
    expect(getUpdateStatus().status).toBe("disabled");
    expect(infoLines).toHaveLength(0);
  });

  it("never crashes/propagates when the underlying check fails unexpectedly", async () => {
    process.env.UPDATE_FEED_URL = "https://example.com/releases.json";
    const errorLines: string[] = [];
    // fetchReleasesFeed never throws, so this exercises maybeDailyUpdateCheck's
    // own defensive try/catch by pointing fetch at something that will
    // reject at the network layer — still must resolve without throwing.
    globalThis.fetch = (async () => {
      throw new Error("simulated network failure");
    }) as unknown as typeof fetch;
    await expect(
      maybeDailyUpdateCheck({ log: { info: () => {}, error: (m) => errorLines.push(m) } }),
    ).resolves.toBeUndefined();
    // fetchReleasesFeed swallows the network error into an "unreachable"
    // result rather than throwing, so maybeDailyUpdateCheck's own catch
    // block is never actually reached here — but the important, tested
    // guarantee is that the whole call resolves cleanly either way.
    expect(getUpdateStatus().status).toBe("unreachable");
  });
});

// ---------------------------------------------------------------------------
// GET /api/version, GET /api/update-status, POST /api/update-status/check
// — against a real buildApp() instance (fastify.inject, no bound port, no
// database access needed since none of these routes touch the DB).
// ---------------------------------------------------------------------------

describe("version + update-status routes", () => {
  const originalFeedUrl = process.env.UPDATE_FEED_URL;
  const originalFetch = globalThis.fetch;

  afterEach(() => {
    if (originalFeedUrl === undefined) delete process.env.UPDATE_FEED_URL;
    else process.env.UPDATE_FEED_URL = originalFeedUrl;
    globalThis.fetch = originalFetch;
  });

  it("GET /api/version returns the product version from root package.json", async () => {
    const app = await buildApp();
    try {
      const res = await app.inject({ method: "GET", url: "/api/version" });
      expect(res.statusCode).toBe(200);
      expect(res.json()).toEqual({ version: getVersion() });
    } finally {
      await app.close();
    }
  });

  it("GET /api/update-status returns the disabled baseline when UPDATE_FEED_URL is unset", async () => {
    delete process.env.UPDATE_FEED_URL;
    const app = await buildApp();
    try {
      const res = await app.inject({ method: "GET", url: "/api/update-status" });
      expect(res.statusCode).toBe(200);
      const body = res.json() as { status: string; latest: string | null };
      expect(body.status).toBe("disabled");
      expect(body.latest).toBeNull();
    } finally {
      await app.close();
    }
  });

  it("POST /api/update-status/check forces a fresh check, and GET /api/update-status then reflects it", async () => {
    process.env.UPDATE_FEED_URL = "https://example.com/releases.json";
    const feed: ReleasesFeed = {
      latest: "99.0.0",
      releases: [
        { version: "99.0.0", date: "2026-09-01", notes: "New stuff.", downloadUrl: "https://example.com/99.0.0.zip" },
      ],
    };
    globalThis.fetch = (async () =>
      ({ ok: true, status: 200, text: async () => JSON.stringify(feed) }) as unknown as Response) as unknown as typeof fetch;

    const app = await buildApp();
    try {
      const checkRes = await app.inject({ method: "POST", url: "/api/update-status/check" });
      expect(checkRes.statusCode).toBe(200);
      const checkBody = checkRes.json() as { status: string; latest: string | null };
      expect(checkBody.status).toBe("update-available");
      expect(checkBody.latest).toBe("99.0.0");

      const statusRes = await app.inject({ method: "GET", url: "/api/update-status" });
      expect(statusRes.statusCode).toBe(200);
      const statusBody = statusRes.json() as { status: string; latest: string | null; downloadUrl: string | null };
      expect(statusBody.status).toBe("update-available");
      expect(statusBody.latest).toBe("99.0.0");
      expect(statusBody.downloadUrl).toBe("https://example.com/99.0.0.zip");
    } finally {
      await app.close();
    }
  });
});
