import { ConvexError, v } from "convex/values";
import type { Doc } from "./_generated/dataModel";
import {
  internalMutation,
  internalQuery,
  type MutationCtx,
  type QueryCtx,
  mutation,
  query,
} from "./_generated/server";
import {
  COUNTRY_COUNT_PRIVACY_FLOOR,
  FREE_TIER_FOLLOW_LIMIT,
  MAX_MERGE_INPUT,
  SHARD_COUNT,
} from "./constants";
import { isValidIso2, validIso2Codes } from "./lib/iso2";
import { userIdToShard } from "./lib/shards";

/**
 * Layer-2 entitlement gate for the followed-countries watchlist primitive
 * (plan U13). Returns the user's effective tier (0 = free, ≥1 = PRO).
 *
 * Mirrors `convex/alertRules.ts::assertProEntitlement` — kept inline (not
 * imported from a shared helper) for security-review readability.
 *
 *   - no entitlement row → tier 0 (free)
 *   - validUntil < Date.now() → expired, treat as tier 0
 *   - tier ≥ 1 → PRO
 *
 * Unlike alertRules (which throws PRO_REQUIRED), the watchlist gate is
 * NOT all-or-nothing: free users may follow up to FREE_TIER_FOLLOW_LIMIT
 * countries; only over-cap inserts throw FREE_CAP. So we return the tier
 * for the caller to decide.
 */
async function readEntitlementTier(
  ctx: MutationCtx,
  userId: string,
): Promise<number> {
  const entitlement = await ctx.db
    .query("entitlements")
    .withIndex("by_userId", (q) => q.eq("userId", userId))
    .first();
  if (!entitlement) return 0;
  if (entitlement.validUntil < Date.now()) return 0;
  return entitlement.features.tier ?? 0;
}

/**
 * Read the pre-seeded shard row for `userId` and throw a loud
 * SHARDS_NOT_SEEDED error if it doesn't exist (operator error — the
 * `_seedShards` mutation must have run as part of deploy + the daily
 * cron keeps it seeded). Returns the shard `_id` so the caller can patch
 * it at the end of the mutation. The read+write pair on the shard row is
 * what triggers Convex's per-document OCC to serialize concurrent
 * same-user mutations against an ALREADY-EXISTING document — closing the
 * nested TOCTOU on the lazy-created `followedCountriesUserMeta` row.
 *
 * Uses `.first()` (not `.unique()`) so duplicate shard rows for the same
 * `shardId` — possible from a concurrent-seed race in `_seedShards`
 * before the daily `_dedupeShards` cron self-heals — never throw.
 * `.first()` returns rows in `_creationTime` order, so concurrent
 * mutations all pick the OLDEST duplicate row deterministically. That
 * means OCC contention is preserved for the duration of the
 * duplicate-window: every parallel same-user mutation touches the same
 * single row, so the per-document OCC retry still serializes them. The
 * `_dedupeShards` cron deletes extras keeping the oldest by
 * `_creationTime`, restoring exactly one row per `shardId` on the next
 * daily tick.
 *
 * See `convex/lib/shards.ts::userIdToShard` for the hash and
 * `convex/constants.ts::SHARD_COUNT` for the fixed shard count.
 */
async function readShardOrThrow(
  ctx: MutationCtx,
  userId: string,
): Promise<Doc<"followedCountriesShards">> {
  const shardId = userIdToShard(userId);
  const shard = await ctx.db
    .query("followedCountriesShards")
    .withIndex("by_shard", (q) => q.eq("shardId", shardId))
    .first();
  if (!shard) {
    // Operator error — should never happen in production after deploy +
    // the daily `_seedShards` cron. Logged loudly so on-call sees it.
    console.error(
      JSON.stringify({
        breadcrumb: "followed_countries_shards_not_seeded",
        shardId,
        shardCount: SHARD_COUNT,
      }),
    );
    throw new ConvexError({ kind: "SHARDS_NOT_SEEDED", shardId });
  }
  return shard;
}

/**
 * Patch the shard row's `lastTouchedAt` — the OCC-serializing write that
 * pairs with `readShardOrThrow`. MUST run on the success path AFTER all
 * other writes; on any throw, Convex aborts the entire transaction and
 * the patch is rolled back along with everything else.
 */
async function touchShard(
  ctx: MutationCtx,
  shard: Doc<"followedCountriesShards">,
): Promise<void> {
  await ctx.db.patch(shard._id, { lastTouchedAt: Date.now() });
}

async function readCountryLockOrThrow(
  ctx: MutationCtx,
  country: string,
): Promise<Doc<"followedCountriesCountryLocks">> {
  const lock = await ctx.db
    .query("followedCountriesCountryLocks")
    .withIndex("by_country", (q) => q.eq("country", country))
    .first();
  if (!lock) {
    console.error(
      JSON.stringify({
        breadcrumb: "followed_countries_country_locks_not_seeded",
        country,
      }),
    );
    throw new ConvexError({ kind: "COUNTRY_LOCKS_NOT_SEEDED", country });
  }
  return lock;
}

async function touchCountryLock(
  ctx: MutationCtx,
  lock: Doc<"followedCountriesCountryLocks">,
): Promise<void> {
  await ctx.db.patch(lock._id, { lastTouchedAt: Date.now() });
}

/**
 * Read the per-user serialization row for `userId`. Returns the row (if
 * exists) and the denormalized count (0 if no row). Every mutation that
 * mutates `followedCountries` for the user MUST call this AND patch/insert
 * the row at the end — that read+write pair is what triggers Convex
 * per-document OCC retry under same-user concurrency.
 *
 * NOTE: this row is created LAZILY on first use, so its OCC ALONE does
 * NOT serialize the brand-new-user race (two parallel first-ever
 * mutations both read empty and both INSERT, producing duplicate rows
 * that break the `.unique()` read). The pre-seeded shard row above
 * closes that window — it always exists, so its OCC is bulletproof.
 */
async function readUserMeta(
  ctx: MutationCtx,
  userId: string,
): Promise<{
  meta: Doc<"followedCountriesUserMeta"> | null;
  count: number;
}> {
  const meta = await ctx.db
    .query("followedCountriesUserMeta")
    .withIndex("by_user", (q) => q.eq("userId", userId))
    .unique();
  return { meta, count: meta?.count ?? 0 };
}

/**
 * Patch (or insert) the per-user serialization row to `newCount`. This is
 * the OCC-serializing write — concurrent same-user mutations that both
 * read the same `meta._id` will conflict here, and Convex retries the
 * loser. The retry re-reads everything (including the row that the winner
 * inserted), so the second attempt sees the post-winner state and either
 * passes correctly (still under cap), throws FREE_CAP, or returns
 * idempotent (winner already inserted the same `(userId, country)`).
 */
async function writeUserMeta(
  ctx: MutationCtx,
  userId: string,
  meta: Doc<"followedCountriesUserMeta"> | null,
  newCount: number,
): Promise<void> {
  const now = Date.now();
  if (meta) {
    await ctx.db.patch(meta._id, { count: newCount, updatedAt: now });
  } else {
    await ctx.db.insert("followedCountriesUserMeta", {
      userId,
      count: newCount,
      updatedAt: now,
    });
  }
}

async function updateCountryCounter(
  ctx: MutationCtx,
  country: string,
  delta: 1 | -1,
): Promise<void> {
  const countryLock = await readCountryLockOrThrow(ctx, country);
  const rows = await ctx.db
    .query("followedCountriesCounts")
    .withIndex("by_country", (q) => q.eq("country", country))
    .collect();
  rows.sort((a, b) => a._creationTime - b._creationTime);
  const total = rows.reduce((sum, row) => sum + row.count, 0);
  const nextCount = Math.max(0, total + delta);
  const now = Date.now();
  const primary = rows[0];
  if (primary) {
    await ctx.db.patch(primary._id, {
      count: nextCount,
      updatedAt: now,
    });
    for (let i = 1; i < rows.length; i++) {
      const duplicate = rows[i];
      if (duplicate !== undefined) {
        await ctx.db.delete(duplicate._id);
      }
    }
  } else if (nextCount > 0) {
    await ctx.db.insert("followedCountriesCounts", {
      country,
      count: nextCount,
      updatedAt: now,
    });
  }
  await touchCountryLock(ctx, countryLock);
}

/**
 * Atomic +1 on the `followedCountriesCounts` aggregate row for `country`.
 * Counter writes also repair any duplicate rows left by the old lazy-create
 * race: the oldest row is kept at the summed count and extras are deleted.
 * Runs inside the parent mutation transaction so the counter never drifts
 * from the row table (memory: `convex-mutation-from-mutation-not-one-
 * transaction` — intentionally a helper, NOT a child mutation).
 */
async function incrementCountryCounter(
  ctx: MutationCtx,
  country: string,
): Promise<void> {
  await updateCountryCounter(ctx, country, 1);
}

/**
 * Atomic -1 on the `followedCountriesCounts` aggregate row for `country`,
 * defensively clamped at zero. Also repairs duplicate counter rows produced
 * before the country-lock migration.
 */
async function decrementCountryCounter(
  ctx: MutationCtx,
  country: string,
): Promise<void> {
  await updateCountryCounter(ctx, country, -1);
}

async function readRawCountryFollowerCount(
  ctx: QueryCtx,
  country: string,
): Promise<number> {
  const rows = await ctx.db
    .query("followedCountriesCounts")
    .withIndex("by_country", (q) => q.eq("country", country))
    .collect();
  return rows.reduce((sum, row) => sum + row.count, 0);
}

/**
 * Discriminated return shape for `followCountry` and `unfollowCountry`.
 * `idempotent: true` means the mutation observed the desired end state
 * already and made no changes (counter NOT touched).
 *
 * `ok: false, reason: 'FREE_CAP'` is `followCountry`-only — emitted when a
 * tier=0 caller hits the free-tier cap. Previously thrown as
 * `ConvexError({kind:'FREE_CAP', ...})`; the throw was forwarded by Convex
 * Cloud's server-side auto-Sentry directly to our DSN (bypassing browser
 * `Sentry.init({ignoreErrors:[...]})`), producing high-volume noise from
 * an expected business condition. Return-instead-of-throw eliminates the
 * source — see also `convex/userPreferences.ts:81-83` (CAS-guard CONFLICT)
 * for the same pattern. Skill:
 * `convex-gotchas/reference/convex-autosentry-forwards-intentional-convexerror-throws.md`.
 */
export type FollowMutationResult =
  | { ok: true; idempotent: false }
  | { ok: true; idempotent: true }
  | { ok: false; reason: "FREE_CAP"; currentCount: number; limit: number };

/**
 * Return shape for `mergeAnonymousLocal`. `accepted` is the list of
 * NEWLY-inserted countries (in canonicalized first-seen order); existing
 * rows are silently deduped against table state. `droppedInvalid` is
 * inputs that failed `isValidIso2`; `droppedDueToCap` is valid-but-
 * over-cap inputs for free users that the client should surface in an
 * upgrade modal. PRO users receive `droppedDueToCap: []`.
 */
export type MergeAnonymousLocalResult = {
  totalCount: number;
  accepted: string[];
  droppedInvalid: string[];
  droppedDueToCap: string[];
};

/**
 * `followCountry({ country })` — authoritative single-country follow.
 *
 * 1. Auth gate: throws ConvexError({kind:'UNAUTHENTICATED'}) if absent.
 * 2. Validates `country` against the canonical ISO-2 registry; throws
 *    ConvexError({kind:'INVALID_COUNTRY', country}) on miss.
 * 3. Idempotent on (userId, country) — second call returns
 *    {idempotent:true} and does NOT touch the counter or user-meta.
 * 4. Free-tier cap: tier=0 callers with currentCount >= FREE_TIER_FOLLOW_LIMIT
 *    RETURN {ok:false, reason:'FREE_CAP', currentCount, limit} (was thrown
 *    as ConvexError pre-PR; switched to return-instead-of-throw to avoid
 *    Convex auto-Sentry noise on an expected business condition). PRO callers
 *    are unlimited.
 * 5. Atomic counter +1 in the same transaction as the row insert.
 * 6. Atomic per-user-meta count patch — THIS is the OCC-serializing write.
 *
 * Two-tier per-user serialization (cap-bypass mitigation):
 *   Tier 1 — pre-seeded shard row (Codex round-4 P0 v2): EVERY mutation
 *   reads + patches `followedCountriesShards[userIdToShard(userId)]` at
 *   the boundaries of the handler. Because the row is pre-seeded, its
 *   OCC is bulletproof — the loser of two parallel first-ever mutations
 *   retries against the post-winner state and observes the winner's
 *   user-meta insert.
 *
 *   Tier 2 — denormalized user-meta count (Codex round-3 P0): under the
 *   shard lock, we safely lazy-create the per-user `followedCountriesUserMeta`
 *   row (kept additionally for the O(1) cap-check denominator and as the
 *   parity invariant `count === COUNT(followedCountries WHERE userId=X)`).
 *
 * Without Tier 1, two parallel first-ever mutations could both read
 * `meta=undefined`, both INSERT, and produce duplicate meta rows that
 * break the `.unique()` read AND re-open the cap-bypass window. With
 * Tier 1 in place, the brand-new-user race is closed deterministically.
 *
 * Errors are typed `ConvexError({kind, ...})` with object data so callers
 * can branch on `err.data.kind` (memory:
 * `convex-error-string-data-strips-errordata-on-wire`).
 */
export const followCountry = mutation({
  args: { country: v.string() },
  handler: async (ctx, args): Promise<FollowMutationResult> => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new ConvexError({ kind: "UNAUTHENTICATED" });
    const userId = identity.subject;

    if (!isValidIso2(args.country)) {
      throw new ConvexError({
        kind: "INVALID_COUNTRY",
        country: args.country,
      });
    }

    // Tier-1 lock: pre-seeded shard row. Read at top, patch at end.
    const shard = await readShardOrThrow(ctx, userId);

    // Tier-2 read: per-user denormalized count (lazy-created, but safe
    // under the shard lock above).
    const { meta, count: currentCount } = await readUserMeta(ctx, userId);

    const existingRow = await ctx.db
      .query("followedCountries")
      .withIndex("by_user_country", (q) =>
        q.eq("userId", userId).eq("country", args.country),
      )
      .first();
    if (existingRow) {
      return { ok: true, idempotent: true };
    }

    // P3 #21 — Tier-first cap check using the denormalized count. PRO
    // users have no cap (returned for free); for free users the
    // denormalized count is the cap input — no `.collect()` needed.
    //
    // Return-instead-of-throw: see FollowMutationResult doc comment and
    // companion skill `convex-gotchas/reference/convex-autosentry-forwards-intentional-convexerror-throws.md`.
    // Throwing here forwarded to Sentry via Convex auto-Sentry on every
    // hit (high-volume by nature, expected business behavior). Other
    // throws in this handler (UNAUTHENTICATED, INVALID_COUNTRY, shard
    // missing) intentionally still throw — those ARE bugs and we WANT
    // them in Sentry.
    const tier = await readEntitlementTier(ctx, userId);
    if (tier < 1 && currentCount >= FREE_TIER_FOLLOW_LIMIT) {
      return {
        ok: false,
        reason: "FREE_CAP",
        currentCount,
        limit: FREE_TIER_FOLLOW_LIMIT,
      };
    }

    await ctx.db.insert("followedCountries", {
      userId,
      country: args.country,
      addedAt: Date.now(),
    });
    await incrementCountryCounter(ctx, args.country);
    // Tier-2 OCC write — patch the user-meta row.
    await writeUserMeta(ctx, userId, meta, currentCount + 1);
    // Tier-1 OCC write — patch the pre-seeded shard row.
    await touchShard(ctx, shard);

    return { ok: true, idempotent: false };
  },
});

/**
 * `unfollowCountry({ country })` — authoritative single-country unfollow.
 *
 * 1. Auth gate.
 * 2. Validates ISO-2.
 * 3. Idempotent on absent: missing row returns {idempotent:true} and does
 *    NOT decrement the counter.
 * 4. Atomic counter -1 (clamped at 0) in the same transaction as the row
 *    delete.
 */
export const unfollowCountry = mutation({
  args: { country: v.string() },
  handler: async (ctx, args): Promise<FollowMutationResult> => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new ConvexError({ kind: "UNAUTHENTICATED" });
    const userId = identity.subject;

    if (!isValidIso2(args.country)) {
      throw new ConvexError({
        kind: "INVALID_COUNTRY",
        country: args.country,
      });
    }

    // Tier-1 lock: pre-seeded shard row. Read at top, patch at end on
    // the non-idempotent path. The idempotent (no-row) branch returns
    // before any write, so there's no race to lose.
    const shard = await readShardOrThrow(ctx, userId);

    // Tier-2 read — see followCountry for full rationale.
    const { meta, count: currentCount } = await readUserMeta(ctx, userId);

    const existingRow = await ctx.db
      .query("followedCountries")
      .withIndex("by_user_country", (q) =>
        q.eq("userId", userId).eq("country", args.country),
      )
      .first();
    if (!existingRow) {
      return { ok: true, idempotent: true };
    }

    await ctx.db.delete(existingRow._id);
    await decrementCountryCounter(ctx, args.country);
    // Tier-2 OCC write — clamp at zero defensively in case a hand-seeded
    // test or migration left meta out of parity. The mutations are the
    // only writers in production; this clamp protects tests.
    await writeUserMeta(ctx, userId, meta, Math.max(0, currentCount - 1));
    // Tier-1 OCC write.
    await touchShard(ctx, shard);

    return { ok: true, idempotent: false };
  },
});

/**
 * `mergeAnonymousLocal({ countries })` — sign-in merge of an anonymous
 * localStorage list into the authoritative table.
 *
 * Algorithm (verbatim, plan U13 step list):
 *   1. Auth gate.
 *   2. Reject empty input with ConvexError({kind:'EMPTY_INPUT'}).
 *   3. Reject inputs > MAX_MERGE_INPUT with INPUT_TOO_LARGE.
 *   4. Filter through isValidIso2; collect droppedInvalid.
 *   5. Canonicalize: dedupe in first-seen order.
 *   6. Read existing rows; build existingSet.
 *   7. newCandidates = canonicalized.filter(c => !existingSet.has(c)).
 *   8. tier=0 free user: accept up to (LIMIT - existingCount); rest →
 *      droppedDueToCap.
 *   9. tier>=1 PRO: accept all newCandidates.
 *  10. Insert accepted rows; +1 counter for each (atomic).
 *  11. Return {totalCount, accepted, droppedInvalid, droppedDueToCap}.
 *  12. If droppedDueToCap.length > 0, log structured warning.
 *
 * Resolves Codex-deepening round-1 P0 (server-side cap on merge) and
 * round-2 P1 (canonicalize duplicates before counting). Free users with
 * existingCount >= LIMIT accept zero new rows — never silently grow above
 * the cap during merge. (Grandfathering above-cap rows on PRO→free
 * downgrade is a separate concern handled by NOT auto-deleting on
 * downgrade; merge is the FIRST sign-in and has no PRO history to
 * grandfather.)
 */
export const mergeAnonymousLocal = mutation({
  args: { countries: v.array(v.string()) },
  handler: async (ctx, args): Promise<MergeAnonymousLocalResult> => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new ConvexError({ kind: "UNAUTHENTICATED" });
    const userId = identity.subject;

    // Step 2: empty-input guard.
    if (args.countries.length === 0) {
      throw new ConvexError({ kind: "EMPTY_INPUT" });
    }

    // Step 3: defensive upper-bound on input length.
    if (args.countries.length > MAX_MERGE_INPUT) {
      throw new ConvexError({
        kind: "INPUT_TOO_LARGE",
        max: MAX_MERGE_INPUT,
        received: args.countries.length,
      });
    }

    // Step 4: ISO-2 registry filter; collect droppedInvalid in input order.
    const droppedInvalid: string[] = [];
    const validInputs: string[] = [];
    for (const code of args.countries) {
      if (isValidIso2(code)) {
        validInputs.push(code);
      } else {
        droppedInvalid.push(code);
      }
    }

    // Step 5: canonicalize — dedupe in first-seen order. Without this, a
    // PRO merge of ['US','US','US'] would attempt 3 inserts and 3 counter
    // increments for one logical follow.
    const seen = new Set<string>();
    const canonicalized: string[] = [];
    for (const code of validInputs) {
      if (!seen.has(code)) {
        seen.add(code);
        canonicalized.push(code);
      }
    }

    // Tier-1 lock: pre-seeded shard row. Read at top, patch at end if
    // any row was actually accepted (no patch on the all-no-op branch
    // since there's no observable change to race on).
    const shard = await readShardOrThrow(ctx, userId);

    // Tier-2 read. Under the shard lock above, the brand-new-user lazy
    // create on `followedCountriesUserMeta` is now race-free.
    const { meta, count: existingCount } = await readUserMeta(ctx, userId);

    // Step 6: read existing rows; build existingSet. Still required for
    // the dedup against (userId, country) — the meta count is a scalar,
    // it can't tell us WHICH countries the user already follows.
    const existingRows = await ctx.db
      .query("followedCountries")
      .withIndex("by_user", (q) => q.eq("userId", userId))
      .collect();
    const existingSet = new Set<string>(existingRows.map((r) => r.country));

    // Step 7: filter against existing.
    const newCandidates = canonicalized.filter((c) => !existingSet.has(c));

    // Step 8/9: cap-bounded accept based on entitlement tier. Uses the
    // OCC-tracked meta count as the cap denominator — under concurrency,
    // the loser retries against the post-winner count.
    const tier = await readEntitlementTier(ctx, userId);
    let accepted: string[];
    let droppedDueToCap: string[];
    if (tier < 1) {
      const remaining = Math.max(0, FREE_TIER_FOLLOW_LIMIT - existingCount);
      accepted = newCandidates.slice(0, remaining);
      droppedDueToCap = newCandidates.slice(remaining);
    } else {
      accepted = newCandidates;
      droppedDueToCap = [];
    }

    // Step 10: insert accepted rows + atomic counter +1 each.
    const now = Date.now();
    for (const country of accepted) {
      await ctx.db.insert("followedCountries", {
        userId,
        country,
        addedAt: now,
      });
      await incrementCountryCounter(ctx, country);
    }

    // Tier-2 OCC write — same rule as followCountry. Skip when
    // accepted=[] (no change to count → no write to race on).
    if (accepted.length > 0) {
      await writeUserMeta(ctx, userId, meta, existingCount + accepted.length);
      // Tier-1 OCC write — only on the path that produced observable
      // changes. The all-no-op branch leaves both shard and user-meta
      // untouched.
      await touchShard(ctx, shard);
    }

    // Step 12: structured warning when free users overflow cap. No
    // server-side Sentry SDK in convex/ today; emit a structured
    // console.warn that the platform log aggregator can pick up.
    if (droppedDueToCap.length > 0) {
      const userIdHashed = hashUserIdForLog(userId);
      console.warn(
        JSON.stringify({
          breadcrumb: "followed_countries_merge_cap_drop",
          userIdHashed,
          existingCount,
          droppedCount: droppedDueToCap.length,
        }),
      );
    }

    // Step 11: return shape.
    return {
      totalCount: existingCount + accepted.length,
      accepted,
      droppedInvalid,
      droppedDueToCap,
    };
  },
});

/**
 * Stable, non-cryptographic hash of a userId for log breadcrumbs. We do
 * NOT want raw Clerk subjects in our log aggregator. djb2 is fine — this
 * is for grouping/correlation, not security.
 */
function hashUserIdForLog(userId: string): string {
  let h = 5381;
  for (let i = 0; i < userId.length; i++) {
    h = ((h << 5) + h + userId.charCodeAt(i)) | 0;
  }
  // Convert to unsigned 32-bit hex for compact log readability.
  return (h >>> 0).toString(16).padStart(8, "0");
}

// ---------------------------------------------------------------------------
// Queries (plan U14)
// ---------------------------------------------------------------------------

/**
 * `listFollowed()` — auth'd reactive read of the current user's watchlist.
 *
 * Returns ONLY the country codes (string[]); `addedAt` and `userId` are
 * not exposed to clients. Sorted by `addedAt` ascending (earliest-added
 * first) so the client gets a stable, intuitive order — the country a
 * user followed first appears first.
 *
 * If no auth identity is present, returns `[]` (consistent with
 * `convex/alertRules.ts::getAlertRules`). Reactive: Convex will
 * auto-resubscribe whenever the underlying `followedCountries` rows for
 * this user change.
 */
export const listFollowed = query({
  args: {},
  handler: async (ctx): Promise<string[]> => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return [];
    const rows = await ctx.db
      .query("followedCountries")
      .withIndex("by_user", (q) => q.eq("userId", identity.subject))
      .collect();
    // Sort by addedAt ascending — earliest-added first. Documented choice
    // (plan U14 test scenario: PRO user with `['US','GB']` added in that
    // order returns `['US','GB']`).
    return rows
      .sort((a, b) => a.addedAt - b.addedAt)
      .map((r) => r.country);
  },
});

/**
 * `countFollowers({ country })` — public, no auth.
 *
 * Reads the aggregate `followedCountriesCounts` rows. The normal state is
 * exactly one row per country, but the query sums duplicates so any rows
 * left behind by the old lazy-create race do not undercount publicly while
 * waiting for the next write-path repair.
 *
 * Privacy floor (P2 #12 — doc/code alignment):
 *   `raw < COUNTRY_COUNT_PRIVACY_FLOOR` returns 0. With
 *   COUNTRY_COUNT_PRIVACY_FLOOR=5, counts of 1-4 followers return 0; a
 *   count of 5 or more is returned exactly. The unbucketed count is
 *   internally accessible to ops via direct DB reads on the
 *   `followedCountriesCounts` table — this floor only applies at the
 *   public-query layer.
 */
export const countFollowers = query({
  args: { country: v.string() },
  handler: async (ctx, args): Promise<number> => {
    if (!isValidIso2(args.country)) {
      throw new ConvexError({
        kind: "INVALID_COUNTRY",
        country: args.country,
      });
    }
    const raw = await readRawCountryFollowerCount(ctx, args.country);
    // `<` is the canonical comparator: returns 0 when count is below
    // COUNTRY_COUNT_PRIVACY_FLOOR (1-4 followers); count of 5 or more
    // is returned exactly.
    if (raw < COUNTRY_COUNT_PRIVACY_FLOOR) return 0;
    return raw;
  },
});

/**
 * `listFollowersPage({ country, cursor, limit })` — INTERNAL-ONLY
 * paginated cursor over the followers of a country.
 *
 * Declared via `internalQuery` (NOT `query`) so it never appears in
 * `api.followedCountries` — only in `internal.followedCountries`. This
 * is the privacy boundary: follower lists are never publicly readable.
 *
 * `limit` is clamped to `[1, 500]` defensively so a buggy/abusive
 * caller can't request a 10k-element response.
 *
 * Returns `{ userIds, nextCursor }` where `nextCursor` is `null` when
 * Convex's paginator reports `isDone`.
 */
export const listFollowersPage = internalQuery({
  args: {
    country: v.string(),
    cursor: v.optional(v.union(v.string(), v.null())),
    limit: v.number(),
  },
  handler: async (
    ctx,
    args,
  ): Promise<{ userIds: string[]; nextCursor: string | null }> => {
    if (!isValidIso2(args.country)) {
      throw new ConvexError({
        kind: "INVALID_COUNTRY",
        country: args.country,
      });
    }
    const clampedLimit = Math.max(1, Math.min(500, args.limit));
    const result = await ctx.db
      .query("followedCountries")
      .withIndex("by_country", (q) => q.eq("country", args.country))
      .paginate({ cursor: args.cursor ?? null, numItems: clampedLimit });
    return {
      userIds: result.page.map((r) => r.userId),
      nextCursor: result.isDone ? null : result.continueCursor,
    };
  },
});

/**
 * `internalListFollowedForUser({ userId })` — INTERNAL-ONLY helper
 * used by the `/relay/followed-countries` HTTP action.
 *
 * The relay has no Clerk identity (it authenticates via the shared
 * secret in the Authorization header), so it can't call the public
 * `listFollowed`. This helper takes an explicit `userId` and returns
 * the same `string[]` shape. Sorting matches `listFollowed`: by
 * `addedAt` ascending.
 */
export const internalListFollowedForUser = internalQuery({
  args: { userId: v.string() },
  handler: async (ctx, args): Promise<string[]> => {
    const rows = await ctx.db
      .query("followedCountries")
      .withIndex("by_user", (q) => q.eq("userId", args.userId))
      .collect();
    return rows
      .sort((a, b) => a.addedAt - b.addedAt)
      .map((r) => r.country);
  },
});

// ---------------------------------------------------------------------------
// Sharded-lock seed (Codex round-4 P0 v2)
// ---------------------------------------------------------------------------

/**
 * `_seedShards()` — INTERNAL-ONLY, idempotent pre-seeder for the
 * `followedCountriesShards` table. Inserts rows for every shard id in
 * `[0, SHARD_COUNT)` that doesn't already exist; existing rows are left
 * alone (the `lastTouchedAt` value is opaque — it's only used as the
 * OCC-tracking field, never read for application logic).
 *
 * Wired into:
 *   - `convex/crons.ts` daily cron (idempotent, cheap — defends against
 *     a deploy-time seed step being skipped). Runs alongside
 *     `_dedupeShards` so any concurrent-seed duplicates self-heal within
 *     24h.
 *   - `.github/workflows/convex-deploy.yml` post-deploy step — operator
 *     surface is `npx convex run --prod followedCountries:_seedShards`.
 *     `npx convex run` targets internal functions by their file:export
 *     path, so a public-mutation wrapper is unnecessary (and was a
 *     security hazard — see Codex round-3 P1).
 *
 * Returns `{ seeded: N }` — the number of NEW rows inserted. After
 * steady-state, every call returns `{ seeded: 0 }` and the table has
 * exactly SHARD_COUNT rows.
 *
 * Concurrent-seed race: two simultaneous calls against an empty table
 * both read empty, both compute `have` as empty, and both insert the
 * full range — producing 128 rows (2 per `shardId`). This is a real
 * possibility on a re-deploy that races the daily cron. The race is
 * tolerated rather than prevented: `readShardOrThrow` uses `.first()`
 * (not `.unique()`), so duplicates never break a follow/unfollow/merge
 * mutation; `_dedupeShards` runs in the same daily cron and removes
 * extras, restoring exactly one row per shardId. The user-meta count
 * remains the authoritative cap denominator, so cap correctness is
 * unaffected by transient duplicates.
 */
export const _seedShards = internalMutation({
  args: {},
  handler: async (ctx): Promise<{ seeded: number }> => {
    const existing = await ctx.db
      .query("followedCountriesShards")
      .collect();
    const have = new Set<number>(existing.map((r) => r.shardId));
    const now = Date.now();
    let seeded = 0;
    for (let i = 0; i < SHARD_COUNT; i++) {
      if (!have.has(i)) {
        await ctx.db.insert("followedCountriesShards", {
          shardId: i,
          lastTouchedAt: now,
        });
        seeded += 1;
      }
    }
    return { seeded };
  },
});

export const _seedCountryLocks = internalMutation({
  args: {},
  handler: async (ctx): Promise<{ seeded: number }> => {
    const existing = await ctx.db
      .query("followedCountriesCountryLocks")
      .collect();
    const have = new Set<string>(existing.map((r) => r.country));
    const now = Date.now();
    let seeded = 0;
    for (const country of validIso2Codes()) {
      if (!have.has(country)) {
        await ctx.db.insert("followedCountriesCountryLocks", {
          country,
          lastTouchedAt: now,
        });
        seeded += 1;
      }
    }
    return { seeded };
  },
});

export const _dedupeCountryLocks = internalMutation({
  args: {},
  handler: async (ctx): Promise<{ deleted: number }> => {
    const all = await ctx.db.query("followedCountriesCountryLocks").collect();
    const byCountry = new Map<string, Doc<"followedCountriesCountryLocks">[]>();
    for (const row of all) {
      const list = byCountry.get(row.country);
      if (list) {
        list.push(row);
      } else {
        byCountry.set(row.country, [row]);
      }
    }
    let deleted = 0;
    for (const rows of byCountry.values()) {
      if (rows.length <= 1) continue;
      rows.sort((a, b) => a._creationTime - b._creationTime);
      for (let i = 1; i < rows.length; i++) {
        const extra = rows[i];
        if (extra !== undefined) {
          await ctx.db.delete(extra._id);
          deleted += 1;
        }
      }
    }
    return { deleted };
  },
});

/**
 * `_dedupeShards()` — INTERNAL-ONLY daily cron companion to `_seedShards`.
 * Walks the `followedCountriesShards` table, groups by `shardId`, and
 * deletes all but the OLDEST row (by `_creationTime`) for any shardId
 * with `count > 1`. The "oldest" choice is intentional — under a
 * concurrent-seed race, the loser's row is what `readShardOrThrow`'s
 * `.first()` returned to in-flight mutations between the race and this
 * cleanup, so the oldest row already has the most "real" `lastTouchedAt`
 * traffic and is the safer survivor.
 *
 * After every daily run the table satisfies `count = SHARD_COUNT` AND
 * `every shardId in [0, SHARD_COUNT)` appears exactly once. Idempotent
 * in the steady-state (no duplicates → no deletes).
 *
 * Returns `{ deleted: N }` — the number of duplicate rows removed.
 */
export const _dedupeShards = internalMutation({
  args: {},
  handler: async (ctx): Promise<{ deleted: number }> => {
    const all = await ctx.db.query("followedCountriesShards").collect();
    // Group by shardId; for each group with > 1 row, keep the oldest by
    // `_creationTime` and delete the rest.
    const byShard = new Map<number, Doc<"followedCountriesShards">[]>();
    for (const row of all) {
      const list = byShard.get(row.shardId);
      if (list) {
        list.push(row);
      } else {
        byShard.set(row.shardId, [row]);
      }
    }
    let deleted = 0;
    for (const rows of byShard.values()) {
      if (rows.length <= 1) continue;
      // Sort ascending by `_creationTime` — oldest first.
      rows.sort((a, b) => a._creationTime - b._creationTime);
      // Keep [0]; delete [1..]. `noUncheckedIndexedAccess` requires the
      // explicit `extra !== undefined` guard even though the loop bound
      // makes it unreachable.
      for (let i = 1; i < rows.length; i++) {
        const extra = rows[i];
        if (extra !== undefined) {
          await ctx.db.delete(extra._id);
          deleted += 1;
        }
      }
    }
    return { deleted };
  },
});
