###############################################################################
# EC2 Instance Module
###############################################################################

data "aws_partition" "current" {}
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

locals {
  # One entry per browser-direct upload purpose. The presigned PUT is signed
  # with THIS role's credentials, so S3 evaluates these resource ARNs when the
  # browser uploads — a purpose registered in
  # ``app/core/storage_uploads.PURPOSE_MAX_BYTES`` but missing here fails with
  # 403 AccessDenied in production only, invisible to every local test and to
  # CI. Adding a purpose means adding its prefix in the SAME PR.
  uploads_read_write_prefixes = toset([
    "inventory_evidence",
    "feed_post_image",
    "feed_announcement",
    "approval_supplement",
    "evidence_documents",
    "vendor_assessment_evidence",
    "self_check_evidence",
    "inbound_questionnaire",
    # Added to the LIVE inline policy out-of-band when the bounty-attachment
    # feature shipped, never written back here — so a ``terraform apply`` of
    # this module would have silently revoked it and broken bounty
    # attachments. Reconciled 2026-09-02 (found while auditing why the new
    # asset_disposal_evidence prefix was missing).
    "bounty_attachment",
    # 资产处置凭证 (2026-09-02) — 回收单 / 付款截图 / 归还回执 / 销毁照片.
    "asset_disposal_evidence",
    # 安全宣贯素材 (2026-09-03) — 漫画页 / 文章配图 / 封面. Unlike its
    # neighbours this purpose is also role-gated on the WRITE side
    # (storage_router._PURPOSE_WRITE_GATES): only the security desk
    # uploads it, every employee reads it.
    "awareness_material",
    # AI 实验室 (2026-09-03). Three INPUT purposes the browser PUTs to
    # directly, plus one OUTPUT namespace the backend writes itself.
    #
    # ``ai_labs_output`` is the one entry here that is NOT an upload
    # purpose, so `test_every_upload_purpose_has_an_iam_prefix` cannot
    # catch its absence: the executor calls `put_object_bytes` with the
    # EC2 role's own credentials to re-home a generated image or clip, and
    # `presign_get` signs the employee's download with the same role.
    # Without the grant every generation would succeed at the vendor and
    # then fail to store — in production only, exactly like
    # `asset_disposal_evidence` did.
    "ai_labs_image",
    "ai_labs_document",
    "ai_labs_audio",
    "ai_labs_output",
  ])
  uploads_read_only_prefixes = toset([
    "cli-dist",
    "plugin-dist",
  ])
  # The hourly orphan cleanup only deletes durable StorageUploadGrant objects.
  # Every such key is date-partitioned as <purpose>/YYYY/..., and
  # approval_supplement is not a grant-producing namespace.
  uploads_cleanup_prefixes = setsubtract(
    local.uploads_read_write_prefixes,
    toset(["approval_supplement"]),
  )
  uploads_all_prefixes = setunion(
    local.uploads_read_write_prefixes,
    local.uploads_read_only_prefixes,
  )
}

data "aws_iam_policy_document" "ec2_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ec2" {
  name               = "${var.project}-${var.environment}-ec2-role"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json

  tags = {
    Project     = var.project
    Environment = var.environment
  }
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ec2.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

data "aws_iam_policy_document" "ecr_secrets" {
  statement {
    sid       = "ECRAuthorization"
    actions   = ["ecr:GetAuthorizationToken"]
    resources = ["*"]
  }

  statement {
    sid = "ECRRepositoryRead"
    actions = [
      "ecr:BatchCheckLayerAvailability",
      "ecr:GetDownloadUrlForLayer",
      "ecr:BatchGetImage",
    ]
    resources = var.ecr_arns
  }

  # deploy.sh -> render-env.sh reads only this environment's Parameter
  # Store namespace. AmazonSSMManagedInstanceCore manages the agent but does
  # not grant application parameter reads.
  statement {
    sid = "ParameterStoreRead"
    actions = [
      "ssm:GetParameter",
      "ssm:GetParameters",
      "ssm:GetParametersByPath",
    ]
    resources = [
      "arn:${data.aws_partition.current.partition}:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${var.project}/${var.environment}/*",
    ]
  }

  # AmazonSSMManagedInstanceCore currently grants GetParameter/GetParameters
  # on Resource="*". IAM allows are additive, so the scoped allow above does
  # not constrain that managed policy. This explicit deny is the effective
  # application boundary and still permits the exact production namespace.
  statement {
    sid    = "ParameterStoreBoundary"
    effect = "Deny"
    actions = [
      "ssm:GetParameter",
      "ssm:GetParameters",
      "ssm:GetParametersByPath",
    ]
    not_resources = [
      "arn:${data.aws_partition.current.partition}:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${var.project}/${var.environment}/*",
    ]
  }

  statement {
    sid       = "S3DeployAssetsRead"
    actions   = ["s3:GetObject"]
    resources = ["${var.state_bucket_arn}/deploy-assets/*"]
  }

  statement {
    sid       = "S3DeployAssetsList"
    actions   = ["s3:ListBucket"]
    resources = [var.state_bucket_arn]
    condition {
      test     = "StringLike"
      variable = "s3:prefix"
      values   = ["deploy-assets/*"]
    }
  }
}

resource "aws_iam_role_policy" "ecr_secrets" {
  # Preserve the live inline-policy identity during state adoption. Policy
  # content remains fully declarative and can be tightened in place.
  name   = "${var.project}-${var.environment}-ecr-ssm-s3"
  role   = aws_iam_role.ec2.id
  policy = data.aws_iam_policy_document.ecr_secrets.json
}

# Adopt the existing production inline policy by its exact live name. Keep
# application storage independent from state-bucket deploy-assets access.
data "aws_iam_policy_document" "uploads_access" {
  statement {
    sid     = "ApplicationObjectReadWrite"
    actions = ["s3:GetObject", "s3:PutObject"]
    resources = [
      for prefix in local.uploads_read_write_prefixes :
      "${var.uploads_bucket_arn}/${prefix}/*"
    ]
  }

  statement {
    sid     = "SelfServiceArtifactRead"
    actions = ["s3:GetObject"]
    resources = [
      for prefix in local.uploads_read_only_prefixes :
      "${var.uploads_bucket_arn}/${prefix}/*"
    ]
  }

  statement {
    sid     = "OrphanUploadCleanup"
    actions = ["s3:DeleteObject"]
    resources = [
      for prefix in local.uploads_cleanup_prefixes :
      "${var.uploads_bucket_arn}/${prefix}/20??/*"
    ]
  }

  # S3 returns 403 for a missing HeadObject when ListBucket is absent, but
  # stat_object must distinguish a missing upload (404) from IAM denial. The
  # condition grants that absence check only for the registered prefixes
  # (``uploads_all_prefixes``) — a hard-coded count here has already gone
  # stale once, so the comment names the set instead.
  statement {
    sid       = "PermittedPrefixExistenceCheck"
    actions   = ["s3:ListBucket"]
    resources = [var.uploads_bucket_arn]

    condition {
      test     = "StringLike"
      variable = "s3:prefix"
      values = [
        for prefix in local.uploads_all_prefixes : "${prefix}/*"
      ]
    }
  }
}

resource "aws_iam_role_policy" "uploads_access" {
  name   = "HelixProdUploadsAccess"
  role   = aws_iam_role.ec2.id
  policy = data.aws_iam_policy_document.uploads_access.json
}

resource "aws_iam_instance_profile" "ec2" {
  name = "${var.project}-${var.environment}-ec2-profile"
  role = aws_iam_role.ec2.name

  tags = {
    Project     = var.project
    Environment = var.environment
  }
}

###############################################################################
# EC2 Instance
###############################################################################

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "this" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = var.instance_type
  subnet_id              = var.subnet_id
  vpc_security_group_ids = [var.security_group_id]
  iam_instance_profile   = aws_iam_instance_profile.ec2.name
  key_name               = var.key_name

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"
    http_put_response_hop_limit = 2
    instance_metadata_tags      = "disabled"
  }

  root_block_device {
    volume_size = 50
    volume_type = "gp3"
    encrypted   = true
  }

  user_data = <<-EOF
    #!/bin/bash
    set -euo pipefail

    # Install Docker
    dnf update -y
    dnf install -y docker
    systemctl enable docker
    systemctl start docker

    # Install docker-compose-plugin
    dnf install -y docker-compose-plugin

    # Add ec2-user to docker group
    usermod -aG docker ec2-user

    # Create Helix directory structure
    mkdir -p /opt/helix/{app,config,data,logs}
    chown -R ec2-user:ec2-user /opt/helix
  EOF

  tags = {
    Name        = "${var.project}-${var.environment}-app"
    Project     = var.project
    Environment = var.environment
  }

  lifecycle {
    # The live bootstrap predates this module. Replacing user_data during state
    # adoption could force a stop/start; bootstrap convergence is a separate
    # reviewed rollout.
    ignore_changes = [ami, user_data]
  }
}
