# frozen_string_literal: true

class ThemeSettingsMigrationsRunner
  # Methods defined in this module will be made available in the JS context where the theme settings migrations are
  # executed.
  #
  # Defining a method `get_category_id_by_name` will result in the `getCategoryIdByName` function being made available
  # in the JS context that migrations are ran in.
  module Helpers
    extend self

    # @param [String] Name of the category to retrieve the id of.
    # @return [Integer|nil] The id of the category with the given name or nil if a category does not exist for the given
    #   name.
    def get_category_id_by_name(category_name)
      Category.where("name_lower = LOWER(?)", category_name).pick(:id)
    end

    # @param [String] URL string to check if it is a valid absolute URL, path or anchor.
    # @return [Boolean] True if the URL is a valid URL or path, false otherwise.
    def is_valid_url(url)
      UrlHelper.is_valid_url?(url)
    end
  end

  Migration = Struct.new(:version, :name, :original_name, :code, :theme_field_id)

  MIGRATION_ENTRY_POINT_JS = <<~JS
    const migrate = require("discourse/theme/migration")?.default;
    const helpers = require("discourse/theme/migration-helpers")?.default;

    function main(settingsObj) {
      if (!migrate) {
        throw new Error("no_exported_migration_function");
      }

      if (typeof migrate !== "function") {
        throw new Error("default_export_is_not_a_function");
      }

      const map = new Map(Object.entries(settingsObj));
      const updatedMap = migrate(map, helpers);

      if (!updatedMap) {
        throw new Error("migration_function_no_returned_value");
      }

      if (!(updatedMap instanceof Map)) {
        throw new Error("migration_function_wrong_return_type");
      }

      return Object.fromEntries(updatedMap.entries());
    }
  JS

  private_constant :MIGRATION_ENTRY_POINT_JS

  def self.loader_js_lib_content
    @loader_js_lib_content ||=
      File.read(File.join(Rails.root, "node_modules/loader.js/dist/loader/loader.js"))
  end

  def initialize(theme, limit: 100, timeout: 100, memory: 2.megabytes)
    @theme = theme
    @limit = limit
    @timeout = timeout
    @memory = memory
  end

  def run(fields: nil, raise_error_on_out_of_sequence: true)
    fields ||= lookup_pending_migrations_fields

    count = fields.count
    return [] if count == 0

    raise_error("themes.import_error.migrations.too_many_pending_migrations") if count > @limit

    migrations = convert_fields_to_migrations(fields)
    migrations.sort_by!(&:version)

    current_migration_version =
      @theme.theme_settings_migrations.order(version: :desc).pick(:version)

    current_migration_version ||= -Float::INFINITY

    current_settings = lookup_overriden_settings

    migrations.map do |migration|
      if migration.version <= current_migration_version && raise_error_on_out_of_sequence
        raise_error(
          "themes.import_error.migrations.out_of_sequence",
          name: migration.original_name,
          current: current_migration_version,
        )
      end

      migrated_settings = execute(migration, current_settings)

      results = {
        version: migration.version,
        name: migration.name,
        original_name: migration.original_name,
        theme_field_id: migration.theme_field_id,
        settings_before: current_settings,
        settings_after: migrated_settings,
      }
      current_settings = migrated_settings
      current_migration_version = migration.version
      results
    rescue DiscourseJsProcessor::TranspileError => error
      raise_error(
        "themes.import_error.migrations.syntax_error",
        name: migration.original_name,
        error: error.message,
      )
    rescue MiniRacer::V8OutOfMemoryError
      raise_error(
        "themes.import_error.migrations.exceeded_memory_limit",
        name: migration.original_name,
      )
    rescue MiniRacer::ScriptTerminatedError
      raise_error("themes.import_error.migrations.timed_out", name: migration.original_name)
    rescue MiniRacer::RuntimeError => error
      message = error.message
      if message.include?("no_exported_migration_function")
        raise_error(
          "themes.import_error.migrations.no_exported_function",
          name: migration.original_name,
        )
      elsif message.include?("default_export_is_not_a_function")
        raise_error(
          "themes.import_error.migrations.default_export_not_a_function",
          name: migration.original_name,
        )
      elsif message.include?("migration_function_no_returned_value")
        raise_error(
          "themes.import_error.migrations.no_returned_value",
          name: migration.original_name,
        )
      elsif message.include?("migration_function_wrong_return_type")
        raise_error(
          "themes.import_error.migrations.wrong_return_type",
          name: migration.original_name,
        )
      else
        raise_error(
          "themes.import_error.migrations.runtime_error",
          name: migration.original_name,
          error: message,
        )
      end
    end
  end

  private

  def lookup_pending_migrations_fields
    @theme
      .migration_fields
      .left_joins(:theme_settings_migration)
      .where(theme_settings_migration: { id: nil })
      .order(created_at: :asc)
  end

  def convert_fields_to_migrations(fields)
    fields.map do |field|
      match_data = /\A(?<version>\d{4})-(?<name>.+)/.match(field.name)

      if !match_data
        raise_error("themes.import_error.migrations.invalid_filename", filename: field.name)
      end

      version = match_data[:version].to_i
      name = match_data[:name]
      original_name = field.name

      Migration.new(
        version: version,
        name: name,
        original_name: original_name,
        code: field.value,
        theme_field_id: field.id,
      )
    end
  end

  def lookup_overriden_settings
    hash = {}
    @theme.theme_settings.each { |row| hash[row.name] = ThemeSettingsManager.cast_row_value(row) }
    hash
  end

  def execute(migration, settings)
    context = MiniRacer::Context.new(timeout: @timeout, max_memory: @memory)

    context.eval(self.class.loader_js_lib_content, filename: "loader.js")

    context.eval(
      DiscourseJsProcessor.transpile(migration.code, "", "discourse/theme/migration"),
      filename: "theme-#{@theme.id}-migration.js",
    )

    Helpers.instance_methods.each do |method_name|
      context.attach("__helpers.#{method_name.to_s.camelize(:lower)}", Helpers.method(method_name))
    end

    context.eval(
      DiscourseJsProcessor.transpile(
        "export default __helpers",
        "",
        "discourse/theme/migration-helpers",
      ),
      filename: "theme-#{@theme.id}-migration-helpers.js",
    )

    context.eval(MIGRATION_ENTRY_POINT_JS, filename: "migration-entrypoint.js")

    context.call("main", settings)
  ensure
    context&.dispose
  end

  def raise_error(message_key, **i18n_args)
    raise Theme::SettingsMigrationError.new(I18n.t(message_key, **i18n_args))
  end
end