// 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 a LOCAL, self-hosted app (CLAUDE.md/local-app design):
// it must work perfectly with no internet connection, forever. So every
// piece of this module is built around one rule — nothing here may ever
// make the app less reliable offline than it would be with this feature
// deleted entirely:
//
//  - UPDATE_FEED_URL unset (the out-of-the-box state, before the product's
//    site exists at a real domain) -> the feature is silently DISABLED, not
//    "unreachable". No network call is even attempted.
//  - Any failure reaching or parsing the feed (network error, timeout,
//    non-2xx, invalid JSON, JSON that doesn't match the expected shape) ->
//    "unreachable", logged ONCE at debug level (never error — an offline
//    machine or a not-yet-deployed site is not an application malfunction)
//    and never thrown.
//  - No auto-download, no auto-execute. This module only ever produces
//    information (a cached status object); the user decides, in Settings,
//    whether to click a link that opens their browser.
//
// The releases feed shape (site/public/releases.json is the real one,
// site/ workspace, Task 3):
//   { latest: "1.1.0", releases: [{ version, date, notes, downloadUrl }] }

import { getVersion } from "./version.js";

// ---------------------------------------------------------------------------
// feed shape + validation (pure — no I/O)
// ---------------------------------------------------------------------------

export interface ReleaseInfo {
  version: string;
  date: string;
  notes: string;
  downloadUrl: string;
}

export interface ReleasesFeed {
  latest: string;
  releases: ReleaseInfo[];
}

/** A feed-supplied version string is accepted ONLY in bare major[.minor[.patch]]
 *  numeric form — no prerelease ("-beta") or build-metadata ("+build.1")
 *  suffixes. This channel doesn't ship prereleases, and compareSemver's
 *  lenient parseSemver silently truncates anything after the numeric
 *  fields (so "2.0.0-beta" would otherwise compare equal to "2.0.0" and
 *  get treated as current/up to date — actively misleading). Rejecting the
 *  whole feed as malformed here is safer than comparing a truncated,
 *  misleading value: refuse to guess rather than silently misreport
 *  "you're current" for a version that hasn't actually shipped yet. */
function isCleanVersionString(v: unknown): v is string {
  return typeof v === "string" && v.trim() !== "" && !v.includes("-") && !v.includes("+");
}

/** A feed-supplied downloadUrl is accepted ONLY as an absolute http(s) URL.
 *  This string ends up directly in a browser-facing "Download the update"
 *  link (target="_blank") in Settings — a `javascript:`, `data:`, or `file:`
 *  URI from a compromised or malicious feed must never reach that link.
 *  Any URL that fails to parse, or parses to a non-http(s) scheme, makes
 *  the WHOLE release entry (and therefore the whole feed) invalid — same
 *  "refuse rather than risk it" posture as isCleanVersionString above. */
function isHttpUrl(v: unknown): v is string {
  if (typeof v !== "string") return false;
  try {
    const parsed = new URL(v);
    return parsed.protocol === "http:" || parsed.protocol === "https:";
  } catch {
    return false;
  }
}

function isReleaseInfo(x: unknown): x is ReleaseInfo {
  if (!x || typeof x !== "object") return false;
  const r = x as Record<string, unknown>;
  return (
    isCleanVersionString(r.version) &&
    typeof r.date === "string" &&
    typeof r.notes === "string" &&
    isHttpUrl(r.downloadUrl)
  );
}

/** Pure shape validation for a parsed (already JSON.parse'd) feed body.
 *  Returns null for anything that doesn't match {latest, releases:[...]}
 *  exactly — malformed feed content is treated identically to an
 *  unreachable feed by every caller, never a thrown error. This includes a
 *  `latest`/release `version` carrying a prerelease/build suffix, or a
 *  `downloadUrl` that isn't a plain http(s) URL — see isCleanVersionString
 *  and isHttpUrl above for why those are treated as shape failures, not
 *  just "unusual" values to pass through. */
export function validateReleasesFeed(x: unknown): ReleasesFeed | null {
  if (!x || typeof x !== "object") return null;
  const obj = x as Record<string, unknown>;
  if (!isCleanVersionString(obj.latest)) return null;
  if (!Array.isArray(obj.releases)) return null;
  if (!obj.releases.every(isReleaseInfo)) return null;
  return { latest: obj.latest, releases: obj.releases as ReleaseInfo[] };
}

// ---------------------------------------------------------------------------
// semver compare (pure, no deps)
// ---------------------------------------------------------------------------

/** Parses "1.9", "v1.10.2", "1.0.0-ish" leniently into [major, minor,
 *  patch] numbers, tolerating a missing patch (and even a missing minor) by
 *  treating an absent/non-numeric field as 0. This is intentionally NOT a
 *  full semver parser (no prerelease/build-metadata handling) — the feed is
 *  our own releases.json, produced by our own RELEASING.md process, never
 *  arbitrary third-party version strings. */
function parseSemver(v: string): [number, number, number] {
  const cleaned = v.trim().replace(/^v/i, "");
  const parts = cleaned.split(".");
  const num = (s: string | undefined): number => {
    const n = s === undefined ? 0 : parseInt(s, 10);
    return Number.isFinite(n) ? n : 0;
  };
  return [num(parts[0]), num(parts[1]), num(parts[2])];
}

/** Pure semver comparison: -1 if a < b, 0 if equal, 1 if a > b. Compares
 *  major/minor/patch NUMERICALLY (never as strings — "1.9.0" must sort
 *  before "1.10.0", which a string compare gets backwards), and treats a
 *  missing patch (or minor) as 0, so "1.0" and "1.0.0" compare equal. */
export function compareSemver(a: string, b: string): -1 | 0 | 1 {
  const pa = parseSemver(a);
  const pb = parseSemver(b);
  for (let i = 0; i < 3; i++) {
    if (pa[i]! !== pb[i]!) return pa[i]! < pb[i]! ? -1 : 1;
  }
  return 0;
}

// ---------------------------------------------------------------------------
// fetchReleasesFeed — network + parse + validate, collapsing every failure
// mode to `null`. Never throws.
// ---------------------------------------------------------------------------

const FEED_TIMEOUT_MS = 5000;

export interface MinimalLogger {
  debug: (msg: string) => void;
}

const defaultLogger: MinimalLogger = { debug: (m: string) => console.debug(m) };

// "Log once" state: this feature must never spam the log with a fresh line
// every time a long-running (or frequently-restarted) local process fails
// to reach a feed that simply isn't configured/deployed yet. Resettable via
// resetUpdateModuleStateForTests() so tests asserting the log call don't
// depend on suite ordering.
let hasLoggedFeedIssue = false;

function logFeedIssueOnce(message: string, logger: MinimalLogger): void {
  if (hasLoggedFeedIssue) return;
  hasLoggedFeedIssue = true;
  logger.debug(message);
}

export interface FetchReleasesFeedOptions {
  /** Injectable for tests — real callers use the global fetch. */
  fetchImpl?: typeof fetch;
  /** Injectable for tests — real callers use console.debug. */
  logger?: MinimalLogger;
  /** Overrides FEED_TIMEOUT_MS — used by tests to keep a "the feed never
   *  responds" test fast without waiting out the real 5s production
   *  timeout. Real callers never set this. */
  timeoutMs?: number;
}

/**
 * Fetches `url`, parses it as JSON, and validates its shape. Returns the
 * parsed feed on success, or `null` for ANY failure whatsoever — an
 * unreachable host, a timeout (aborted after FEED_TIMEOUT_MS), a non-2xx
 * response, invalid JSON, or JSON that doesn't match ReleasesFeed's shape.
 * This function is the ONE place all of those distinct failure modes
 * collapse into the single "couldn't check" outcome every caller treats
 * identically — never throws, so a caller can `await` it with no try/catch
 * of its own.
 */
export async function fetchReleasesFeed(
  url: string,
  opts: FetchReleasesFeedOptions = {},
): Promise<ReleasesFeed | null> {
  const fetchImpl = opts.fetchImpl ?? fetch;
  const logger = opts.logger ?? defaultLogger;
  const timeoutMs = opts.timeoutMs ?? FEED_TIMEOUT_MS;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const res = await fetchImpl(url, { signal: controller.signal });
    if (!res.ok) {
      logFeedIssueOnce(`Update feed unreachable: HTTP ${res.status} from ${url}`, logger);
      return null;
    }

    const text = await res.text();
    let parsed: unknown;
    try {
      parsed = JSON.parse(text);
    } catch {
      logFeedIssueOnce(`Update feed malformed: response from ${url} was not valid JSON`, logger);
      return null;
    }

    const feed = validateReleasesFeed(parsed);
    if (!feed) {
      logFeedIssueOnce(`Update feed malformed: response from ${url} didn't match the expected shape`, logger);
      return null;
    }
    return feed;
  } catch (err) {
    logFeedIssueOnce(`Update feed unreachable: ${(err as Error).message}`, logger);
    return null;
  } finally {
    clearTimeout(timer);
  }
}

// ---------------------------------------------------------------------------
// checkForUpdate — reads UPDATE_FEED_URL, fetches+compares, caches the
// result in module state.
// ---------------------------------------------------------------------------

export type UpdateStatus = "disabled" | "unreachable" | "current" | "update-available";

export interface UpdateStatusResult {
  current: string;
  latest: string | null;
  notes: string | null;
  downloadUrl: string | null;
  checkedAt: string | null;
  status: UpdateStatus;
}

function disabledResult(): UpdateStatusResult {
  return { current: getVersion(), latest: null, notes: null, downloadUrl: null, checkedAt: null, status: "disabled" };
}

let cache: UpdateStatusResult = disabledResult();

/** Resets ALL module state (the cached status AND the "logged once" flag)
 *  — test-only, so each test file/case starts from a known, feature-off
 *  baseline regardless of what ran before it in the same process. */
export function resetUpdateModuleStateForTests(): void {
  cache = disabledResult();
  hasLoggedFeedIssue = false;
}

/** The cached result of the most recent checkForUpdate() call (or the
 *  disabled-baseline object if none has ever run in this process) — what
 *  GET /api/update-status returns. Synchronous and side-effect-free; never
 *  triggers a network call itself. */
export function getUpdateStatus(): UpdateStatusResult {
  return cache;
}

export interface CheckForUpdateOptions {
  /** Injectable for tests — real callers use the global fetch. */
  fetchImpl?: typeof fetch;
  /** Overrides process.env.UPDATE_FEED_URL — used by tests. */
  feedUrl?: string;
  /** Overrides "now" for a deterministic checkedAt — used by tests. */
  now?: () => Date;
  /** Injectable for tests — real callers use console.debug. */
  logger?: MinimalLogger;
}

/**
 * Runs one update check and stores the result in module state (returned
 * from getUpdateStatus()/GET /api/update-status until the next check).
 * Never throws — every failure path (feed disabled, unreachable, malformed)
 * resolves to a cached result with the corresponding `status`, exactly like
 * fetchReleasesFeed() never throwing.
 *
 * UPDATE_FEED_URL unset (or blank) means the feature is DISABLED, full
 * stop — no network call is attempted at all. This is deliberate even
 * though the design brief mentions a future default of the product site's
 * own /releases.json: until that site is deployed at a real, stable URL, a
 * hardcoded default would mean every install "phones home" to a domain
 * that doesn't resolve yet, for a feature nobody configured — indistinguishable
 * from a bug. An operator wires UPDATE_FEED_URL in .env once the site
 * exists; before that, no free-download software should attempt an
 * unconfigured network call on their behalf.
 */
export async function checkForUpdate(opts: CheckForUpdateOptions = {}): Promise<UpdateStatusResult> {
  const current = getVersion();
  const feedUrl = opts.feedUrl ?? process.env.UPDATE_FEED_URL;
  const now = (opts.now ?? (() => new Date()))();

  if (!feedUrl || feedUrl.trim() === "") {
    cache = { current, latest: null, notes: null, downloadUrl: null, checkedAt: null, status: "disabled" };
    return cache;
  }

  const feed = await fetchReleasesFeed(feedUrl, { fetchImpl: opts.fetchImpl, logger: opts.logger });
  if (!feed) {
    cache = {
      current, latest: null, notes: null, downloadUrl: null,
      checkedAt: now.toISOString(), status: "unreachable",
    };
    return cache;
  }

  const latestRelease = feed.releases.find((r) => r.version === feed.latest) ?? null;
  const status: UpdateStatus = compareSemver(feed.latest, current) > 0 ? "update-available" : "current";

  cache = {
    current,
    latest: feed.latest,
    notes: latestRelease?.notes ?? null,
    downloadUrl: latestRelease?.downloadUrl ?? null,
    checkedAt: now.toISOString(),
    status,
  };
  return cache;
}

// ---------------------------------------------------------------------------
// maybeDailyUpdateCheck — called once from index.ts, after listen(),
// non-blocking. Mirrors backup.ts's maybeDailyBackup() exactly: runs a
// check if none has happened yet in this process, or the last one was more
// than DAILY_THRESHOLD_HOURS ago; swallows and logs every error so a
// feed-check failure can never crash (or even delay) the server. Because
// the cache is in-memory module state (not persisted to disk), a fresh
// server process always has checkedAt: null, so this naturally runs once
// per server start — and, on a long-lived process, at most once per day
// after that.
// ---------------------------------------------------------------------------

const DAILY_THRESHOLD_HOURS = 24;

export interface UpdateCheckLogger {
  info: (msg: string) => void;
  error: (msg: string) => void;
}

export async function maybeDailyUpdateCheck(options: { log?: UpdateCheckLogger } = {}): Promise<void> {
  const log = options.log ?? { info: (m: string) => console.log(m), error: (m: string) => console.error(m) };

  try {
    const feedUrl = process.env.UPDATE_FEED_URL;
    if (!feedUrl || feedUrl.trim() === "") {
      cache = disabledResult();
      return; // Feature not configured — nothing to check, nothing to log.
    }

    const last = cache.checkedAt ? new Date(cache.checkedAt) : null;
    const ageHours = last ? (Date.now() - last.getTime()) / 3_600_000 : Infinity;
    if (ageHours < DAILY_THRESHOLD_HOURS) {
      log.info(`Update check: skipped — last check was ${ageHours.toFixed(1)}h ago.`);
      return;
    }

    const result = await checkForUpdate();
    log.info(
      result.status === "update-available"
        ? `Update check: done — version ${result.latest} is available (current: ${result.current}).`
        : `Update check: done — ${result.status}.`,
    );
  } catch (err) {
    // checkForUpdate()/fetchReleasesFeed() never throw, so this is a
    // last-resort safety net against something unexpected escaping them
    // anyway — same defensive posture as maybeDailyBackup's own catch.
    log.error(`Update check: failed — ${(err as Error).message}`);
  }
}
