# frozen_string_literal: true

require 'litany/resource'
require 'litany/mixins'

module Litany
  class ECSService < Resource
    include ServiceRoot

    cfn_type 'AWS::ECS::Service'

    child_resource ApplicationLoadBalancer, :alb, automatic: :on_access
    child_resource CloudWatchLogGroup, :log_group
    child_resource ECSTaskDefinition, :task_definition
    child_resource NetworkLoadBalancer, :nlb, automatic: :on_access
    child_resource VPCEndpointService, :vpc_endpoint_service, automatic: :on_access

    flag :dualstack, false
    flag :fargate, false
    flag :internal, false
    flag :sticky_sessions, false

    property :access_logs_prefix, nil, [String, nil]
    property :container_count, 0, Integer
    property :container_port, 8080, 1..65_535
    property :deregistration_delay, 10, [5..3600]
    property :health_check_grace_period, nil, [nil, 5..900]
    property :health_check_interval, 5, [5..300]
    property :health_check_path, '/', [String]
    property :health_check_port, nil, [nil, Integer, String], &:to_s
    property :health_check_protocol, nil, [nil, :http, :https, :tcp, :HTTP, :HTTPS, :TCP], &:upcase
    property :health_check_timeout, 2, [2..60]
    property :healthy_threshold, 3, [2..10]
    property :healthy_statuses, '200', [String, Integer, Range, Array] do |value|
      case value
        when Array
          raise 'Invalid value for `healthy_statuses` array. Only Integers and Ranges allowed.' unless value.all? { |v| v.is_a?(Range) || v.is_a?(Integer) }
          value.collect { |status| status.is_a?(Range) ? "#{status.first}-#{status.last}" : status.to_s }.join(',').to_s
        when Integer
          value.to_s
        when Range
          "#{value.first}-#{value.last}"
        else
          raise 'Invalid value for `healthy_statuses` string. Only comma separated Integers and Ranges allowed.' unless value.split(',').all? { |v| /(^\d+$)|(^\d+-\d+$)/.match(v) }
          value
      end
    end

    property :idle_timeout, 60, [1..3600]
    property :maximum_deployment, 200, 50..500
    property :minimum_deployment, 75, 0..100
    property :unhealthy_threshold, 2, [2..10]

    property_collection :valid_hostname, [], [String], required: false

    resource_collection DNSAlias, :dns_alias, required: false
    resource_collection IAMRole, :role
    resource_collection KMSKey, :kms_key, required: false
    resource_collection SecurityGroupIngress, :ingress_rule, required: false
    resource_collection SecurityGroup, :security_group, required: false

    resource_reference Certificate, :certificate, required: false
    resource_reference S3Bucket, :access_logs_bucket, required: false
    resource_reference VPC, :vpc, inherited: true

    resource_references Environment, :only_in, required: false

    validator(:lb_jeopardy) do
      raise 'You cannot use both a NLB and a ALB for this service.  This is likely a Litany bug.' if set_alb? && set_nlb?

      protocols = listener_port_protocols.values
      raise 'You cannot have both http(s) and tcp ingress rules on the same service.' if protocols.include?(:tcp) && (protocols.include?(:http) || protocols.include?(:https))

      raise 'You may not declare an endpoint_service unless using an NLB.' if set_vpc_endpoint_service? && load_balancer_type != :nlb

      raise 'You may not declare a valid_hostname unless using an ALB.' if set_valid_hostnames? && load_balancer_type != :alb

      if load_balancer_type == :nlb
        raise 'When using a NLB `health_check_interval` only accepts 10 and 30.' if set_health_check_interval? && ![10, 30].include?(health_check_interval)
        raise 'You may not set a distinct `unhealthy_threshold` when using NLBs.' if set_unhealthy_threshold?
        raise 'You may not set a `certificate` with a NLB.' if set_certificate?
        raise 'You may not set `access_logs_bucket` when using a NLB.' if set_access_logs_bucket?
        raise 'You may not set `sticky_sessions` when using an NLB.' if sticky_sessions?
      end

      if load_balancer_type == :none
        raise 'You may not manually set alb settings if you do not have an ingress rule.' if set_alb? || set_nlb?

        incompatible = [
          :access_logs_bucket, :access_logs_prefix, :certificate, :container_port, :deregistration_delay, :dns_alias, :dualstack,
          :health_check_grace_period, :health_check_interval, :health_check_path, :health_check_port, :health_check_protocol, :health_check_timeout, :healthy_statuses, :healthy_threshold,
          :idle_timeout, :internal, :security_group_ingress, :sticky_sessions, :unhealthy_threshold
        ]

        incompatible.each do |setting|
          raise "ECS Service `#{name.inspect}` has no load balancer and thus may not use the `#{setting}` setting." if (respond_to?(:"set_#{setting}?") && send(:"set_#{setting}?")) || (respond_to?(:"set_#{setting.pluralize}?") && send(:"set_#{setting.pluralize}?"))
        end
      end
    end

    validator(:fargate_jeopardy) do
      raise "Security groups may only be defined on a ecs service if you set the `fargate` flag." unless fargate? || !set_security_groups?
    end

    def access_logs(bucket:, prefix: nil)
      access_logs_bucket bucket
      access_logs_prefix prefix
    end

    # Old deprecated functions
    def add_alias(name, zone = nil, latency: nil)
      record = dns_alias(name)
      record.zone zone || name

      deferred do
        record.latency_routing if (latency.nil? && vpc.environments.length > 1) || latency
        record.target load_balancer

        load_balancer.valid_hostname(name.to_s) if load_balancer_type == :alb
      end
    end

    def add_regional_alias(domain:, subdomain:, zone: nil, region: nil, **_ignored)
      add_alias("#{subdomain}-#{region || active_environment_name}.#{domain}", zone || domain, latency: false)
    end

    # New hotness
    def add_dns_alias(subdomain:, domain:, zone: nil, type: nil)
      raise 'If type is provided to `add_dns_alias` it must be must be either `:normal`, `:regional`, or `:latency`.' unless [nil, :normal, :regional, :latency].include?(type)
      raise "Subdomain must either be a string or '@', it may not be nil." if subdomain.nil?

      subdomain = subdomain == '@' ? active_environment_name : "#{subdomain}-#{active_environment_name}" if type == :regional
      record_name = subdomain == '@' ? domain : "#{subdomain}.#{domain}"
      resource_name = zone.nil? ? record_name : :"#{record_name}_in_#{zone}"

      record = dns_alias(project.alias_compat_mode? ? record_name : resource_name)
      record.alias_name record_name
      record.zone zone || domain

      deferred do
        record.latency_routing if (type.nil? && vpc.environments.length > 1) || type == :latency
        record.healthy_alarm load_balancer.target_group(:"#{name}_service").healthy_alarm if record.latency_routing?
        record.target load_balancer

        load_balancer.valid_hostname(record_name.to_s) if load_balancer_type == :alb
      end
    end

    def depends_on
      return [] if load_balancer_type == :none

      [load_balancer.resource_name] + load_balancer.listeners.collect(&:resource_name) + load_balancer.target_groups.collect(&:resource_name)
    end

    def ecs
      parent_resource.is_a?(ECS) ? parent_resource : parent_resource.cluster
    end

    # Exposes the ECS Service as an endpoint service.  The behavior differs depending on how the load balancer is configured.
    #   If using a NLB the process is straight forward and exposes it directly with no additional changes.  All ports on the NLB are exposed as part of the endpoint service and normal ingress rules should be used to control access.
    #   If using an ALB we currently have to put a NLB between it.  Due to implementation limitations only a single port can be proxied through the endpoint service, and it's possible you may need to set a source as part of this helper.
    # @param trusted_accounts: [String, Array<String>] one or more account ids to trust to create endpoints to this service.  All other accounts must be listed here in order to see the service
    # @param port: [Integer, nil] (ALB powered services) if multiple ports are open via ingress rules you must specify which one to proxy
    # @param source: [CIDR, SecurityGroup] (ALB powered services) used to override the source value inferred from the ingress rules
    # @param block [Block] used to further configure the created VPCEndpointService
    # @return [VPCEndpointService]
    def endpoint_service(port: nil, source: nil, trusted_accounts: nil, &block)
      deferred do
        case load_balancer_type
          when :nlb
            raise 'NLB powered services do not allow for explicit `port` or `source` arguments.' unless port.nil? && source.nil?

            svc = vpc_endpoint_service(&block)
            svc.trust account: trusted_accounts unless trusted_accounts.nil?
          when :alb
            port = listener_port_protocols.keys.first if port.nil? && listener_port_protocols.keys.count == 1
            raise "Unable to infer desired port for the endpoint_service for #{name}.  Currently limited to only one port when exposing an ALB as an endpoint_service." if port.nil?

            source = ingress_rules.select { |rule| (rule.from_port..rule.to_port).include?(port) }.first&.source if source.nil?
            raise "Unable to infer source for the endpoint_service for #{name}. No matching ingress rule found." if source.nil?

            # We have to create a proxy service that will be using an NLB
            svc = parent_resource.service(:"#{name}_privatelink_proxy")

            # Service settings
            svc.fargate
            svc.internal
            svc.container_count ecs.vpc.subnets.count
            svc.container_port port

            # Task definition
            command = <<~CMD
              echo -e '
              listen alb
                bind *:#{port}
                option tcplog
                mode tcp

                timeout connect 10s
                timeout client 1m
                timeout server 1m              

                server alb1 HOSTNAME:#{port} check inter 60000 fastinter 15000 downinter 5000
              
              ' > /haproxy.cfg && echo 'CONFIG START\n' && cat /haproxy.cfg && echo '\nCONFIG END\n' && haproxy -W -db -f /haproxy.cfg
            CMD

            command = command.split('HOSTNAME')
            command.insert(1, ref(load_balancer, :DNSName))

            command = join(command, '')

            task = svc.task_definition
            task.cloudwatch_logs
            task.command [command]
            task.entry_point ['ash', '-c']
            task.image('haproxy:1.8-alpine')
            task.memory 512

            # Configure NLB
            svc.ingress(port: port, source: source, protocol: :tcp)

            # Create and configure the endpoint service
            esvc = svc.vpc_endpoint_service(&block)
            esvc.trust account: trusted_accounts unless trusted_accounts.nil?
          else
            raise "Unable to configure a endpoint_service when using a #{load_balancer_type.inspect} type of load balancer."
        end
      end
    end

    def fargate_sg
      security_group(:"#{name}_fargate")
    end

    def finalize_resource
      task_role.trust service: 'ecs-tasks.amazonaws.com'

      if fargate? || @task_execution_role
        task_execution_role.trust service: 'ecs-tasks.amazonaws.com'
        task_execution_role.managed_policy 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy'
      end

      if ArcanaSupport.automatic?
        key = kms_key(service_id)
        key.allow role: :task_role
        key.allow role: ArcanaSupport.instance.admin_role

        task_role.allow actions: 's3:GetObject', resource: join([ref(ArcanaSupport.instance.bucket, :Arn), service_id, '*'], '/')
      end

      # Need this even for load_balancer less fargate services
      fargate_sg.description "Used to reference instances of the #{name} Fargate service." if fargate?

      return if load_balancer_type == :none

      service_role do
        trust service: 'ecs.amazonaws.com'
        allow resource: '*', actions: [
          'ec2:AuthorizeSecurityGroupIngress',
          'ec2:Describe*',
          'elasticloadbalancing:DeregisterInstancesFromLoadBalancer',
          'elasticloadbalancing:DeregisterTargets',
          'elasticloadbalancing:Describe*',
          'elasticloadbalancing:RegisterInstancesWithLoadBalancer',
          'elasticloadbalancing:RegisterTargets'
        ]
      end

      # Base LB config
      tg = load_balancer.target_group(:"#{name}_service")
      tg.target_type :ip if fargate?
      if load_balancer_type == :alb
        tg.protocol(:http)
      else
        tg.protocol(:tcp)
        tg.health_check_interval 10 unless set_health_check_interval?
      end

      # Setup LB listeners
      listener_port_protocols.each do |port, protocol|
        listener = load_balancer.listener(:"#{name}_#{load_balancer_type}_#{protocol}")

        listener.certificate certificate if protocol == :https
        listener.port port
        listener.protocol protocol
        listener.target_group :"#{name}_service"
      end


      load_balancer.access_logs bucket: access_logs_bucket, prefix: access_logs_prefix if set_access_logs_bucket?
      vpc_endpoint_service.load_balancer(load_balancer) if set_vpc_endpoint_service?

      # Forward valid hostnames instead of relying on property inheritance
      load_balancer.valid_hostname(valid_hostnames) if load_balancer_type == :alb && set_valid_hostnames?
    end

    def ingress(port:, source:, protocol: nil)
      protocol ||= :http if port == 80
      protocol ||= :https if port == 443

      raise 'When setting ingress on a non standard port you must provide [:http, :https] for protocol:.' if protocol.nil?
      raise "Invalid protocol `#{protocol}` provided for port #{port}. Must be [:http, :https, :tcp]." unless [:http, :https, :tcp].include?(protocol)
      raise 'Attempting to add ingress rule after load balancer has been resolved.' if @resolved

      port = port.first if port.is_a?(Range) and port.count == 1

      if port.is_a?(Range)
        port.each { |p| listener_port_protocols[p] = protocol }
      else
        listener_port_protocols[port] = protocol
      end

      deferred do
        if load_balancer_type == :alb
          passthrough_port = fargate? ? container_port : 1025..65_535

          reference_sg.ingress(port: passthrough_port, source: :"#{name}_#{load_balancer_type}_port_#{port}_ingress")
          load_balancer.security_group(:"#{name}_#{load_balancer_type}_port_#{port}_ingress")

          sg = (fargate? ? self : ecs).security_group(:"#{name}_#{load_balancer_type}_port_#{port}_ingress")
          sg.description("Ingress for the #{name} ecs service on port #{port}.")
        else
          sg = fargate? ? fargate_sg : ecs.to_lbs_sg
          port = fargate? ? container_port : 1025..65_535
        end

        first_port = port.is_a?(Range) ? port.first : port
        last_port = port.is_a?(Range) ? port.last : port

        sources = source.is_a?(Array) ? source : [source]

        rule_protocol = :tcp # placeholder in case we support UDP later

        sources.each do |source|
          source = source.name if source.is_a?(SecurityGroup)
          source = source.to_s if source.is_a?(NetAddr::CIDR)

          name_parts = ['Allow', rule_protocol]
          name_parts << (first_port == last_port ? "Port_#{port}" : "Ports_#{first_port}_To_#{last_port}")
          name_parts << "From_#{source.tr('.', '_').tr('/', '_')}"
          name_parts << sg.name
          name = name_parts.join('_')

          rule = ingress_rule(name)
          rule.protocol(rule_protocol)
          rule.from_port(first_port)
          rule.to_port(last_port)
          rule.source(source)
          rule.security_group(sg)
        end
      end

    end

    def listener_port_protocols
      @listener_port_protocols ||= {}
    end

    def load_balancer(&block)
      case load_balancer_type
        when :nlb
          nlb(&block)
        when :alb
          alb(&block)
        else
          raise 'Attempting to pass in a configuration block to `load_balancer` when no load balancer is resolvable.' unless block.nil?

          nil
      end
    end

    def load_balancer_type
      @resolved = true

      @load_balancer_type ||= :none if listener_port_protocols.empty?
      @load_balancer_type ||= listener_port_protocols.values.include?(:tcp) ? :nlb : :alb

      @load_balancer_type
    end

    def properties
      # Not Implementing: PlacementConstraints

      props = {
        Cluster: ref(ecs.cluster),
        DeploymentConfiguration: {
          MaximumPercent: maximum_deployment,
          MinimumHealthyPercent: minimum_deployment
        },
        DesiredCount: container_count,
        ServiceName: name,
        TaskDefinition: ref(task_definition)
      }

      unless load_balancer_type == :none
        props[:LoadBalancers] = [
          {
            ContainerName: name,
            ContainerPort: container_port,
            TargetGroupArn: ref(load_balancer.target_group("#{name}_service"))
          }
        ]

        props[:Role] = ref(service_role) unless fargate?
      end

      if fargate?
        props[:LaunchType] = :FARGATE

        props[:NetworkConfiguration] = {
          AwsvpcConfiguration: {
            AssignPublicIp: :ENABLED,
            SecurityGroups: [ref(fargate_sg)],
            Subnets: ref(ecs.vpc.subnets)
          }
        }
      else
        props[:PlacementStrategies] = [
          {
            Type: :spread,
            Field: 'attribute:ecs.availability-zone'
          },
          {
            Type: :spread,
            Field: :host
          }
        ]
      end

      props[:HealthCheckGracePeriodSeconds] = health_check_grace_period if set_health_check_grace_period?

      props
    end

    def reference_sg
      fargate? ? fargate_sg : ecs.reference_sg
    end

    def service_role(&block)
      role(:"#{name}_service", &block)
    end

    def task_role(&block)
      role(:"#{name}_task", &block)
    end

    def task_execution_role(&block)
      @task_execution_role = true # kinda a hack, but lets us track that people accessed this helper.
      role(:"#{name}_task_execution", &block)
    end
  end
end
