import { Low, type Adapter } from "lowdb";
import { contextFromJson, renderPost } from "@/lib/data/post-view";
import { reelContextFromJson, renderReel } from "@/lib/data/reel-view";
import { JSONFile } from "lowdb/node";
import { MySQLAdapter } from "@/lib/data/mysql-adapter";
import { sqlEnabled } from "@/lib/sql/client";
import fs from "fs";
import path from "path";
import { nanoid } from "nanoid";
import bcrypt from "bcryptjs";
import { saveDataUrl } from "./storage";
import type {
  Community,
  CommunityMember,
  User,
  StoredUser,
  StoredPost,
  Post,
  Comment,
  ReelComment,
  Story,
  StoredReel,
  Reel,
  Follow,
  Block,
  Interaction,
  PollVote,
  AppNotification,
  NotificationType,
  Conversation,
  DirectMessage,
  WalletTransaction,
  WalletTransactionType,
  MarketplaceListing,
  SellerReview,
  MarketplaceSettings,
  CrowdfundingCampaign,
  Report,
  NewsArticle,
  Highlight, Badge, UserBadge, SiteSettings, MoneySettings, WithdrawalRequest, AdCampaign, Subscription, Gift, Event, EventRsvp, StaticPage, Blacklist, UserGroup, MailSettings, StorageSettings, SocialLoginSettings, CaptchaSettings, Theme, ThemePurchase, GatewaySettings, Payment, TaxonomyItem, BlogPost, Invitation, ListingOffer, Job, JobApplication, Offer, ContactMessage, SupportTicket, Category, Membership, Announcement } from "./types";

export type DBShape = {
  siteSettings: SiteSettings;
  /** Failed sign-ins, kept only long enough to enforce a lockout. */
  loginAttempts: { username: string; at: number }[];
  invitations: Invitation[];
  listingOffers: ListingOffer[];
  jobs: Job[];
  jobApplications: JobApplication[];
  offers: Offer[];
  contactMessages: ContactMessage[];
  supportTickets: SupportTicket[];
  /** Module categories — marketplace, blogs, groups and the rest. */
  categories: Category[];
  /** Banners across the top of the site. */
  announcements: Announcement[];
  /** Site memberships — who is Pro, and how they came to be. */
  memberships: Membership[];
  /** Rolling record of sent texts, for the daily cap. */
  smsLog: { at: number }[];
  /** Demo top-ups, so they can be rate-limited. */
  depositLog: { userId: string; amount: number; at: number }[];
  /** Rolling record of upload sizes, for the daily allowance. */
  uploadLog: { userId: string; bytes: number; at: number }[];
  blogs: BlogPost[];
  taxonomies: Record<string, TaxonomyItem[]>;
  gateways: GatewaySettings;
  payments: Payment[];
  themes: Theme[];
  themePurchases: ThemePurchase[];
  captcha: CaptchaSettings;
  socialLogin: SocialLoginSettings;
  storageSettings: StorageSettings;
  mailSettings: MailSettings;
  staticPages: StaticPage[];
  blacklist: Blacklist;
  userGroups: UserGroup[];
  events: Event[];
  eventRsvps: EventRsvp[];
  moneySettings: MoneySettings;
  withdrawals: WithdrawalRequest[];
  adCampaigns: AdCampaign[];
  subscriptions: Subscription[];
  gifts: Gift[];
  badges: Badge[];
  userBadges: UserBadge[];
  users: StoredUser[];
  posts: StoredPost[];
  comments: Comment[];
  stories: Story[];
  reels: StoredReel[];
  reelComments: ReelComment[];
  follows: Follow[];
  /** People whose follow you chose not to return. */
  followDismissals: { userId: string; otherId: string; createdAt: string }[];
  blocks: Block[];
  notifications: AppNotification[];
  communities: Community[];
  communityMembers: CommunityMember[];
  postLikes: Interaction[];
  postSaves: Interaction[];
  postReposts: Interaction[];
  reelLikes: Interaction[];
  reelSaves: Interaction[];
  reelReposts: Interaction[];
  commentLikes: Interaction[];
  reelCommentLikes: Interaction[];
  pollVotes: PollVote[];
  conversations: Conversation[];
  messages: DirectMessage[];
  walletTransactions: WalletTransaction[];
  marketplaceListings: MarketplaceListing[];
  sellerReviews: SellerReview[];
  marketplaceSettings: MarketplaceSettings;
  crowdfundingCampaigns: CrowdfundingCampaign[];
  reports: Report[];
  news: NewsArticle[];
  highlights: Highlight[];
};

// Strip the password hash before a user object is ever embedded in a post/
// comment/story/reel author field, or sent back to the client.
export function publicUser(u: StoredUser): User {
  // Badges travel with the user so posts, comments and lists all show them
  // without another request.

  const {
    passwordHash: _passwordHash,
    walletBalance: _walletBalance,
    premiumSince: _premiumSince,
    isAdmin,
    status: _status,
    // Secrets. These must never reach a browser: a bcrypt hash is worth
    // cracking offline, a live 2FA code defeats the second factor, and the
    // signup address is personal data nobody else needs.
    recoveryHash: _recoveryHash,
    recoveryCode: _recoveryCode,
    pendingTwoFactor: _pendingTwoFactor,
    signupIp: _signupIp,
    knownIps: _knownIps,
    email: _email,
    phone: _phone,
    twoFactorSecret: _twoFactorSecret,
    ...pub
  } = u;
  // `admin` is a display-only flag (the badge is already public); the
  // private isAdmin field stays out of the payload.
  return isAdmin ? { ...pub, admin: true } : pub;
}

const file = path.join(process.cwd(), "data", "db.json");
/**
 * Where this object lives.
 *
 * With MySQL configured it is the database, through an adapter that presents
 * the tables in the shape this file expects and writes back only what
 * changed. Without one it is data/db.json, which is the demo mode: unzip,
 * run, look around.
 *
 * This used to be the file and nothing else, which meant a site on MySQL ran
 * on two databases at once -- the pages people look at reading `openStore()`
 * and the database, everything here reading a file that only agreed with it
 * until the first save. Choosing here, once, from the same environment
 * variables `openStore()` reads, is what keeps the two halves of the site
 * looking at the same data.
 */
/**
  * Chosen per build, not once at import.
  *
  * It used to be a module-level const, which is fine for a site whose
  * .env is already written. The installer's database step writes .env
  * into a process that is already running, and a const evaluated at
  * import would have kept the file adapter for the rest of that
  * process — so the admin account created two steps later, and every
  * setting with it, would have gone into data/db.json while the tables
  * sat empty. The site would look installed and be running on the wrong
  * thing. Deciding here means reloadDb() switches it over.
  */
function makeAdapter(): Adapter<DBShape> {
  return sqlEnabled() ? new MySQLAdapter<DBShape>() : new JSONFile<DBShape>(file);
}

/**
 * Serialising the whole file costs time proportional to its size, so requests
 * arriving together shouldn't each pay it. If a write is already running, the
 * next one waits for a single follow-up write rather than queueing its own —
 * they all persist the same in-memory object, so one catch-up write suffices.
 *
 * No timer is used: delaying a write would add latency to every request while
 * batching nothing when they arrive one at a time.
 */
function coalesceWrites(db: Low<DBShape>) {
  const realWrite = db.write.bind(db);
  let inFlight: Promise<void> | null = null;
  let queued: Promise<void> | null = null;

  db.write = () => {
    if (!inFlight) {
      inFlight = realWrite().finally(() => {
        inFlight = null;
      });
      return inFlight;
    }

    // Someone is already writing; ride along with one follow-up.
    queued ??= inFlight
      .then(() => realWrite())
      .finally(() => {
        queued = null;
      });
    return queued;
  };

  return db;
}
/** Sensible defaults; the seed overwrites these on a fresh database. */
const defaultMarketplaceSettings: MarketplaceSettings = {
  enabled: true,
  categories: [],
  currency: "USD",
  minPrice: 1,
  maxPrice: 1000000,
  requireApproval: false,
  allowVideo: true,
  maxVideoMb: 40,
  maxPhotos: 6,
  allowOffers: true,
  commissionPercent: 0,
  soldVisibleDays: 14,
  distanceUnit: "km",
  bannerTitle: "Buy & Sell Anything",
  bannerSubtitle: "",
};

const defaultData: DBShape = {
  siteSettings: defaultSiteSettings(),
  loginAttempts: [],
  invitations: [],
  listingOffers: [],
  jobs: [],
  jobApplications: [],
  offers: [],
  categories: [],
  announcements: [],
  memberships: [],
  contactMessages: [],
  supportTickets: [],
  smsLog: [],
  depositLog: [],
  uploadLog: [],
  blogs: [],
  taxonomies: defaultTaxonomies(),
  gateways: defaultGateways(),
  payments: [],
  themes: defaultThemes(),
  themePurchases: [],
  captcha: defaultCaptcha(),
  socialLogin: defaultSocialLogin(),
  storageSettings: defaultStorageSettings(),
  mailSettings: defaultMailSettings(),
  staticPages: defaultStaticPages(),
  blacklist: { usernames: [], emails: [], domains: [] },
  userGroups: defaultUserGroups(),
  events: [],
  eventRsvps: [],
  moneySettings: defaultMoneySettings(),
  withdrawals: [],
  adCampaigns: [],
  subscriptions: [],
  gifts: defaultGifts(),
  marketplaceSettings: defaultMarketplaceSettings,
  badges: [],
  userBadges: [],
  users: [],
  posts: [],
  comments: [],
  stories: [],
  reels: [],
  reelComments: [],
  follows: [],
  followDismissals: [],
  blocks: [],
  notifications: [],
  communities: [],
  communityMembers: [],
  postLikes: [],
  postSaves: [],
  postReposts: [],
  reelLikes: [],
  reelSaves: [],
  reelReposts: [],
  commentLikes: [],
  reelCommentLikes: [],
  pollVotes: [],
  conversations: [],
  messages: [],
  walletTransactions: [],
  marketplaceListings: [],
  sellerReviews: [],
  crowdfundingCampaigns: [],
  reports: [],
  news: [],
  highlights: [],
};

// Singleton across hot-reloads in dev
const g = globalThis as unknown as {
  __notrdb?: Low<DBShape>;
  /** The build in progress, so concurrent callers share one. */
  __notrdbInit?: Promise<Low<DBShape>>;
  /** When that copy was loaded -- see REFRESH_MS. */
  __notrdbAt?: number;
};

/** A sensible default set, so the badge admin isn't empty on first run. */
function starterBadges(): Badge[] {
  const at = new Date().toISOString();
  return [
    { id: "verified", name: "Verified", description: "Identity confirmed by the team.",
      icon: "BadgeCheck", color: "#3b82f6", color2: "#2563eb", order: 1, active: true, createdAt: at },
    { id: "founder", name: "Founder", description: "Here from the very beginning.",
      icon: "Rocket", color: "#a855f7", color2: "#7c3aed", order: 2, active: true, createdAt: at },
    { id: "top-seller", name: "Top Seller", description: "Ten or more completed sales.",
      icon: "Trophy", color: "#f59e0b", color2: "#d97706",
      rule: { metric: "sales", atLeast: 10 }, order: 3, active: true, createdAt: at },
    { id: "popular", name: "Popular", description: "A hundred followers and counting.",
      icon: "Flame", color: "#ec4899", color2: "#db2777",
      rule: { metric: "followers", atLeast: 100 }, order: 4, active: true, createdAt: at },
    { id: "storyteller", name: "Storyteller", description: "Fifty posts shared.",
      icon: "PenLine", color: "#10b981", color2: "#059669",
      rule: { metric: "posts", atLeast: 50 }, order: 5, active: true, createdAt: at },
  ];
}

/** Sensible defaults, so a fresh install works without configuring anything. */
/** Money features start conservative — an admin opens them up. */
export function defaultMoneySettings(): MoneySettings {
  return {
    enabled: true,
    commissionPercent: 5,
    minWithdrawal: 20,
    withdrawalMethods: ["paypal", "bank"],
    allowPaidPosts: true,
    maxPaidPostPrice: 100,
    allowSubscriptions: true,
    maxSubscriptionPrice: 50,
    allowTransfers: true,
    allowGifts: true,
    referralBonus: 5,
    ads: {
      enabled: true,
      requireApproval: true,
      costPerView: 0.01,
      costPerClick: 0.25,
    },
  };
}

export function defaultGifts(): Gift[] {
  return [
    { id: "coffee", name: "Coffee", emoji: "☕", price: 3, active: true },
    { id: "rocket", name: "Rocket", emoji: "🚀", price: 10, active: true },
    { id: "diamond", name: "Diamond", emoji: "💎", price: 25, active: true },
    { id: "crown", name: "Crown", emoji: "👑", price: 50, active: true },
  ];
}

function seedEvents(): Event[] {
  const soon = (days: number) => {
    const t = new Date();
    t.setDate(t.getDate() + days);
    t.setHours(19, 0, 0, 0);
    return t.toISOString();
  };

  return [
    {
      id: "ev-launch",
      slug: "xrcoin-launch-night",
      title: "XRcoin launch night",
      description:
        "Drinks, a short demo of what we've been building, and a chance to meet the people behind it. Come early — the good seats go quickly.",
      host: { id: "seed", name: "XRcoin", username: "xrcoin", avatarColor: "from-purple-500 to-indigo-500" } as User,
      gradient: "from-purple-500 to-indigo-600",
      category: "Technology",
      startsAt: soon(7),
      online: false,
      venue: "The Old Warehouse",
      address: "Southend-on-Sea",
      privacy: "public",
      createdAt: new Date().toISOString(),
    },
    {
      id: "ev-workshop",
      slug: "shader-workshop",
      title: "Shader workshop for beginners",
      description:
        "Two hours on the basics of writing shaders. Bring a laptop; no maths degree required.",
      host: { id: "seed", name: "XRcoin", username: "xrcoin", avatarColor: "from-pink-500 to-rose-500" } as User,
      gradient: "from-pink-500 to-rose-600",
      category: "Design",
      startsAt: soon(14),
      online: true,
      meetingUrl: "https://example.com/workshop",
      privacy: "public",
      createdAt: new Date().toISOString(),
    },
  ];
}

/** The pages every site is expected to have. */
function defaultStaticPages(): StaticPage[] {
  const at = new Date().toISOString();
  return [
    {
      id: "terms",
      slug: "terms",
      title: "Terms of service",
      body: "Replace this with your own terms before launching.",
      inFooter: true,
      published: true,
      updatedAt: at,
    },
    {
      id: "privacy",
      slug: "privacy",
      title: "Privacy policy",
      body: "Explain what you collect and why. This placeholder isn't a policy.",
      inFooter: true,
      published: true,
      updatedAt: at,
    },
    {
      id: "about",
      slug: "about",
      title: "About",
      body: "Tell people what this place is for.",
      inFooter: true,
      published: true,
      updatedAt: at,
    },
  ];
}

function defaultUserGroups(): UserGroup[] {
  const all = {
    post: true, comment: true, message: true, sell: true,
    createEvents: true, createCommunities: true, advertise: true, withdraw: true,
  };
  return [
    { id: "member", name: "Member", colour: "#7c3aed", can: { ...all }, isDefault: true },
    {
      id: "restricted",
      name: "Restricted",
      colour: "#f59e0b",
      can: { ...all, sell: false, advertise: false, withdraw: false },
      isDefault: false,
    },
    {
      id: "readonly",
      name: "Read only",
      colour: "#71717a",
      can: {
        post: false, comment: false, message: false, sell: false,
        createEvents: false, createCommunities: false, advertise: false, withdraw: false,
      },
      isDefault: false,
    },
  ];
}

/** Email is off until someone fills in a server. */
/** Local disk until someone configures a bucket. */
export function defaultStorageSettings(): StorageSettings {
  return {
    driver: "local",
    bucket: "",
    region: "us-east-1",
    endpoint: "",
    accessKeyId: "",
    secretAccessKey: "",
    publicUrl: "",
    forcePathStyle: false,
  };
}

/** Off until someone adds credentials from the provider. */
export function defaultSocialLogin(): SocialLoginSettings {
  return {
    google: { enabled: false, clientId: "", clientSecret: "" },
    facebook: { enabled: false, appId: "", appSecret: "" },
    github: { enabled: false, clientId: "", clientSecret: "" },
    siteUrl: "",
  };
}

/** No captcha until someone picks a provider. */
export function defaultCaptcha(): CaptchaSettings {
  return {
    provider: "none",
    siteKey: "",
    secretKey: "",
    on: { signup: true, login: false, post: false, contact: true },
  };
}

/**
 * The theme the site ships with.
 *
 * One entry, deliberately. Sample themes made the panel look busy and gave
 * buyers four half-designed looks to explain; anything else worth having is
 * bought and installed, which is where the real ones come from.
 */
export function defaultThemes(): Theme[] {
  return [
    {
      id: "default",
      name: "Twitter",
      description: "The theme this site ships with, free — clean and light.",
      preview: { bg: "#f4f6fa", card: "#ffffff", accent: "#7c3aed", text: "#18181b" },
      tokens: {},
      price: 0,
      isDefault: true,
      active: true,
      order: 1,
    },
  ];
}

/** Both gateways off until keys are added. */
export function defaultGateways(): GatewaySettings {
  return {
    currency: "USD",
    currencySymbol: "$",
    stripe: {
      enabled: false,
      publishableKey: "",
      secretKey: "",
      webhookSecret: "",
      testMode: true,
    },
    paypal: {
      enabled: false,
      clientId: "",
      clientSecret: "",
      sandbox: true,
    },
    minTopUp: 5,
    maxTopUp: 1000,
  };
}

/** The lists that were previously hardcoded across several files. */
export function defaultTaxonomies(): Record<string, TaxonomyItem[]> {
  const list = (items: (string | [string, string])[]) =>
    items.map((item, i) => {
      const [label, extra] = Array.isArray(item) ? item : [item, undefined];
      return {
        id: label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || `item-${i}`,
        label,
        ...(extra ? { icon: extra } : {}),
        order: i + 1,
        active: true,
      };
    });

  return {
    marketplaceCategories: list([
      ["Electronics", "📱"], ["Vehicles", "🚗"], ["Property", "🏠"],
      ["Fashion", "👕"], ["Home & Garden", "🪴"], ["Design", "🎨"],
      ["Services", "🛠"], ["Sports", "⚽"], ["Other", "📦"],
    ]),
    communityCategories: list([
      "General", "Technology", "Design", "Sports", "Music",
      "Food", "Gaming", "Travel", "Business",
    ]),
    eventCategories: list([
      "General", "Technology", "Design", "Music", "Sports", "Business", "Social",
    ]),
    postTags: list(["General", "Question", "Announcement", "Help", "Showcase"]),
    reportReasons: list([
      "Spam", "Harassment", "Hate speech", "Nudity",
      "Violence", "Misinformation", "Scam", "Other",
    ]),
    genders: list(["Male", "Female", "Other", "Prefer not to say"]),
    relationships: list([
      "Single", "In a relationship", "Engaged", "Married", "It's complicated",
    ]),
    countries: list([
      "United Kingdom", "United States", "Canada", "Australia", "Germany",
      "France", "Spain", "Italy", "Netherlands", "Turkey",
      "Brazil", "India", "Japan", "Nigeria", "South Africa",
    ]),
    currencies: list([
      ["USD", "$"], ["EUR", "€"], ["GBP", "£"], ["TRY", "₺"],
      ["CAD", "$"], ["AUD", "$"], ["JPY", "¥"], ["INR", "₹"],
    ]),
    reactions: list([
      ["Like", "👍"], ["Love", "❤️"], ["Laugh", "😂"],
      ["Wow", "😮"], ["Sad", "😢"], ["Fire", "🔥"],
    ]),
  };
}

export function defaultMailSettings(): MailSettings {
  return {
    enabled: false,
    host: "",
    port: 587,
    secure: false,
    username: "",
    password: "",
    fromName: "XRcoin",
    fromAddress: "",
    send: {
      welcome: true,
      passwordReset: true,
      verifyAddress: false,
      newFollower: false,
      newMessage: false,
      marketplaceSale: true,
    },
  };
}

export function defaultSiteSettings(): SiteSettings {
  return {
    siteName: "XRcoin",
    tagline: "Share, sell, connect.",
    accentColor: "#7c3aed",
    signupsOpen: true,
    requireEmailVerification: false,
    minimumAge: 13,
    maxPostLength: 2000,
    maxImageMb: 15,
    maxVideoMb: 100,
    maxVideoSeconds: 90,
    maxBioLength: 300,
    features: {
      reels: true,
      stories: true,
      communities: true,
      marketplace: true,
      crowdfunding: true,
      match: true,
      news: true,
      leaderboard: true,
      wallet: true,
    },
    bannedWords: [],
    autoHideReported: 5,
    requirePostApproval: false,
    announcement: { text: "", active: false, tone: "info" },
    premiumPriceUsd: 5,
    appLinks: { ios: "", android: "" },
    premiumPerks: [
      "No ads",
      "Profile frames",
      "Higher upload limits",
      "Priority support",
    ],
  };
}

/**
 * The database, built once however many callers arrive at once.
 *
 * This used to be `if (!g.__notrdb) { await read(); ...; g.__notrdb = db }`.
 * The awaits inside are the problem: every caller arriving before the
 * first one finishes also finds an empty global, builds its own Low
 * instance, re-reads the file and re-runs every migration. One page
 * render that touches the database from a dozen components did it
 * thousands of times over -- 5,899 copies in a single render when this
 * was measured -- and the heap went with it. That is the "JavaScript heap
 * out of memory" crash.
 *
 * Caching the promise rather than the result makes the second caller wait
 * for the first instead of racing it.
 */
/**
 * How long a loaded copy is trusted before it is loaded again.
 *
 * On the file there is nothing to refresh from: this process is the only
 * thing writing db.json, so its copy is the truth and it is kept for the
 * life of the process, as it always was.
 *
 * On MySQL it is not. `openStore()` writes rows straight to the database --
 * a new post, a like, a message -- and none of that reaches an object loaded
 * at boot. So the copy is rebuilt once it is this old. Raise DB_REFRESH_MS
 * on a large site where a full reload costs more than a few seconds of
 * staleness in the admin panel; lower it to see changes sooner.
 */
/** Same reason as the adapter: read when asked, not when imported. */
function refreshMs(): number {
  return sqlEnabled() ? Number(process.env.DB_REFRESH_MS ?? 5000) : 0;
}

export async function getDb(): Promise<Low<DBShape>> {
  // Rebuilding replaces the shared copy but leaves the old one working. A
  // request already holding it keeps its own adapter, which still knows
  // which rows *it* changed, so its pending write lands correctly instead of
  // being lost to the swap. That is why this drops the reference rather than
  // reloading in place.
  // No stamp means the copy was built before this file was, by a dev-server
  // hot reload that kept the global. Rebuild it rather than serve a copy from
  // a storage backend that is no longer the one configured.
  const stale = refreshMs();
  if (stale > 0 && (!g.__notrdbAt || Date.now() - g.__notrdbAt > stale)) {
    delete g.__notrdb;
    delete g.__notrdbInit;
  }
  if (g.__notrdb) return g.__notrdb;
  g.__notrdbInit ??= buildDb();
  return g.__notrdbInit;
}

/**
 * Throw away the cached database and read the file again.
 *
 * Everything above is built on the file being read once per process and
 * held in memory. That is right for a running site and wrong the moment
 * something replaces db.json underneath it -- an import, a restore -- and
 * it fails in the worst way: the server carries on serving what it read at
 * boot, and the next write flushes that stale copy back over the new file.
 * A whole migration disappears and nothing reports an error.
 *
 * So anything that rewrites db.json from outside the running server calls
 * this immediately afterwards.
 */
export async function reloadDb(): Promise<Low<DBShape>> {
  delete g.__notrdb;
  delete g.__notrdbInit;
  delete g.__notrdbAt;
  return getDb();
}

/**
 * Sngine's own job and offer categories, so a site that moves across finds
 * the list it already had rather than an empty dropdown. An admin can edit,
 * disable or add to them afterwards; these are only what is there on day one.
 */
const JOB_CATEGORIES = [
  "Admin & Office",
  "Art & Design",
  "Business Operations",
  "Cleaning & Facilities",
  "Community & Social Services",
  "Computer & Data",
  "Construction & Mining",
  "Education",
  "Farming & Forestry",
  "Healthcare",
  "Installation, Maintenance & Repair",
  "Legal",
  "Management",
  "Manufacturing",
  "Media & Communication",
  "Personal Care",
  "Protective Services",
  "Restaurant & Hospitality",
  "Retail & Sales",
  "Science & Engineering",
  "Sports & Entertainment",
  "Transportation",
  "Other",
];

const OFFER_CATEGORIES = [
  "Apparel & Accessories",
  "Autos & Vehicles",
  "Baby & Children's Products",
  "Beauty Products & Services",
  "Computers & Peripherals",
  "Consumer Electronics",
  "Dating Services",
  "Financial Services",
  "Gifts & Occasions",
  "Home & Garden",
  "Other",
];

/**
 * Fill a module's categories in, once.
 *
 * Keyed on the module having none at all rather than on each name, so an
 * admin who deletes "Legal" doesn't find it back the next time the server
 * starts. Deleting the lot and expecting them to return is the one case
 * this gets wrong, and it is the rarer mistake to make.
 */
function seedCategories(rows: Category[], module: string, names: string[]): boolean {
  if (rows.some((c) => c.module === module)) return false;
  names.forEach((name, order) => {
    const slug = name
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/^-|-$/g, '');
    rows.push({
      // Built from the name, not random. This runs on every server start
      // until something saves, and a random id would mean a different row
      // each time -- two of them racing to write would leave the module
      // with two sets of the same categories.
      id: (module + '-' + slug).slice(0, 40),
      module,
      name,
      slug: name
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-|-$/g, ""),
      enabled: true,
      order,
    });
  });
  return true;
}

async function buildDb(): Promise<Low<DBShape>> {
  {
    const db = new Low<DBShape>(makeAdapter(), defaultData);
    await db.read();
    coalesceWrites(db);
    if (!db.data || db.data.users.length === 0) {
      db.data = seed();
      await db.write();

      // Written now, saying not installed. Without this the next request
      // would find a database, decide the site already existed, and skip
      // the wizard after a single page load.
      try {
        const installFile = path.join(process.cwd(), "install.json");
        if (!fs.existsSync(installFile)) {
          fs.writeFileSync(
            installFile,
            JSON.stringify({ installed: false, seeded: true }, null, 2)
          );
        }
      } catch {
        // Best effort: a read-only filesystem shouldn't stop the site.
      }
    } else {
      // Backfill fields for anyone who already has an older db.json on disk
      db.data.follows ??= [];
      db.data.blocks ??= [];
      db.data.followDismissals ??= [];
      db.data.notifications ??= [];
      db.data.siteSettings ??= defaultSiteSettings();
      db.data.loginAttempts ??= [];
      db.data.invitations ??= [];
      db.data.listingOffers ??= [];
      db.data.jobs ??= [];
      db.data.jobApplications ??= [];
      db.data.offers ??= [];
      db.data.categories ??= [];
      // Saved the first time rather than rebuilt on every start: until they
      // are written they exist only in this process's copy, so the admin
      // panel would be editing rows that disappear on restart.
      const seeded = [
        seedCategories(db.data.categories, "jobs", JOB_CATEGORIES),
        seedCategories(db.data.categories, "offers", OFFER_CATEGORIES),
      ].some(Boolean);
      if (seeded) await db.write();
      db.data.jobs ??= [];
      db.data.jobApplications ??= [];
      db.data.offers ??= [];
      db.data.contactMessages ??= [];
      db.data.categories ??= [];
      db.data.announcements ??= [];
      db.data.memberships ??= [];
      db.data.supportTickets ??= [];
      db.data.smsLog ??= [];
      db.data.depositLog ??= [];
      db.data.uploadLog ??= [];
      db.data.blogs ??= [];
      db.data.mailSettings ??= defaultMailSettings();
      db.data.taxonomies ??= defaultTaxonomies();
      // A new list added in an update should appear without a reseed.
      for (const [name, items] of Object.entries(defaultTaxonomies())) {
        db.data.taxonomies[name] ??= items;
      }
      db.data.gateways ??= defaultGateways();
      db.data.payments ??= [];
      db.data.themes ??= defaultThemes();
      db.data.themePurchases ??= [];
      db.data.captcha ??= defaultCaptcha();
      db.data.socialLogin ??= defaultSocialLogin();
      db.data.storageSettings ??= defaultStorageSettings();
      db.data.siteSettings.appLinks ??= { ios: "", android: "" };

      // An existing site: its db.json was on disk before this boot, so it
      // predates the wizard and has already been set up. Marking it
      // installed stops it being sent through a setup it doesn't need.
      //
      // "A database exists" can't mean "installed" on its own — the app
      // seeds one on first access — so only a database that was already
      // there counts.
      try {
        const installFile = path.join(process.cwd(), "install.json");
        if (!fs.existsSync(installFile)) {
          fs.writeFileSync(
            installFile,
            JSON.stringify({ installed: true, migrated: true }, null, 2)
          );
        }
      } catch {
        // Best effort: a read-only filesystem shouldn't stop the site.
      }

      // Media used to live as base64 inside this file, which made it enormous
      // and capped video size. Write anything left over out as real files.
      //
      // Once. This walks every user, post, reel, story, listing, community
      // and message on the way in, and doing it on every boot cost tens of
      // seconds before the first page could render.
      const alreadySwept =
        (db.data.siteSettings as Record<string, unknown>).mediaMigrated === true;
      let movedMedia = false;

      const move = async (url: unknown) => {
        if (alreadySwept) return null;
        if (typeof url !== "string" || !url.startsWith("data:")) return null;
        const saved = await saveDataUrl(url);
        if (!saved) return null;
        movedMedia = true;
        return saved.url;
      };

      for (const u of db.data.users) {
        for (const f of ["avatarUrl", "coverUrl"] as const) {
          const next = await move(u[f]);
          if (next) u[f] = next;
        }
      }

      for (const p of db.data.posts) {
        const next = await move(p.imageUrl);
        if (next) p.imageUrl = next;
      }

      for (const r of db.data.reels) {
        const next = await move(r.videoUrl);
        if (next) r.videoUrl = next;
      }

      for (const s of db.data.stories) {
        const next = await move(s.imageUrl);
        if (next) s.imageUrl = next;
      }

      for (const l of db.data.marketplaceListings) {
        for (const f of ["imageUrl", "videoUrl"] as const) {
          const next = await move(l[f]);
          if (next) l[f] = next;
        }
        if (Array.isArray(l.images)) {
          for (let i = 0; i < l.images.length; i++) {
            const next = await move(l.images[i]);
            if (next) l.images[i] = next;
          }
        }
      }

      for (const c of db.data.communities) {
        for (const f of ["coverUrl", "logoUrl"] as const) {
          const next = await move(c[f]);
          if (next) c[f] = next;
        }
      }

      for (const m of db.data.messages) {
        const next = await move(m.imageUrl);
        if (next) m.imageUrl = next;
      }

      if (!alreadySwept) {
        (db.data.siteSettings as Record<string, unknown>).mediaMigrated = true;
        await db.write();
      }
      db.data.staticPages ??= defaultStaticPages();
      db.data.blacklist ??= { usernames: [], emails: [], domains: [] };
      db.data.userGroups ??= defaultUserGroups();
      db.data.events ??= [];
      db.data.eventRsvps ??= [];
      db.data.moneySettings ??= defaultMoneySettings();
      db.data.withdrawals ??= [];
      db.data.adCampaigns ??= [];
      db.data.subscriptions ??= [];
      db.data.gifts ??= defaultGifts();
      db.data.reelComments ??= [];
      db.data.communities ??= [];
      db.data.communityMembers ??= [];
      db.data.postLikes ??= [];
      db.data.postSaves ??= [];
      db.data.postReposts ??= [];
      db.data.reelLikes ??= [];
      db.data.reelSaves ??= [];
      db.data.reelReposts ??= [];
      db.data.commentLikes ??= [];
      db.data.reelCommentLikes ??= [];
      db.data.pollVotes ??= [];
      db.data.conversations ??= [];
      db.data.messages ??= [];
      db.data.walletTransactions ??= [];
      db.data.marketplaceListings ??= [];
      db.data.sellerReviews ??= [];
      db.data.marketplaceSettings ??= seed().marketplaceSettings;
      db.data.marketplaceSettings.soldVisibleDays ??= 14;
      db.data.marketplaceSettings.distanceUnit ??= "km";
      db.data.badges ??= starterBadges();
      db.data.userBadges ??= [];

      // Older highlights could hold the same story more than once.
      for (const h of db.data.highlights ?? []) {
        const seen = new Set<string>();
        h.stories = h.stories.filter((s) =>
          seen.has(s.id) ? false : (seen.add(s.id), true)
        );
      }
      // One-off migration: the coin was retired in favour of dollars.
      if (["XRC", "XR\u00A2"].includes(db.data.marketplaceSettings.currency)) {
        db.data.marketplaceSettings.currency = "USD";
      }
      db.data.crowdfundingCampaigns ??= [];
      db.data.reports ??= [];
      db.data.news ??= [];
      db.data.highlights ??= [];
      // Anyone who already exists on disk from before wallets existed needs
      // a starting balance backfilled too, or every debit would fail.
      for (const u of db.data.users) {
        if (typeof u.walletBalance !== "number") u.walletBalance = 2500;
      }
    }
    g.__notrdb = db;
    g.__notrdbAt = Date.now();
    return db;
  }
}

/* ---------------------- Per-viewer post/reel shaping ---------------------- */

export function toPost(
  stored: StoredPost,
  viewerId: string | null,
  db: DBShape,
  repostContext?: { by: User; at: string }
): Post {
  // One post, the way most callers want it. The work moved into
  // lib/data/post-view.ts so that both storage engines shape a post
  // identically, and so a page of posts can do the lookups once instead
  // of once per post — see toPosts below, and the feed route.
  return renderPost(
    stored,
    viewerId,
    contextFromJson([stored], viewerId, db),
    repostContext
  );
}

/**
 * A whole page of posts.
 *
 * Prefer this over mapping toPost: the viewer's likes, saves, votes,
 * follows and the authors' badges are gathered in one pass for the page
 * rather than re-scanned for every card.
 */
export function toPosts(
  stored: StoredPost[],
  viewerId: string | null,
  db: DBShape
): Post[] {
  const ctx = contextFromJson(stored, viewerId, db);
  return stored.map((p) => renderPost(p, viewerId, ctx));
}

export function toReel(stored: StoredReel, viewerId: string | null, db: DBShape): Reel {
  // Same split as toPost: the lookups live in lib/data/reel-view.ts so both
  // storage engines shape a reel identically.
  return renderReel(stored, reelContextFromJson([stored], viewerId, db));
}

/** A page of reels, with the lookups done once for the page. */
export function toReels(
  stored: StoredReel[],
  viewerId: string | null,
  db: DBShape
): Reel[] {
  const ctx = reelContextFromJson(stored, viewerId, db);
  return stored.map((r) => renderReel(r, ctx));
}

/* ---------------------------- Follow helpers ---------------------------- */

/**
 * The small counts a profile asks for.
 *
 * These used to filter whole arrays. Through the store they're either the
 * same filter (demo mode) or one indexed COUNT(*) — the difference between
 * a profile page that stays fast and one that doesn't.
 */
/** Everyone this viewer has blocked, for filtering a list they can see. */
export function blockedIdsFor(viewerId: string, db: DBShape): Set<string> {
  return new Set(
    db.blocks.filter((b) => b.blockerId === viewerId).map((b) => b.blockedId)
  );
}

export async function isFollowing(followerId: string, followingId: string) {
  const { openStore } = await import("@/lib/data/store");
  const store = await openStore();
  return Boolean(await store.follows.find(followerId, followingId));
}

export async function isBlocked(blockerId: string, blockedId: string) {
  const { openStore } = await import("@/lib/data/store");
  const store = await openStore();
  return Boolean(await store.blocks.find(blockerId, blockedId));
}

export async function followerCount(userId: string) {
  const { openStore } = await import("@/lib/data/store");
  const store = await openStore();
  return store.follows.countFollowers(userId);
}

export async function followingCount(userId: string) {
  const { openStore } = await import("@/lib/data/store");
  const store = await openStore();
  return store.follows.countFollowing(userId);
}

export async function postCountFor(userId: string) {
  const { openStore } = await import("@/lib/data/store");
  const store = await openStore();
  return store.posts.countByAuthor(userId);
}

/**
 * Money is held to two decimal places. Floating point made balances like
 * 12.299999999 possible, which meant someone couldn't spend what the screen
 * told them they had.
 */
export function roundMoney(amount: number) {
  return Math.round(amount * 100) / 100;
}

// Keeps every embedded copy of a user (post/comment/story/reel author
// fields) in sync after something about their public profile changes —
// name, avatar, premium status, etc. Kept for the demo store; on MySQL the
// same job is store.syncAuthor(), which does it in five statements.
export function syncAuthorEverywhere(db: DBShape, updated: User) {
  for (const p of db.posts) if (p.author.id === updated.id) p.author = updated;
  for (const c of db.comments) if (c.author.id === updated.id) c.author = updated;
  for (const rc of db.reelComments) if (rc.author.id === updated.id) rc.author = updated;
  for (const s of db.stories) if (s.author.id === updated.id) s.author = updated;
  for (const r of db.reels) if (r.author.id === updated.id) r.author = updated;
}

/* ------------------------- Notification helper --------------------------- */

/** Which notifications are also sent by email, and under which switch. */
const EMAIL_SETTING: Record<string, string> = {
  follow: "emailOnFollow",
  comment: "emailOnComment",
  mention: "emailOnMention",
  message: "emailOnMessage",
  sale: "emailOnSale",
};

/** Which admin switch governs each kind of notification. */
const NOTIFY_SETTING: Record<string, string> = {
  follow: "notifyOnFollow",
  like: "notifyOnLike",
  reaction: "notifyOnLike",
  comment: "notifyOnComment",
  comment_like: "notifyOnLike",
  mention: "notifyOnMention",
  repost: "notifyOnShare",
  share: "notifyOnShare",
  wall_post: "notifyOnProfilePost",
  message: "notifyOnMessage",
  sale: "notifyOnSale",
  offer: "notifyOnSale",
  tip: "notifyOnSale",
};

/**
 * Take money out of a wallet, if there is enough.
 *
 * Goes through the store, so it works the same on the demo file and on
 * MySQL. Returns false rather than throwing when the balance is short —
 * every caller treats that as "declined".
 */
export async function walletDebit(
  userId: string,
  amount: number,
  type: WalletTransactionType,
  description: string,
  otherUser?: User
): Promise<boolean> {
  const { openStore } = await import("@/lib/data/store");
  const store = await openStore();
  const user = await store.users.get(userId);
  if (!user || user.walletBalance < amount) return false;

  user.walletBalance = roundMoney(user.walletBalance - amount);
  await store.users.put(user);
  await pushLedger(store, {
    id: nanoid(8),
    userId,
    type,
    amount,
    description,
    otherUser,
    createdAt: new Date().toISOString(),
  });
  await store.save();
  return true;
}

/** Put money into a wallet. */
export async function walletCredit(
  userId: string,
  amount: number,
  type: WalletTransactionType,
  description: string,
  otherUser?: User
): Promise<void> {
  const { openStore } = await import("@/lib/data/store");
  const store = await openStore();
  const user = await store.users.get(userId);
  if (!user) return;

  user.walletBalance += roundMoney(amount);
  await store.users.put(user);
  await pushLedger(store, {
    id: nanoid(8),
    userId,
    type,
    amount,
    description,
    otherUser,
    createdAt: new Date().toISOString(),
  });
  await store.save();
}

/** One line onto the wallet ledger. */
async function pushLedger(
  store: import("@/lib/data/store").Store,
  entry: WalletTransaction
) {
  const ledger = await store.small<WalletTransaction[]>("walletTransactions", []);
  ledger.unshift(entry);
  await store.putSmall("walletTransactions", ledger);
}

export async function notify(params: {
  userId: string; // recipient
  actor: User;
  type: NotificationType;
  text: string;
  postId?: string;
  link?: string;
}) {
  if (params.userId === params.actor.id) return; // never notify yourself
  const db = await getDb();

  // An admin can switch off a whole kind of notification.
  const set = db.data.siteSettings as Record<string, unknown>;
  const governing = NOTIFY_SETTING[String(params.type)];
  if (governing && set[governing] === false) {
    return;
  }

  // And as a push notification, when that's set up.
  if (set.pushEnabled === true) {
    const { sendPush } = await import("./push");
    void sendPush({
      userId: params.userId,
      heading: params.actor.name,
      text: params.text,
      url: params.link,
    }).catch(() => {});
  }

  // The same event can also go out by email, when that's switched on and
  // SMTP is configured. Failures here never block the on-site notification.
  const emailSwitch = EMAIL_SETTING[String(params.type)];
  if (emailSwitch && set[emailSwitch] === true) {
    const recipient = db.data.users.find((u) => u.id === params.userId);
    if (recipient?.email) {
      const { sendMail } = await import("./mail");
      void sendMail({
        to: recipient.email,
        subject: params.text,
        heading: `${params.actor.name} ${params.type === "follow" ? "followed you" : "was in touch"}`,
        body: params.text,
        action: params.link
          ? { label: "Open", url: params.link }
          : undefined,
      }).catch(() => {});
    }
  }
  const notification: AppNotification = {
    id: nanoid(8),
    userId: params.userId,
    type: params.type,
    actor: params.actor,
    postId: params.postId,
    link: params.link,
    text: params.text,
    createdAt: new Date().toISOString(),
    read: false,
  };
  // Imported here rather than at the top: lib/data/store.ts reads getDb
  // from this file, and a static import both ways is a cycle.
  const { openStore } = await import("@/lib/data/store");
  const store = await openStore();
  await store.notifications.put(notification);
  await store.save();
}

/* --------------------------------- Seed ---------------------------------- */

// Demo password for every seeded account, so you can log in and try the app
// immediately: username "alberto" / "mayachen" / "leofontaine" / etc, password
// "xrcoin1234". Delete data/db.json to reset everything back to this seed.
const DEMO_PASSWORD_HASH = bcrypt.hashSync("xrcoin1234", 10);

function u(
  name: string,
  username: string,
  avatarColor: string,
  opts: Partial<StoredUser> = {}
): StoredUser {
  return {
    id: nanoid(8),
    name,
    username,
    avatarColor,
    passwordHash: DEMO_PASSWORD_HASH,
    walletBalance: 2500,
    ...opts,
  };
}

function seed(): DBShape {
  const xrcoin = u("Alberto", "alberto", "from-violet-500 to-purple-600", {
    decoration: "flame",
    verified: true,
    badges: ["✨"],
    bio: "Official XRcoin account. Building this with you, one bug at a time. 🚀",
    isAdmin: true,
  });
  const maya = u("Maya Chen", "mayachen", "from-pink-500 to-rose-500", {
    bio: "Frontend engineer. Shaders, glass, and too much CSS.",
  });
  const leo = u("Leo Fontaine", "leofontaine", "from-amber-400 to-orange-500", {
    bio: "Product @ a startup you haven't heard of yet.",
  });
  const priya = u("Priya Nair", "priyan", "from-emerald-400 to-teal-500", {
    bio: "Coffee-powered backend dev. Reading docs on weekends.",
  });
  const jonas = u("Jonas Weber", "jonasw", "from-sky-400 to-blue-600", {
    bio: "UI motion & interaction design.",
    premium: true,
    premiumSince: new Date(Date.now() - 20 * 86_400_000).toISOString(),
    walletBalance: 4200,
  });
  const ama = u("Ama Boateng", "amab", "from-fuchsia-500 to-pink-600", {
    bio: "Runner. Designer. Golden hour enthusiast.",
  });

  // A wider cast so "People you may know" always has suggestions, even
  // after you've added the main demo accounts.
  const ana = u("Ana Souza", "anasouza", "from-rose-400 to-purple-600", {
    bio: "Illustrator. Colour is my whole personality.",
  });
  const tiago = u("Tiago Fraga", "tiagofraga", "from-sky-400 to-blue-700", {
    bio: "Skater, filmmaker, occasional coder.",
  });
  const sarah = u("Sarah Vibes", "sarahvibes", "from-amber-400 to-pink-500", {
    bio: "Ocean days and film cameras.",
  });
  const joao = u("João Pereira", "joaop", "from-emerald-400 to-green-700", {
    bio: "Guitar, gigs, and too many pedals.",
  });
  const luna = u("Luna Costa", "lunacs", "from-violet-400 to-indigo-700", {
    bio: "CS student. Dancing between commits.",
  });
  const mike = u("Mike Reyes", "mikereyes", "from-cyan-400 to-sky-700", {
    bio: "Gamer. Streams most evenings.",
  });
  const carla = u("Carla Dias", "carlad", "from-pink-500 to-purple-700", {
    bio: "Food, travel, and very strong opinions on pizza.",
  });
  const ben = u("Ben Okafor", "benok", "from-orange-400 to-red-600", {
    bio: "Building small tools that do one thing well.",
  });

  const communities: Community[] = [];
  const communityMembers: CommunityMember[] = [];

  const users = [
    xrcoin, maya, leo, priya, jonas, ama,
    ana, tiago, sarah, joao, luna, mike, carla, ben,
  ];

  const pXRcoin = publicUser(xrcoin);
  const pMaya = publicUser(maya);
  const pLeo = publicUser(leo);
  const pPriya = publicUser(priya);
  const pJonas = publicUser(jonas);
  const pAma = publicUser(ama);

  const now = Date.now();
  const ago = (mins: number) => new Date(now - mins * 60_000).toISOString();
  /** For seed posts old enough to show a real date rather than "23h". */
  const daysAgo = (days: number) => ago(days * 24 * 60);

  const posts: StoredPost[] = [
    {
      id: nanoid(8),
      author: pXRcoin,
      text: "Welcome to XRcoin — we're in closed beta. Thanks for building this with us. 🚀 #welcome #beta",
      tag: "Announcements",
      image: "from-indigo-500 via-purple-500 to-pink-500",
      createdAt: daysAgo(4),
      likeCount: 74,
      repostCount: 3,
      views: 802,
      commentCount: 2,
    },
    {
      id: nanoid(8),
      author: pMaya,
      text: "Glass refraction on the web hits different 💧✨ built an infinite WebGL slider today. #design #webdev",
      tag: "WebDesign",
      image: "from-cyan-400 via-sky-500 to-blue-600",
      createdAt: daysAgo(9),
      likeCount: 41,
      repostCount: 5,
      views: 310,
      commentCount: 1,
    },
    {
      id: nanoid(8),
      author: pLeo,
      text: "Shipped the new onboarding flow. Conversion up 12% in the first day 📈 #product #shipit",
      tag: "Product",
      createdAt: daysAgo(16),
      likeCount: 19,
      repostCount: 1,
      views: 140,
      commentCount: 0,
    },
    {
      id: nanoid(8),
      author: pPriya,
      text: "Coffee, keyboard, and three tabs of docs. Sunday well spent ☕ #weekend #coding️",
      image: "from-orange-300 via-amber-400 to-yellow-500",
      createdAt: ago(400),
      likeCount: 63,
      repostCount: 2,
      views: 512,
      commentCount: 3,
    },
    {
      id: nanoid(8),
      author: pAma,
      text: "Reading week: three papers on rendering pipelines down, two to go 📚 #reading #rendering",
      createdAt: ago(520),
      likeCount: 41,
      repostCount: 1,
      views: 388,
      commentCount: 0,
    },
    {
      id: nanoid(8),
      author: pJonas,
      text: "Refactored the whole state layer today and the bundle got smaller. Rare win. #webdev #refactor",
      image: "from-teal-400 via-cyan-500 to-sky-600",
      createdAt: ago(640),
      likeCount: 96,
      repostCount: 5,
      views: 1240,
      commentCount: 0,
    },
    {
      id: nanoid(8),
      author: pLeo,
      text: "Design tip: if you can remove it and nothing breaks, remove it.",
      createdAt: ago(760),
      likeCount: 187,
      repostCount: 22,
      views: 3100,
      commentCount: 0,
    },
    {
      id: nanoid(8),
      author: pMaya,
      text: "Late night shader debugging. The bug was a single flipped sign 🙃",
      image: "from-indigo-500 via-violet-500 to-purple-600",
      createdAt: ago(880),
      likeCount: 74,
      repostCount: 3,
      views: 902,
      commentCount: 0,
    },
    {
      id: nanoid(8),
      author: pPriya,
      text: "Shipped my first open source PR this week and it got merged 🎉",
      createdAt: ago(1000),
      likeCount: 233,
      repostCount: 14,
      views: 4500,
      commentCount: 0,
    },
  ];

  const firstReplyId = nanoid(8);
  const comments: Comment[] = [
    {
      id: firstReplyId,
      postId: posts[0].id,
      author: pAma,
      text: "hey!",
      createdAt: ago(50),
      likes: 0,
    },
    {
      id: nanoid(8),
      postId: posts[0].id,
      author: pXRcoin,
      text: "Hey! 👋 Welcome to XRcoin. How can I help you today?",
      createdAt: ago(48),
      likes: 4,
      parentId: firstReplyId,
    },
    {
      id: nanoid(8),
      postId: posts[1].id,
      author: pJonas,
      text: "the distortion shader is so smooth, what's the perf like on mobile?",
      createdAt: ago(90),
      likes: 6,
    },
  ];

  const stories: Story[] = [
    {
      id: nanoid(8),
      author: pXRcoin,
      createdAt: ago(240),
      gradient: "from-indigo-500 via-purple-500 to-pink-500",
      caption: "Support our Community",
      seen: false,
      likes: 1,
    },
    {
      id: nanoid(8),
      author: pMaya,
      createdAt: ago(180),
      gradient: "from-rose-400 via-pink-500 to-fuchsia-600",
      caption: "New shader drop 🎨",
      seen: false,
      likes: 12,
    },
    {
      id: nanoid(8),
      author: pLeo,
      createdAt: ago(90),
      gradient: "from-amber-300 via-orange-400 to-red-500",
      caption: "Shipping day",
      seen: true,
      likes: 4,
    },
    {
      id: nanoid(8),
      author: pAma,
      createdAt: ago(30),
      gradient: "from-emerald-400 via-teal-500 to-cyan-500",
      caption: "Golden hour run 🏃",
      seen: false,
      likes: 8,
    },
  ];

  const reels: StoredReel[] = [
    {
      id: nanoid(8),
      author: pMaya,
      gradient: "from-fuchsia-600 via-purple-600 to-indigo-700",
      caption: "Liquid glass carousel — built with three.js",
      sound: "Original Sound - @mayachen",
      likeCount: 128,
      commentCount: 1,
      repostCount: 6,
      views: 3400,
    },
    {
      id: nanoid(8),
      author: pAma,
      gradient: "from-orange-500 via-rose-500 to-pink-600",
      caption: "Sunset run through the old town 🌅",
      sound: "Golden Hour - @amab",
      likeCount: 302,
      commentCount: 0,
      repostCount: 12,
      views: 9100,
    },
    {
      id: nanoid(8),
      author: pJonas,
      gradient: "from-sky-500 via-cyan-500 to-teal-500",
      caption: "60 seconds of clean UI transitions",
      sound: "Lo-fi Focus - @jonasw",
      likeCount: 87,
      commentCount: 0,
      repostCount: 2,
      views: 1800,
    },
    {
      id: nanoid(8),
      author: pLeo,
      gradient: "from-emerald-500 via-green-500 to-lime-500",
      caption: "Shipping a feature in one sitting ⚡",
      sound: "Deep Work - @leofontaine",
      likeCount: 214,
      commentCount: 0,
      repostCount: 9,
      views: 5600,
    },
    {
      id: nanoid(8),
      author: pPriya,
      gradient: "from-violet-600 via-fuchsia-600 to-rose-500",
      caption: "Coffee, code, repeat ☕",
      sound: "Morning Loop - @priyan",
      likeCount: 156,
      commentCount: 0,
      repostCount: 4,
      views: 4200,
    },
    {
      id: nanoid(8),
      author: pXRcoin,
      gradient: "from-amber-400 via-orange-500 to-red-500",
      caption: "Behind the scenes of the closed beta 🚀",
      sound: "Launch Day - @alberto",
      likeCount: 421,
      commentCount: 0,
      repostCount: 18,
      views: 12400,
    },
  ];

  const reelComments: ReelComment[] = [
    {
      id: nanoid(8),
      reelId: reels[0].id,
      author: pJonas,
      text: "the shader work here is unreal 🔥",
      createdAt: ago(60),
      likes: 3,
    },
  ];

  const follows: Follow[] = [
    { id: nanoid(8), followerId: maya.id, followingId: xrcoin.id, createdAt: ago(500) },
    { id: nanoid(8), followerId: leo.id, followingId: xrcoin.id, createdAt: ago(480) },
    { id: nanoid(8), followerId: priya.id, followingId: xrcoin.id, createdAt: ago(460) },
    { id: nanoid(8), followerId: jonas.id, followingId: xrcoin.id, createdAt: ago(440) },
    { id: nanoid(8), followerId: ama.id, followingId: xrcoin.id, createdAt: ago(420) },
    { id: nanoid(8), followerId: maya.id, followingId: jonas.id, createdAt: ago(300) },
    { id: nanoid(8), followerId: jonas.id, followingId: maya.id, createdAt: ago(290) },
    { id: nanoid(8), followerId: leo.id, followingId: priya.id, createdAt: ago(200) },
  ];

  const notifications: AppNotification[] = [
    {
      id: nanoid(8),
      userId: xrcoin.id,
      type: "follow",
      actor: pAma,
      text: "started following you",
      createdAt: ago(20),
      read: false,
    },
    {
      id: nanoid(8),
      userId: xrcoin.id,
      type: "comment",
      actor: pAma,
      postId: posts[0].id,
      text: "replied to your comment",
      createdAt: ago(45),
      read: true,
    },
  ];

  // A few seeded per-user likes/saves so the demo doesn't feel completely
  // blank — these are real Interaction rows, not shared booleans.
  // Seeded likes are spread across posts and users so the "liked by …"
  // avatar row has real faces to show. Each post's likeCount is a larger
  // display number; these records are the identifiable subset of it, which
  // is exactly how a real system behaves (you can't list every liker).
  const postLikes: Interaction[] = [
    { id: nanoid(8), userId: priya.id, targetId: posts[3].id, reaction: "heart" as const, createdAt: ago(390) },
    { id: nanoid(8), userId: xrcoin.id, targetId: posts[1].id, reaction: "fire" as const, createdAt: ago(100) },
    { id: nanoid(8), userId: maya.id, targetId: posts[0].id, reaction: "laugh" as const, createdAt: ago(120) },
    { id: nanoid(8), userId: leo.id, targetId: posts[0].id, reaction: "thumb" as const, createdAt: ago(200) },
    { id: nanoid(8), userId: ama.id, targetId: posts[0].id, reaction: "party" as const, createdAt: ago(260) },
    { id: nanoid(8), userId: jonas.id, targetId: posts[0].id, reaction: "wow" as const, createdAt: ago(300) },
    { id: nanoid(8), userId: priya.id, targetId: posts[0].id, reaction: "heart" as const, createdAt: ago(340) },
    { id: nanoid(8), userId: ama.id, targetId: posts[1].id, reaction: "fire" as const, createdAt: ago(150) },
    { id: nanoid(8), userId: leo.id, targetId: posts[1].id, reaction: "thumb" as const, createdAt: ago(210) },
    { id: nanoid(8), userId: maya.id, targetId: posts[2].id, reaction: "laugh" as const, createdAt: ago(180) },
    { id: nanoid(8), userId: jonas.id, targetId: posts[2].id, reaction: "heart" as const, createdAt: ago(240) },
    { id: nanoid(8), userId: xrcoin.id, targetId: posts[3].id, reaction: "party" as const, createdAt: ago(160) },
    { id: nanoid(8), userId: leo.id, targetId: posts[3].id, reaction: "wow" as const, createdAt: ago(220) },
  ];
  const postSaves: Interaction[] = [];
  const postReposts: Interaction[] = [];
  const reelLikes: Interaction[] = [
    { id: nanoid(8), userId: ama.id, targetId: reels[1].id, createdAt: ago(200) },
  ];
  const reelSaves: Interaction[] = [
    { id: nanoid(8), userId: ama.id, targetId: reels[1].id, createdAt: ago(200) },
  ];
  const reelReposts: Interaction[] = [];

  // Five sample threads with different people, so Messages has real
  // conversations to browse on a fresh install.
  const convWith = (other: typeof xrcoin, minsAgo: number) => ({
    id: nanoid(8),
    participantIds: [maya.id, other.id] as [string, string],
    createdAt: ago(minsAgo),
  });
  const cXRcoin = convWith(xrcoin, 180);
  const cLeo = convWith(leo, 300);
  const cPriya = convWith(priya, 620);
  const cJonas = convWith(jonas, 1500);
  const cAma = convWith(ama, 2600);
  // xrcoin (the admin demo account) needs its own threads as well, otherwise
  // logging in as xrcoin shows only the single shared conversation.
  const nLeo = { id: nanoid(8), participantIds: [xrcoin.id, leo.id] as [string, string], createdAt: ago(400) };
  const nPriya = { id: nanoid(8), participantIds: [xrcoin.id, priya.id] as [string, string], createdAt: ago(900) };
  const nJonas = { id: nanoid(8), participantIds: [xrcoin.id, jonas.id] as [string, string], createdAt: ago(1700) };
  const nAma = { id: nanoid(8), participantIds: [xrcoin.id, ama.id] as [string, string], createdAt: ago(3000) };

  const conversations: Conversation[] = [
    cXRcoin, cLeo, cPriya, cJonas, cAma,
    nLeo, nPriya, nJonas, nAma,
  ];

  const dm = (
    c: Conversation,
    sender: typeof xrcoin,
    text: string,
    minsAgo: number,
    read = true
  ): DirectMessage => ({
    id: nanoid(8),
    conversationId: c.id,
    senderId: sender.id,
    text,
    createdAt: ago(minsAgo),
    read,
  });

  const messages: DirectMessage[] = [
    dm(cXRcoin, maya, "hey! loving the new beta 👋", 178),
    dm(cXRcoin, xrcoin, "So glad to hear it! Let us know if you hit any bugs 🚀", 175),
    dm(cXRcoin, maya, "Will do. The poll charts are gorgeous 📊", 170),

    dm(cLeo, leo, "Did you see the onboarding numbers? 📈", 298),
    dm(cLeo, maya, "Just looked — 12% up, that's wild!", 296),
    dm(cLeo, leo, "Want to pair on the next iteration this week?", 294, false),

    dm(cPriya, priya, "Coffee before standup? ☕", 618),
    dm(cPriya, maya, "Always. Usual place at 9?", 616),
    dm(cPriya, priya, "Perfect, see you there 😄", 614),

    dm(cJonas, jonas, "The shader you posted is unreal ⚡", 1498),
    dm(cJonas, maya, "haha thanks! took three evenings to debug 🙃", 1495),
    dm(cJonas, jonas, "Worth it. Can you write it up sometime?", 1490, false),

    dm(cAma, ama, "Run on Saturday? Slow pace, promise 🏃‍♀️", 2598),
    dm(cAma, maya, "I'm in. 8am by the river?", 2595),
    dm(cAma, ama, "See you there ❤️", 2590),

    dm(nLeo, leo, "Shipping the onboarding tweak tonight 🚢", 398),
    dm(nLeo, xrcoin, "Nice. Ping me when it's live and I'll announce it", 396),

    dm(nPriya, priya, "Found a rounding bug in the wallet totals 🐛", 898),
    dm(nPriya, xrcoin, "Good catch — opening a ticket now", 895, false),

    dm(nJonas, jonas, "Motion specs for the new ring are ready ⚡", 1698),
    dm(nJonas, xrcoin, "Amazing, the thunder one looks incredible", 1695),

    dm(nAma, ama, "Can we feature community runs this month? 🏃", 2998),
    dm(nAma, xrcoin, "Love it. Let's talk Monday", 2995, false),
  ];

  const walletTransactions: WalletTransaction[] = [
    {
      id: nanoid(8),
      userId: jonas.id,
      type: "premium_purchase",
      amount: 500,
      description: "Upgraded to Go Premium",
      createdAt: ago(20 * 1440),
    },
  ];

  const marketplaceListings: MarketplaceListing[] = [
    {
      id: nanoid(8),
      seller: pMaya,
      title: "Custom Figma UI kit — glassmorphism pack",
      description: "40+ components, dark/light variants, source files included.",
      price: 350,
      category: "Design",
      location: "London, United Kingdom",
      condition: "new",
      createdAt: ago(300),
      sold: false,
    },
    {
      id: nanoid(8),
      seller: pJonas,
      title: "1-on-1 motion design feedback session (30 min)",
      description: "Send me your prototype, I'll record a Loom walkthrough with notes.",
      price: 800,
      location: "Reading, United Kingdom",
      condition: "new",
      category: "Services",
      createdAt: ago(600),
      sold: false,
    },
    {
      id: nanoid(8),
      seller: pAma,
      title: "Vintage 35mm film camera — lightly used",
      description: "Great condition, includes a half-used roll of Portra 400.",
      price: 1200,
      location: "Manchester, United Kingdom",
      condition: "like-new",
      category: "Other",
      createdAt: ago(1200),
      sold: false,
    },
  ];

  const reports: Report[] = [
    {
      id: nanoid(8),
      reporter: pJonas,
      targetType: "post",
      targetId: posts[1].id,
      reason: "Spam",
      contentPreview: posts[1].text,
      contentAuthor: pMaya,
      status: "pending",
      createdAt: ago(90),
    },
    {
      id: nanoid(8),
      reporter: pAma,
      targetType: "post",
      targetId: posts[3].id,
      reason: "Misleading content",
      contentPreview: posts[3].text,
      contentAuthor: pPriya,
      status: "pending",
      createdAt: ago(40),
    },
  ];

  const crowdfundingCampaigns: CrowdfundingCampaign[] = [
    {
      id: nanoid(8),
      creator: pLeo,
      title: "Open-source component library for indie devs",
      description:
        "Building a free, accessible React component kit so solo developers don't have to reinvent buttons and modals every project. Funds go toward documentation, testing, and a proper Figma kit.",
      goal: 5000,
      raised: 1850,
      contributorCount: 6,
      category: "Tech",
      createdAt: ago(2000),
    },
    {
      id: nanoid(8),
      creator: pAma,
      title: "Community darkroom — keep film photography alive",
      description:
        "Renting a small shared darkroom space so local photographers can develop their own film without buying a full home setup. Every goes toward chemicals, paper, and the first month's rent.",
      goal: 3000,
      raised: 3000,
      contributorCount: 14,
      category: "Community",
      createdAt: ago(4200),
    },
    {
      id: nanoid(8),
      creator: pJonas,
      title: "Motion design workshop series",
      description:
        "A free 6-part video series teaching motion design fundamentals — easing, timing, and how to actually ship animations that don't feel janky. Funding covers editing and hosting costs.",
      goal: 1500,
      raised: 420,
      contributorCount: 3,
      category: "Education",
      createdAt: ago(600),
    },
  ];

  const news: NewsArticle[] = [
    {
      id: nanoid(8),
      title: "XRcoin closed beta passes 500 active testers this week",
      category: "Platform",
      views: 1240,
      publishedAt: ago(1440),
    },
    {
      id: nanoid(8),
      title: "How three indie developers ship products entirely from XRcoin DMs",
      category: "Community",
      views: 860,
      publishedAt: ago(2600),
    },
    {
      id: nanoid(8),
      title: "$ wallet tipping crosses 10,000 transactions since launch",
      category: "Platform",
      views: 512,
      publishedAt: ago(4000),
    },
    {
      id: nanoid(8),
      title: "Opinion: why smaller social platforms make better first communities",
      category: "Opinion",
      views: 305,
      publishedAt: ago(7200),
    },
  ];

  const mk = (
    kind: "group" | "page",
    name: string,
    slug: string,
    description: string,
    category: string,
    gradient: string,
    owner: typeof xrcoin,
    members: (typeof xrcoin)[]
  ) => {
    const c: Community = {
      id: nanoid(8),
      kind,
      name,
      slug,
      description,
      category,
      gradient,
      owner: publicUser(owner),
      memberCount: members.length + 1,
      privacy: "public",
      createdAt: ago(20000),
    };
    communities.push(c);
    communityMembers.push({
      id: nanoid(8),
      communityId: c.id,
      userId: owner.id,
      role: "owner",
      joinedAt: ago(20000),
    });
    members.forEach((m) =>
      communityMembers.push({
        id: nanoid(8),
        communityId: c.id,
        userId: m.id,
        role: "member",
        joinedAt: ago(9000),
      })
    );
    return c;
  };

  mk("group", "XRcoin Developers", "xrcoin-developers",
    "Builders shipping on XRcoin. Share work, ask questions, break things.",
    "Technology", "from-orange-500 via-red-500 to-rose-600", xrcoin, [maya, leo, jonas, priya]);
  mk("group", "Shader & WebGL", "shader-webgl",
    "Fragment shaders, GLSL tricks, and render loops that shouldn't work but do.",
    "Design", "from-indigo-500 via-purple-600 to-fuchsia-600", maya, [jonas, ama]);
  mk("group", "Weekend Runners", "weekend-runners",
    "Slow miles, good company. Post your routes.",
    "Sports", "from-emerald-400 via-green-500 to-teal-600", ama, [priya, leo]);
  mk("page", "XRcoin", "xrcoin-official",
    "Official updates from the XRcoin team.",
    "Platform", "from-fuchsia-500 via-purple-600 to-indigo-700", xrcoin, [maya, leo, priya, jonas, ama]);
  mk("page", "Design Weekly", "design-weekly",
    "One good design idea, every week.",
    "Design", "from-sky-400 via-blue-500 to-indigo-600", leo, [maya, priya]);

  // A few reviews so seller profiles aren't empty on a fresh install.
  const sellerReviews: SellerReview[] = [
    {
      id: nanoid(8), sellerId: maya.id, author: publicUser(leo), rating: 5,
      text: "Exactly as described and shipped the same day. Would buy again.",
      createdAt: ago(2600),
    },
    {
      id: nanoid(8), sellerId: maya.id, author: publicUser(priya), rating: 4,
      text: "Great quality, packaging could be a little sturdier.",
      createdAt: ago(5400),
    },
    {
      id: nanoid(8), sellerId: jonas.id, author: publicUser(ama), rating: 5,
      text: "Really helpful over messages and delivered ahead of schedule.",
      createdAt: ago(1800),
    },
  ];

  const marketplaceSettings: MarketplaceSettings = {
    enabled: true,
    categories: [
      { id: "tech", label: "Electronics", emoji: "\u{1F4BB}", tint: "tech", enabled: true },
      { id: "fashion", label: "Fashion", emoji: "\u{1F455}", tint: "fashion", enabled: true },
      { id: "home", label: "Home & Living", emoji: "\u{1F6CB}\uFE0F", tint: "home", enabled: true },
      { id: "sports", label: "Sports", emoji: "\u26BD", tint: "sports", enabled: true },
      { id: "books", label: "Books", emoji: "\u{1F4DA}", tint: "books", enabled: true },
      { id: "beauty", label: "Beauty", emoji: "\u{1F484}", tint: "beauty", enabled: true },
      { id: "toys", label: "Toys", emoji: "\u{1F9F8}", tint: "toys", enabled: true },
      { id: "other", label: "Other", emoji: "\u{1F4E6}", tint: "other", enabled: true },
    ],
    currency: "USD",
    minPrice: 1,
    maxPrice: 1000000,
    requireApproval: false,
    allowVideo: true,
    maxVideoMb: 40,
    maxPhotos: 6,
    allowOffers: true,
    commissionPercent: 0,
    soldVisibleDays: 14,
    distanceUnit: "km",
    bannerTitle: "Buy & Sell Anything",
    bannerSubtitle:
      "Find great deals, sell what you don't need and discover amazing items in your community.",
  };

  return {
    siteSettings: defaultSiteSettings(),
    loginAttempts: [],
    invitations: [],
    listingOffers: [],
  jobs: [],
  jobApplications: [],
  offers: [],
    contactMessages: [],
    categories: [],
  announcements: [],
  memberships: [],
    supportTickets: [],
    smsLog: [],
    depositLog: [],
    uploadLog: [],
    blogs: [],
    taxonomies: defaultTaxonomies(),
    gateways: defaultGateways(),
    payments: [],
    themes: defaultThemes(),
    themePurchases: [],
    captcha: defaultCaptcha(),
    socialLogin: defaultSocialLogin(),
    storageSettings: defaultStorageSettings(),
    mailSettings: defaultMailSettings(),
    staticPages: defaultStaticPages(),
    blacklist: { usernames: [], emails: [], domains: [] },
    userGroups: defaultUserGroups(),
    events: seedEvents(),
    eventRsvps: [],
    moneySettings: defaultMoneySettings(),
    withdrawals: [],
    adCampaigns: [],
    subscriptions: [],
    gifts: defaultGifts(),
    users,
    posts,
    comments,
    stories,
    reels,
    reelComments,
    follows,
    followDismissals: [],
    blocks: [],
    notifications,
    postLikes,
    postSaves,
    postReposts,
    reelLikes,
    reelSaves,
    reelReposts,
    commentLikes: [],
    reelCommentLikes: [],
    pollVotes: [],
    conversations,
    messages,
    walletTransactions,
    marketplaceListings,
    sellerReviews,
    marketplaceSettings,
    crowdfundingCampaigns,
    reports,
    news,
    highlights: [],
    badges: starterBadges(),
    userBadges: [],
        communities,
    communityMembers,
  };
}


/**
 * Emails the administrators about something needing attention, when that
 * particular alert is switched on. Never throws — an alert failing must not
 * stop the thing it was reporting.
 */
export async function notifyAdmins(opts: {
  setting: "emailAdminVerification" | "emailAdminApproval" | "emailAdminNewUser";
  subject: string;
  heading: string;
  body: string;
  link?: string;
}) {
  const db = await getDb();
  const set = db.data.siteSettings as Record<string, unknown>;
  if (set[opts.setting] !== true) return;

  const admins = db.data.users.filter((u) => u.isAdmin && u.email);
  if (admins.length === 0) return;

  const { sendMail } = await import("./mail");
  for (const admin of admins) {
    void sendMail({
      to: admin.email!,
      subject: opts.subject,
      heading: opts.heading,
      body: opts.body,
      action: opts.link ? { label: "Open the admin panel", url: opts.link } : undefined,
    }).catch(() => {});
  }
}
