import { defineCollection, reference, z } from "astro:content";

// Constants

export const DEFAULT_LANG = "en";
export const ANONYMOUS_USER_ID = "anonymous";
export const SELF_USER_ID = "bad-manners";

// Transformers

function parseRegex<R extends { [key: string]: string }>(regex: RegExp) {
  return (url: string, ctx: z.RefinementCtx) => {
    const match = regex.exec(url);
    if (!match?.groups) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: `"${ctx.path}" did not match regex`,
      });
      return z.NEVER;
    }
    return match.groups as R;
  };
}

// Schema definitions

/** Record of website links for a user.
 *
 * For each entry, you can enter a URL for the value, or a pre-parsed object
 * containing a link and a username (except for `website`).
 */
const websiteLinks = z.object({
  website: z.object({ link: z.string().url() }).or(
    z
      .string()
      .url()
      .transform((link) => {
        link;
      }),
  ),
  eka: z.object({ link: z.string().url(), username: z.string() }).or(
    z.string().transform((link, ctx) => {
      const { username } = parseRegex<{ username: string }>(
        /^(?:https?:\/\/)?(?:www\.)?aryion\.com\/g4\/user\/(?<username>[^\/]+)\/?$/,
      )(link, ctx);
      return { link, username };
    }),
  ),
  furaffinity: z.object({ link: z.string().url(), username: z.string() }).or(
    z.string().transform((link, ctx) => {
      const { username } = parseRegex<{ username: string }>(
        /^(?:https?:\/\/)?(?:www\.)?furaffinity\.net\/user\/(?<username>[^\/]+)\/?$/,
      )(link, ctx);
      return { link, username };
    }),
  ),
  weasyl: z.object({ link: z.string().url(), username: z.string() }).or(
    z.string().transform((link, ctx) => {
      const { username } = parseRegex<{ username: string }>(
        /^(?:https?:\/\/)?(?:www\.)?weasyl\.com\/\~(?<username>[^\/]+)\/?$/,
      )(link, ctx);
      return { link, username };
    }),
  ),
  inkbunny: z.object({ link: z.string().url(), username: z.string() }).or(
    z.string().transform((link, ctx) => {
      const { username } = parseRegex<{ username: string }>(
        /^(?:https?:\/\/)?(?:www\.)?inkbunny\.net\/(?<username>[^\/]+)\/?$/,
      )(link, ctx);
      return { link, username };
    }),
  ),
  sofurry: z.object({ link: z.string().url(), username: z.string() }).or(
    z.string().transform((link, ctx) => {
      const { username } = parseRegex<{ username: string }>(/^(?:https?:\/\/)?(?<username>[^\.]+).sofurry.com\/?$/)(
        link,
        ctx,
      );
      return { link, username };
    }),
  ),
  sofurrybeta: z.object({ link: z.string().url(), username: z.string() }).or(
    z.string().transform((link, ctx) => {
      const { username } = parseRegex<{ username: string }>(
        /^(?:https?:\/\/)?sofurrybeta.com\/u\/(?<username>[^\/]+)\/?$/,
      )(link, ctx);
      return { link, username };
    }),
  ),
  mastodon: z
    .object({ link: z.string().url(), username: z.string().regex(/^[^@]+@[^@]+$/) })
    .or(
      z.string().transform((link, ctx) => {
        const { username, instance } = parseRegex<{ username: string; instance: string }>(
          /^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/(?:@|users\/)(?<username>[a-zA-Z][a-zA-Z0-9_-]+)\/?$/,
        )(link, ctx);
        return { link, username: `${username}@${instance}` };
      }),
    )
    .transform(({ link, username }) => {
      const i = username.indexOf("@");
      return { link, username, handle: username.slice(0, i), instance: username.slice(i + 1) };
    }),
  twitter: z.object({ link: z.string().url(), username: z.string() }).or(
    z.string().transform((link, ctx) => {
      const { username } = parseRegex<{ username: string }>(
        /^(?:https?:\/\/)?(?:www\.)?(?:twitter\.com|x\.com)\/@?(?<username>[^\/]+)\/?$/,
      )(link, ctx);
      return { link, username };
    }),
  ),
  bluesky: z.object({ link: z.string().url(), username: z.string() }).or(
    z.string().transform((link, ctx) => {
      const { username } = parseRegex<{ username: string }>(
        /^(?:https?:\/\/)?bsky\.app\/profile\/(?<username>[^\/]+)\/?$/,
      )(link, ctx);
      return { link, username };
    }),
  ),
  itaku: z.object({ link: z.string().url(), username: z.string() }).or(
    z.string().transform((link, ctx) => {
      const { username } = parseRegex<{ username: string }>(
        /^(?:https?:\/\/)?(?:www\.)?itaku\.ee\/profile\/(?<username>[^\/]+)\/?$/,
      )(link, ctx);
      return { link, username };
    }),
  ),
});
/** Available languages. See https://r12a.github.io/app-subtags/ */
const lang = z.enum(["en", "tok"]).default(DEFAULT_LANG);
/** Platforms for a game. */
const platform = z.enum(["web", "windows", "linux", "macos", "android", "ios"]);
const userList = z
  .array(reference("users"))
  .refine((value) => value.length > 0, `user list cannot be empty`)
  .or(reference("users").transform((user) => [user]));
/** A record of the format `{"Character name": "user-id"}`.
 *
 * An empty character name `""` indicates that all characters are copyrighted
 * by a certain user.
 */
const copyrightedCharacters = z
  .record(z.string(), reference("users"))
  .refine(
    (value) => !("" in value) || Object.keys(value).length === 1,
    `"copyrightedCharacters" cannot mix empty catch-all key with other keys`,
  )
  .default({});
/** A record with a mandatory `en` value and optional strings for the remaining languages. */
const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()));
/** Common attributes for published content (stories + games). */
const publishedContent = z
  .object({
    // Required parameters
    title: z.string(),
    authors: userList,
    // Required parameters, but optional for drafts (isDraft === true)
    pubDate: z
      .date()
      .transform((date: Date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0))
      .optional(),
    description: z.string().trim(),
    tags: z.array(z.string()),
    // Optional parameters
    isDraft: z.boolean().default(false),
    isAgeRestricted: z.boolean().default(true),
    isFeatured: z.boolean().default(false),
    relatedStories: z.array(reference("stories")).default([]),
    relatedGames: z.array(reference("games")).default([]),
    relatedBlogPosts: z.array(reference("blog")).default([]),
    lang: lang,
    series: reference("series").optional(),
    posts: z
      .object({
        eka: z.string().transform((link, ctx) => {
          const { postId } = parseRegex<{ postId: string }>(
            /^(?:https?:\/\/)(?:www\.)?aryion\.com\/g4\/view\/(?<postId>[1-9]\d*)\/?$/,
          )(link, ctx);
          return { link, postId };
        }),
        furaffinity: z.string().transform((link, ctx) => {
          const { postId } = parseRegex<{ postId: string }>(
            /^(?:https?:\/\/)(?:www\.)?furaffinity\.net\/view\/(?<postId>[1-9]\d*)\/?$/,
          )(link, ctx);
          return { link, postId };
        }),
        weasyl: z.string().transform((link, ctx) => {
          const { user, postId } = parseRegex<{ user: string; postId: string }>(
            /^(?:https?:\/\/)(?:www\.)?weasyl\.com\/~(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/(?<postId>[1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/,
          )(link, ctx);
          return { link, user, postId };
        }),
        inkbunny: z.string().transform((link, ctx) => {
          const { postId } = parseRegex<{ postId: string }>(
            /^(?:https?:\/\/)(?:www\.)?inkbunny\.net\/s\/(?<postId>[1-9]\d*)\/?$/,
          )(link, ctx);
          return { link, postId };
        }),
        sofurry: z.string().transform((link, ctx) => {
          const { postId } = parseRegex<{ postId: string }>(
            /^(?:https?:\/\/)www\.sofurry\.com\/view\/(?<postId>[1-9]\d*)\/?$/,
          )(link, ctx);
          return { link, postId };
        }),
        sofurrybeta: z.string().transform((link, ctx) => {
          const { postId } = parseRegex<{ postId: string }>(
            /^(?:https?:\/\/)sofurrybeta\.com\/s\/(?<postId>[a-zA-Z0-9]+)\/?$/,
          )(link, ctx);
          return { link, postId };
        }),
        itch: z.string().transform((link, ctx) => {
          const { user, postId } = parseRegex<{ user: string; postId: string }>(
            /^(?:https?:\/\/)(?<user>[a-z-]+)\.itch\.io\/(?<postId>[a-z0-9_-]+)\/?$/,
          )(link, ctx);
          return { link, user, postId };
        }),
        twitter: z.string().transform((link, ctx) => {
          const { user, postId } = parseRegex<{ user: string; postId: string }>(
            /^(?:https?:\/\/)(?:www\.)?(?:twitter\.com|x\.com)\/(?<user>[a-zA-Z0-9_-]+)\/status\/(?<postId>[1-9]\d*)\/?$/,
          )(link, ctx);
          return { link, user, postId };
        }),
        bluesky: z.string().transform((link, ctx) => {
          const { user, postId } = parseRegex<{ user: string; postId: string }>(
            /^(?:https?:\/\/)bsky\.app\/profile\/(?<user>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/post\/(?<postId>[a-z0-9]+)\/?$/,
          )(link, ctx);
          return { link, user, postId };
        }),
        mastodon: z.string().transform((link, ctx) => {
          const { instance, user, postId } = parseRegex<{ instance: string; user: string; postId: string }>(
            /^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/(?<postId>[1-9]\d*)\/?$/,
          )(link, ctx);
          return { link, instance, user, postId };
        }),
      })
      .partial()
      .default({}),
    blacklistedMastodonComments: z.array(z.string()).default([]),
  })
  .refine(
    ({ posts, blacklistedMastodonComments }) => !blacklistedMastodonComments.length || posts.mastodon,
    `Cannot include "blacklistedMastodonComments" without a Mastodon post`,
  )
  .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published content`)
  .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published content`)
  .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published content`);

// Types

export type Lang = z.output<typeof lang>;
export type UserWebsite = keyof z.input<typeof websiteLinks>;
export type GamePlatform = z.infer<typeof platform>;
export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
export type PublishedContent = z.infer<typeof publishedContent>;
export type PostWebsite = keyof PublishedContent["posts"];
export type Posts = PublishedContent["posts"];

// Content collections

const storiesCollection = defineCollection({
  type: "content",
  schema: ({ image }) =>
    z
      .object({
        // Required parameters, but optional for drafts (isDraft === true)
        contentWarning: z.string().trim(),
        wordCount: z.number().int().optional(),
        thumbnail: image().optional(),
        // Optional parameters
        thumbnailWidth: z.number().int().optional(),
        thumbnailHeight: z.number().int().optional(),
        prev: reference("stories").nullish(),
        next: reference("stories").nullish(),
        copyrightedCharacters: copyrightedCharacters,
        shortTitle: z.string().optional(),
        commissioners: userList.optional(),
        requesters: userList.optional(),
        summary: z.string().trim().optional(),
      })
      .and(publishedContent)
      .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published story`)
      .refine(
        ({ isDraft, contentWarning }) => isDraft || contentWarning,
        `Missing "contentWarning" for published story`,
      )
      .refine(({ isDraft, wordCount }) => isDraft || wordCount, `Missing "wordCount" for published story`),
});

const gamesCollection = defineCollection({
  type: "content",
  schema: ({ image }) =>
    z
      .object({
        // Required parameters, but optional for drafts (isDraft === true)
        contentWarning: z.string().trim(),
        platforms: z.array(platform).default([]),
        thumbnail: image().optional(),
        // Optional parameters
        thumbnailWidth: z.number().int().optional(),
        thumbnailHeight: z.number().int().optional(),
        prev: reference("games").nullish(),
        next: reference("games").nullish(),
        copyrightedCharacters: copyrightedCharacters,
      })
      .and(publishedContent)
      .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published game`)
      .refine(({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published game`)
      .refine(({ isDraft, platforms }) => isDraft || platforms.length, `Missing "platforms" for published game`),
});

const blogCollection = defineCollection({
  type: "content",
  schema: ({ image }) =>
    z
      .object({
        // Required parameters, but optional for drafts (isDraft === true)
        thumbnail: image().optional(),
        // Optional parameters
        thumbnailWidth: z.number().int().optional(),
        thumbnailHeight: z.number().int().optional(),
        prev: reference("blog").nullish(),
        next: reference("blog").nullish(),
      })
      .and(publishedContent)
      .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published blog post`),
});

// Data collections

const usersCollection = defineCollection({
  type: "data",
  schema: ({ image }) =>
    z
      .object({
        // Required parameters
        name: langRecord.or(z.string()),
        links: websiteLinks.partial(),
        // Optional parameters
        preferredLink: websiteLinks.keyof().nullish(),
        avatar: image().optional(),
      })
      .refine(
        ({ links, preferredLink }) => !preferredLink || preferredLink in links,
        ({ preferredLink }) => ({
          message: `"${preferredLink}" not defined in "links"`,
          path: ["preferredLink"],
        }),
      ),
});

const seriesCollection = defineCollection({
  type: "data",
  schema: z.object({
    // Required parameters
    name: z.string(),
    link: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"link" must be a local URL`),
  }),
});

const tagCategoriesCollection = defineCollection({
  type: "data",
  schema: z.object({
    // Required parameters
    name: z.string(),
    index: z.number().int(),
    tags: z
      .array(
        z.object({
          name: langRecord.or(z.string()),
          description: langRecord.or(z.string().trim()).optional(),
          related: z.array(z.string()).default([]),
        }),
      )
      .refine((tags) => tags.length, `"tags" cannot be empty`),
  }),
});

export const collections = {
  stories: storiesCollection,
  games: gamesCollection,
  blog: blogCollection,
  users: usersCollection,
  series: seriesCollection,
  "tag-categories": tagCategoriesCollection,
};
