import { type Database } from "@/services/drizzle-service.server";
import { videos } from "@/db/schema";
import {
  NotFoundError,
  UnknownDBServiceError,
  VideoTitleTakenError,
} from "@/services/db-service-errors";
import { and, eq, ne } from "drizzle-orm";
import { Effect } from "effect";
import type { VideoFormat } from "@/features/videos/video-format";
import {
  requireDraftVersionForLesson,
  requireDraftVersionForVideo,
} from "@/services/draft-guard.server";

const makeDbCall = <T>(fn: () => Promise<T>) =>
  Effect.tryPromise({
    try: fn,
    catch: (e) => new UnknownDBServiceError({ cause: e }),
  });

/**
 * The Video WRITE verbs added for the `cvm` CLI, split out of
 * db-video-operations.server.ts to keep that file under the repo's per-file
 * token budget (see db-video-operations.copy.server.ts for the same pattern).
 * These are spread into VideoOperationsService's returned object, so callers
 * see them as ordinary service methods.
 */
export const createVideoWriteOps = (db: Database) => {
  /**
   * Fetch a single Video row (no relations) by id, failing NotFoundError when
   * absent. Used by the CLI write verbs to echo the affected row.
   */
  const getVideoRowById = Effect.fn("getVideoRowById")(function* (id: string) {
    const video = yield* makeDbCall(() =>
      db.query.videos.findFirst({ where: eq(videos.id, id) })
    );

    if (!video) {
      return yield* new NotFoundError({
        type: "getVideoRowById",
        params: { id },
      });
    }

    return video;
  });

  /**
   * Link a Video to a Pitch, enforcing the single-parent invariant: setting a
   * pitch parent also clears any lesson parent (a Pitch packages Standalone
   * videos, so a pitch-bound video is never lesson-bound). Returns the updated
   * row; NotFoundError when the video id is absent.
   */
  const linkVideoToPitch = Effect.fn("linkVideoToPitch")(function* (opts: {
    videoId: string;
    pitchId: string;
  }) {
    yield* requireDraftVersionForVideo(db, opts.videoId);
    const [updated] = yield* makeDbCall(() =>
      db
        .update(videos)
        .set({ pitchId: opts.pitchId, lessonId: null, updatedAt: new Date() })
        .where(eq(videos.id, opts.videoId))
        .returning()
    );

    if (!updated) {
      return yield* new NotFoundError({
        type: "linkVideoToPitch",
        params: { videoId: opts.videoId },
      });
    }

    return updated;
  });

  /**
   * Move a Video into a Lesson, enforcing the single-parent invariant: setting a
   * lesson parent also clears any pitch parent. Re-checks the (lessonId, title)
   * uniqueness guard (VideoTitleTakenError) before mutating — unlike
   * updateVideoLesson, which does not. Returns the updated row; NotFoundError
   * when the video id is absent.
   */
  const moveVideoToLesson = Effect.fn("moveVideoToLesson")(function* (opts: {
    videoId: string;
    lessonId: string;
  }) {
    yield* requireDraftVersionForVideo(db, opts.videoId);
    yield* requireDraftVersionForLesson(db, opts.lessonId);
    const current = yield* makeDbCall(() =>
      db.query.videos.findFirst({
        where: eq(videos.id, opts.videoId),
        columns: { title: true },
      })
    );

    if (!current) {
      return yield* new NotFoundError({
        type: "moveVideoToLesson",
        params: { videoId: opts.videoId },
      });
    }

    // (lessonId, title) must be free among the target lesson's active videos.
    const clash = yield* makeDbCall(() =>
      db.query.videos.findFirst({
        where: and(
          eq(videos.lessonId, opts.lessonId),
          eq(videos.title, current.title),
          eq(videos.archived, false),
          ne(videos.id, opts.videoId)
        ),
        columns: { id: true },
      })
    );

    if (clash) {
      return yield* new VideoTitleTakenError({
        title: current.title,
        message: `Video name "${current.title}" is already taken in this lesson`,
      });
    }

    const [updated] = yield* makeDbCall(() =>
      db
        .update(videos)
        .set({ lessonId: opts.lessonId, pitchId: null, updatedAt: new Date() })
        .where(eq(videos.id, opts.videoId))
        .returning()
    );

    if (!updated) {
      return yield* new NotFoundError({
        type: "moveVideoToLesson",
        params: { videoId: opts.videoId },
      });
    }

    return updated;
  });

  const updateVideoBody = Effect.fn("updateVideoBody")(function* (opts: {
    videoId: string;
    body: string | null;
  }) {
    yield* requireDraftVersionForVideo(db, opts.videoId);
    const [updated] = yield* makeDbCall(() =>
      db
        .update(videos)
        .set({ body: opts.body, updatedAt: new Date() })
        .where(eq(videos.id, opts.videoId))
        .returning()
    );

    if (!updated) {
      return yield* new NotFoundError({
        type: "updateVideoBody",
        params: { videoId: opts.videoId },
      });
    }

    return updated;
  });

  const updateVideoScript = Effect.fn("updateVideoScript")(function* (opts: {
    videoId: string;
    script: string | null;
  }) {
    yield* requireDraftVersionForVideo(db, opts.videoId);
    const [updated] = yield* makeDbCall(() =>
      db
        .update(videos)
        .set({ script: opts.script, updatedAt: new Date() })
        .where(eq(videos.id, opts.videoId))
        .returning()
    );

    if (!updated) {
      return yield* new NotFoundError({
        type: "updateVideoScript",
        params: { videoId: opts.videoId },
      });
    }

    return updated;
  });

  const updateVideoDescription = Effect.fn("updateVideoDescription")(
    function* (opts: { videoId: string; description: string | null }) {
      yield* requireDraftVersionForVideo(db, opts.videoId);
      const [updated] = yield* makeDbCall(() =>
        db
          .update(videos)
          .set({ description: opts.description, updatedAt: new Date() })
          .where(eq(videos.id, opts.videoId))
          .returning()
      );

      if (!updated) {
        return yield* new NotFoundError({
          type: "updateVideoDescription",
          params: { videoId: opts.videoId },
        });
      }

      return updated;
    }
  );

  const unlinkVideoFromPitch = Effect.fn("unlinkVideoFromPitch")(function* (
    videoId: string
  ) {
    const [updated] = yield* makeDbCall(() =>
      db
        .update(videos)
        .set({ pitchId: null, updatedAt: new Date() })
        .where(eq(videos.id, videoId))
        .returning()
    );

    if (!updated) {
      return yield* new NotFoundError({
        type: "unlinkVideoFromPitch",
        params: { videoId },
      });
    }

    return updated;
  });

  const updateVideoFormat = Effect.fn("updateVideoFormat")(function* (opts: {
    videoId: string;
    format: VideoFormat;
  }) {
    yield* requireDraftVersionForVideo(db, opts.videoId);
    const [updated] = yield* makeDbCall(() =>
      db
        .update(videos)
        .set({ format: opts.format, lessonId: null, updatedAt: new Date() })
        .where(eq(videos.id, opts.videoId))
        .returning()
    );

    if (!updated) {
      return yield* new NotFoundError({
        type: "updateVideoFormat",
        params: { videoId: opts.videoId },
      });
    }

    return updated;
  });

  return {
    getVideoRowById,
    linkVideoToPitch,
    unlinkVideoFromPitch,
    moveVideoToLesson,
    updateVideoBody,
    updateVideoScript,
    updateVideoDescription,
    updateVideoFormat,
  };
};

/**
 * The guarded Video write verbs (from both this file and
 * db-video-operations.server.ts) that transactionalizeWrites wraps so guard +
 * write share one transaction (issue #1403). Standalone-only writes
 * (createStandaloneVideo, unlinkVideoFromPitch, updateVideoArchiveStatus)
 * touch no CourseVersion and stay unwrapped.
 */
export const videoWriteMethods = [
  "createVideo",
  "updateVideo",
  "deleteVideo",
  "updateVideoTitle",
  "copyVideo",
  "updateVideoLesson",
  "linkVideoToPitch",
  "moveVideoToLesson",
  "updateVideoBody",
  "updateVideoScript",
  "updateVideoDescription",
  "updateVideoFormat",
] as const;
