// Punktfunk's winget REST source — the read path only.
//
// The winget client consumes exactly three endpoints. `microsoft/winget-cli-restsource`'s other 20
// (`/packages/**` CRUD) are that reference implementation's ADMIN API for mutating a CosmosDB —
// they are not part of what a client calls, and a source whose data is generated at release time
// has nothing to mutate. So this serves:
//
//   GET  /information                        server identity + declared capabilities
//   POST /manifestSearch                     search; 204 when nothing matches
//   GET  /packageManifests/{PackageIdentifier}   the full manifest (optional ?Version=)
//
// Contract: documentation/WinGet-1.1.0.yaml in that repo. Every response is wrapped in `Data`
// (ResponseObjectSchema). Note the spec's content-type key is literally `application/Json`; the
// client is not picky about the response's own header, so we send standard `application/json`.
//
// Pure request->response logic with NO I/O and no dependencies: the catalogue is passed in, so
// server.mjs owns reloading it and test.mjs can drive this directly without a server or a network.
// `data` is produced by build-data.mjs from the published release manifests — see the README.

const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };

const json = (body, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });

// A bare 204 carries no body — this is how the spec says "no results", distinct from an empty array.
const noContent = () => new Response(null, { status: 204 });

const error = (status, message) =>
  json({ ErrorCode: status, ErrorMessage: message }, status);

// Match fields we deliberately do NOT claim to serve.
//
// `NormalizedPackageNameAndPublisher` is the notable one: winget derives it client-side from ARP
// entries using its own normalization (case folding plus stripping legal suffixes, punctuation and
// version-like tails). Claiming support would mean reimplementing that exactly, and a near-miss
// produces silently wrong correlation between an installed host and this package. ProductCode is
// exact and Inno already gives us one, so correlation rides on that instead.
//
// Command / PackageFamilyName are genuinely absent (no declared commands; not an MSIX). Market is
// not modelled.
const UNSUPPORTED_MATCH_FIELDS = [
  "Command",
  "PackageFamilyName",
  "NormalizedPackageNameAndPublisher",
  "Market",
];

/** Compare one candidate string against a SearchRequestMatch. */
function matches(value, request) {
  if (value == null || request == null) return false;
  const keyword = String(request.KeyWord ?? "");
  const hay = String(value).toLowerCase();
  const needle = keyword.toLowerCase();
  switch (request.MatchType) {
    case "Exact":
      return String(value) === keyword;
    case "StartsWith":
      return hay.startsWith(needle);
    // Fuzzy variants are served as substring rather than pretending to a real fuzzy ranker. That
    // over-matches slightly, which is the safe direction: winget re-ranks and filters what we return.
    case "Substring":
    case "Fuzzy":
    case "FuzzySubstring":
      return hay.includes(needle);
    case "Wildcard":
      return true;
    case "CaseInsensitive":
    default:
      return hay === needle;
  }
}

/** The candidate strings a package offers for a given PackageMatchField. */
function fieldValues(pkg, field) {
  switch (field) {
    case "PackageIdentifier":
      return [pkg.PackageIdentifier];
    case "PackageName":
      return [pkg.PackageName];
    case "Moniker":
      return pkg.Moniker ? [pkg.Moniker] : [];
    case "Tag":
      return pkg.Tags ?? [];
    case "ProductCode":
      return pkg.ProductCodes ?? [];
    default:
      return []; // including everything in UNSUPPORTED_MATCH_FIELDS
  }
}

/** A free-text `Query` sweeps the fields a human would expect to type. */
function matchesQuery(pkg, query) {
  if (!query) return true; // no Query block = match everything, narrowed by Inclusions/Filters
  return ["PackageIdentifier", "PackageName", "Moniker", "Tag"].some((f) =>
    fieldValues(pkg, f).some((v) => matches(v, query)),
  );
}

function matchesOne(pkg, entry) {
  // Both Inclusions and Filters use SearchRequestInclusionAndFilterSchema.
  const req = entry?.RequestMatch;
  return fieldValues(pkg, entry?.PackageMatchField).some((v) => matches(v, req));
}

function search(data, body) {
  const { Query, Inclusions, Filters, FetchAllManifests, MaximumResults } = body ?? {};

  let hits = data.packages.filter((pkg) => {
    if (FetchAllManifests) return true;
    if (!matchesQuery(pkg, Query)) return false;
    // Inclusions widen (OR); an empty/absent list imposes nothing.
    if (Array.isArray(Inclusions) && Inclusions.length > 0) {
      if (!Inclusions.some((i) => matchesOne(pkg, i))) return false;
    }
    // Filters narrow (AND) — every filter must hold.
    if (Array.isArray(Filters) && Filters.length > 0) {
      if (!Filters.every((f) => matchesOne(pkg, f))) return false;
    }
    return true;
  });

  if (Number.isInteger(MaximumResults) && MaximumResults > 0) {
    hits = hits.slice(0, MaximumResults);
  }
  return hits;
}

async function handle(request, data) {
  const url = new URL(request.url);
  // Tolerate a mount under a sub-path (e.g. /winget/information) so the same Worker can sit behind
  // a reverse proxy that does not strip a prefix.
  const path = url.pathname.replace(/\/+$/, "");
  const tail = (name) => path === `/${name}` || path.endsWith(`/${name}`);

  if (tail("information") && request.method === "GET") {
    return json({
      Data: {
        SourceIdentifier: data.sourceIdentifier,
        ServerSupportedVersions: data.serverSupportedVersions,
        UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
        RequiredPackageMatchFields: [],
        UnsupportedQueryParameters: [],
        RequiredQueryParameters: [],
      },
    });
  }

  if (tail("manifestSearch")) {
    if (request.method !== "POST") return error(405, "manifestSearch requires POST");
    let body;
    try {
      body = await request.json();
    } catch {
      return error(400, "malformed JSON body");
    }
    const hits = search(data, body);
    if (hits.length === 0) return noContent();
    return json({
      Data: hits.map((pkg) => ({
        PackageIdentifier: pkg.PackageIdentifier,
        PackageName: pkg.PackageName,
        Publisher: pkg.Publisher,
        Versions: pkg.Versions.map((v) => ({
          PackageVersion: v.PackageVersion,
          ProductCodes: v.ProductCodes ?? [],
        })),
      })),
      RequiredPackageMatchFields: [],
      UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
    });
  }

  const manifestMatch = path.match(/\/packageManifests\/([^/]+)$/);
  if (manifestMatch) {
    if (request.method !== "GET") return error(405, "packageManifests requires GET");
    const id = decodeURIComponent(manifestMatch[1]);
    // PackageIdentifier is case-insensitive per the winget client's own handling.
    const pkg = data.packages.find(
      (p) => p.PackageIdentifier.toLowerCase() === id.toLowerCase(),
    );
    if (!pkg) return error(404, `unknown PackageIdentifier '${id}'`);

    const wanted = url.searchParams.get("Version");
    const versions = wanted
      ? pkg.Manifest.Versions.filter((v) => v.PackageVersion === wanted)
      : pkg.Manifest.Versions;
    if (versions.length === 0) return error(404, `version '${wanted}' not found`);

    return json({ Data: { PackageIdentifier: pkg.Manifest.PackageIdentifier, Versions: versions } });
  }

  return error(404, `no route for ${request.method} ${url.pathname}`);
}

/** Wraps handle() so no request can escape as an unhandled rejection. */
export async function respond(request, data) {
  try {
    return await handle(request, data);
  } catch (e) {
    return error(500, `unhandled: ${e?.message ?? e}`);
  }
}

export { handle, search, matches };
