# frozen_string_literal: true

class AssetProcessor
  BASE_COMPILER_VERSION = 107

  PROCESSOR_DIR = "tmp/asset-processor"
  LOCK_FILE = "#{PROCESSOR_DIR}/build.lock"

  CACHE_DEPENDENCY_GLOBS = %w[
    node_modules/.pnpm/lock.yaml
    frontend/asset-processor/**/*.js
    frontend/discourse/lib/babel-transform-module-renames.js
    frontend/discourse/config/targets.js
    frontend/discourse-plugins/transform-action-syntax.js
  ]

  @mutex = Mutex.new
  @ctx_init = Mutex.new

  class TranspileError < StandardError
  end

  class TimeoutError < StandardError
  end

  def self.booted?
    !!@ctx
  end

  def self.append_es6_deprecation(content, file_path)
    pseudo_random_identifier = "deprecated_gjYVqPLMxe" # Just needs to be unique enough to avoid collisions with real code
    <<~JS
      #{content}
      import #{pseudo_random_identifier} from "discourse/lib/deprecated";
      #{pseudo_random_identifier}(
        "The file '#{file_path}' uses the deprecated `.js.es6` extension. Use `.js` instead.",
        {
          id: "discourse.es6-extension",
          url: "https://meta.discourse.org/t/398894",
        }
      );
    JS
  end

  def self.transpile(data, root_path, logical_path, theme_id: nil, extension: nil)
    processor = new(skip_module: skip_module?(data))
    processor.perform(data, root_path, logical_path, theme_id: theme_id, extension: extension)
  end

  def self.skip_module?(data)
    !!(data.present? && data =~ %r{^// discourse-skip-module$})
  end

  def self.mutex
    @mutex
  end

  def self.build_asset_processor
    Discourse::Utils.execute_command("pnpm", "-C=frontend/asset-processor", "node", "build.js")
  end

  def self.inputs_digest
    digest = Digest::MD5.new

    CACHE_DEPENDENCY_GLOBS.each do |pattern|
      files = Dir.glob(pattern).sort
      raise "No files matched #{pattern}" if files.empty?

      files.each do |file|
        digest.update(file)
        digest.update(File.read(file))
      end
    end

    digest.hexdigest.to_i(16).to_s(36) # base36
  end

  def self.processor_file_path
    "#{PROCESSOR_DIR}/asset-processor-#{inputs_digest}.js"
  end

  def self.with_file_lock(&block)
    lock_path = "#{Rails.root}/#{LOCK_FILE}"
    FileUtils.mkdir_p(File.dirname(lock_path))
    File.open(lock_path, File::CREAT | File::RDWR) do |lock_file|
      lock_file.flock(File::LOCK_EX)
      yield
    end
  end

  def self.cleanup_old_cache_files
    Dir
      .glob("#{PROCESSOR_DIR}/asset-processor-*.js")
      .reject { it.end_with?(processor_file_path) }
      .each { File.delete(it) }
  end

  def self.load_or_build_processor_source
    cache_path = processor_file_path

    if File.exist?(cache_path)
      File.read(cache_path)
    else
      with_file_lock do
        if File.exist?(cache_path)
          File.read(cache_path)
        else
          built_source = build_asset_processor
          FileUtils.mkdir_p(PROCESSOR_DIR)
          File.write(cache_path, built_source)
          cleanup_old_cache_files
          built_source
        end
      end
    end
  end

  def self.timeout
    @timeout ||= 15_000
  end

  def self.timeout=(value)
    @timeout = value
    reset_context
  end

  def self.create_new_context
    # timeout any eval that takes longer than 15 seconds
    ctx = MiniRacer::Context.new(timeout: timeout, ensure_gc_after_idle: 2000)

    # General shims
    ctx.attach(
      "rails.logger.info",
      proc do |err|
        Rails.logger.info(err.to_s)
        nil
      end,
    )
    ctx.attach(
      "rails.logger.warn",
      proc do |err|
        Rails.logger.warn(err.to_s)
        nil
      end,
    )
    ctx.attach(
      "rails.logger.error",
      proc do |err|
        Rails.logger.error(err.to_s)
        nil
      end,
    )

    source = load_or_build_processor_source

    ctx.eval("globalThis.ROLLUP_PLUGIN_COMPILER = #{ENV["ROLLUP_PLUGIN_COMPILER"].to_json}")
    ctx.eval(source, filename: "asset-processor.js")
    ctx.low_memory_notification # GC to free up memory used during init

    ctx
  end

  def self.reset_context
    @ctx&.dispose
    @ctx = nil
  end

  def self.v8
    return @ctx if @ctx

    # ensure we only init one of these
    @ctx_init.synchronize do
      return @ctx if @ctx
      @ctx = create_new_context
    end

    @ctx
  end

  # Call a method in the global scope of the v8 context.
  # The `fetch_result_call` kwarg provides a workaround for the lack of mini_racer async
  # result support. The first call can perform some async operation, and then `fetch_result_call`
  # will be called to fetch the result.
  def self.v8_call(*args, **kwargs)
    fetch_result_call = kwargs.delete(:fetch_result_call)
    mutex.synchronize do
      result = v8.call(*args, **kwargs)
      result = v8.call(fetch_result_call) if fetch_result_call
      v8.low_memory_notification if GlobalSetting.mini_racer_single_threaded
      result
    end
  rescue MiniRacer::ScriptTerminatedError => e
    timeout_error = TimeoutError.new("Script terminated: timeout after #{timeout / 1000}s")
    timeout_error.set_backtrace(e.backtrace)
    raise timeout_error
  rescue MiniRacer::RuntimeError => e
    message = e.message
    begin
      # Workaround for https://github.com/rubyjs/mini_racer/issues/262
      possible_encoded_message = message.delete_prefix("Error: ")
      decoded = JSON.parse("{\"value\": #{possible_encoded_message}}")["value"]
      message = "Error: #{decoded}"
    rescue JSON::ParserError
      message = e.message
    end
    transpile_error = TranspileError.new(message)
    transpile_error.set_backtrace(e.backtrace)
    raise transpile_error
  end

  def self.ember_version
    v8_call("emberVersion")
  end

  def initialize(skip_module: false)
    @skip_module = skip_module
  end

  def perform(
    source,
    root_path = nil,
    logical_path = nil,
    theme_id: nil,
    extension: nil,
    generate_map: false
  )
    self.class.v8_call(
      "transpile",
      source,
      {
        skipModule: @skip_module,
        moduleId: module_name(root_path, logical_path),
        filename: logical_path || "unknown",
        extension: extension,
        themeId: theme_id,
        generateMap: generate_map,
      },
    )
  end

  def module_name(root_path, logical_path)
    path = nil

    root_base = File.basename(Rails.root)
    # If the resource is a plugin, use the plugin name as a prefix
    if root_path =~ %r{(.*/#{root_base}/plugins/[^/]+)/}
      plugin_path = "#{Regexp.last_match[1]}/plugin.rb"

      plugin = Discourse.plugins.find { |p| p.path == plugin_path }
      path = "discourse/plugins/#{plugin.name}/#{logical_path.sub(%r{javascripts/}, "")}" if plugin
    end

    # We need to strip the app subdirectory to replicate how ember-cli works.
    path || logical_path&.gsub("app/", "")&.gsub("addon/", "")&.gsub("admin/addon", "admin")
  end

  def compile_raw_template(source, theme_id: nil)
    self.class.v8_call("compileRawTemplate", source, theme_id)
  end

  def terser(tree, opts)
    self.class.v8_call("minify", tree, opts, fetch_result_call: "getMinifyResult")
  end

  def rollup(tree, opts)
    self.class.v8_call("rollup", tree, opts, fetch_result_call: "getRollupResult")
  end

  def post_css(css:, map:, source_map_file:)
    self.class.v8_call("postCss", css, map, source_map_file, fetch_result_call: "getPostCssResult")
  end
end
