# Two load balancers.
#
# The second ALB is the most questionable line item in this stack: it is about 17 dollars a month
# to serve one partner. Host-based routing on a single ALB would do the same job for free.
#
# It is kept because the two workloads differ in what they are ALLOWED to do, not just in who calls
# them. The official endpoint is read-only and anonymous; the POC is bearer-token and has write
# templates enabled. Sharing an ALB means one listener-rule mistake exposes the write-enabled
# service on the public hostname, and a listener rule is exactly the kind of thing that gets edited
# under time pressure. Separate ALBs make that mistake require two changes instead of one.
#
# Staging is the counter-example and worth noticing: it shares the official ALB behind a rule
# restricted to operator addresses, because staging is the SAME configuration as production. The
# rule is only a convenience, not a boundary, which is why sharing is acceptable there and not for
# the POC.

locals {
  # Long, because a current-spec MCP client can hold an SSE stream open. The default 60 seconds
  # would cut those, and the symptom is a client that hangs rather than an error anyone can read.
  alb_idle_timeout = 300

  # The container port. Fixed here rather than in a variable: the app's own default is 3000 and
  # nothing benefits from the two disagreeing.
  container_port = 3000

  # Rule ordering as arithmetic, after the /health rule was once created ABOVE the staging rule and
  # silently answered staging health checks with production. "Health sits below staging" is now a
  # property of the expression rather than a fact about two literals in different parts of the file
  # that nothing forced to agree.
  staging_rule_priority = 100
  health_rule_priority  = local.staging_rule_priority + 100
}

# ---------------------------------------------------------------------------------------------
# Official
# ---------------------------------------------------------------------------------------------

resource "aws_lb" "official" {
  name               = "mcp-official"
  load_balancer_type = "application"
  subnets            = aws_subnet.public[*].id
  security_groups    = [aws_security_group.alb_official.id]
  idle_timeout       = local.alb_idle_timeout

  # Cheap insurance against a `terraform destroy` typed in the wrong directory. Removing an ALB
  # also changes its DNS name, which breaks every client that has the old one configured.
  enable_deletion_protection = true

  tags = { Name = "mcp-official" }
}

resource "aws_lb_target_group" "official_prod" {
  name        = "mcp-official-prod"
  port        = local.container_port
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = aws_vpc.main.id

  health_check {
    path                = "/health"
    matcher             = "200"
    interval            = 30
    timeout             = 5
    healthy_threshold   = 2
    unhealthy_threshold = 3
  }

  # Below the ALB idle timeout on purpose. A target draining for longer than the load balancer is
  # willing to wait produces 502s during every deploy.
  deregistration_delay = 30
}

resource "aws_lb_target_group" "official_staging" {
  name        = "mcp-official-staging"
  port        = local.container_port
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = aws_vpc.main.id

  health_check {
    path                = "/health"
    matcher             = "200"
    interval            = 30
    timeout             = 5
    healthy_threshold   = 2
    unhealthy_threshold = 3
  }

  deregistration_delay = 30
}

# Port 80 REFUSES the application, on both load balancers, until that endpoint has a certificate.
#
# The earlier version forwarded straight to production and relied on a written rule that only dummy
# credentials would cross it. That rule appeared in three places (a security-group comment, a
# listener comment, and the runbook) which is the shape a rule takes when nothing enforces it. The
# deploy workflow, meanwhile, never consulted it at all.
#
# Now the default action is a fixed 503 and exactly one path is forwarded: `/health`, which carries
# no credential and is the only thing the pre-TLS smoke test needs.
#
# BE PRECISE ABOUT WHAT THIS BUYS, because the obvious summary is wrong and someone reading later
# will act on it. This does NOT stop a credential from crossing the wire. A client configured with
# the plaintext URL and a real API key sends that key, in the clear, on every attempt; the ALB
# answers 503 only after the request has arrived. What it removes is a WORKING plaintext endpoint
# and the continuing exposure of one. The misconfigured client's first requests are not protected
# by anything here, and only TLS fixes that.
#
# When the endpoint's certificate exists, the default becomes a redirect to 443 and the health
# exception stops mattering.
resource "aws_lb_listener" "official_http" {
  load_balancer_arn = aws_lb.official.arn
  port              = 80
  protocol          = "HTTP"

  dynamic "default_action" {
    for_each = local.official_tls_ready ? [1] : []
    content {
      type = "redirect"
      redirect {
        port        = "443"
        protocol    = "HTTPS"
        status_code = "HTTP_301"
      }
    }
  }

  dynamic "default_action" {
    for_each = local.official_tls_ready ? [] : [1]
    content {
      type = "fixed-response"
      fixed_response {
        content_type = "text/plain"
        status_code  = "503"
        message_body = "This endpoint does not serve requests over plaintext HTTP. See /health."
      }
    }
  }
}

# The one exception, and the reason the 503 above costs nothing operationally.
#
# Priority 200, BELOW the staging rule at 100, and the order is the whole point. ALB evaluates rules
# from the lowest number up. At priority 1 this rule won every `/health` request, including the ones
# an operator sent with `X-Mcp-Staging: 1` from an allowed address, so the first person to check
# staging by hand would have been shown production answering healthily and read it as staging. The
# routing was wrong in the direction that produces false confidence rather than an error.
resource "aws_lb_listener_rule" "official_health_over_http" {
  count = local.official_tls_ready ? 0 : 1

  listener_arn = aws_lb_listener.official_http.arn
  priority     = local.health_rule_priority

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.official_prod.arn
  }

  condition {
    path_pattern {
      values = ["/health"]
    }
  }
}

resource "aws_lb_listener" "official_https" {
  count = local.official_tls_ready ? 1 : 0

  load_balancer_arn = aws_lb.official.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate.interim[0].arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.official_prod.arn
  }
}

# Staging, behind a source-address restriction.
#
# Created ONLY when operator_cidrs is non-empty. An empty list here would render as a rule that
# matches nothing, or, with the wrong condition type, everything. Not creating the rule is the
# unambiguous version.
resource "aws_lb_listener_rule" "staging" {
  count = length(var.operator_cidrs) > 0 ? 1 : 0

  listener_arn = local.official_tls_ready ? aws_lb_listener.official_https[0].arn : aws_lb_listener.official_http.arn
  priority     = local.staging_rule_priority

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.official_staging.arn
  }

  condition {
    source_ip {
      values = var.operator_cidrs
    }
  }

  condition {
    http_header {
      http_header_name = "X-Mcp-Staging"
      values           = ["1"]
    }
  }
}

# ---------------------------------------------------------------------------------------------
# Partner POC
# ---------------------------------------------------------------------------------------------

resource "aws_lb" "poc" {
  name               = "mcp-poc"
  load_balancer_type = "application"
  subnets            = aws_subnet.public[*].id
  security_groups    = [aws_security_group.alb_poc.id]
  idle_timeout       = local.alb_idle_timeout

  enable_deletion_protection = true

  tags = { Name = "mcp-poc" }
}

resource "aws_lb_target_group" "poc" {
  name        = "mcp-poc"
  port        = local.container_port
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = aws_vpc.main.id

  health_check {
    path                = "/health"
    matcher             = "200"
    interval            = 30
    timeout             = 5
    healthy_threshold   = 2
    unhealthy_threshold = 3
  }

  deregistration_delay = 30
}

# Same shape as the official listener, and it did not used to be.
#
# This ALB previously forwarded port 80 to the service unconditionally, with no HTTPS listener
# anywhere and no certificate: the POC had no TLS path at all, while the official endpoint was one
# variable away from having one. Filling in `interim_domain` would have quietly given official
# HTTPS and left the POC in plaintext, which is the worse half to leave behind. The POC is the
# write-enabled endpoint, it authenticates with a static bearer token in a header, and that token
# is the specific reason the Fly cutover is blocked.
resource "aws_lb_listener" "poc_http" {
  load_balancer_arn = aws_lb.poc.arn
  port              = 80
  protocol          = "HTTP"

  dynamic "default_action" {
    for_each = local.poc_tls_ready ? [1] : []
    content {
      type = "redirect"
      redirect {
        port        = "443"
        protocol    = "HTTPS"
        status_code = "HTTP_301"
      }
    }
  }

  dynamic "default_action" {
    for_each = local.poc_tls_ready ? [] : [1]
    content {
      type = "fixed-response"
      fixed_response {
        content_type = "text/plain"
        status_code  = "503"
        message_body = "This endpoint does not serve requests over plaintext HTTP. See /health."
      }
    }
  }
}

# Numbered to match the official listener rather than to its own needs. Nothing shares the POC
# listener today, so any priority would work; keeping the two the same is what stops the next rule
# added here from being slotted in above it by someone copying the other file.
resource "aws_lb_listener_rule" "poc_health_over_http" {
  count = local.poc_tls_ready ? 0 : 1

  listener_arn = aws_lb_listener.poc_http.arn
  priority     = local.health_rule_priority

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.poc.arn
  }

  condition {
    path_pattern {
      values = ["/health"]
    }
  }
}

resource "aws_lb_listener" "poc_https" {
  count = local.poc_tls_ready ? 1 : 0

  load_balancer_arn = aws_lb.poc.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate.poc_interim[0].arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.poc.arn
  }
}

# ---------------------------------------------------------------------------------------------
# TLS
# ---------------------------------------------------------------------------------------------

locals {
  # Tracked PER ENDPOINT rather than once for the stack.
  #
  # A single `tls_ready` read as a property of the deployment and was in fact a property of the
  # official ALB alone, which is how the POC ended up with no HTTPS path at all while looking like
  # it shared one. Two names make the asymmetry impossible to state accidentally: filling in one
  # domain and not the other now produces a POC that still refuses plaintext, rather than a POC
  # quietly serving a bearer token in the clear.
  official_tls_ready = var.interim_domain != ""
  poc_tls_ready      = var.poc_interim_domain != ""
}

# Requested early, because DNS validation has lead time and the public hostname is blocked on the
# security review.
#
# Worth stating to whoever approves it: issuing a certificate publishes the hostname to Certificate
# Transparency logs, so the name becomes publicly searchable BEFORE any DNS record exists. That is
# a disclosure, and it should not be a surprise discovered afterwards.
resource "aws_acm_certificate" "interim" {
  count = local.official_tls_ready ? 1 : 0

  domain_name       = var.interim_domain
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_acm_certificate" "poc_interim" {
  count = local.poc_tls_ready ? 1 : 0

  domain_name       = var.poc_interim_domain
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

output "acm_validation_records" {
  description = "DNS records to create for certificate validation. Hand these to whoever owns the zone."
  value = local.official_tls_ready ? [
    for o in aws_acm_certificate.interim[0].domain_validation_options : {
      name  = o.resource_record_name
      type  = o.resource_record_type
      value = o.resource_record_value
    }
  ] : []
}

output "poc_acm_validation_records" {
  description = <<-EOT
    DNS records to create for the PARTNER POC certificate. Separate from the official list because
    the two endpoints can be validated independently, and because this is the one that unblocks the
    Fly cutover.
  EOT
  value = local.poc_tls_ready ? [
    for o in aws_acm_certificate.poc_interim[0].domain_validation_options : {
      name  = o.resource_record_name
      type  = o.resource_record_type
      value = o.resource_record_value
    }
  ] : []
}
