# frozen_string_literal: true

describe ThemeSettingsMigrationsRunner do
  fab!(:theme)
  fab!(:migration_field) { Fabricate(:migration_theme_field, version: 1, theme: theme) }
  fab!(:settings_field) { Fabricate(:settings_theme_field, theme: theme, value: <<~YAML) }
      integer_setting: 1
      boolean_setting: true
      string_setting: ""
    YAML

  describe "#run" do
    it "passes values of overridden settings only to migrations" do
      theme.update_setting(:integer_setting, 1)
      theme.update_setting(:string_setting, "osama")
      theme.save!

      migration_field.update!(value: <<~JS)
        export default function migrate(settings) {
          if (settings.get("integer_setting") !== 1) {
            throw new Error(`expected integer_setting to equal 1, but it's actually ${settings.get("integer_setting")}`);
          }
          if (settings.get("string_setting") !== "osama") {
            throw new Error(`expected string_setting to equal "osama", but it's actually "${settings.get("string_setting")}"`);
          }
          if (settings.size !== 2) {
            throw new Error(`expected the settings map to have only 2 keys, but instead got ${settings.size} keys`);
          }
          return settings;
        }
      JS
      results = described_class.new(theme).run
      expect(results.first[:theme_field_id]).to eq(migration_field.id)
      expect(results.first[:settings_before]).to eq(
        { "integer_setting" => 1, "string_setting" => "osama" },
      )
    end

    it "passes values of `objects` typed settings to migrations and the values are parsed (not json string)" do
      settings_field.update!(value: <<~YAML)
        objects_setting:
          type: objects
          default:
            - text: "hello, default link"
              url: "https://google.com"
            - text: "hi, another default link"
              url: "https://discourse.org"
          schema:
            name: link
            properties:
              text:
                type: string
              url:
                type: string
      YAML
      theme.update_setting(
        :objects_setting,
        [{ text: "custom link 1", url: "https://meta.discourse.org" }],
      )
      theme.save!

      migration_field.update!(value: <<~JS)
        export default function migrate(settings) {
          settings.get("objects_setting").push(
            {
              text: "another custom link",
              url: "https://try.discourse.org"
            }
          )
          return settings;
        }
      JS

      results = described_class.new(theme).run

      expect(results.first[:settings_before]).to eq(
        {
          "objects_setting" => [
            { "url" => "https://meta.discourse.org", "text" => "custom link 1" },
          ],
        },
      )
      expect(results.first[:settings_after]).to eq(
        {
          "objects_setting" => [
            { "url" => "https://meta.discourse.org", "text" => "custom link 1" },
            { "url" => "https://try.discourse.org", "text" => "another custom link" },
          ],
        },
      )
    end

    it "passes the output of the previous migration as input to the next one" do
      theme.update_setting(:integer_setting, 1)

      migration_field.update!(value: <<~JS)
        export default function migrate(settings) {
          settings.set("integer_setting", 111);
          return settings;
        }
      JS

      another_migration_field =
        Fabricate(:migration_theme_field, theme: theme, version: 2, value: <<~JS)
        export default function migrate(settings) {
          if (settings.get("integer_setting") !== 111) {
            throw new Error(`expected integer_setting to equal 111, but it's actually ${settings.get("integer_setting")}`);
          }
          settings.set("integer_setting", 222);
          return settings;
        }
      JS

      results = described_class.new(theme).run

      expect(results.size).to eq(2)

      expect(results[0][:theme_field_id]).to eq(migration_field.id)
      expect(results[1][:theme_field_id]).to eq(another_migration_field.id)

      expect(results[0][:settings_before]).to eq({})
      expect(results[0][:settings_after]).to eq({ "integer_setting" => 111 })

      expect(results[1][:settings_before]).to eq({ "integer_setting" => 111 })
      expect(results[1][:settings_after]).to eq({ "integer_setting" => 222 })
    end

    it "doesn't run migrations that have already been ran" do
      Fabricate(:theme_settings_migration, theme: theme, theme_field: migration_field)

      pending_field = Fabricate(:migration_theme_field, theme: theme, version: 23)

      results = described_class.new(theme).run

      expect(results.size).to eq(1)

      expect(results.first[:version]).to eq(23)
      expect(results.first[:theme_field_id]).to eq(pending_field.id)
    end

    it "doesn't error when no migrations have been ran yet" do
      results = described_class.new(theme).run

      expect(results.size).to eq(1)
      expect(results.first[:version]).to eq(1)
      expect(results.first[:theme_field_id]).to eq(migration_field.id)
    end

    it "doesn't error when there are no pending migrations" do
      Fabricate(:theme_settings_migration, theme: theme, theme_field: migration_field)

      results = described_class.new(theme).run

      expect(results.size).to eq(0)
    end

    it "raises an error when there are too many pending migrations" do
      Fabricate(:migration_theme_field, theme: theme, version: 2)

      expect do described_class.new(theme, limit: 1).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t("themes.import_error.migrations.too_many_pending_migrations"),
      )
    end

    it "raises an error if a migration field has a badly formatted name" do
      migration_field.update_attribute(:name, "020-some-name")

      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t("themes.import_error.migrations.invalid_filename", filename: "020-some-name"),
      )

      migration_field.update_attribute(:name, "0020some-name")

      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t("themes.import_error.migrations.invalid_filename", filename: "0020some-name"),
      )

      migration_field.update_attribute(:name, "0020")

      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t("themes.import_error.migrations.invalid_filename", filename: "0020"),
      )
    end

    it "raises an error if a pending migration has version lower than the last ran migration" do
      migration_field.update!(name: "0020-some-name")
      Fabricate(:theme_settings_migration, theme: theme, theme_field: migration_field, version: 20)

      Fabricate(:migration_theme_field, theme: theme, version: 19, name: "0019-failing-migration")

      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t(
          "themes.import_error.migrations.out_of_sequence",
          name: "0019-failing-migration",
          current: 20,
        ),
      )
    end

    it "detects bad syntax in migrations and raises an error" do
      migration_field.update!(value: <<~JS)
        export default function migrate() {
      JS
      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t(
          "themes.import_error.migrations.syntax_error",
          name: "0001-some-name",
          error:
            'SyntaxError: "/discourse/theme/migration: Unexpected token (2:0)\n\n  1 | export default function migrate() {\n> 2 |\n    | ^"',
        ),
      )
    end

    it "imposes memory limit on migrations and raises an error if they exceed the limit" do
      migration_field.update!(value: <<~JS)
        export default function migrate(settings) {
          let a = new Array(10000);
          while(true) {
            a = a.concat(new Array(10000));
          }
          return settings;
        }
      JS

      expect do described_class.new(theme, memory: 10.kilobytes).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t("themes.import_error.migrations.exceeded_memory_limit", name: "0001-some-name"),
      )
    end

    it "imposes time limit on migrations and raises an error if they exceed the limit" do
      migration_field.update!(value: <<~JS)
        export default function migrate(settings) {
          let a = 1;
          while(true) {
            a += 1;
          }
          return settings;
        }
      JS

      expect do described_class.new(theme, timeout: 10).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t("themes.import_error.migrations.timed_out", name: "0001-some-name"),
      )
    end

    it "raises a clear error message when the migration file doesn't export anything" do
      migration_field.update!(value: <<~JS)
        function migrate(settings) {
          return settings;
        }
      JS

      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t("themes.import_error.migrations.no_exported_function", name: "0001-some-name"),
      )
    end

    it "raises a clear error message when the migration file exports the default as something that's not a function" do
      migration_field.update!(value: <<~JS)
        export function migrate(settings) {
          return settings;
        }

        const AA = 1;
        export default AA;
      JS

      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t(
          "themes.import_error.migrations.default_export_not_a_function",
          name: "0001-some-name",
        ),
      )
    end

    it "raises a clear error message when the migration function doesn't return anything" do
      migration_field.update!(value: <<~JS)
        export default function migrate(settings) {}
      JS

      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t("themes.import_error.migrations.no_returned_value", name: "0001-some-name"),
      )
    end

    it "raises a clear error message when the migration function doesn't return a Map" do
      migration_field.update!(value: <<~JS)
        export default function migrate(settings) {
          return {};
        }
      JS

      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t("themes.import_error.migrations.wrong_return_type", name: "0001-some-name"),
      )
    end

    it "surfaces runtime errors that occur within the migration" do
      migration_field.update!(value: <<~JS)
        export default function migrate(settings) {
          null.toString();
          return settings;
        }
      JS

      expect do described_class.new(theme).run end.to raise_error(
        Theme::SettingsMigrationError,
        I18n.t(
          "themes.import_error.migrations.runtime_error",
          name: "0001-some-name",
          error: "TypeError: Cannot read properties of null (reading 'toString')",
        ),
      )
    end

    it "returns a list of objects that each has data representing the migration and the results" do
      results = described_class.new(theme).run

      expect(results[0][:version]).to eq(1)
      expect(results[0][:name]).to eq("some-name")
      expect(results[0][:original_name]).to eq("0001-some-name")
      expect(results[0][:theme_field_id]).to eq(migration_field.id)
      expect(results[0][:settings_before]).to eq({})
      expect(results[0][:settings_after]).to eq({})
    end

    it "attaches the isValidUrl() function to the context of the migrations" do
      theme.update_setting(:string_setting, "https://google.com")
      theme.save!

      migration_field.update!(value: <<~JS)
        export default function migrate(settings, helpers) {
          if (!helpers.isValidUrl("some_invalid_string")) {
            settings.set("string_setting", "is_not_valid_string");
          }

          return settings;
        }
      JS

      results = described_class.new(theme).run

      expect(results[0][:settings_after]).to eq({ "string_setting" => "is_not_valid_string" })
    end

    it "attaches the getCategoryIdByName() function to the context of the migrations" do
      category = Fabricate(:category, name: "Some Category Name")

      theme.update_setting(:integer_setting, -10)
      theme.save!

      migration_field.update!(value: <<~JS)
        export default function migrate(settings, helpers) {
          const categoryId = helpers.getCategoryIdByName("some CatEgory Name");
          settings.set("integer_setting", categoryId);
          return settings;
        }
      JS

      results = described_class.new(theme).run

      expect(results[0][:settings_after]).to eq({ "integer_setting" => category.id })
    end
  end
end