# encoding: ascii-8bit

# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# Modified by OpenC3, Inc.
# All changes Copyright 2022, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.

require 'rubygems'
require 'rubygems/package'
require 'openc3'
require 'openc3/utilities/bucket'
require 'openc3/utilities/store'
require 'openc3/config/config_parser'
require 'openc3/models/model'
require 'openc3/models/gem_model'
require 'openc3/models/target_model'
require 'openc3/models/interface_model'
require 'openc3/models/router_model'
require 'openc3/models/tool_model'
require 'openc3/models/widget_model'
require 'openc3/models/microservice_model'
require 'tmpdir'
require 'tempfile'

module OpenC3
  # Represents a OpenC3 plugin that can consist of targets, interfaces, routers
  # microservices and tools. The PluginModel installs all these pieces as well
  # as destroys them all when the plugin is removed.
  class PluginModel < Model
    PRIMARY_KEY = 'openc3_plugins'
    # Reserved VARIABLE names. See local_mode.rb: update_local_plugin()
    RESERVED_VARIABLE_NAMES = ['target_name', 'microservice_name']

    attr_accessor :variables
    attr_accessor :plugin_txt_lines
    attr_accessor :needs_dependencies

    # NOTE: The following three class methods are used by the ModelController
    # and are reimplemented to enable various Model class methods to work
    def self.get(name:, scope: nil)
      super("#{scope}__#{PRIMARY_KEY}", name: name)
    end

    def self.names(scope: nil)
      super("#{scope}__#{PRIMARY_KEY}")
    end

    def self.all(scope: nil)
      super("#{scope}__#{PRIMARY_KEY}")
    end

    # Called by the PluginsController to parse the plugin variables
    # Doesn't actaully create the plugin during the phase
    def self.install_phase1(gem_file_path, existing_variables: nil, existing_plugin_txt_lines: nil, process_existing: false, scope:, validate_only: false)
      gem_name = File.basename(gem_file_path).split("__")[0]

      temp_dir = Dir.mktmpdir
      tf = nil
      begin
        if File.exist?(gem_file_path)
          # Load gem to internal gem server
          OpenC3::GemModel.put(gem_file_path, gem_install: false, scope: scope) unless validate_only
        else
          gem_file_path = OpenC3::GemModel.get(gem_name)
        end

        # Extract gem and process plugin.txt to determine what VARIABLEs need to be filled in
        pkg = Gem::Package.new(gem_file_path)

        if existing_plugin_txt_lines and process_existing
          # This is only used in openc3cli load when everything is known
          plugin_txt_lines = existing_plugin_txt_lines
          file_data = existing_plugin_txt_lines.join("\n")
          tf = Tempfile.new("plugin.txt")
          tf.write(file_data)
          tf.close
          plugin_txt_path = tf.path
        else
          # Otherwise we always process the new and return both
          pkg.extract_files(temp_dir)
          plugin_txt_path = File.join(temp_dir, 'plugin.txt')
          plugin_text = File.read(plugin_txt_path)
          plugin_txt_lines = []
          plugin_text.each_line do |line|
            plugin_txt_lines << line.chomp
          end
        end

        parser = OpenC3::ConfigParser.new("https://openc3.com")

        # Phase 1 Gather Variables
        variables = {}
        parser.parse_file(plugin_txt_path,
                          false,
                          true,
                          false) do |keyword, params|
          case keyword
          when 'VARIABLE'
            usage = "#{keyword} <Variable Name> <Default Value>"
            parser.verify_num_parameters(2, nil, usage)
            variable_name = params[0]
            if RESERVED_VARIABLE_NAMES.include?(variable_name)
              raise "VARIABLE name '#{variable_name}' is reserved"
            end
            value = params[1..-1].join(" ")
            variables[variable_name] = value
            if existing_variables && existing_variables.key?(variable_name)
              variables[variable_name] = existing_variables[variable_name]
            end
          end
        end

        model = PluginModel.new(name: gem_name, variables: variables, plugin_txt_lines: plugin_txt_lines, scope: scope)
        result = model.as_json(:allow_nan => true)
        result['existing_plugin_txt_lines'] = existing_plugin_txt_lines if existing_plugin_txt_lines and not process_existing and existing_plugin_txt_lines != result['plugin_txt_lines']
        return result
      ensure
        FileUtils.remove_entry(temp_dir) if temp_dir and File.exist?(temp_dir)
        tf.unlink if tf
      end
    end

    # Called by the PluginsController to create the plugin
    # Because this uses ERB it must be run in a seperate process from the API to
    # prevent corruption and single require problems in the current proces
    def self.install_phase2(plugin_hash, scope:, gem_file_path: nil, validate_only: false)
      # Register plugin to aid in uninstall if install fails
      plugin_hash.delete("existing_plugin_txt_lines")
      plugin_model = PluginModel.new(**(plugin_hash.transform_keys(&:to_sym)), scope: scope)
      plugin_model.create unless validate_only

      temp_dir = Dir.mktmpdir
      begin
        tf = nil

        # Get the gem from local gem server if it hasn't been passed
        unless gem_file_path
          gem_name = plugin_hash['name'].split("__")[0]
          gem_file_path = OpenC3::GemModel.get(gem_name)
        end

        # Actually install the gem now (slow)
        OpenC3::GemModel.install(gem_file_path, scope: scope)

        # Extract gem contents
        gem_path = File.join(temp_dir, "gem")
        FileUtils.mkdir_p(gem_path)
        pkg = Gem::Package.new(gem_file_path)
        pkg.extract_files(gem_path)
        Dir[File.join(gem_path, '**/screens/*.txt')].each do |filename|
          if File.basename(filename) != File.basename(filename).downcase
            raise "Invalid screen filename: #{filename}. Screen filenames must be lowercase."
          end
        end
        needs_dependencies = pkg.spec.runtime_dependencies.length > 0
        needs_dependencies = true if Dir.exist?(File.join(gem_path, 'lib'))
        # If needs_dependencies hasn't already been set we need to scan the plugin.txt
        # to see if they've explicitly set the NEEDS_DEPENDENCIES keyword
        unless needs_dependencies
          if plugin_hash['plugin_txt_lines'].join("\n").include?('NEEDS_DEPENDENCIES')
            needs_dependencies = true
          end
        end
        if needs_dependencies
          plugin_model.needs_dependencies = true
          plugin_model.update unless validate_only
        end

        # Temporarily add all lib folders from the gem to the end of the load path
        load_dirs = []
        begin
          Dir.glob("#{gem_path}/**/*").each do |load_dir|
            if File.directory?(load_dir) and File.basename(load_dir) == 'lib'
              load_dirs << load_dir
              $LOAD_PATH << load_dir
            end
          end

          # Process plugin.txt file
          file_data = plugin_hash['plugin_txt_lines'].join("\n")
          tf = Tempfile.new("plugin.txt")
          tf.write(file_data)
          tf.close
          plugin_txt_path = tf.path
          variables = plugin_hash['variables']
          if File.exist?(plugin_txt_path)
            parser = OpenC3::ConfigParser.new("https://openc3.com")

            current_model = nil
            parser.parse_file(plugin_txt_path, false, true, true, variables) do |keyword, params|
              case keyword
              when 'VARIABLE', 'NEEDS_DEPENDENCIES'
                # Ignore during phase 2
              when 'TARGET', 'INTERFACE', 'ROUTER', 'MICROSERVICE', 'TOOL', 'WIDGET'
                if current_model
                  current_model.create unless validate_only
                  current_model.deploy(gem_path, variables, validate_only: validate_only)
                  current_model = nil
                end
                current_model = OpenC3.const_get((keyword.capitalize + 'Model').intern).handle_config(parser,
                  keyword, params, plugin: plugin_model.name, needs_dependencies: needs_dependencies, scope: scope)
              else
                if current_model
                  current_model.handle_config(parser, keyword, params)
                else
                  raise "Invalid keyword '#{keyword}' in plugin.txt"
                end
              end
            end
            if current_model
              current_model.create unless validate_only
              current_model.deploy(gem_path, variables, validate_only: validate_only)
              current_model = nil
            end
          end
        ensure
          load_dirs.each do |load_dir|
            $LOAD_PATH.delete(load_dir)
          end
        end
      rescue => err
        # Install failed - need to cleanup
        plugin_model.destroy unless validate_only
        raise err
      ensure
        FileUtils.remove_entry(temp_dir) if temp_dir and File.exist?(temp_dir)
        tf.unlink if tf
      end
      return plugin_model.as_json(:allow_nan => true)
    end

    def initialize(
      name:,
      variables: {},
      plugin_txt_lines: [],
      needs_dependencies: false,
      updated_at: nil,
      scope:
    )
      super("#{scope}__#{PRIMARY_KEY}", name: name, updated_at: updated_at, scope: scope)
      @variables = variables
      @plugin_txt_lines = plugin_txt_lines
      @needs_dependencies = ConfigParser.handle_true_false(needs_dependencies)
    end

    def create(update: false, force: false)
      @name = @name + "__#{Time.now.utc.strftime("%Y%m%d%H%M%S")}" if not update and not @name.index("__")
      super(update: update, force: force)
    end

    def as_json(*a)
      {
        'name' => @name,
        'variables' => @variables,
        'plugin_txt_lines' => @plugin_txt_lines,
        'needs_dependencies' => @needs_dependencies,
        'updated_at' => @updated_at
      }
    end

    # Undeploy all models associated with this plugin
    def undeploy
      microservice_count = 0
      microservices = MicroserviceModel.find_all_by_plugin(plugin: @name, scope: @scope)
      microservices.each do |name, model_instance|
        model_instance.destroy
        microservice_count += 1
      end
      # Wait for the operator to wake up and remove the microservice processes
      sleep 12 if microservice_count > 0 # Cycle time 5s times 2 plus 2s wait for soft stop and then hard stop
      # Remove all the other models now that the processes have stopped
      # Save TargetModel for last as it has the most to cleanup
      [InterfaceModel, RouterModel, ToolModel, WidgetModel, TargetModel].each do |model|
        model.find_all_by_plugin(plugin: @name, scope: @scope).each do |name, model_instance|
          model_instance.destroy
        end
      end
      # Cleanup Redis stuff that might have been left by microservices
      microservices.each do |name, model_instance|
        model_instance.cleanup
      end
    end

    # Reinstall
    def restore
      plugin_hash = self.as_json(:allow_nan => true)
      plugin_hash['name'] = plugin_hash['name'].split("__")[0]
      OpenC3::PluginModel.install_phase2(plugin_hash, scope: @scope)
      @destroyed = false
    end

    # Get list of plugin gem names across all scopes to prevent uninstall of gems from GemModel
    def self.gem_names
      result = []
      scopes = ScopeModel.names
      scopes.each do |scope|
        plugin_names = self.names(scope: scope)
        plugin_names.each do |plugin_name|
          gem_name = plugin_name.split("__")[0]
          result << gem_name unless result.include?(gem_name)
        end
      end
      return result.sort
    end
  end
end