#define STRVIEW_IMPLEMENTATION
#include "strview.h"

#include "compiler.h"
#include "parser.h"

#include <math.h>
#include <stdio.h>
#include <string.h>

static int failures;
static int tests_run;

static bool contains(const char* text, const char* part) {
  return sv_contains(sv_from_cstr(text), sv_from_cstr(part));
}

static bool stage_contains(const StagedCommand* stage, const char* part) {
  for (size_t i = 0; i < stage->argc; i++) if (contains(stage->argv[i], part)) return true;
  return false;
}

#define CHECK(condition) do {                                                     \
  if (!(condition)) {                                                             \
    fprintf(stderr, "%s:%d: check failed: %s\n", __FILE__, __LINE__, #condition); \
    failures++;                                                                   \
  }                                                                               \
} while (0)

#define TEST(name) static void name(void)
#define RUN(name) do { tests_run++; name(); } while (0)

typedef struct {
  int calls;
  double video_duration;
  double audio_duration;
  ProbeStream streams[PROBE_STREAM_LIMIT];
  size_t stream_count;
  ProbeChapter chapters[PROBE_CHAPTER_LIMIT];
  size_t chapter_count;
  ProbeAttachment attachments[PROBE_ATTACHMENT_LIMIT];
  size_t attachment_count;
  ProbeTag tags[PROBE_TAG_LIMIT];
  size_t tag_count;
} FakeProbe;

static bool fake_probe(const char* path, ProbeResult* result, void* context, Error* error) {
  (void)error;
  FakeProbe* fake = context;
  fake->calls++;
  bool video_only = sv_ends_with(sv_from_cstr(path), svlit(".mov"));
  bool audio_only = sv_ends_with(sv_from_cstr(path), svlit(".wav"));
  *result = (ProbeResult){
    .has_video = !audio_only,
    .has_audio = !video_only,
    .video_duration_known = !audio_only,
    .audio_duration_known = !video_only,
    .video_duration = fake->video_duration,
    .audio_duration = fake->audio_duration,
  };
  if (fake->stream_count != 0) {
    *result = (ProbeResult){0};
    result->stream_count = fake->stream_count;
    memcpy(result->streams, fake->streams, fake->stream_count * sizeof(*fake->streams));
    for (size_t i = 0; i < fake->stream_count; i++) {
      ProbeStream stream = fake->streams[i];
      if (stream.type == TYPE_VIDEO) {
        result->has_video = true;
        result->video_duration_known = stream.duration_known;
        result->video_duration = fmax(result->video_duration, stream.duration);
      } else if (stream.type == TYPE_AUDIO) {
        result->has_audio = true;
        result->audio_duration_known = stream.duration_known;
        result->audio_duration = fmax(result->audio_duration, stream.duration);
      } else if (stream.type == TYPE_SUBTITLES) {
        result->has_subtitles = true;
        result->subtitle_duration_known = stream.duration_known;
        result->subtitle_duration = fmax(result->subtitle_duration, stream.duration);
      }
    }
  }
  result->chapter_count = fake->chapter_count;
  memcpy(result->chapters, fake->chapters, fake->chapter_count * sizeof(*fake->chapters));
  result->attachment_count = fake->attachment_count;
  memcpy(result->attachments, fake->attachments,
         fake->attachment_count * sizeof(*fake->attachments));
  result->tag_count = fake->tag_count;
  memcpy(result->tags, fake->tags, fake->tag_count * sizeof(*fake->tags));
  return true;
}

static bool parse_and_check(const char* text, Program* program, TypedProgram* typed, Error* error) {
  return parse_program(text, program, error) && typecheck_program(program, typed, error);
}

static bool compile_text(const char* text, FakeProbe* fake, Program* program, TypedProgram* typed,
                         ProbeCache* cache, CompiledCommand* command, Error* error) {
  if (!parse_and_check(text, program, typed, error)) {
    return false;
  }
  probe_cache_init(cache, fake_probe, fake);
  return compile_program(program, typed, cache, "out.mp4", command, error);
}

static bool compile_text_to(const char* text, const char* output, FakeProbe* fake,
                            Program* program, TypedProgram* typed, ProbeCache* cache,
                            CompiledCommand* command, Error* error) {
  if (!parse_and_check(text, program, typed, error)) return false;
  probe_cache_init(cache, fake_probe, fake);
  return compile_program(program, typed, cache, output, command, error);
}

static void destroy_compilation(Program* program, TypedProgram* typed, ProbeCache* cache,
                                CompiledCommand* command) {
  compiled_command_destroy(command);
  probe_cache_destroy(cache);
  typed_program_destroy(typed);
  program_destroy(program);
}

TEST(parser_comments_and_paths) {
  Program program;
  Error error;
  CHECK(parse_program("# heading\nvideo \"close up #1.mov\" # camera\n", &program, &error));
  CHECK(program.count == 1);
  CHECK(sv_equal(sv_from_cstr(program.items[0].as.source.path), svlit("close up #1.mov")));
  CHECK(program.items[0].line == 2);
  program_destroy(&program);
}

TEST(positional_script_arguments) {
  const char* arguments[] = {"close up", "dialogue track.wav"};
  Program program;
  Error error;
  CHECK(parse_program_with_args("video \"clips/$1.mov\"\naudio \"$2\"\nmux\n", 2, arguments,
                                &program, &error));
  CHECK(sv_equal(sv_from_cstr(program.items[0].as.source.path), svlit("clips/close up.mov")));
  CHECK(sv_equal(sv_from_cstr(program.items[1].as.source.path), sv_from_cstr(arguments[1])));
  program_destroy(&program);

  CHECK(!parse_program_with_args("video \"$3\"\n", 2, arguments, &program, &error));
  CHECK(error.line == 1 && contains(error.message, "missing script argument"));
  CHECK(!parse_program_with_args("video \"$0\"\n", 2, arguments, &program, &error));
  CHECK(contains(error.message, "invalid"));
  CHECK(!parse_program("video \"$1\"\n", &program, &error));
  CHECK(contains(error.message, "missing script argument"));
  CHECK(!parse_program_with_args("video $1\n", 2, arguments, &program, &error));
  CHECK(contains(error.message, "must be quoted"));
}

TEST(arena_alignment_and_reset) {
  Arena arena = arena_create();
  arena.block_size = 32;
  void* first = arena_alloc_aligned(&arena, 7, 64);
  void* second = arena_alloc_aligned(&arena, 80, 32);
  CHECK(first != NULL && (uintptr_t)first % 64 == 0);
  CHECK(second != NULL && (uintptr_t)second % 32 == 0);
  arena_reset(&arena);
  void* reused = arena_alloc_aligned(&arena, 7, 64);
  CHECK(reused == first);
  arena_destroy(&arena);
}

TEST(duration_parsing) {
  Program program;
  Error error;
  CHECK(parse_program("duration 5s\nduration 250ms\nduration 01:23.500\n", &program, &error));
  CHECK(fabs(program.items[0].as.number - 5.0) < 1e-9);
  CHECK(fabs(program.items[1].as.number - 0.25) < 1e-9);
  CHECK(fabs(program.items[2].as.number - 83.5) < 1e-9);
  program_destroy(&program);
}

TEST(scalar_parsing) {
  Program program;
  Error error;
  CHECK(parse_program("scalar 0.5\nscalar 2\n", &program, &error));
  CHECK(program.items[0].as.number == 0.5);
  CHECK(program.items[1].as.number == 2.0);
  program_destroy(&program);
}

TEST(parser_errors) {
  Program program;
  Error error;
  CHECK(!parse_program("wibble\n", &program, &error));
  CHECK(error.line == 1 && contains(error.message, "unknown"));
  CHECK(!parse_program("video \"unfinished\n", &program, &error));
  CHECK(contains(error.message, "unterminated"));
  CHECK(!parse_program("duration 12\n", &program, &error));
  CHECK(contains(error.message, "malformed duration"));
  CHECK(!parse_program("scalar nope\n", &program, &error));
  CHECK(contains(error.message, "malformed scalar"));
  CHECK(!parse_program("index 1.5\n", &program, &error));
  CHECK(contains(error.message, "malformed index"));
}

TEST(type_errors) {
  Program program;
  TypedProgram typed;
  Error error;
  CHECK(parse_program("drop\n", &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "underflow"));
  program_destroy(&program);

  CHECK(parse_program("audio \"a.wav\"\nscalar 2\ntake\n", &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "expected DURATION"));
  program_destroy(&program);

  CHECK(parse_program("video \"a.mov\"\naudio \"a.wav\"\nconcat\n", &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "same type"));
  program_destroy(&program);

  CHECK(parse_program("audio \"a.wav\"\nvideo \"a.mov\"\nmux\n", &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "expected VIDEO"));
  program_destroy(&program);
}

TEST(valid_types) {
  Program program;
  TypedProgram typed;
  Error error;
  CHECK(parse_and_check("video \"a.mov\"\nvideo \"b.mov\"\nconcat\n", &program, &typed,
                        &error));
  CHECK(typed.final_type == TYPE_VIDEO);
  typed_program_destroy(&typed);
  program_destroy(&program);

  CHECK(parse_and_check("combined \"a.mp4\"\nsplit\nmux\n", &program, &typed, &error));
  CHECK(typed.items[1].produced_count == 2);
  CHECK(typed.items[1].produced[0] == TYPE_VIDEO);
  CHECK(typed.items[1].produced[1] == TYPE_AUDIO);
  typed_program_destroy(&typed);
  program_destroy(&program);

  CHECK(parse_and_check("audio \"a.wav\"\nscalar 0.7\nvolume\n", &program, &typed, &error));
  CHECK(typed.final_type == TYPE_AUDIO);
  typed_program_destroy(&typed);
  program_destroy(&program);
}

TEST(speed_validation) {
  Program program;
  TypedProgram typed;
  Error error;
  CHECK(parse_program("video \"a.mov\"\nscalar 0\nspeed\n", &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "greater than zero"));
  program_destroy(&program);
}

TEST(probe_cache) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 11};
  ProbeCache cache;
  Error error;
  ProbeResult first;
  ProbeResult second;
  probe_cache_init(&cache, fake_probe, &fake);
  CHECK(probe_cache_get(&cache, "same.mp4", &first, &error));
  CHECK(probe_cache_get(&cache, "same.mp4", &second, &error));
  CHECK(fake.calls == 1);
  CHECK(cache.invocation_count == 1);
  CHECK(first.video_duration == 10 && second.audio_duration == 11);
  probe_cache_destroy(&cache);
}

TEST(duration_and_fade_propagation) {
  const char* text = "video \"a.mov\"\nduration 2s\nduration 20s\nslice\n"
                     "scalar 2\nspeed\nduration 1s\nfade-out\n";
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text(text, &fake, &program, &typed, &cache, &command, &error));
  CHECK(contains(command.filter_complex, "trim=start=2:duration=20"));
  CHECK(contains(command.filter_complex, "setpts=PTS/2"));
  CHECK(contains(command.filter_complex, "fade=t=out:st=3:d=1"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(length_and_silence) {
  FakeProbe fake = {.video_duration = 7.5, .audio_duration = 9};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text("combined \"a.mp4\"\nlength\nsilence\n", &fake, &program, &typed,
                     &cache, &command, &error));
  CHECK(contains(command.filter_complex, "atrim=duration=9"));
  CHECK(!contains(command.filter_complex, "0:v:0"));
  CHECK(!contains(command.filter_complex, "0:a:0"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(video_duplication) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text("video \"a.mov\"\ndup\nconcat\n", &fake, &program, &typed, &cache,
                     &command, &error));
  CHECK(contains(command.filter_complex, "split=2"));
  CHECK(contains(command.filter_complex, "concat=n=2:v=1:a=0"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(audio_duplication) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text("audio \"a.wav\"\ndup\nmix\n", &fake, &program, &typed, &cache,
                     &command, &error));
  CHECK(contains(command.filter_complex, "asplit=2"));
  CHECK(contains(command.filter_complex, "amix=inputs=2:duration=longest"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(no_unnecessary_split_and_mapping) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text("combined \"a.mp4\"\nsplit\nmux\n", &fake, &program, &typed, &cache,
                     &command, &error));
  CHECK(!contains(command.filter_complex, "split="));
  CHECK(!contains(command.filter_complex, "asplit="));
  CHECK(contains(command.shell_command, "'[vout]'"));
  CHECK(contains(command.shell_command, "'[aout]'"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(dropped_graph_is_unreachable) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  const char* text = "video \"keep.mov\"\nvideo \"drop.mov\"\nduration 1s\ntake\ndrop\n";
  CHECK(compile_text(text, &fake, &program, &typed, &cache, &command, &error));
  CHECK(!contains(command.filter_complex, "trim=duration=1"));
  CHECK(contains(command.filter_complex, "[0:0]null[vout]"));
  CHECK(!contains(command.shell_command, "drop.mov"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(subtitle_mapping) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  const char* text = "combined \"a.mp4\"\nsubtitles \"captions.srt\"\nattach-subtitles\n";
  CHECK(compile_text(text, &fake, &program, &typed, &cache, &command, &error));
  CHECK(contains(command.shell_command, "2:s:0"));
  CHECK(contains(command.shell_command, "-c:s:0 mov_text"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(burn_subtitles_and_video_conversion) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text("video \"a.mov\"\nsubtitles \"captions.srt\"\nburn-subtitles\n",
                     &fake, &program, &typed, &cache, &command, &error));
  CHECK(command.stage_count == 1);
  CHECK(contains(command.filter_complex, "subtitles=filename="));
  CHECK(!contains(command.filter_complex, "force_style="));
  destroy_compilation(&program, &typed, &cache, &command);

  const char* styled =
    "video \"a.mov\"\nsubtitles \"captions.srt\"\n"
    "burn-style \"font=Inter; size=42; color=#FFFFFF; outline-color=#000000; "
    "outline-width=2; align=bottom-center; margin=48\"\nburn-subtitles\n";
  CHECK(compile_text(styled, &fake, &program, &typed, &cache, &command, &error));
  CHECK(contains(command.filter_complex, "force_style='FontName=Inter,FontSize=42"));
  CHECK(contains(command.filter_complex, "PrimaryColour=&H00FFFFFF"));
  CHECK(contains(command.filter_complex, "Alignment=2,MarginV=48"));
  destroy_compilation(&program, &typed, &cache, &command);

  CHECK(!compile_text("video \"a.mov\"\nsubtitles \"a.srt\"\n"
                      "burn-style \"size=-1\"\nburn-subtitles\n", &fake,
                      &program, &typed, &cache, &command, &error));
  CHECK(contains(error.message, "invalid burn style"));
  probe_cache_destroy(&cache);
  typed_program_destroy(&typed);
  program_destroy(&program);

  CHECK(parse_and_check("video \"a.mov\"\nvideo-to-combined\n", &program, &typed, &error));
  CHECK(typed.final_type == TYPE_COMBINED);
  typed_program_destroy(&typed);
  program_destroy(&program);

  CHECK(parse_program("combined \"a.mp4\"\nsubtitles \"a.srt\"\nburn-subtitles\n",
                      &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "expected VIDEO"));
  program_destroy(&program);

  CHECK(!parse_program("combined \"a.mp4\"\nsubtitles \"a.srt\"\nattach\n",
                       &program, &error));
  CHECK(contains(error.message, "unknown instruction"));
}

TEST(bitmap_subtitle_burn_rejected) {
  FakeProbe fake = {
    .streams = {{.type = TYPE_VIDEO, .stream_index = 0, .duration_known = true, .duration = 10},
                {.type = TYPE_SUBTITLES, .stream_index = 1, .duration_known = true, .duration = 10}},
    .stream_count = 2,
  };
  snprintf(fake.streams[1].codec_name, sizeof(fake.streams[1].codec_name), "hdmv_pgs_subtitle");
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(!compile_text("video \"a.mkv\"\nsubtitles \"a.mkv\"\nburn-subtitles\n",
                      &fake, &program, &typed, &cache, &command, &error));
  CHECK(contains(error.message, "text subtitles only"));
  probe_cache_destroy(&cache);
  typed_program_destroy(&typed);
  program_destroy(&program);
}

TEST(multitrack_combined_mapping) {
  FakeProbe fake = {
    .streams = {
      {TYPE_VIDEO, 0, true, 10}, {TYPE_AUDIO, 1, true, 10},
      {TYPE_AUDIO, 2, true, 10}, {TYPE_SUBTITLES, 3, true, 9},
      {TYPE_SUBTITLES, 4, true, 8},
    },
    .stream_count = 5,
  };
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text("combined \"multi.mkv\"\n", &fake, &program, &typed, &cache,
                     &command, &error));
  CHECK(contains(command.filter_complex, "[0:0]null[vout]"));
  CHECK(contains(command.filter_complex, "[0:1]anull[aout]"));
  CHECK(contains(command.filter_complex, "[0:2]anull[aout1]"));
  CHECK(command.stage_count == 2);
  CHECK(stage_contains(&command.stages[0], "0:3"));
  CHECK(stage_contains(&command.stages[1], "0:4"));
  CHECK(contains(command.shell_command, "1:s:0"));
  CHECK(contains(command.shell_command, "2:s:0"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(indexed_combined_operations) {
  FakeProbe fake = {
    .streams = {
      {TYPE_VIDEO, 0, true, 10}, {TYPE_AUDIO, 1, true, 10},
      {TYPE_AUDIO, 2, true, 10}, {TYPE_SUBTITLES, 3, true, 9},
      {TYPE_SUBTITLES, 4, true, 8},
    },
    .stream_count = 5,
  };
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  const char* text = "combined \"multi.mkv\"\nindex 1\ndrop-audio\n"
                     "index 1\nindex 0\nmove-subtitles\n";
  CHECK(compile_text(text, &fake, &program, &typed, &cache, &command, &error));
  CHECK(contains(command.filter_complex, "[0:1]anull[aout]"));
  CHECK(!contains(command.filter_complex, "[0:2]"));
  CHECK(command.stage_count == 2);
  CHECK(stage_contains(&command.stages[0], "0:4"));
  CHECK(stage_contains(&command.stages[1], "0:3"));
  destroy_compilation(&program, &typed, &cache, &command);

  CHECK(parse_and_check("combined \"a.mp4\"\nindex 0\ntake-subtitles\nadd-subtitles\n",
                        &program, &typed, &error));
  typed_program_destroy(&typed);
  program_destroy(&program);
}

TEST(subtitle_timeline_operations) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  const char* text = "subtitles \"a.srt\"\nduration 2s\nduration 4s\nslice\n"
                     "scalar 2\nspeed\n";
  CHECK(compile_text(text, &fake, &program, &typed, &cache, &command, &error));
  CHECK(command.stage_count == 3);
  CHECK(stage_contains(&command.stages[1], "-ss"));
  CHECK(stage_contains(&command.stages[1], "-t"));
  CHECK(stage_contains(&command.stages[2], "-itsscale"));
  CHECK(contains(command.shell_command, "--internal-subtitle-clip"));
  destroy_compilation(&program, &typed, &cache, &command);

  CHECK(compile_text("subtitles \"a.srt\"\nsubtitles \"b.srt\"\nconcat\n", &fake,
                     &program, &typed, &cache, &command, &error));
  CHECK(command.stage_count == 3);
  CHECK(stage_contains(&command.stages[2], "concat"));
  CHECK(contains(command.shell_command, "--internal-subtitle-concat-list"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(atempo_chain) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text("audio \"a.wav\"\nscalar 8\nspeed\n", &fake, &program, &typed, &cache,
                     &command, &error));
  CHECK(contains(command.filter_complex, "atempo=2,atempo=2,atempo=2"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(arithmetic_comparison_and_logic) {
  Program program;
  TypedProgram typed;
  Error error;
  const char* valid = "video \"a.mov\"\nscalar 2\nscalar 3\nadd\nscalar 5\neq\ntrue\nand\n"
                      "if\nduration 1s\ntake\nelse\nduration 2s\ntake\nend\n";
  CHECK(parse_and_check(valid, &program, &typed, &error));
  typed_program_destroy(&typed);
  program_destroy(&program);

  CHECK(parse_program("video \"a.mov\"\nduration 1s\nscalar 2\nadd\n", &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "matching SCALAR or DURATION"));
  program_destroy(&program);

  CHECK(parse_program("video \"a.mov\"\nscalar 1\nscalar 0\ndiv\ndrop\n", &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "division by zero"));
  program_destroy(&program);
}

TEST(conditional_compilation) {
  const char* text = "video \"a.mov\"\nscalar 3\nscalar 2\ngt\nif\n"
                     "duration 1s\ntake\nelse\nduration 9s\ntake\nend\n";
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text(text, &fake, &program, &typed, &cache, &command, &error));
  CHECK(contains(command.filter_complex, "trim=duration=1"));
  CHECK(!contains(command.filter_complex, "trim=duration=9"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(conditional_type_errors) {
  Program program;
  TypedProgram typed;
  Error error;
  const char* mismatch = "video \"a.mov\"\ntrue\nif\nscalar 1\nelse\nduration 1s\nend\n";
  CHECK(parse_program(mismatch, &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "identical stack types"));
  program_destroy(&program);

  const char* invalid_unselected = "video \"a.mov\"\ntrue\nif\n"
                                   "duration 1s\ntake\nelse\nscalar 1\ntake\nend\n";
  CHECK(parse_program(invalid_unselected, &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "expected DURATION"));
  program_destroy(&program);
}

TEST(loop_unrolling) {
  const char* text = "video \"a.mov\"\nscalar 0\nbegin\ndup\nscalar 3\nlt\nwhile\n"
                     "swap\nduration 1s\ntake\nswap\nscalar 1\nadd\nrepeat\ndrop\n";
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text(text, &fake, &program, &typed, &cache, &command, &error));
  strview graph = sv_from_cstr(command.filter_complex);
  size_t take_count = 0;
  while (sv_contains(graph, svlit("trim=duration=1"))) {
    bool found = false;
    size_t offset = sv_find(graph, svlit("trim=duration=1"), &found);
    CHECK(found);
    sv_chop_left(&graph, offset + svlit("trim=duration=1").len);
    take_count++;
  }
  CHECK(take_count == 3);
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(nested_control_flow) {
  const char* text = "video \"a.mov\"\nscalar 0\nbegin\ndup\nscalar 2\nlt\nwhile\n"
                     "dup\nscalar 1\neq\nif\nswap\nduration 1s\ntake\nswap\nelse\n"
                     "end\nscalar 1\nadd\nrepeat\ndrop\n";
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(compile_text(text, &fake, &program, &typed, &cache, &command, &error));
  CHECK(contains(command.filter_complex, "trim=duration=1"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(loop_type_errors) {
  Program program;
  TypedProgram typed;
  Error error;
  const char* bad_condition = "video \"a.mov\"\nbegin\nscalar 1\nwhile\nrepeat\n";
  CHECK(parse_program(bad_condition, &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "S BOOL"));
  program_destroy(&program);

  const char* bad_body = "video \"a.mov\"\nbegin\ntrue\nwhile\nscalar 1\nrepeat\n";
  CHECK(parse_program(bad_body, &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "preserve the loop stack"));
  program_destroy(&program);

  CHECK(parse_program("video \"a.mov\"\nbegin\ntrue\n", &program, &error));
  CHECK(!typecheck_program(&program, &typed, &error));
  CHECK(contains(error.message, "matching repeat"));
  program_destroy(&program);
}

TEST(loop_iteration_limit) {
  const char* text = "video \"a.mov\"\nbegin\ntrue\nwhile\nrepeat\n";
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program;
  TypedProgram typed;
  ProbeCache cache;
  CompiledCommand command;
  Error error;
  CHECK(parse_and_check(text, &program, &typed, &error));
  probe_cache_init(&cache, fake_probe, &fake);
  CHECK(!compile_program(&program, &typed, &cache, "out.mp4", &command, &error));
  CHECK(contains(error.message, "10000 iterations"));
  probe_cache_destroy(&cache);
  typed_program_destroy(&typed);
  program_destroy(&program);
}

TEST(metadata_chapters_and_attachments) {
  FakeProbe fake = {
    .streams = {
      {.type = TYPE_VIDEO, .stream_index = 0, .duration_known = true, .duration = 10},
      {.type = TYPE_AUDIO, .stream_index = 1, .duration_known = true, .duration = 10,
       .language = "jpn", .title = "Japanese", .dispositions = DISPOSITION_DEFAULT},
    },
    .stream_count = 2,
    .chapters = {{0, 2, "Opening"}}, .chapter_count = 1,
    .tags = {{"title", "Episode 01"}}, .tag_count = 1,
  };
  Program program; TypedProgram typed; ProbeCache cache; CompiledCommand command; Error error;
  const char* tracks = "combined \"episode.mkv\"\nindex 0\ntake-audio\n"
                       "string \"eng\"\nset-language\nstring \"Dub\"\nset-title\n"
                       "false\nset-default\nadd-audio\n";
  CHECK(compile_text_to(tracks, "out.mkv", &fake, &program, &typed, &cache, &command, &error));
  CHECK(contains(command.shell_command, "language=eng"));
  CHECK(contains(command.shell_command, "title=Dub"));
  CHECK(contains(command.shell_command, "-disposition:a:0 0"));
  CHECK(contains(command.shell_command, "--internal-write-file"));
  CHECK(contains(command.shell_command, "title=Opening"));
  CHECK(contains(command.shell_command, "title=Episode 01"));
  destroy_compilation(&program, &typed, &cache, &command);

  const char* side_data = "combined \"episode.mkv\"\n"
                          "duration 3s\nduration 5s\nstring \"Part B\"\nchapter\nadd-chapter\n"
                          "string \"title\"\nstring \"Retitled\"\nset-tag\n"
                          "attachment \"font.ttf\"\nadd-attachment\n";
  CHECK(compile_text_to(side_data, "out.mkv", &fake, &program, &typed, &cache, &command, &error));
  CHECK(contains(command.shell_command, "title=Part B"));
  CHECK(contains(command.shell_command, "title=Retitled"));
  CHECK(contains(command.shell_command, "-attach font.ttf"));
  CHECK(contains(command.shell_command, "mimetype=application/x-truetype-font"));
  destroy_compilation(&program, &typed, &cache, &command);
}

TEST(subtitle_codec_preservation) {
  FakeProbe ass = {
    .streams = {
      {.type = TYPE_VIDEO, .stream_index = 0, .duration_known = true, .duration = 10},
      {.type = TYPE_SUBTITLES, .stream_index = 1, .duration_known = true, .duration = 10,
       .codec_name = "ass", .subtitle_kind = SUBTITLE_ASS, .language = "eng",
       .dispositions = DISPOSITION_FORCED},
    }, .stream_count = 2,
  };
  Program program; TypedProgram typed; ProbeCache cache; CompiledCommand command; Error error;
  CHECK(compile_text_to("combined \"episode.mkv\"\n", "out.mkv", &ass, &program, &typed,
                        &cache, &command, &error));
  CHECK(command.stage_count == 0);
  CHECK(contains(command.shell_command, "-map 0:1"));
  CHECK(contains(command.shell_command, "-c:s:0 copy"));
  CHECK(contains(command.shell_command, "-disposition:s:0 forced"));
  destroy_compilation(&program, &typed, &cache, &command);

  CHECK(compile_text_to("subtitles \"episode.mkv\"\nduration 2s\ntake\n", "out.mks",
                        &ass, &program, &typed, &cache, &command, &error));
  CHECK(command.stage_count == 2);
  CHECK(stage_contains(&command.stages[1], "ass"));
  CHECK(command.stages[1].clip_subtitles);
  CHECK(contains(command.shell_command, "-c:s:0 ass"));
  destroy_compilation(&program, &typed, &cache, &command);

  CHECK(!compile_text_to("combined \"episode.mkv\"\n", "out.mp4", &ass, &program, &typed,
                         &cache, &command, &error));
  CHECK(contains(error.message, "ASS/SSA"));
  probe_cache_destroy(&cache); typed_program_destroy(&typed); program_destroy(&program);

  FakeProbe bitmap = {
    .streams = {{.type = TYPE_SUBTITLES, .stream_index = 0, .duration_known = true,
                 .duration = 10, .codec_name = "hdmv_pgs_subtitle",
                 .subtitle_kind = SUBTITLE_BITMAP}}, .stream_count = 1,
  };
  CHECK(compile_text_to("subtitles \"signs.sup\"\nduration 2s\ntake\n", "out.mks",
                        &bitmap, &program, &typed, &cache, &command, &error));
  CHECK(stage_contains(&command.stages[0], "copy"));
  CHECK(stage_contains(&command.stages[1], "copy"));
  CHECK(!command.stages[1].clip_subtitles);
  destroy_compilation(&program, &typed, &cache, &command);

  CHECK(!compile_text_to("subtitles \"signs.sup\"\n", "out.mp4", &bitmap, &program,
                         &typed, &cache, &command, &error));
  CHECK(contains(error.message, "bitmap subtitles"));
  probe_cache_destroy(&cache); typed_program_destroy(&typed); program_destroy(&program);
}

TEST(custom_final_ffmpeg_arguments) {
  FakeProbe fake = {.video_duration = 10, .audio_duration = 10};
  Program program; TypedProgram typed; ProbeCache cache; CompiledCommand command; Error error;
  const char* arguments[] = {
    "-c:v", "libx265", "-crf", "18", "-preset", "slow", "-c:a", "libopus",
  };
  CHECK(parse_and_check("combined \"episode.mp4\"\n", &program, &typed, &error));
  probe_cache_init(&cache, fake_probe, &fake);
  CHECK(compile_program_with_ffmpeg_args(&program, &typed, &cache, "out.mkv",
        sizeof(arguments) / sizeof(arguments[0]), arguments, &command, &error));
  CHECK(contains(command.shell_command, "-c:v libx265 -crf 18 -preset slow -c:a libopus"));
  CHECK(!contains(command.shell_command, "libx264"));
  CHECK(!contains(command.shell_command, "-c:a aac"));
  destroy_compilation(&program, &typed, &cache, &command);
}

int main(void) {
  RUN(arena_alignment_and_reset);
  RUN(parser_comments_and_paths);
  RUN(positional_script_arguments);
  RUN(duration_parsing);
  RUN(scalar_parsing);
  RUN(parser_errors);
  RUN(type_errors);
  RUN(valid_types);
  RUN(speed_validation);
  RUN(probe_cache);
  RUN(duration_and_fade_propagation);
  RUN(length_and_silence);
  RUN(video_duplication);
  RUN(audio_duplication);
  RUN(no_unnecessary_split_and_mapping);
  RUN(dropped_graph_is_unreachable);
  RUN(subtitle_mapping);
  RUN(burn_subtitles_and_video_conversion);
  RUN(bitmap_subtitle_burn_rejected);
  RUN(multitrack_combined_mapping);
  RUN(indexed_combined_operations);
  RUN(subtitle_timeline_operations);
  RUN(atempo_chain);
  RUN(arithmetic_comparison_and_logic);
  RUN(conditional_compilation);
  RUN(conditional_type_errors);
  RUN(loop_unrolling);
  RUN(nested_control_flow);
  RUN(loop_type_errors);
  RUN(loop_iteration_limit);
  RUN(metadata_chapters_and_attachments);
  RUN(subtitle_codec_preservation);
  RUN(custom_final_ffmpeg_arguments);
  if (failures != 0) {
    fprintf(stderr, "%d checks failed across %d tests\n", failures, tests_run);
    return 1;
  }
  printf("all %d tests passed\n", tests_run);
  return 0;
}