# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 09.23.00
# BRIEF: PR gate — branch policy + code validation before merge

name: "Universal: PR Check"

on:
  pull_request:
    types: [opened, synchronize, reopened, edited]

permissions:
  contents: read
  pull-requests: write

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
  # ── Branch Policy ──────────────────────────────────────────────────────
  branch-policy:
    name: Branch Policy
    runs-on: ubuntu-latest
    steps:
      - name: Check branch merge target
        run: |
          HEAD="${{ github.head_ref }}"
          BASE="${{ github.base_ref }}"

          echo "PR: ${HEAD} → ${BASE}"

          ALLOWED=true
          REASON=""

          case "$HEAD" in
            feature/*|feat/*)
              if [ "$BASE" != "dev" ]; then
                ALLOWED=false
                REASON="Feature branches must target 'dev', not '${BASE}'"
              fi
              ;;
            fix/*|bugfix/*)
              if [ "$BASE" != "dev" ]; then
                ALLOWED=false
                REASON="Fix branches must target 'dev', not '${BASE}'"
              fi
              ;;
            patch/*)
              if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
                ALLOWED=false
                REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
              fi
              ;;
            hotfix/*)
              if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
                ALLOWED=false
                REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
              fi
              ;;
            rc)
              if [ "$BASE" != "main" ]; then
                ALLOWED=false
                REASON="RC branch can only merge into 'main', not '${BASE}'"
              fi
              ;;
            dev)
              if [ "$BASE" != "main" ]; then
                ALLOWED=false
                REASON="Dev branch can only merge into 'main', not '${BASE}'"
              fi
              ;;
          esac

          if [ "$ALLOWED" = false ]; then
            echo "::error::${REASON}"
            echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
            echo "" >> $GITHUB_STEP_SUMMARY
            echo "${REASON}" >> $GITHUB_STEP_SUMMARY
            echo "" >> $GITHUB_STEP_SUMMARY
            echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
            echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
            echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
            echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
            echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
            echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
            exit 1
          fi

          echo "Branch policy: OK (${HEAD} → ${BASE})"
          echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY

  # ── Secret Scanning ──────────────────────────────────────────────────
  gitleaks:
    name: Secret Scan
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install Gitleaks
        run: |
          GITLEAKS_VERSION="8.21.2"
          curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
            | tar -xz -C /usr/local/bin gitleaks

      - name: Scan PR commits for secrets
        run: |
          if gitleaks detect --source . --verbose \
            --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
            echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
          else
            echo "::error::Potential secrets detected in PR commits"
            exit 1
          fi

  # ── Code Validation ────────────────────────────────────────────────────
  validate:
    name: Validate PR
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Check for merge conflict markers
        run: |
          CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
          if [ -n "$CONFLICTS" ]; then
            echo "::error::Merge conflict markers found in source files"
            echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
            echo '```' >> $GITHUB_STEP_SUMMARY
            echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
            echo '```' >> $GITHUB_STEP_SUMMARY
            exit 1
          fi
          echo "No conflict markers found"

      - name: Detect platform
        id: platform
        run: |
          # Read platform from XML manifest (<platform> tag) or plain text fallback
          PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
          [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
          [ -z "$PLATFORM" ] && PLATFORM="generic"
          echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"

      - name: Setup PHP
        if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
        run: |
          if ! command -v php &> /dev/null; then
            sudo apt-get update -qq
            sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
          fi

      - name: PHP syntax check
        if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
        run: |
          ERRORS=0
          while IFS= read -r -d '' file; do
            if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
              ERRORS=$((ERRORS + 1))
            fi
          done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
          echo "PHP lint: ${ERRORS} error(s)"
          [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }

      - name: Joomla JEXEC guard check
        if: steps.platform.outputs.platform == 'joomla'
        run: |
          ERRORS=0
          while IFS= read -r -d '' file; do
            # Skip vendor, node_modules, and index.html stub files
            case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
            # Check first 10 lines for JEXEC or JPATH guard
            if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then
              echo "::error file=${file}::Missing JEXEC guard: ${file}"
              ERRORS=$((ERRORS + 1))
            fi
          done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
          if [ "$ERRORS" -gt 0 ]; then
            echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
            echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
            echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
            exit 1
          fi
          echo "JEXEC guard: OK"

      - name: Joomla directory listing protection
        if: steps.platform.outputs.platform == 'joomla'
        run: |
          MISSING=0
          SOURCE_DIR="src"
          [ ! -d "$SOURCE_DIR" ] && exit 0
          while IFS= read -r dir; do
            if [ ! -f "${dir}/index.html" ]; then
              echo "::warning::Missing index.html in ${dir} (directory listing protection)"
              MISSING=$((MISSING + 1))
            fi
          done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
          if [ "$MISSING" -gt 0 ]; then
            echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
            echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
          fi
          echo "Directory protection: ${MISSING} missing (advisory)"

      - name: Joomla script file and asset checks
        if: steps.platform.outputs.platform == 'joomla'
        run: |
          ERRORS=0
          MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
          [ -z "$MANIFEST" ] && exit 0
          MANIFEST_DIR=$(dirname "$MANIFEST")

          # Check scriptfile exists if declared
          SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
          if [ -n "$SCRIPTFILE" ]; then
            if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
              echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
              ERRORS=$((ERRORS + 1))
            else
              echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
            fi
          fi

          # Require joomla.asset.json and validate it
          ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
          if [ -z "$ASSET_JSON" ]; then
            echo "::error::joomla.asset.json not found — Joomla asset system is required"
            ERRORS=$((ERRORS + 1))
          else
            if command -v php &> /dev/null; then
              php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
                echo "::error::joomla.asset.json is not valid JSON"
                ERRORS=$((ERRORS + 1))
              }
            fi
            echo "joomla.asset.json: valid"
          fi

          # Validate all XML files in src/ are well-formed
          XML_ERRORS=0
          if command -v php &> /dev/null; then
            while IFS= read -r -d '' xmlfile; do
              if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
                XML_ERRORS=$((XML_ERRORS + 1))
              fi
            done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
          fi
          if [ "$XML_ERRORS" -gt 0 ]; then
            echo "::error::${XML_ERRORS} XML file(s) are malformed"
            ERRORS=$((ERRORS + 1))
          else
            echo "XML well-formedness: OK"
          fi

          [ "$ERRORS" -gt 0 ] && exit 1
          echo "Joomla asset checks: OK"

      - name: Validate platform manifest
        run: |
          PLATFORM="${{ steps.platform.outputs.platform }}"
          case "$PLATFORM" in
            joomla)
              MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
              if [ -z "$MANIFEST" ]; then
                echo "::warning::No Joomla manifest found (WaaS site)"
                exit 0
              fi
              echo "Manifest: ${MANIFEST}"
              if command -v php &> /dev/null; then
                php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
              fi
              for ELEMENT in name version description; do
                grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
              done
              # Block legacy raw/branch update server URLs on MokoGitea
              RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
              if [ -n "$RAW_URLS" ]; then
                echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
                echo "$RAW_URLS"
                exit 1
              fi
              echo "Joomla manifest valid"
              ;;
            dolibarr)
              MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
              if [ -z "$MOD_FILE" ]; then
                echo "::error::No mod*.class.php found"
                exit 1
              fi
              echo "Dolibarr module: ${MOD_FILE}"
              ;;
            *)
              echo "Generic platform — no manifest validation"
              ;;
          esac

      - name: Check update stream format
        run: |
          PLATFORM="${{ steps.platform.outputs.platform }}"
          case "$PLATFORM" in
            joomla)
              if [ -f "updates.xml" ]; then
                if command -v php &> /dev/null; then
                  php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
                fi
                echo "updates.xml valid"
              fi
              ;;
            dolibarr)
              [ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
              ;;
          esac

      - name: Validate Joomla language files
        if: steps.platform.outputs.platform == 'joomla'
        run: |
          ERRORS=0
          WARNINGS=0

          # Require both en-GB and en-US language directories
          LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
          if [ -z "$LANG_ROOT" ]; then
            echo "No language/ directory found — skipping"
            exit 0
          fi

          if [ ! -d "$LANG_ROOT/en-GB" ]; then
            echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
            ERRORS=$((ERRORS + 1))
          fi
          if [ ! -d "$LANG_ROOT/en-US" ]; then
            echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
            ERRORS=$((ERRORS + 1))
          fi

          # Check that en-GB and en-US have matching .ini files
          if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
            for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
              [ ! -f "$GB_INI" ] && continue
              US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
              if [ ! -f "$US_INI" ]; then
                echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
                ERRORS=$((ERRORS + 1))
              fi
            done
            for US_INI in "$LANG_ROOT/en-US"/*.ini; do
              [ ! -f "$US_INI" ] && continue
              GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
              if [ ! -f "$GB_INI" ]; then
                echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
                ERRORS=$((ERRORS + 1))
              fi
            done
          fi

          # Find all .ini language files
          INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
          if [ -z "$INI_FILES" ]; then
            echo "No .ini language files found"
            [ "$ERRORS" -gt 0 ] && exit 1
            exit 0
          fi

          echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"

          for FILE in $INI_FILES; do
            FNAME=$(basename "$FILE")
            LINENUM=0
            SEEN_KEYS=""

            while IFS= read -r line || [ -n "$line" ]; do
              LINENUM=$((LINENUM + 1))

              # Skip empty lines and comments
              [ -z "$line" ] && continue
              echo "$line" | grep -qE '^\s*;' && continue
              echo "$line" | grep -qE '^\s*$' && continue

              # Must match KEY="VALUE" format
              if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
                echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
                ERRORS=$((ERRORS + 1))
                continue
              fi

              # Extract key and check for duplicates
              KEY=$(echo "$line" | sed 's/=.*//')
              if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
                echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
                ERRORS=$((ERRORS + 1))
              fi
              SEEN_KEYS="${SEEN_KEYS}
          ${KEY}"
            done < "$FILE"

            echo "  ${FILE}: checked ${LINENUM} lines"
          done

          # Cross-check en-GB vs en-US key consistency
          GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
          US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)

          if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
            for GB_FILE in "$GB_DIR"/*.ini; do
              [ ! -f "$GB_FILE" ] && continue
              FNAME=$(basename "$GB_FILE")
              US_FILE="$US_DIR/$FNAME"
              [ ! -f "$US_FILE" ] && continue

              GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
              US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)

              # Keys in en-GB but not en-US
              MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
              if [ -n "$MISSING_US" ]; then
                echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
                echo "$MISSING_US" | while read -r k; do echo "  - $k"; done
                WARNINGS=$((WARNINGS + 1))
              fi

              # Keys in en-US but not en-GB
              MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
              if [ -n "$MISSING_GB" ]; then
                echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
                echo "$MISSING_GB" | while read -r k; do echo "  - $k"; done
                WARNINGS=$((WARNINGS + 1))
              fi
            done
          fi

          {
            echo "### Language File Validation"
            echo "| Metric | Count |"
            echo "|---|---|"
            echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
            echo "| Errors | ${ERRORS} |"
            echo "| Warnings | ${WARNINGS} |"
          } >> $GITHUB_STEP_SUMMARY

          if [ "$ERRORS" -gt 0 ]; then
            echo "::error::Language validation failed with ${ERRORS} error(s)"
            exit 1
          fi
          echo "Language files: OK (${WARNINGS} warning(s))"

      - name: Check changelog has unreleased entry
        run: |
          if [ ! -f "CHANGELOG.md" ]; then
            echo "::warning::No CHANGELOG.md found"
            exit 0
          fi
          # Check for content under [Unreleased] section
          if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
            echo "::error::CHANGELOG.md missing [Unreleased] section"
            exit 1
          fi
          # Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
          UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
          if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
            echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
            echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
            echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
            echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
            exit 1
          fi
          echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"

      - name: Verify package source
        run: |
          SOURCE_DIR="src"
          [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
          if [ ! -d "$SOURCE_DIR" ]; then
            echo "::warning::No src/ or htdocs/ directory"
            exit 0
          fi
          FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
          echo "Source: ${FILE_COUNT} files"
          [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }

  # ── Pre-Release RC Build ─────────────────────────────────────────────────
  pre-release:
    name: Build RC Package
    runs-on: ubuntu-latest
    needs: [branch-policy, validate]

    steps:
      - name: Trigger RC pre-release
        env:
          GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
          REPO: ${{ github.repository }}
          BRANCH: ${{ github.head_ref }}
          GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
        run: |
          curl -s -X POST             "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches"             -H "Authorization: token ${GITEA_TOKEN}"             -H "Content-Type: application/json"             -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
          echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
          echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY

  # ── Issue Reporter ──────────────────────────────────────────────────────
  report-issues:
    name: Report Issues
    runs-on: ubuntu-latest
    needs: [branch-policy, validate]
    if: >-
      always() &&
      needs.validate.result == 'failure'

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          sparse-checkout: automation/ci-issue-reporter.sh
          sparse-checkout-cone-mode: false

      - name: "File issue for PR validation failure"
        env:
          GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
          GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
        run: |
          chmod +x automation/ci-issue-reporter.sh
          ./automation/ci-issue-reporter.sh \
            --gate "PR Validation" \
            --workflow "PR Check" \
            --severity error \
            --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
