import { describe, it, expect, beforeAll } from "vitest";
import { ConfigProvider, Effect, Layer } from "effect";
import { NodeContext } from "@effect/platform-node";
import fs from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
import {
  createTestDb,
  truncateAllTables,
  type TestDb,
} from "@/test-utils/pglite";
import { CourseOperationsService } from "@/services/db-course-operations.server";
import { VideoOperationsService } from "@/services/db-video-operations.server";
import { VersionOperationsService } from "@/services/db-version-operations.server";
import { LessonSectionOperationsService } from "@/services/db-lesson-section-operations.server";
import { DrizzleService } from "@/services/drizzle-service.server";
import { VideoProcessingService } from "@/services/video-processing-service";
import { CoursePublishService } from "@/services/course-publish-service";
import { computeExportHash, type ExportClip } from "@/services/export-hash";
import { clips as clipsTable } from "@/db/schema";

let testDb: TestDb;
let finishedVideosDir: string;

beforeAll(async () => {
  const result = await createTestDb();
  testDb = result.testDb;
});

/** Create temp directories and seed a course with one version, one section,
 *  one lesson, one video with clips in the PGLite database. Returns IDs. */
const setup = async () => {
  await truncateAllTables(testDb);

  finishedVideosDir = fs.mkdtempSync(
    path.join(tmpdir(), "publish-test-videos-")
  );

  const drizzleLayer = Layer.succeed(DrizzleService, testDb as any);
  const dbLayer = Layer.mergeAll(
    CourseOperationsService.Default,
    VideoOperationsService.Default,
    VersionOperationsService.Default,
    LessonSectionOperationsService.Default
  ).pipe(Layer.provide(drizzleLayer));

  // Mock VideoProcessingService: creates a dummy file at {videoId}.mp4
  const mockVideoProcessing = Layer.succeed(VideoProcessingService, {
    exportVideoClips: (opts: any) =>
      Effect.sync(() => {
        const outputPath = path.join(finishedVideosDir, `${opts.videoId}.mp4`);
        fs.mkdirSync(path.dirname(outputPath), { recursive: true });
        fs.writeFileSync(outputPath, "dummy-video-content");
        opts.onStageChange?.("concatenating-clips");
        opts.onProgress?.({ stage: "concatenating-clips", percent: 50 });
        opts.onStageChange?.("normalizing-audio");
        opts.onProgress?.({ stage: "normalizing-audio", percent: 50 });
        return outputPath;
      }),
  } as any);

  const configLayer = Layer.setConfigProvider(
    ConfigProvider.fromMap(
      new Map([["FINISHED_VIDEOS_DIRECTORY", finishedVideosDir]])
    )
  );

  // Build a core layer with all deps, then provide to CoursePublishService
  const coreTestLayer = Layer.mergeAll(
    CourseOperationsService.Default,
    VideoOperationsService.Default,
    VersionOperationsService.Default,
    mockVideoProcessing,
    NodeContext.layer
  ).pipe(Layer.provide(drizzleLayer), Layer.provide(configLayer));

  const testLayer = Layer.merge(
    coreTestLayer,
    CoursePublishService.Default.pipe(Layer.provide(coreTestLayer))
  );

  // Seed data
  const course = await Effect.gen(function* () {
    const courseOps = yield* CourseOperationsService;
    return yield* courseOps.createCourse({
      name: "test-course",
    });
  }).pipe(Effect.provide(dbLayer), Effect.runPromise);

  const version = await Effect.gen(function* () {
    const versionOps = yield* VersionOperationsService;
    return yield* versionOps.createCourseVersion({
      repoId: course.id,
      name: "v1",
    });
  }).pipe(Effect.provide(dbLayer), Effect.runPromise);

  const section = await Effect.gen(function* () {
    const lsOps = yield* LessonSectionOperationsService;
    const sections = yield* lsOps.createSections({
      repoVersionId: version.id,
      sections: [{ sectionPathWithNumber: "01-intro", sectionNumber: 1 }],
    });
    return sections[0]!;
  }).pipe(Effect.provide(dbLayer), Effect.runPromise);

  const lesson = await Effect.gen(function* () {
    const lsOps = yield* LessonSectionOperationsService;
    const lessons = yield* lsOps.createLessons(section.id, [
      { lessonPathWithNumber: "01.01-welcome", lessonNumber: 1 },
    ]);
    return lessons[0]!;
  }).pipe(Effect.provide(dbLayer), Effect.runPromise);

  const video = await Effect.gen(function* () {
    const videoOps = yield* VideoOperationsService;
    return yield* videoOps.createVideo(lesson.id, {
      title: "Problem",
      originalFootagePath: "/tmp/footage.mp4",
    });
  }).pipe(Effect.provide(dbLayer), Effect.runPromise);

  // Add clips to the video (direct insert)
  await testDb.insert(clipsTable).values([
    {
      videoId: video.id,
      videoFilename: "recording.mp4",
      sourceStartTime: 0,
      sourceEndTime: 10,
      order: "a0",
      text: "Hello world",
      pauseType: "none",
      zoomType: "none",
    },
    {
      videoId: video.id,
      videoFilename: "recording.mp4",
      sourceStartTime: 15,
      sourceEndTime: 25,
      order: "a1",
      text: "Welcome to the course",
      pauseType: "none",
      zoomType: "none",
    },
  ]);

  const clips: ExportClip[] = [
    {
      videoFilename: "recording.mp4",
      sourceStartTime: 0,
      sourceEndTime: 10,
      pauseType: "none",
      zoomType: "none",
    },
    {
      videoFilename: "recording.mp4",
      sourceStartTime: 15,
      sourceEndTime: 25,
      pauseType: "none",
      zoomType: "none",
    },
  ];
  const exportHash = computeExportHash(clips, "landscape")!;

  const run = <A, E>(effect: Effect.Effect<A, E, any>) =>
    Effect.runPromise(
      effect.pipe(Effect.provide(testLayer) as any)
    ) as Promise<A>;

  return {
    course,
    version,
    section,
    lesson,
    video,
    exportHash,
    clips,
    run,
    testLayer,
    dbLayer,
  };
};

describe("CoursePublishService", () => {
  describe("isExported", () => {
    it("returns false when no file exists", async () => {
      const { video, run } = await setup();

      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return yield* svc.isExported(video.id);
        })
      );

      expect(result).toBe(false);
    });

    it("returns true when content-addressed file exists", async () => {
      const { video, course, exportHash, run } = await setup();

      // Create the content-addressed file
      const filePath = path.join(
        finishedVideosDir,
        `${course.id}-${exportHash}.mp4`
      );
      fs.writeFileSync(filePath, "video-data");

      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return yield* svc.isExported(video.id);
        })
      );

      expect(result).toBe(true);
    });

    it("accepts a video object and returns false when no file exists", async () => {
      const { video, run } = await setup();

      const result = await run(
        Effect.gen(function* () {
          const videoOps = yield* VideoOperationsService;
          const svc = yield* CoursePublishService;
          const fullVideo = yield* videoOps.getVideoWithClipsById(video.id);
          return yield* svc.isExported(fullVideo);
        })
      );

      expect(result).toBe(false);
    });

    it("accepts a video object and returns true when file exists", async () => {
      const { video, course, exportHash, run } = await setup();

      const filePath = path.join(
        finishedVideosDir,
        `${course.id}-${exportHash}.mp4`
      );
      fs.writeFileSync(filePath, "video-data");

      const result = await run(
        Effect.gen(function* () {
          const videoOps = yield* VideoOperationsService;
          const svc = yield* CoursePublishService;
          const fullVideo = yield* videoOps.getVideoWithClipsById(video.id);
          return yield* svc.isExported(fullVideo);
        })
      );

      expect(result).toBe(true);
    });
  });

  describe("resolveExportPath", () => {
    it("returns content-addressed path for video with clips", async () => {
      const { video, course, exportHash, run } = await setup();

      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return yield* svc.resolveExportPath(video.id);
        })
      );

      expect(result).toBe(
        path.join(finishedVideosDir, `${course.id}-${exportHash}.mp4`)
      );
    });

    it("accepts a video object and returns content-addressed path", async () => {
      const { video, course, exportHash, run } = await setup();

      const result = await run(
        Effect.gen(function* () {
          const videoOps = yield* VideoOperationsService;
          const svc = yield* CoursePublishService;
          const fullVideo = yield* videoOps.getVideoWithClipsById(video.id);
          return yield* svc.resolveExportPath(fullVideo);
        })
      );

      expect(result).toBe(
        path.join(finishedVideosDir, `${course.id}-${exportHash}.mp4`)
      );
    });
  });

  describe("exportVideo", () => {
    it("exports video to content-addressed path", async () => {
      const { video, course, exportHash, run } = await setup();

      const stages: string[] = [];
      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return yield* svc.exportVideo(video.id, (stage) => {
            stages.push(stage);
          });
        })
      );

      const expectedPath = path.join(
        finishedVideosDir,
        `${course.id}-${exportHash}.mp4`
      );
      expect(result).toBe(expectedPath);
      expect(fs.existsSync(expectedPath)).toBe(true);
      expect(stages).toContain("concatenating-clips");
      expect(stages).toContain("normalizing-audio");
    });

    it("skips rendering if already exported", async () => {
      const { video, course, exportHash, run } = await setup();

      // Pre-create the content-addressed file
      const expectedPath = path.join(
        finishedVideosDir,
        `${course.id}-${exportHash}.mp4`
      );
      fs.writeFileSync(expectedPath, "already-exported");

      const stages: string[] = [];
      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return yield* svc.exportVideo(video.id, (stage) => {
            stages.push(stage);
          });
        })
      );

      expect(result).toBe(expectedPath);
      // Should NOT have called ffmpeg (no stage events)
      expect(stages).toEqual([]);
      // File content should be unchanged (not re-rendered)
      expect(fs.readFileSync(expectedPath, "utf-8")).toBe("already-exported");
    });
  });

  describe("validatePublishability", () => {
    it("returns unexported video IDs when videos are not exported", async () => {
      const { version, video, run } = await setup();

      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return (yield* svc.validatePublishability(version.id)).withTodo;
        })
      );

      expect(result.unexportedVideoIds).toContain(video.id);
    });

    it("returns empty list when all videos are exported", async () => {
      const { version, course, exportHash, run } = await setup();

      // Create the exported file
      fs.writeFileSync(
        path.join(finishedVideosDir, `${course.id}-${exportHash}.mp4`),
        "data"
      );

      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return (yield* svc.validatePublishability(version.id)).withTodo;
        })
      );

      expect(result.unexportedVideoIds).toEqual([]);
    });

    it("surfaces incomplete shipping videos (the seed video has no body/description)", async () => {
      const { version, run } = await setup();

      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return (yield* svc.validatePublishability(version.id)).withTodo;
        })
      );

      expect(result.incompleteVideos).toMatchObject([
        {
          sectionPath: "01-intro",
          lessonPath: "01.01-welcome",
          videoTitle: "Problem",
          missing: ["body", "description"],
        },
      ]);
      expect(result.invalidLessonCombos).toEqual([]);
    });
  });

  describe("exportVideo → isExported integration", () => {
    it("isExported returns true after exportVideo", async () => {
      const { video, run } = await setup();

      // Initially not exported
      const beforeExport = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return yield* svc.isExported(video.id);
        })
      );
      expect(beforeExport).toBe(false);

      // Export
      await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          yield* svc.exportVideo(video.id);
        })
      );

      // Now exported
      const afterExport = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return yield* svc.isExported(video.id);
        })
      );
      expect(afterExport).toBe(true);
    });
  });

  describe("export → validatePublishability integration", () => {
    it("validatePublishability passes after all videos exported", async () => {
      const { version, video, run } = await setup();

      // Before export: validation fails
      const before = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return (yield* svc.validatePublishability(version.id)).withTodo;
        })
      );
      expect(before.unexportedVideoIds).toContain(video.id);

      // Export video
      await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          yield* svc.exportVideo(video.id);
        })
      );

      // After export: validation passes
      const after = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return (yield* svc.validatePublishability(version.id)).withTodo;
        })
      );
      expect(after.unexportedVideoIds).toEqual([]);
    });
  });

  describe("validatePublishability — course-view lints", () => {
    it("returns courseViewLintCount > 0 when missingChapters warning exists", async () => {
      const { version, run } = await setup();

      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return (yield* svc.validatePublishability(version.id)).withTodo;
        })
      );

      expect(result.courseViewLintCount).toBeGreaterThan(0);
    });

    it("returns courseViewLintCount including lesson warnings for invalid video-role combos", async () => {
      await truncateAllTables(testDb);

      const drizzleLayer = Layer.succeed(DrizzleService, testDb as any);
      const dbLayer = Layer.mergeAll(
        CourseOperationsService.Default,
        VideoOperationsService.Default,
        VersionOperationsService.Default,
        LessonSectionOperationsService.Default
      ).pipe(Layer.provide(drizzleLayer));

      const course = await Effect.gen(function* () {
        const courseOps = yield* CourseOperationsService;
        return yield* courseOps.createCourse({
          name: "lint-test-course",
        });
      }).pipe(Effect.provide(dbLayer), Effect.runPromise);

      const version = await Effect.gen(function* () {
        const versionOps = yield* VersionOperationsService;
        return yield* versionOps.createCourseVersion({
          repoId: course.id,
          name: "v1",
        });
      }).pipe(Effect.provide(dbLayer), Effect.runPromise);

      const section = await Effect.gen(function* () {
        const lsOps = yield* LessonSectionOperationsService;
        const sections = yield* lsOps.createSections({
          repoVersionId: version.id,
          sections: [{ sectionPathWithNumber: "01-intro", sectionNumber: 1 }],
        });
        return sections[0]!;
      }).pipe(Effect.provide(dbLayer), Effect.runPromise);

      const lesson = await Effect.gen(function* () {
        const lsOps = yield* LessonSectionOperationsService;
        const lessons = yield* lsOps.createLessons(section.id, [
          { lessonPathWithNumber: "01.01-welcome", lessonNumber: 1 },
        ]);
        return lessons[0]!;
      }).pipe(Effect.provide(dbLayer), Effect.runPromise);

      // Create a "Solution" video without a "Problem" — invalid combo
      await Effect.gen(function* () {
        const videoOps = yield* VideoOperationsService;
        yield* videoOps.createVideo(lesson.id, {
          title: "Solution",
          originalFootagePath: "/tmp/footage.mp4",
        });
      }).pipe(Effect.provide(dbLayer), Effect.runPromise);

      finishedVideosDir = fs.mkdtempSync(
        path.join(tmpdir(), "publish-test-lint-")
      );

      const mockVideoProcessing = Layer.succeed(VideoProcessingService, {
        exportVideoClips: () => Effect.succeed(""),
      } as any);

      const configLayer = Layer.setConfigProvider(
        ConfigProvider.fromMap(
          new Map([["FINISHED_VIDEOS_DIRECTORY", finishedVideosDir]])
        )
      );

      const coreTestLayer = Layer.mergeAll(
        CourseOperationsService.Default,
        VideoOperationsService.Default,
        VersionOperationsService.Default,
        mockVideoProcessing,
        NodeContext.layer
      ).pipe(Layer.provide(drizzleLayer), Layer.provide(configLayer));

      const testLayer = Layer.merge(
        coreTestLayer,
        CoursePublishService.Default.pipe(Layer.provide(coreTestLayer))
      );

      const run = <A, E>(effect: Effect.Effect<A, E, any>) =>
        Effect.runPromise(
          effect.pipe(Effect.provide(testLayer) as any)
        ) as Promise<A>;

      const result = await run(
        Effect.gen(function* () {
          const svc = yield* CoursePublishService;
          return (yield* svc.validatePublishability(version.id)).withTodo;
        })
      );

      // Solution without Problem = solutionWithoutProblem lesson warning
      expect(result.courseViewLintCount).toBeGreaterThanOrEqual(1);
    });
  });
});
