# frozen_string_literal: true

require 'aws-sdk-cloudformation'
require 'fileutils'
require 'set'
require 'yaml'

require 'litany/project/project_workspace_options_dsl'

module Litany
  # Validates and manipulates project workspaces
  class ProjectWorkspace < Base
    extend ProjectWorkspaceOptionsDSL
    # @!parse include ProjectWorkspaceOptionsDSL::InstanceMethods

    # Returns the internal caches directory.
    # @return [String]
    attr_reader :cache_directory

    # Returns the current {ProjectWorkspaceDelegate} implementation for this workspace.
    # @return [ProjectWorkspaceDelegate]
    attr_reader :delegate

    # Returns the directory which contains lambda templates
    # @return [String]
    attr_reader :lambda_template_directory

    # Returns the full path to the litany configuration directory.  This is the `.litany` sub-directory of the {#workspace_directory}.
    # @return [String] the full expanded path to the `.litany` sub-directory
    attr_reader :litany_directory

    # Returns the public output directory.
    # @return [String]
    attr_reader :output_directory

    # Returns the current stage this project workspace is considering active.  This will impact default behavior of option retrieval.
    # @return [Symbol, nil] returns nil if not specific stage is set for this workspace.
    attr_reader :stage

    # Returns the directory which contains userdata templates
    # @return [String]
    attr_reader :userdata_template_directory

    # Returns the full path to the litany workspace directory.
    # @return [String] the full expanded path to the project workspace
    attr_reader :workspace_directory

    workspace_option 'project.name', String
    workspace_option 'project.allowed_stages', Symbol
    workspace_option 'project.default_stage', Symbol, nilable: true

    workspace_option 'aws.profile', String

    # @param directory: [String] The directory the workspace is or will be created in.
    # @param delegate: [ProjectWorkspaceDelegate] A concrete implementation of the abstract workspace delegate interface.
    # @param stage: [Symbol] The name of the stage this workspace is expecting to work with.
    def initialize(directory:, delegate:, stage: nil)
      @workspace_directory = File.expand_path(directory)

      @litany_directory = File.join(workspace_directory, '.litany')
      @lambda_template_directory = File.join(workspace_directory, 'lambda')
      @userdata_template_directory = File.join(workspace_directory, 'userdata')

      @cache_directory = File.join(litany_directory, 'caches')
      @output_directory = File.join(workspace_directory, 'output')

      @configuration = load_configuration
      @stage = stage&.to_sym

      @delegate = delegate
    end

    # Creates a new initialized workspace.
    # @return [Boolean] was the workspace creation successful.
    def initialize_workspace
      FileUtils.mkpath(workspace_directory) unless workspace_exist?

      return false unless workspace_empty? || delegate.use_non_empty_workspace?

      if litany_directory_exist?
        return false unless delegate.overwrite_existing_workspace?

        FileUtils.rmtree litany_directory
        @configuration = {}
      end

      FileUtils.mkpath(litany_directory)

      set_option 'project.name', delegate.new_workspace_name
      set_option 'project.allowed_stages', delegate.allowed_stages

      get_option('project.allowed_stages').each do |stage|
        set_option 'aws.profile', delegate.aws_profile(for_stage: stage), layer: stage
      end
    end

    # @!group AWS Helpers

    def cfn_client(region:)
      @cfn_clients ||= {}

      # Delete any existing AWS Credentials from our environment to avoiding making changes to unexpected accounts
      %w( AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN).each do |var|
        ENV.delete(var)
      end

      @cfn_clients[region] ||= Aws::CloudFormation::Client.new(region: region, profile: get_option('aws.profile'))
    end

    # @!endgroup

    # @!group Helpers

    # Returns the path to a specific lambda template
    # @return [String]
    def lambda_template(name)
      File.join(lambda_template_directory, name)
    end

    # Tests the existence of the litany configuration directory.
    # @return [Boolean]
    def litany_directory_exist?
      Dir.exist?(litany_directory)
    end

    # Returns a list of paths to the non-settings files in this workspace.
    # @return [Array<String>]
    def litany_files
      @litany_files ||= Dir[File.join(workspace_directory, '**/*')].collect { |path| File.expand_path(path) if /.lit(any)?$/.match(path) && !settings_files.include?(path) }.compact.sort
    end

    # Returns a project instance for the specified environment
    # @param environment: [String, nil]
    # @return [Project]
    def project(environment: nil)
      Project.new(workspace: self, active_environment: environment)
    end

    # Returns a list of paths to plugin files this workspace contains.
    # @return [Array<String>]
    def plugin_files
      @plugin_files ||= Dir[File.join(workspace_directory, 'plugins', '**/*.rb')].each { |path| File.expand_path(path) }.sort
    end

    # Tests whether all required options have been set on this workspace.
    # @return [Boolean]
    def required_options?
      unset_required_options.empty?
    end

    # Returns a list of paths to the settings files in this workspace.
    # @return [Array<String>]
    def settings_files
      @settings_files ||= Dir[File.join(workspace_directory, 'settings.*'), File.join(workspace_directory, 'settings', '**/*')].collect { |path| File.expand_path(path) if /.lit(any)?$/.match(path) }.compact.sort
    end

    # Returns the keys of the unset required options
    # @return [Array<String>]
    def unset_required_options
      @unset_required_options ||= workspace_options.reject { |_, option| option.nilable? || !option.default.nil? }.select { |key, _| !option_set?(key) }.keys
    end

    # Returns the path to a specific userdata template.
    # @return [String]
    def userdata_template(name)
      File.join(userdata_template_directory, name)
    end

    # Tests whether this workspace is valid.
    # @return [Boolean]
    def valid?
      unless litany_directory_exist?
        delegate.warn "Litany configuration folder `#{litany_directory}` does not exist"
        return false
      end

      unless valid_stage?
        delegate.warn "This project does not allow `#{stage}` as the stage"
        return false
      end

      unless required_options?
        delegate.warn "This project is missing the following required options: `#{unset_required_options.join('`, `')}`"
        return false
      end

      true
    end

    # Tests whether the active stage is a allowed.
    # @return [Boolean]
    def valid_stage?
      get_option('project.allowed_stages').include?(stage)
    end

    # Tests whether the workspace directory exists.
    # @return [Boolean]
    def workspace_exist?
      Dir.exist?(workspace_directory)
    end

    # Tests whether the workspace directory is completely empty.
    # @return [Boolean]
    def workspace_empty?
      # having to do a .collect to replace since `base:` didn't seem to work.
      file_list = Dir.glob(File.join(workspace_directory, '**'), File::FNM_DOTMATCH).collect { |file| file.sub /^#{workspace_directory}\/?/, '' }.compact
      (file_list - ['.', '..']).empty?
    end

    # @!endgroup

    # @!group Options

    # Alias for a get_option call with only a key parameters.
    # @param key (see #get_option)
    # @return (see #get_option)
    def [](key)
      get_option(key)
    end

    # Alias for a set_option call with only the key and value parameters.
    # @param key (see #set_option)
    # @param value (see #set_option)
    # @return (see #set_option)
    def []=(key, value)
      set_option(key, value)
    end

    # Returns the value or values of a given configuration option
    # @param key [String] the name of the setting you're querying. Example: 'project.name'
    # @param layer: [Symbol] the name of the layer you're querying for the effective value of this option.
    # @return [Object] the value or values of the option requested.
    def get_option(key, layer: nil)
      layer ||= stage || :project

      unless option_exist?(key)
        delegate.option_does_not_exist(key, :get) if delegate.respond_to?(:option_does_not_exist)
        return nil
      end

      value = @configuration.dig(layer, key)
      value = @configuration.dig(:project, key) if value.nil? && key != :project

      value.nil? ? workspace_option(key).default : value
    end

    # Sets a value in context of this workspace.
    # @param key [String] the name of the setting you're manipulating. Example: 'project.name'
    # @param value [Object] the new value of this setting.
    # @param layer: [Symbol] the name of the layer this setting belongs to.
    # @return [Object] the `value` parameter
    def set_option(key, value, layer: nil)
      layer ||= stage || :project

      unless option_exist?(key)
        delegate.option_does_not_exist(key, :set) if delegate.respond_to?(:option_does_not_exist)
        return value
      end

      unless valid_option_value?(key, value)
        delegate.invalid_option_value(workspace_options[key].clone, value) if delegate.respond_to?(:invalid_option_value)
        return value
      end

      config = @configuration[layer] ||= {}
      config[key] = value
      save_configuration

      config[key]
    end

    # @!endgroup

    # @!group Stack Storage

    # Caches the output for use in other operations.  The stored json will be compact.
    # @param environment_name [String, Token] the name of the environment these stacks are generated for
    # @param stacks [Hash<String, Hash>] a hash of stacks, name to compiled stack hash
    # @return [void]
    def cache_output(environment_name, stacks)
      directory = File.join(cache_directory, 'output', stage.to_s, environment_name.to_s)
      write_stacks(directory, stacks)
    end

    # Returns the cached output from disk for a given stack.
    # @param environment_name [String, Token] the name of the environment these are cached for
    # @param stack_name [String, Token] the name of the stack you're looking for
    # @return [String] The string representation of the json this block is for
    # @return [void]
    def cached_output(environment_name, stack_name)
      path = File.join(cache_directory, 'output', stage.to_s, environment_name.to_s, "#{stack_name}.json")
      File.exist?(path) ? File.read(path) : nil
    end

    # Stores the output in the top level output directory.  The stored json will be formatted for readability and is
    #   expected to be committed to a repository.
    # @param environment_name [String, Token] the name of the environment these stacks are generated for
    # @param stacks [Hash<String, Hash>] a hash of stacks, name to compiled stack hash
    # @return [void]
    def store_output(environment_name, stacks)
      directory = File.join(output_directory, stage.to_s, environment_name.to_s)
      write_stacks(directory, stacks, mode: :neat)
    end

    # Commits a set of stacks to a specific directory.
    # @param directory [String] the full path to output the stacks to
    # @param stacks [Hash<String, Hash>] a hash of stacks, name to compiled stack hash
    # @param mode: [:normal, :neat] if :neat the stored JSON will be sent through neat_generate before being written
    # @param redact_secrets: [Boolean] if true secrets will be redacted before storage
    # @return [void]
    private def write_stacks(directory, stacks, mode: :normal, redact_secrets: false)
      FileUtils.rm_rf(directory) if Dir.exist?(directory)
      FileUtils.mkdir_p(directory)

      stacks.each do |name, stack|
        contents = stack.to_json
        contents = JSON.neat_generate(JSON.parse(contents), wrap: true, padding: 1, after_colon: 1, sort: ->(key) { key.to_s }) if mode == :neat

        File.open(File.join(directory, "#{name}.json"), 'w') { |f| f.puts(contents) }
      end
    end
    # @!endgroup


    # Tests if the option is set.
    # @return [Boolean]
    def option_set?(key, layer: nil)
      layer ||= stage || :project
      return false unless option_exist?(key)

      !get_option(key, layer: layer).nil?
    end

    # Recursive helper that does a deep scan of a hash and deletes all keys with a nil or empty hash value.
    # @return [void]
    private def compact_configuration(config = nil)
      config ||= @configuration
      config.delete_if { |k, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) || workspace_option(k)&.default == v }
      config.each_value { |v| compact_configuration(v) if v.is_a?(Hash) }
    end

    # Returns the full path to a configuration file.
    # @param ext: [String] the file extension of the configuration file.
    # @return [String]
    private def configuration_file
      File.join(litany_directory, 'config.yaml')
    end

    # Tests for the existence of the configuration file.
    # @param ext: (see #configuration_file)
    # @return [Boolean]
    private def configuration_file_exist?
      File.exist?(configuration_file)
    end

    # Loads the configuration for this workspace.
    # @return [void]
    private def load_configuration
      return {} unless configuration_file_exist?

      YAML.load_file(configuration_file)
    end

    # Serializes the configuration to the litany workspace directory.
    # @return [void]
    private def save_configuration
      compact_configuration

      File.open(configuration_file, 'w') do |file|
        file.write(@configuration.to_yaml)
      end
    end
  end
end
