name: Build and Push Frontend

on:
  push:
    branches:
      - main
    paths: &frontend_paths
      # Código y configuración real del frontend.
      # Los cambios exclusivos de GitOps (frontend.yaml, ingress, patches, etc.)
      # no vuelven a construir la imagen.
      - 'workloads/ecommerce/Dockerfile'
      - 'workloads/ecommerce/.dockerignore'
      - 'workloads/ecommerce/.npmrc'
      - 'workloads/ecommerce/package.json'
      - 'workloads/ecommerce/package-lock.json'
      - 'workloads/ecommerce/next.config.*'
      - 'workloads/ecommerce/tsconfig.json'
      - 'workloads/ecommerce/tailwind.config.*'
      - 'workloads/ecommerce/postcss.config.*'
      - 'workloads/ecommerce/eslint.config.*'
      - 'workloads/ecommerce/*.js'
      - 'workloads/ecommerce/*.mjs'
      - 'workloads/ecommerce/*.ts'
      - 'workloads/ecommerce/*.tsx'
      - 'workloads/ecommerce/app/**'
      - 'workloads/ecommerce/src/**'
      - 'workloads/ecommerce/components/**'
      - 'workloads/ecommerce/lib/**'
      - 'workloads/ecommerce/public/**'
      - 'workloads/ecommerce/styles/**'
      - '.gitea/workflows/build.yaml'
  pull_request:
    branches:
      - main
    paths: *frontend_paths

permissions:
  contents: write
  packages: write

jobs:
  # Corre en push y en pull_request, siempre antes que build. Si encuentra
  # un secreto commiteado, el step termina con exit code distinto de 0 y,
  # por el "needs" del job build, la imagen nunca se construye ni se sube.
  gitleaks:
    name: Escaneo de Secretos (Gitleaks)
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - name: Checkout del código
        uses: actions/checkout@v3
        with:
          fetch-depth: 1

      - name: Instalar Gitleaks
        shell: bash
        run: |
          set -euo pipefail
          GITLEAKS_VERSION="8.21.2"
          curl -sSfL \
            "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
            -o /tmp/gitleaks.tar.gz
          tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
          chmod +x /tmp/gitleaks
          /tmp/gitleaks version

      # --no-git: escanea el árbol de archivos del checkout (fetch-depth: 1,
      # sin historial), no el log de commits. El scan histórico completo del
      # repo se corre aparte, manualmente, no en cada push/PR.
      - name: Escanear secretos en el árbol de archivos
        shell: bash
        run: |
          set -euo pipefail
          /tmp/gitleaks detect \
            --source=workloads/ecommerce \
            --no-git \
            --redact \
            --report-format=json \
            --report-path=gitleaks-report.json \
            --exit-code=1

      - name: Publicar reporte de Gitleaks
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: gitleaks-report
          path: gitleaks-report.json
          if-no-files-found: ignore

  build:
    name: Construir y Subir Imagen
    needs: gitleaks
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    timeout-minutes: 20

    env:
      APP_DIR: workloads/ecommerce
      MANIFEST_FILE: workloads/ecommerce/frontend.yaml
      IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-frontend

    steps:
      - name: Checkout del código
        uses: actions/checkout@v3
        with:
          fetch-depth: 1
          persist-credentials: true

      # Conservamos el esquema estable v1.0.X basado en el número del pipeline.
      - name: Definir Versión Semántica
        id: vars
        shell: bash
        run: |
          set -euo pipefail
          echo "VERSION=v1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT"

      - name: Validar secretos del Registry
        shell: bash
        env:
          REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
          REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
        run: |
          set -euo pipefail

          test -n "${REGISTRY_USER}" || {
            echo "ERROR: REGISTRY_USER no está configurado."
            exit 1
          }

          test -n "${REGISTRY_PASSWORD}" || {
            echo "ERROR: REGISTRY_PASSWORD no está configurado."
            exit 1
          }

          echo "OK: secretos del Registry disponibles."

      # Evita que archivos de ejemplo entren en la compilación de Next.js.
      # Se eliminan únicamente del workspace temporal del runner.
      - name: Preparar contexto de producción
        shell: bash
        run: |
          set -euo pipefail

          test -f "${APP_DIR}/Dockerfile"
          test -f "${APP_DIR}/package.json"
          test -f "${APP_DIR}/package-lock.json"
          test -f "${MANIFEST_FILE}"

          rm -rf "${APP_DIR}/integration"

          find "${APP_DIR}"             -type f             \( -name '*.example.ts' -o -name '*.example.tsx' \)             -delete

          echo "Contexto listo:"
          du -sh "${APP_DIR}"

      # Valida los tres componentes reales que controlan la navegación.
      # No busca CategoryMenu.tsx porque ese archivo era código sin uso.
      - name: Validar navegación real del catálogo
        shell: bash
        run: |
          set -euo pipefail

          NAVBAR_FILE="${APP_DIR}/components/layout/navbar.tsx"
          CATALOG_FILE="${APP_DIR}/components/catalog/catalog-client.tsx"
          PAGE_FILE="${APP_DIR}/app/catalog/page.tsx"

          for FILE in "${NAVBAR_FILE}" "${CATALOG_FILE}" "${PAGE_FILE}"; do
            if [ ! -f "${FILE}" ]; then
              echo "ERROR: no existe ${FILE}"
              exit 1
            fi
          done

          echo "Validando Navbar real: ${NAVBAR_FILE}"

          grep -q 'useSearchParams' "${NAVBAR_FILE}" || {
            echo "ERROR: navbar.tsx no usa useSearchParams."
            exit 1
          }

          grep -q 'currentCategory' "${NAVBAR_FILE}" || {
            echo "ERROR: navbar.tsx no calcula currentCategory."
            exit 1
          }

          grep -q 'currentCollection' "${NAVBAR_FILE}" || {
            echo "ERROR: navbar.tsx no calcula currentCollection."
            exit 1
          }

          grep -q 'collection=Marvel' "${NAVBAR_FILE}" || {
            echo "ERROR: falta collection=Marvel en navbar.tsx."
            exit 1
          }

          grep -q 'category=Mochilas' "${NAVBAR_FILE}" || {
            echo "ERROR: falta category=Mochilas en navbar.tsx."
            exit 1
          }

          grep -q 'aria-current={active ? "page" : undefined}' "${NAVBAR_FILE}" || {
            echo "ERROR: navbar.tsx no expone correctamente el menú activo."
            exit 1
          }

          echo "Validando sincronización del catálogo: ${CATALOG_FILE}"

          grep -q 'useEffect' "${CATALOG_FILE}" || {
            echo "ERROR: catalog-client.tsx no usa useEffect."
            exit 1
          }

          grep -q 'setCategory(initialCategory' "${CATALOG_FILE}" || {
            echo "ERROR: catalog-client.tsx no sincroniza initialCategory."
            exit 1
          }

          grep -q 'setCollection(initialCollection' "${CATALOG_FILE}" || {
            echo "ERROR: catalog-client.tsx no sincroniza initialCollection."
            exit 1
          }

          grep -q 'setQuery(initialQuery' "${CATALOG_FILE}" || {
            echo "ERROR: catalog-client.tsx no sincroniza initialQuery."
            exit 1
          }

          echo "Validando remontaje por parámetros: ${PAGE_FILE}"

          grep -q 'key={`${category}:${collection}:${query}:${section}`}' "${PAGE_FILE}" || {
            echo "ERROR: app/catalog/page.tsx no remonta CatalogClient al cambiar la URL."
            exit 1
          }

          echo "OK: navegación real del catálogo validada."

      - name: Instalar Trivy
        shell: bash
        run: |
          set -euo pipefail
          TRIVY_VERSION="0.74.0"
          curl -sSfL \
            "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
            -o /tmp/trivy.tar.gz
          tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
          chmod +x /tmp/trivy
          /tmp/trivy version

      # Escanea el manifiesto Kubernetes real del frontend (no la imagen):
      # contenedores como root, falta de resource limits, falta de
      # readiness/liveness probes, etc. Informativo por ahora — no bloquea
      # el pipeline mientras revisamos juntos qué hallazgos son reales.
      - name: Escanear manifiestos Kubernetes (Trivy IaC)
        shell: bash
        run: |
          set -euo pipefail
          /tmp/trivy config \
            --severity CRITICAL,HIGH,MEDIUM \
            --exit-code 0 \
            "${MANIFEST_FILE}"

      # Modo auditoría: sin --error a propósito, Semgrep siempre termina
      # con exit 0 aunque reporte hallazgos. Es la primera vuelta — se
      # revisan los resultados en conjunto antes de decidir qué reglas
      # deberían pasar a bloquear el pipeline más adelante.
      - name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea
        shell: bash
        run: |
          set -euo pipefail
          SEMGREP_IMAGE="semgrep/semgrep:1.173.0"

          docker run --rm \
            -v "${{ github.workspace }}:/src" \
            -w /src \
            "${SEMGREP_IMAGE}" \
            semgrep scan \
              --config=p/typescript \
              --config=p/react \
              --config=p/nextjs \
              --config=p/security-audit \
              --json \
              --output=semgrep-report.json \
              "${APP_DIR}"

          echo "=== Resumen Semgrep ==="
          docker run --rm \
            -v "${{ github.workspace }}:/src" \
            -w /src \
            "${SEMGREP_IMAGE}" \
            python3 -c "
          import json
          data = json.load(open('semgrep-report.json'))
          results = data.get('results', [])
          print(f'Hallazgos: {len(results)}')
          for r in results:
              print(f\"  [{r['extra']['severity']}] {r['check_id']} - {r['path']}:{r['start']['line']}\")
          "

      - name: Publicar reporte de Semgrep
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: semgrep-report
          path: semgrep-report.json
          if-no-files-found: ignore

      - name: Login en Gitea Registry
        uses: docker/login-action@v2
        with:
          registry: gitea.cruzcloud.net
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
          logout: true

      # push: false — la imagen se queda cargada en el daemon local (load:
      # true) para poder escanearla con Trivy antes de subirla al registry.
      - name: Construir Imagen
        uses: docker/build-push-action@v4
        with:
          context: workloads/ecommerce/
          push: false
          load: true
          tags: |
            ${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
            ${{ env.IMAGE_NAME }}:latest

      # CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
      # crítica conocida y con fix disponible.
      - name: Escanear imagen (Trivy) — CRITICAL bloquea
        shell: bash
        run: |
          set -euo pipefail
          /tmp/trivy image \
            --severity CRITICAL \
            --exit-code 1 \
            --ignore-unfixed \
            "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"

      # HIGH solo informa por ahora — no bloquea mientras aprendemos a leer
      # los reportes y decidimos, con calma, qué reglas deben bloquear.
      - name: Escanear imagen (Trivy) — HIGH informativo
        shell: bash
        run: |
          set -euo pipefail
          /tmp/trivy image \
            --severity HIGH \
            --exit-code 0 \
            --ignore-unfixed \
            "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"

      # SBOM de la imagen que ya pasó el gate de CRITICAL — describe
      # exactamente qué paquetes (y en qué versión) quedaron adentro.
      # CycloneDX: formato más usado por herramientas de consulta/alertas
      # de CVEs (ej. cruzar el SBOM contra un aviso nuevo tipo log4shell).
      - name: Instalar Syft
        shell: bash
        run: |
          set -euo pipefail
          SYFT_VERSION="1.51.0"
          curl -sSfL \
            "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
            -o /tmp/syft.tar.gz
          tar -xzf /tmp/syft.tar.gz -C /tmp syft
          chmod +x /tmp/syft
          /tmp/syft version

      - name: Generar SBOM (Syft)
        shell: bash
        run: |
          set -euo pipefail
          /tmp/syft "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" \
            -o cyclonedx-json=sbom.cdx.json \
            -o spdx-json=sbom.spdx.json

      - name: Publicar SBOM
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: sbom-${{ steps.vars.outputs.VERSION }}
          path: |
            sbom.cdx.json
            sbom.spdx.json
          if-no-files-found: ignore

      # Login ya se hizo arriba; recién acá se sube, después de que la
      # imagen pasó el gate de CRITICAL.
      - name: Subir Imagen al Registry
        shell: bash
        run: |
          set -euo pipefail
          docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
          docker push "${IMAGE_NAME}:latest"

      - name: Instalar Cosign
        shell: bash
        run: |
          set -euo pipefail
          COSIGN_VERSION="3.1.3"
          curl -sSfL \
            "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
            -o /tmp/cosign
          chmod +x /tmp/cosign
          /tmp/cosign version

      # Firma solo la versión (no :latest, que es un tag mutable y firmarlo
      # pierde sentido en cuanto se vuelve a mover). --use-signing-config=false
      # --tlog-upload=false: firma solo con el par de llaves propio, sin
      # publicar metadata en el transparency log público de Sigstore — este
      # registry es privado, no tiene sentido anunciar públicamente qué se
      # firmó y cuándo.
      - name: Firmar Imagen (Cosign)
        shell: bash
        env:
          COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
        run: |
          set -euo pipefail
          /tmp/cosign sign \
            --key env://COSIGN_PRIVATE_KEY \
            --use-signing-config=false \
            --tlog-upload=false \
            --yes \
            "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"

      # Smoke test: confirma en el propio pipeline que la firma que se
      # acaba de crear valida contra la llave pública commiteada en el
      # repo (workloads/ecommerce/cosign.pub). Es la misma verificación
      # que, más adelante, podría correr un admission controller en el
      # cluster antes de dejar desplegar la imagen (ver docs/devsecops/cosign.md).
      - name: Verificar Firma (smoke test)
        shell: bash
        run: |
          set -euo pipefail
          /tmp/cosign verify \
            --key "${APP_DIR}/cosign.pub" \
            --insecure-ignore-tlog=true \
            "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"

      # Evita que una ejecución antigua actualice frontend.yaml después de que
      # ya exista un commit más reciente en main.
      - name: Verificar promoción segura
        id: promotion
        shell: bash
        run: |
          set -euo pipefail

          git fetch origin main

          CURRENT_SHA="${{ github.sha }}"
          REMOTE_SHA="$(git rev-parse origin/main)"

          echo "Commit del pipeline: ${CURRENT_SHA}"
          echo "Último commit remoto: ${REMOTE_SHA}"

          if [ "${CURRENT_SHA}" = "${REMOTE_SHA}" ]; then
            echo "promote=true" >> "$GITHUB_OUTPUT"
            echo "La imagen puede promocionarse."
          else
            echo "promote=false" >> "$GITHUB_OUTPUT"
            echo "La imagen fue publicada, pero no se actualizará el manifiesto."
            echo "Existe un commit más reciente y su pipeline debe realizar la promoción."
          fi

      - name: Actualizar Manifiesto GitOps (CD)
        if: steps.promotion.outputs.promote == 'true'
        shell: bash
        run: |
          set -euo pipefail

          VERSION="${{ steps.vars.outputs.VERSION }}"

          git config user.name "Cristian Felipe"
          git config user.email "cristiancruz0529@gmail.com"

          # Sincronizamos el workspace temporal con main.
          # No se fuerza ni se sobrescribe la rama remota.
          git fetch origin main
          git checkout -B main origin/main

          sed -i -E             "s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g"             "${MANIFEST_FILE}"

          git add "${MANIFEST_FILE}"

          if git diff --cached --quiet; then
            echo "No hay cambios en el manifiesto."
            exit 0
          fi

          git commit             -m "chore: release ${VERSION} [skip ci]"

          # Push normal: si main cambió durante el job, se detiene para evitar
          # sobrescribir commits ajenos o promocionar una versión antigua.
          git push origin HEAD:main

      - name: Resumen del pipeline
        if: always()
        shell: bash
        run: |
          echo "========================================"
          echo "ARI Shopping Frontend"
          echo "Versión: ${{ steps.vars.outputs.VERSION }}"
          echo "Commit:  ${{ github.sha }}"
          echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}"
          echo "========================================"