# frozen_string_literal: true

module Plugin
  class JsManager
    class Cache < ActiveSupport::CurrentAttributes
      # Cache which persists for the duration of a request
      attribute :request_cache
    end

    @url_glob_patterns = {}

    # A plugin route map can mount on a core route, so the build needs these to derive its urls.
    CORE_ROUTE_MAPS = {
      "app-route-map.js" => "frontend/discourse/app/routes/app-route-map.js",
      "admin-route-map.js" => "frontend/discourse/admin/routes/admin-route-map.js",
    }.freeze

    def self.optional_plugin_stub
      "data:text/javascript,/* autogenerated missing optional plugin stub */const m=new Proxy({},{get:()=>null});export default new Proxy({},{get:()=>m});"
    end

    def self.required_plugin_stub(plugin_name)
      message =
        "Plugin '#{plugin_name}' is imported by another bundle, but it is not installed on this site. " \
          "If this dependency is optional, import it with `with { discourseImport: \"optional\" }`."

      "data:text/javascript, /* autogenerated missing required plugin stub */ export default null; throw new Error(#{message.to_json});"
    end

    def self.js_asset_exists?(plugin_directory_name)
      get_set_cache("js_asset_exists_#{plugin_directory_name}") do
        has_source_files_in_dir(plugin_directory_name, "assets/javascripts")
      end
    end

    def self.admin_js_asset_exists?(plugin_directory_name)
      get_set_cache("admin_js_asset_exists_#{plugin_directory_name}") do
        has_source_files_in_dir(plugin_directory_name, "admin/assets/javascripts")
      end
    end

    def self.test_js_asset_exists?(plugin_directory_name)
      get_set_cache("test_js_asset_exists_#{plugin_directory_name}") do
        has_source_files_in_dir(plugin_directory_name, "test/javascripts")
      end
    end

    def self.read_manifest(plugin_directory_name)
      get_set_cache("manifest_#{plugin_directory_name}") do
        manifest_path =
          "#{Rails.root.join("app/assets/generated/#{plugin_directory_name}/manifest.json")}"
        JSON.parse(File.read(manifest_path))
      rescue Errno::ENOENT
        {}
      end
    end

    def self.logical_path(file_name)
      "js/plugins/#{file_name.delete_suffix(".js")}"
    end

    def self.digested_logical_path_for(plugin_directory_name, entrypoint_name)
      manifest_entry = read_manifest(plugin_directory_name)[entrypoint_name]
      logical_path(manifest_entry["fileName"]) if manifest_entry
    end

    def self.import_paths_for(plugin_directory_name, entrypoint_name)
      read_manifest(plugin_directory_name)[entrypoint_name]["imports"].map { logical_path(it) }
    end

    def self.external_plugin_imports(plugin_directory_name, entrypoint_name)
      read_manifest(plugin_directory_name)[entrypoint_name]["externalPluginImports"]
    end

    # The first match wins, so the build emits the most specific glob first.
    def self.route_bundle_for_path(plugin_directory_name, entrypoint_name, path)
      return if path.nil?

      bundles = read_manifest(plugin_directory_name).dig(entrypoint_name, "routeBundles")

      bundle = bundles.to_a.find { |c| path.match?(url_glob_pattern(c["url"])) }

      logical_path(bundle["fileName"]) if bundle
    end

    # Every url the build emits ends in `/*`, so a bundle covers its own route and everything
    # beneath it. Within the rest, `*` is one segment and `**` is one or more, standing in for a
    # splat route segment. A pattern only depends on its glob, so it never goes stale.
    def self.url_glob_pattern(glob)
      @url_glob_patterns[glob] ||= begin
        pattern =
          glob
            .delete_suffix("/*")
            .split("/")
            .map do |segment|
              case segment
              when "**"
                "[^/]+(?:/[^/]+)*"
              when "*"
                "[^/]+"
              else
                Regexp.escape(segment)
              end
            end
            .join("/")

        %r{\A#{pattern}(?:/.*)?\z}
      end
    end
    private_class_method :url_glob_pattern

    def compile!
      log "Compiling #{Discourse.plugins.count} plugins..."
      start = Time.now

      if !GlobalSetting.mini_racer_single_threaded && AssetProcessor.booted?
        raise "Cannot fork Plugin::JsManager for parallel compilation because AssetProcessor is already booted."
      end

      parallel_count = [Etc.nprocessors, 4].min
      AssetProcessor.timeout = 120_000

      Parallel.each(Discourse.plugins, in_processes: parallel_count) do |plugin|
        compile_js_bundle(plugin)
      end

      log "Finished initial compilation of plugins in #{(Time.now - start).round(2)}s"
    end

    def compile_js_bundle(plugin)
      base_output_dir = "#{Rails.root.join("app/assets/generated/#{plugin.directory_name}")}"
      js_dir = "#{base_output_dir}/js/plugins"
      map_dir = "#{base_output_dir}/map/plugins"

      entrypoints = { "main" => "assets/javascripts", "admin" => "admin/assets/javascripts" }
      entrypoints["test"] = "test/javascripts" if Rails.env.local?

      tree = {}
      entrypoints_config = {}

      entrypoints.each do |name, js_path|
        js_base = "#{plugin.directory}/#{js_path}"

        files = Dir.glob("**/*", base: js_base)

        next if files.empty?

        entrypoints_config[name] = { modules: [] }

        files.sort.each do |file|
          full_path = File.join(js_base, file)
          if File.file?(full_path)
            normalized_file_path = file.sub(/\.js\.es6$/, ".js")
            content = File.read(full_path)
            content = AssetProcessor.append_es6_deprecation(content, file) if file.end_with?(
              ".js.es6",
            )
            tree[normalized_file_path] = content
            if name == "test" && file.match(%r{/(acceptance|integration|unit)/})
              if file.match?(/-test\.g?[jt]s$/)
                entrypoints_config[name][:modules] << normalized_file_path
              end
            else
              entrypoints_config[name][:modules] << normalized_file_path
            end
          end
        end
      end

      frontend_config = plugin.about_json_metadata&.dig("frontend")

      # Only a plugin that splits its own routes derives anything from these. Read from source, so
      # plugin compilation does not wait on core's build. Being in `tree` puts them in the digest
      # below, so a core route change rebuilds those plugins and no others.
      if frontend_config&.dig("staticModules") && tree.keys.any? { it.end_with?("route-map.js") }
        CORE_ROUTE_MAPS.each do |name, source_path|
          tree["__core__/#{name}"] = File.read(Rails.root.join(source_path))
        end
      end

      hex_digest =
        Digest::SHA1.hexdigest(
          [
            *tree.keys,
            *tree.values,
            AssetProcessor::BASE_COMPILER_VERSION,
            AssetProcessor.ember_version,
            minify?.to_s,
            plugin.name,
            frontend_config.to_json,
          ].join,
        )
      base36_digest = hex_digest.to_i(16).to_s(36).first(8)

      filename_prefix = "#{plugin.directory_name}_"
      filename_suffix = "-#{base36_digest}.digested"

      existing_files = Dir.glob("#{js_dir}/*").map { |path| File.basename(path) }

      files_exist =
        entrypoints_config.keys.all? do |name|
          existing_files.any? do |file|
            file.start_with?("#{filename_prefix}#{name}.") &&
              file.end_with?("#{filename_suffix}.js")
          end
        end

      if !cache? || !files_exist
        compiler =
          Plugin::JsCompiler.new(
            plugin.name,
            minify: minify?,
            tree: tree,
            entrypoints: entrypoints_config,
            filename_prefix:,
            filename_suffix:,
            frontend_config:,
          )
        result = compiler.compile!

        FileUtils.mkdir_p(js_dir)
        FileUtils.mkdir_p(map_dir)

        manifest = {}
        result.each do |file_name, info|
          code = info["code"]
          code += "\n//# sourceMappingURL=../../map/plugins/#{file_name}.map\n" if info["map"]
          File.write("#{js_dir}/#{file_name}", code)

          File.write("#{map_dir}/#{file_name}.map", info["map"]) if info["map"]

          if info["isEntry"]
            manifest[info["name"]] = {
              fileName: file_name,
              imports: info["imports"],
              externalPluginImports: info["externalPluginImports"],
              routeBundles: info["routeBundles"],
            }
          end
        end

        File.write("#{base_output_dir}/manifest.json", JSON.pretty_generate(manifest))
      end

      # Delete any old versions
      Dir
        .glob("#{base_output_dir}/*/*/*")
        .reject { |path| path.include?(filename_suffix) || path.include?("_extra") }
        .each { |path| FileUtils.rm_rf(path) }
    end

    def watch
      listener =
        Listen.to(
          *Discourse.plugins.map(&:directory),
          { ignore: [%r{/node_modules/}], only: /\.(gjs|js|hbs|ts|gts)\z/ },
        ) do |modified, added, removed|
          changed_files = modified + added + removed
          changed_plugins = Set.new

          log "Changed files:"
          changed_files.each do |file|
            relative_path = Pathname.new(file).relative_path_from(Rails.root)
            log "- #{relative_path}"

            plugin = Discourse.plugins.find { |p| file.start_with?(p.resolved_dir) }
            changed_plugins << plugin if plugin
          end

          log "Recompiling..."
          start = Time.now
          changed_plugins.each { |plugin| compile_js_bundle(plugin) }
          log "Finished recompilation in #{(Time.now - start).round(2)}s"

          MessageBus.publish("/file-change", ["refresh"])
        rescue => e
          log "Plugin JS watcher crashed \n#{e}"
        end

      begin
        listener.start
        compile!
        yield
      ensure
        listener.stop
      end
    end

    private

    def minify?
      Rails.env.production?
    end

    def cache?
      true
    end

    def log(message)
      STDERR.puts message
    end

    private_class_method def self.get_set_cache(key, &blk)
      store =
        if Rails.env.development?
          Cache.request_cache ||= {}
        else
          @production_cache ||= {}
        end

      if store.key?(key)
        store[key]
      else
        store[key] = blk.call
      end
    end

    private_class_method def self.has_source_files_in_dir(plugin_directory_name, dir)
      Dir.glob("plugins/#{plugin_directory_name}/#{dir}/**/*.{js,hbs,gjs,es6,ts,gts}") do
        break true
      end || false
    end
  end
end
