Quality Stack Scripts

first run setup (portable · no Homebrew/Docker/admin)
bash scripts/quality/setup.sh
daily run (default path)
bash scripts/quality/local-quality.sh
setup.shcache SonarQube + sonar-scanner + sonar-flutter plugin
local-quality.shportable-local-sonar.shquality-check.shreports/quality/quality-report.html
legacy onlylocal-quality.sh --legacy-localHomebrew + Colima/Docker path
Changed in this deploy: local-quality.sh
setup.sh First-run portable setup
#!/bin/bash
# First-run setup for portable local quality tooling.
# No admin, no Homebrew, no Docker. Downloads SonarQube + sonar-scanner zips
# and installs a valid cache/vendor/mirror-provided sonar-flutter plugin so
# local-quality.sh can run portable local SonarQube by default.

set -euo pipefail

GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

log_info()  { echo -e "${CYAN}▸ $*${NC}"; }
log_ok()    { echo -e "${GREEN}✓ $*${NC}"; }
log_warn()  { echo -e "${YELLOW}⚠ $*${NC}"; }
log_error() { echo -e "${RED}✗ $*${NC}" >&2; }

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
CACHE_DIR="${TOP_FLUTTER_QUALITY_CACHE:-$HOME/.cache/top-flutter-quality}"
SONARQUBE_PORTABLE_VERSION="${SONARQUBE_PORTABLE_VERSION:-10.7.0.96327}"
SONAR_SCANNER_PORTABLE_VERSION="${SONAR_SCANNER_PORTABLE_VERSION:-6.2.1.4610}"
SONARQUBE_ZIP_URL="${SONARQUBE_ZIP_URL:-https://binaries.sonarsource.com/Distribution/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}.zip}"
SONAR_SCANNER_ZIP_URL="${SONAR_SCANNER_ZIP_URL:-https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_PORTABLE_VERSION}.zip}"
SONAR_FLUTTER_PLUGIN_VERSION="${SONAR_FLUTTER_PLUGIN_VERSION:-0.5.2}"
SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL="${SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL:-}"
SONAR_FLUTTER_PLUGIN_VENDOR_JAR="${SONAR_FLUTTER_PLUGIN_VENDOR_JAR:-$SCRIPT_DIR/vendor/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN="${SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN:-1}"
SONAR_FLUTTER_PLUGIN_JAR="${SONAR_FLUTTER_PLUGIN_JAR:-$CACHE_DIR/plugins/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"

show_help() {
  echo ""
  echo "Usage: bash scripts/quality/setup.sh"
  echo ""
  echo "First-run setup for Top Flutter local quality checks."
  echo "Downloads portable SonarQube and sonar-scanner, then installs a cache/vendor/mirror-provided sonar-flutter plugin."
  echo "No admin, Homebrew, Docker, or sudo required."
  echo ""
  echo "Requirements checked before download:"
  echo "  curl, unzip, python3"
  echo "  java (JDK 17) for bundled SonarQube 10.7; on macOS /usr/libexec/java_home -v 17 is preferred"
  echo ""
  echo "Cache:"
  echo "  TOP_FLUTTER_QUALITY_CACHE   Cache dir (default: ~/.cache/top-flutter-quality)"
  echo ""
  echo "Version/URL overrides:"
  echo "  SONARQUBE_PORTABLE_VERSION      default: $SONARQUBE_PORTABLE_VERSION"
  echo "  SONAR_SCANNER_PORTABLE_VERSION  default: $SONAR_SCANNER_PORTABLE_VERSION"
  echo "  SONARQUBE_ZIP_URL               default SonarQube zip URL"
  echo "  SONAR_SCANNER_ZIP_URL           default sonar-scanner zip URL"
  echo "  SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN  default: 1 (set 0 to skip)"
  echo "  SONAR_FLUTTER_PLUGIN_VERSION    default: $SONAR_FLUTTER_PLUGIN_VERSION"
  echo "  SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL optional explicit mirror URL for sonar-flutter plugin (no default)"
  echo "  SONAR_FLUTTER_PLUGIN_VENDOR_JAR default: scripts/quality/vendor/sonar-flutter-plugin-<version>.jar"
  echo "  SONAR_FLUTTER_PLUGIN_JAR        default: cache/plugins/sonar-flutter-plugin-<version>.jar"
  echo ""
  echo "Options:"
  echo "  --clean   Remove existing extracted SonarQube/scanner/plugin before installing"
  echo "            (cached zip files are kept to avoid re-downloading)"
  echo ""
  echo "After setup run:"
  echo "  bash scripts/quality/local-quality.sh"
  echo ""
}

CLEAN_INSTALL=0
for arg in "$@"; do
  case "$arg" in
    -h|--help) show_help; exit 0 ;;
    --clean)   CLEAN_INSTALL=1 ;;
    *) log_error "Unknown option: $arg"; echo "Run with --help for usage."; exit 2 ;;
  esac
done

resolve_java() {
  java_line_for_home() {
    "$1/bin/java" -version 2>&1 | python3 -c 'import sys; print(sys.stdin.read().split("\n")[0].strip())' 2>/dev/null || true
  }

  java_major_for_home() {
    "$1/bin/java" -version 2>&1 | python3 -c 'import re,sys
text=sys.stdin.read()
m=re.search(r"version \"([^\"]+)\"", text)
if not m:
    sys.exit(0)
v=m.group(1)
print(v.split(".")[1] if v.startswith("1.") else v.split(".")[0])' 2>/dev/null || true
  }

  use_java_home() {
    export JAVA_HOME="$1"
    export PATH="$JAVA_HOME/bin:$PATH"
  }

  require_java17_home() {
    local candidate="$1"
    local label="$2"
    local major=""
    local line=""

    if [[ -z "$candidate" || ! -x "$candidate/bin/java" ]]; then
      log_error "$label does not point to an executable JDK: $candidate"
      exit 1
    fi
    major="$(java_major_for_home "$candidate")"
    line="$(java_line_for_home "$candidate")"
    if [[ "$major" != "17" ]]; then
      log_error "SonarQube ${SONARQUBE_PORTABLE_VERSION} requires Java 17; $label is ${line:-unknown}."
      echo "  Set PORTABLE_JAVA_HOME to a JDK 17 archive extracted under your home directory."
      exit 1
    fi
    use_java_home "$candidate"
  }

  local mac_java17=""
  local path_java=""
  local path_home=""
  local path_major=""
  local path_line=""

  if [[ -n "${PORTABLE_JAVA_HOME:-}" ]]; then
    require_java17_home "$PORTABLE_JAVA_HOME" "PORTABLE_JAVA_HOME"
  elif [[ -n "${JAVA_HOME:-}" && -x "$JAVA_HOME/bin/java" && "$(java_major_for_home "$JAVA_HOME")" == "17" ]]; then
    use_java_home "$JAVA_HOME"
  elif [[ "$(uname -s)" == "Darwin" && -x /usr/libexec/java_home ]] && mac_java17="$(/usr/libexec/java_home -v 17 2>/dev/null || true)" && [[ -n "$mac_java17" && -x "$mac_java17/bin/java" ]]; then
    use_java_home "$mac_java17"
  elif command -v java >/dev/null 2>&1; then
    path_java="$(command -v java)"
    path_home="$(cd "$(dirname "$path_java")/.." && pwd)"
    path_major="$(java_major_for_home "$path_home")"
    path_line="$(java_line_for_home "$path_home")"
    if [[ "$path_major" == "17" ]]; then
      use_java_home "$path_home"
    else
      log_error "SonarQube ${SONARQUBE_PORTABLE_VERSION} requires Java 17; PATH java is ${path_line:-unknown}."
      echo "  Install/extract JDK 17 and set PORTABLE_JAVA_HOME, or on macOS install a JDK 17 visible to /usr/libexec/java_home -v 17."
      exit 1
    fi
  else
    log_error "Java 17 not found."
    echo ""
    echo "  To fix without admin/Homebrew:"
    echo "    1. Download a JDK 17 archive from https://adoptium.net/temurin/releases/"
    echo "    2. Extract it under your home directory, e.g. ~/.cache/jdk/"
    echo "    3. Re-run with: PORTABLE_JAVA_HOME=~/.cache/jdk/<extracted-dir> bash scripts/quality/setup.sh"
    echo ""
    exit 1
  fi

  log_ok "java: $(java_line_for_home "$JAVA_HOME")"
}

require_tool() {
  local tool="$1"
  if command -v "$tool" >/dev/null 2>&1; then
    log_ok "$tool: $(command -v "$tool")"
  else
    log_error "$tool is required but not found on PATH."
    exit 1
  fi
}

download_if_missing() {
  local url="$1"
  local output="$2"
  local label="$3"
  if [[ -f "$output" ]]; then
    log_ok "$label zip already cached: $output"
  else
    log_info "Downloading $label..."
    log_info "URL: $url"
    curl -fL --progress-bar "$url" -o "$output"
    log_ok "$label downloaded: $output"
  fi
}

download_file_if_missing() {
  local url="$1"
  local output="$2"
  local label="$3"
  if [[ -f "$output" ]]; then
    log_ok "$label already cached: $output"
  else
    mkdir -p "$(dirname "$output")"
    log_info "Downloading $label..."
    log_info "URL: $url"
    curl -fL --progress-bar "$url" -o "$output"
    log_ok "$label downloaded: $output"
  fi
}

validate_flutter_plugin_jar() {
  local jar_path="$1"

  [[ -f "$jar_path" ]] || return 1
  unzip -t "$jar_path" >/dev/null 2>&1
}

fail_missing_flutter_plugin() {
  log_error "Missing valid sonar-flutter plugin jar."
  echo ""
  echo "  GitHub fallback downloads are disabled. Provide the plugin explicitly by"
  echo "  placing a valid sonar-flutter plugin jar at:"
  echo "    $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
  echo "  Or prefill the cache with a valid jar at:"
  echo "    $SONAR_FLUTTER_PLUGIN_JAR"
  echo "  Or set SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted non-default mirror."
  echo ""
  exit 1
}

fail_invalid_flutter_plugin_vendor() {
  log_error "Repo-local sonar-flutter plugin vendor jar is not valid: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
  echo ""
  echo "  The vendor jar exists but failed validation. Replace it with a valid"
  echo "  sonar-flutter plugin jar."
  echo ""
  exit 1
}

fail_invalid_flutter_plugin_download() {
  log_error "Mirror-provided sonar-flutter plugin is not a valid jar: $SONAR_FLUTTER_PLUGIN_JAR"
  echo ""
  echo "  Replace it with a valid cache/vendor jar or set"
  echo "  SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted mirror that serves a jar."
  echo ""
  exit 1
}

fail_flutter_plugin_download_failed() {
  log_error "Could not download sonar-flutter plugin from explicit mirror: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL"
  echo ""
  echo "  Place a valid plugin jar at:"
  echo "    $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
  echo "  Or prefill the cache with a valid jar at:"
  echo "    $SONAR_FLUTTER_PLUGIN_JAR"
  echo ""
  exit 1
}

extract_if_missing() {
  local zip="$1"
  local dest_parent="$2"
  local dest_dir="$3"
  local glob_pattern="$4"
  local label="$5"
  if [[ -d "$dest_dir" ]]; then
    log_ok "$label already extracted: $dest_dir"
  else
    log_info "Extracting $label..."
    unzip -q "$zip" -d "$dest_parent"
    if [[ ! -d "$dest_dir" ]]; then
      extracted="$(find "$dest_parent" -maxdepth 1 -name "$glob_pattern" -type d | head -1)"
      if [[ -z "$extracted" ]]; then
        log_error "Could not find extracted $label directory under $dest_parent."
        exit 1
      fi
      mv "$extracted" "$dest_dir"
    fi
    log_ok "$label extracted: $dest_dir"
  fi
}

verify_layout() {
  local sq_dir="$1"
  local ss_dir="$2"
  local sq_script=""

  if [[ "$(uname -s)" == "Darwin" ]]; then
    sq_script="$sq_dir/bin/macosx-universal-64/sonar.sh"
  else
    sq_script="$sq_dir/bin/linux-x86-64/sonar.sh"
  fi
  if [[ ! -f "$sq_script" ]]; then
    sq_script="$(find "$sq_dir/bin" -name "sonar.sh" | head -1 || true)"
  fi
  if [[ -z "$sq_script" || ! -f "$sq_script" ]]; then
    log_error "Cannot find sonar.sh under $sq_dir/bin"
    exit 1
  fi
  chmod +x "$sq_script" 2>/dev/null || true
  log_ok "SonarQube launcher found: $sq_script"

  if [[ ! -x "$ss_dir/bin/sonar-scanner" ]]; then
    chmod +x "$ss_dir/bin/sonar-scanner" 2>/dev/null || true
  fi
  if [[ ! -x "$ss_dir/bin/sonar-scanner" ]]; then
    log_error "Cannot find executable sonar-scanner at $ss_dir/bin/sonar-scanner"
    exit 1
  fi
  log_ok "sonar-scanner found: $ss_dir/bin/sonar-scanner"
}

install_flutter_plugin() {
  local sq_dir="$1"
  local plugin_dest_dir="$sq_dir/extensions/plugins"
  local plugin_dest="$plugin_dest_dir/$(basename "$SONAR_FLUTTER_PLUGIN_JAR")"

  if [[ "$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN" != "1" ]]; then
    log_warn "Skipping sonar-flutter plugin install (SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN)"
    return 0
  fi

  if [[ -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
    if validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
      log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
    else
      log_warn "Removing invalid cached sonar-flutter plugin jar: $SONAR_FLUTTER_PLUGIN_JAR"
      rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
    fi
  fi

  if [[ ! -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
    mkdir -p "$(dirname "$SONAR_FLUTTER_PLUGIN_JAR")"
    if [[ -f "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" ]]; then
      log_info "Using repo-local sonar-flutter plugin vendor jar: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
      if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR"; then
        fail_invalid_flutter_plugin_vendor
      fi
      cp "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" "$SONAR_FLUTTER_PLUGIN_JAR"
      if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
        log_error "Copied sonar-flutter plugin cache jar is invalid: $SONAR_FLUTTER_PLUGIN_JAR"
        exit 1
      fi
      log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
    elif [[ -n "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" ]]; then
      log_info "Fetching sonar-flutter plugin ${SONAR_FLUTTER_PLUGIN_VERSION} from explicit mirror..."
      log_info "URL: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL"
      if ! curl -fL --progress-bar "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" -o "$SONAR_FLUTTER_PLUGIN_JAR"; then
        rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
        fail_flutter_plugin_download_failed
      fi
      if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
        rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
        fail_invalid_flutter_plugin_download
      fi
      log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
    else
      fail_missing_flutter_plugin
    fi
  fi

  mkdir -p "$plugin_dest_dir"
  cp "$SONAR_FLUTTER_PLUGIN_JAR" "$plugin_dest"
  if [[ ! -f "$plugin_dest" ]]; then
    log_error "sonar-flutter plugin was not installed at $plugin_dest"
    exit 1
  fi
  if ! validate_flutter_plugin_jar "$plugin_dest"; then
    log_error "sonar-flutter plugin installed jar is invalid: $plugin_dest"
    exit 1
  fi
  log_ok "sonar-flutter plugin installed: $plugin_dest"
}

echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN}  Top Flutter portable quality setup${NC}"
echo -e "${CYAN}  No Homebrew · No Docker · No admin${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""

require_tool curl
require_tool unzip
require_tool python3
resolve_java

mkdir -p "$CACHE_DIR/sonarqube" "$CACHE_DIR/sonar-scanner" "$CACHE_DIR/runtime" "$CACHE_DIR/plugins"
log_ok "Cache directories ready: $CACHE_DIR"

SQ_ZIP="$CACHE_DIR/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}.zip"
SQ_DIR="$CACHE_DIR/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}"
SS_ZIP="$CACHE_DIR/sonar-scanner/sonar-scanner-cli-${SONAR_SCANNER_PORTABLE_VERSION}.zip"
SS_DIR="$CACHE_DIR/sonar-scanner/sonar-scanner-${SONAR_SCANNER_PORTABLE_VERSION}"

if [[ "$CLEAN_INSTALL" == "1" ]]; then
  echo ""
  log_info "Cleaning previous installation..."
  [[ -d "$SQ_DIR" ]] && { rm -rf "$SQ_DIR"; log_ok "Removed: $SQ_DIR"; }
  [[ -d "$SS_DIR" ]] && { rm -rf "$SS_DIR"; log_ok "Removed: $SS_DIR"; }
  [[ -f "$SONAR_FLUTTER_PLUGIN_JAR" ]] && { rm -f "$SONAR_FLUTTER_PLUGIN_JAR"; log_ok "Removed: $SONAR_FLUTTER_PLUGIN_JAR"; }
  log_ok "Clean done (cached zips kept)"
fi

download_if_missing "$SONARQUBE_ZIP_URL" "$SQ_ZIP" "SonarQube ${SONARQUBE_PORTABLE_VERSION}"
extract_if_missing "$SQ_ZIP" "$CACHE_DIR/sonarqube" "$SQ_DIR" "sonarqube-*" "SonarQube"

download_if_missing "$SONAR_SCANNER_ZIP_URL" "$SS_ZIP" "sonar-scanner ${SONAR_SCANNER_PORTABLE_VERSION}"
extract_if_missing "$SS_ZIP" "$CACHE_DIR/sonar-scanner" "$SS_DIR" "sonar-scanner-*" "sonar-scanner"

verify_layout "$SQ_DIR" "$SS_DIR"
install_flutter_plugin "$SQ_DIR"

# Install very_good_cli for test coverage
if command -v very_good >/dev/null 2>&1; then
  log_ok "very_good_cli: $(very_good --version 2>/dev/null || echo 'installed')"
elif command -v dart >/dev/null 2>&1; then
  log_info "Installing very_good_cli via dart pub global..."
  dart pub global activate very_good_cli
  log_ok "very_good_cli installed"
else
  log_warn "dart not found — skipping very_good_cli (flutter test will be used instead)"
fi

echo ""
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${GREEN}  Portable quality tooling is ready.${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo ""
echo "Next command:"
echo "  bash scripts/quality/local-quality.sh"
echo ""
local-quality.sh ✦ Updated Default portable quality entrypoint
#!/bin/bash
# One-command local quality entry point.
# Default path is portable local SonarQube: no Homebrew, no Docker, no admin/sudo.
# Usage: bash scripts/quality/local-quality.sh [quality-check.sh flags/args]
# Legacy Docker/Homebrew path: bash scripts/quality/local-quality.sh --legacy-local [flags/args]

set -euo pipefail

GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

log_ok()    { echo -e "${GREEN}✓ $*${NC}"; }
log_warn()  { echo -e "${YELLOW}⚠ $*${NC}"; }
log_error() { echo -e "${RED}✗ $*${NC}" >&2; }

show_help() {
  echo ""
  echo "Usage: bash scripts/quality/local-quality.sh [OPTIONS]"
  echo ""
  echo "Default mode:"
  echo "  Portable local SonarQube from ~/.cache/top-flutter-quality."
  echo "  Default URL: http://localhost:9000 (override with SONAR_LOCAL_PORT)."
  echo "  Requires Java 17; macOS prefers /usr/libexec/java_home -v 17."
  echo "  No Homebrew, Docker, admin, or sudo required."
  echo ""
  echo "First run:"
  echo "  bash scripts/quality/setup.sh"
  echo ""
  echo "Options:"
  echo "  --keep-sonar-local   Keep local SonarQube running after scan"
  echo "  --keep-server        Alias for --keep-sonar-local"
  echo "  -d, --dup-threshold  Max duplication % (default: 3)"
  echo "  -c, --cov-threshold  Min coverage % (default: 80)"
  echo "  --focus AREAS        Focus on: coverage,duplication,smell"
  echo "  --minimal-focus      Shorthand for --focus coverage,duplication,smell"
  echo "  --focus-minimal      Alias for --minimal-focus"
  echo "  --smell-threshold N  Max code smells in focus mode (default: 0)"
  echo "  --legacy-local       Use old Homebrew + Colima/Docker local mode"
  echo "  --portable-local     Backward-compatible alias for the default mode"
  echo "  -h, --help           Show this help (no download)"
  echo ""
  echo "Examples:"
  echo "  bash scripts/quality/setup.sh"
  echo "  bash scripts/quality/local-quality.sh"
  echo "  bash scripts/quality/local-quality.sh --keep-sonar-local"
  echo ""
}

# Help is an early exit with no brew/download.
for arg in "$@"; do
  if [[ "$arg" == "-h" || "$arg" == "--help" ]]; then
    show_help
    exit 0
  fi
done

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
QUALITY_CHECK="$SCRIPT_DIR/quality-check.sh"
REPORT_HTML="$PROJECT_ROOT/reports/quality/quality-report.html"

LEGACY_MODE=false
LINT_MODE=""
REMAINING_ARGS=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --legacy-local)
      LEGACY_MODE=true
      shift
      ;;
    --portable-local)
      shift
      ;;
    --dev|--sit)
      LINT_MODE="${1#--}"
      shift
      ;;
    *)
      REMAINING_ARGS+=("$1")
      shift
      ;;
  esac
done

if [[ "$LEGACY_MODE" != true ]]; then
  echo ""
  echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
  echo -e "${CYAN}  local-quality.sh  →  portable-local-sonar.sh${NC}"
  echo -e "${CYAN}  Mode: Portable (no Homebrew/Docker/admin)${NC}"
  [[ -n "$LINT_MODE" ]] && echo -e "${CYAN}  Lint mode: $LINT_MODE${NC}"
  echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
  echo ""

  # Clean previous reports
  echo -e "${CYAN}▸ [0/3] Cleaning reports...${NC}"
  rm -rf "$PROJECT_ROOT/reports/quality" "$PROJECT_ROOT/reports/coverage" "$PROJECT_ROOT/reports/lint"
  mkdir -p "$PROJECT_ROOT/reports/quality" "$PROJECT_ROOT/reports/coverage" "$PROJECT_ROOT/reports/lint"
  echo -e "${CYAN}  ✓ reports/ cleaned${NC}"
  echo ""

  # Step 1 — Lint
  echo -e "${CYAN}▸ [1/3] Lint...${NC}"
  LINT_SCRIPT="$SCRIPT_DIR/flutter-lint.sh"
  if [[ -f "$LINT_SCRIPT" ]]; then
    if [[ -n "$LINT_MODE" ]]; then
      bash "$LINT_SCRIPT" "--$LINT_MODE" || true
    else
      bash "$LINT_SCRIPT" || true
    fi
  else
    echo -e "${YELLOW}⚠ flutter-lint.sh not found — skipping lint${NC}"
  fi

  # Step 2 — Test coverage (run tests fresh, then copy generated lcov)
  echo ""
  echo -e "${CYAN}▸ [2/3] Test coverage...${NC}"
  if command -v very_good >/dev/null 2>&1; then
    TEST_CMD="very_good test --coverage"
    echo -e "${CYAN}  Runner: very_good${NC}"
  else
    TEST_CMD="flutter test --coverage"
    echo -e "${CYAN}  Runner: flutter${NC}"
  fi
  COVERAGE_FOUND=false
  while IFS= read -r pubspec_dir; do
    pkg_rel="${pubspec_dir#$PROJECT_ROOT/}"
    if (cd "$pubspec_dir" && $TEST_CMD 2>&1); then
      LCOV="$pubspec_dir/coverage/lcov.info"
      if [[ -f "$LCOV" ]]; then
        mkdir -p "$PROJECT_ROOT/reports/coverage"
        cp "$LCOV" "$PROJECT_ROOT/reports/coverage/lcov.info"
        echo -e "${CYAN}  ✓ $pkg_rel${NC}"
        COVERAGE_FOUND=true
      fi
    else
      echo -e "${YELLOW}  ⚠ $pkg_rel — tests failed or no tests${NC}"
    fi
  done < <(find "$PROJECT_ROOT" -name "pubspec.yaml" \
    -not -path "*/.dart_tool/*" -not -path "*/build/*" -not -path "*/.git/*" \
    -not -path "$PROJECT_ROOT/pubspec.yaml" \
    -not -path "$PROJECT_ROOT/app/*" \
    -exec dirname {} \; | sort)
  if [[ "$COVERAGE_FOUND" == false ]]; then
    echo -e "${YELLOW}  ⚠ No coverage data generated${NC}"
  fi

  # Step 3 — Sonar scan
  echo ""
  echo -e "${CYAN}▸ [3/3] Sonar scan...${NC}"
  if [[ ${#REMAINING_ARGS[@]} -gt 0 ]]; then
    exec bash "$SCRIPT_DIR/portable-local-sonar.sh" "${REMAINING_ARGS[@]}"
  else
    exec bash "$SCRIPT_DIR/portable-local-sonar.sh"
  fi
fi

# ── Legacy Homebrew/Docker path (explicit --legacy-local only) ────────────────
log_warn "Using legacy Homebrew + Colima/Docker mode (--legacy-local)."

# PATH: prefer Homebrew CLIs on Apple Silicon / Intel / user-local.
[[ -d "$HOME/.homebrew/bin" ]] && export PATH="$HOME/.homebrew/bin:$PATH"
[[ -d "$HOME/homebrew/bin"  ]] && export PATH="$HOME/homebrew/bin:$PATH"
[[ -d /opt/homebrew/bin     ]] && export PATH="/opt/homebrew/bin:$PATH"
[[ -d /usr/local/bin        ]] && export PATH="/usr/local/bin:$PATH"

if ! command -v brew >/dev/null 2>&1; then
  log_error "Homebrew is not installed."
  echo "  Recommended path: bash scripts/quality/setup.sh && bash scripts/quality/local-quality.sh"
  echo "  Legacy path requires Homebrew: https://brew.sh"
  exit 1
fi
log_ok "Homebrew found: $(brew --prefix)"

ensure_brew_tool() {
  local cmd="$1"
  local formula="$2"
  if command -v "$cmd" >/dev/null 2>&1; then
    log_ok "$cmd found"
  else
    log_warn "$cmd not found — installing via brew install $formula ..."
    brew install "$formula"
    log_ok "$cmd installed"
  fi
}

ensure_brew_tool colima        colima
ensure_brew_tool docker        docker
ensure_brew_tool sonar-scanner sonar-scanner
ensure_brew_tool jq            jq
ensure_brew_tool python3       python

if [[ ! -x "$QUALITY_CHECK" ]]; then
  chmod +x "$QUALITY_CHECK" 2>/dev/null || true
fi

echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN}  local-quality.sh  →  quality-check.sh${NC}"
echo -e "${CYAN}  Legacy passing: --sonar-local ${REMAINING_ARGS[*]:-}${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""

quality_exit=0
if [[ ${#REMAINING_ARGS[@]} -gt 0 ]]; then
  bash "$QUALITY_CHECK" --sonar-local "${REMAINING_ARGS[@]}" || quality_exit=$?
else
  bash "$QUALITY_CHECK" --sonar-local || quality_exit=$?
fi

echo ""
if [[ -f "$REPORT_HTML" ]]; then
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
  echo -e "${GREEN}  Report ready: ${REPORT_HTML}${NC}"
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
fi

exit $quality_exit
flutter-lint.sh Flutter/Dart lint runner
#!/bin/bash
# Lint runner for the Flutter monorepo.
# Uses melos analyze (per-package, ordered) if melos is bootstrapped,
# otherwise falls back to flutter analyze at project root.
#
# Usage: bash scripts/quality/flutter-lint.sh [mode]
#   mode: dev | sit  → only errors cause failure (warnings/info ignored)
#   mode: (empty)    → strict, fail on any issue

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"

GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

MODE=""

show_help() {
  echo ""
  echo "Usage: bash scripts/quality/flutter-lint.sh [MODE]"
  echo ""
  echo "  --dev | dev  — only errors cause failure (warnings and info ignored)"
  echo "  --sit | sit  — same as dev"
  echo "  (empty)      — strict, fail on any issue"
  echo ""
}

for arg in "$@"; do
  case "$arg" in
    -h|--help|help) show_help; exit 0 ;;
    --dev|dev) MODE="dev" ;;
    --sit|sit) MODE="sit" ;;
    *) echo -e "${RED}✗ Unknown option: $arg${NC}"; show_help; exit 2 ;;
  esac
done

cd "$PROJECT_ROOT"

# Discover packages: all dirs with pubspec.yaml, excluding root
PACKAGES=()
while IFS= read -r pubspec; do
  dir="$(dirname "$pubspec")"
  [[ "$dir" == "." || "$dir" == "$PROJECT_ROOT" ]] && continue
  PACKAGES+=("$dir")
done < <(find "$PROJECT_ROOT" -name "pubspec.yaml" \
    -not -path "*/.dart_tool/*" \
    -not -path "*/build/*" \
    -not -path "*/.git/*" \
    -not -path "$PROJECT_ROOT/app/*" \
    | sort)

echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN}  Flutter Lint — flutter analyze --no-pub${NC}"
if [[ -n "$MODE" ]]; then
  echo -e "${CYAN}  Mode: $MODE (only errors cause failure)${NC}"
else
  echo -e "${CYAN}  Mode: strict${NC}"
fi
echo -e "${CYAN}  Packages: ${#PACKAGES[@]}${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""

START_TIME=$(date +%s)
RESULT=""
for pkg in "${PACKAGES[@]}"; do
  pkg_rel="${pkg#$PROJECT_ROOT/}"
  echo -e "${CYAN}▸ $pkg_rel${NC}"
  pkg_result=$(cd "$pkg" && flutter analyze --no-pub 2>&1)
  echo "$pkg_result" \
    | sed 's/^[[:space:]]*error[[:space:]]*•/  🔴 error   •/g' \
    | sed 's/^[[:space:]]*warning[[:space:]]*•/  🟡 warning •/g' \
    | sed 's/^[[:space:]]*info[[:space:]]*•/  🔵 info    •/g'
  RESULT="$RESULT"$'\n'"$pkg_result"
done
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))

ERROR_COUNT=$(echo "$RESULT" | grep -cE "^\s*error\s*•" || true)
WARNING_COUNT=$(echo "$RESULT" | grep -cE "^\s*warning\s*•" || true)
INFO_COUNT=$(echo "$RESULT" | grep -cE "^\s*info\s*•" || true)

write_lint_json() {
  local passed_bool
  [[ "$1" == "true" ]] && passed_bool="true" || passed_bool="false"
  mkdir -p "$PROJECT_ROOT/reports/lint"
  local tmp_result="$PROJECT_ROOT/reports/lint/.result.tmp"
  printf '%s' "$RESULT" > "$tmp_result"
  python3 - "$tmp_result" "$PROJECT_ROOT" "${MODE:-strict}" "$passed_bool" \
      "$ERROR_COUNT" "$WARNING_COUNT" "$INFO_COUNT" "$ELAPSED" \
      > "$PROJECT_ROOT/reports/lint/lint-results.json" <<'PYEOF'
import json, re, sys

result_file, project_root, mode, passed_s, errors_s, warnings_s, info_s, elapsed_s = sys.argv[1:]
with open(result_file, encoding='utf-8', errors='replace') as f:
    result = f.read()

files = {}
SEV_KEY = {'error': 'errors', 'warning': 'warnings', 'info': 'info'}
pat = re.compile(r'^\s*(error|warning|info)\s+•\s+(.*?)\s+•\s+(.+?):(\d+):\d+\s+•\s+(.+)$')
for line in result.splitlines():
    m = pat.match(line)
    if not m:
        continue
    sev, msg, path, lineno, rule = m.group(1), m.group(2).strip(), m.group(3).strip(), m.group(4), m.group(5).strip()
    if path.startswith(project_root + '/'):
        path = path[len(project_root)+1:]
    if path not in files:
        files[path] = {'errors': 0, 'warnings': 0, 'info': 0, 'issues': []}
    files[path][SEV_KEY.get(sev, 'info')] += 1
    files[path]['issues'].append({'line': int(lineno), 'message': msg, 'severity': sev, 'rule': rule})

files_list = sorted(files.items(), key=lambda x: (-x[1].get('errors',0), -x[1].get('warnings',0)))[:500]
print(json.dumps({
    'errors': int(errors_s), 'warnings': int(warnings_s), 'info': int(info_s),
    'mode': mode, 'passed': passed_s == 'true', 'elapsed': int(elapsed_s),
    'files': [{'path': p, **v} for p, v in files_list]
}))
PYEOF
  rm -f "$tmp_result"
}

echo ""
if [[ "$MODE" == "dev" || "$MODE" == "sit" ]]; then
  if [[ "$ERROR_COUNT" -gt 0 ]]; then
    write_lint_json "false"
    echo -e "${RED}══════════════════════════════════════════════════${NC}"
    echo -e "${RED}  ❌ LINT FAILED — $ERROR_COUNT error(s) in $MODE mode (took ${ELAPSED}s)${NC}"
    echo -e "${RED}══════════════════════════════════════════════════${NC}"
    [[ "$WARNING_COUNT" -gt 0 ]] && echo -e "  ${YELLOW}⚠ $WARNING_COUNT warning(s) — ignored in $MODE mode${NC}"
    [[ "$INFO_COUNT" -gt 0 ]]    && echo -e "  ${CYAN}ℹ $INFO_COUNT info — ignored in $MODE mode${NC}"
    echo ""
    exit 1
  fi
  write_lint_json "true"
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
  echo -e "${GREEN}  ✅ LINT PASSED — no errors in $MODE mode (took ${ELAPSED}s)${NC}"
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
  [[ "$WARNING_COUNT" -gt 0 ]] && echo -e "  ${YELLOW}⚠ $WARNING_COUNT warning(s) — ignored${NC}"
  [[ "$INFO_COUNT" -gt 0 ]]    && echo -e "  ${CYAN}ℹ $INFO_COUNT info — ignored${NC}"
  echo ""
  exit 0
fi

# Strict mode
TOTAL=$((ERROR_COUNT + WARNING_COUNT + INFO_COUNT))
if [[ "$TOTAL" -gt 0 ]]; then
  write_lint_json "false"
  echo -e "${RED}══════════════════════════════════════════════════${NC}"
  echo -e "${RED}  ❌ LINT FAILED — $TOTAL issue(s) found (took ${ELAPSED}s)${NC}"
  echo -e "${RED}══════════════════════════════════════════════════${NC}"
  [[ "$ERROR_COUNT" -gt 0 ]]   && echo -e "  🔴 $ERROR_COUNT error(s)"
  [[ "$WARNING_COUNT" -gt 0 ]] && echo -e "  ${YELLOW}🟡 $WARNING_COUNT warning(s)${NC}"
  [[ "$INFO_COUNT" -gt 0 ]]    && echo -e "  ${CYAN}🔵 $INFO_COUNT info${NC}"
  echo ""
  exit 1
fi

write_lint_json "true"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${GREEN}  ✅ LINT PASSED — no issues found (took ${ELAPSED}s)${NC}"
echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
echo ""
portable-local-sonar.sh Internal portable SonarQube runner
#!/bin/bash
# Portable local SonarQube mode — no Homebrew, no Docker, no admin/sudo.
# Downloads SonarQube + sonar-scanner zips to a user cache; starts SonarQube
# via its own bundled script; delegates to quality-check.sh in remote mode.
#
# Usage: bash scripts/quality/portable-local-sonar.sh [OPTIONS]
#   (called automatically by: bash scripts/quality/local-quality.sh)

set -euo pipefail

# ── Colors ────────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

log_info()  { echo -e "${CYAN}▸ $*${NC}"; }
log_ok()    { echo -e "${GREEN}✓ $*${NC}"; }
log_warn()  { echo -e "${YELLOW}⚠ $*${NC}"; }
log_error() { echo -e "${RED}✗ $*${NC}" >&2; }

# ── Resolve stable script path before env defaults/help ───────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"

# ── Defaults (all overridable via env) ────────────────────────────────────────
CACHE_DIR="${TOP_FLUTTER_QUALITY_CACHE:-$HOME/.cache/top-flutter-quality}"
SONAR_LOCAL_PORT="${SONAR_LOCAL_PORT:-19102}"
SONARQUBE_PORTABLE_VERSION="${SONARQUBE_PORTABLE_VERSION:-10.7.0.96327}"
SONAR_SCANNER_PORTABLE_VERSION="${SONAR_SCANNER_PORTABLE_VERSION:-6.2.1.4610}"

# Override with SONAR_LOCAL_ADMIN_PASSWORD env var if default admin password has been changed

# URL env overrides let users point to a mirror or new release without editing this file.
SONARQUBE_ZIP_URL="${SONARQUBE_ZIP_URL:-https://binaries.sonarsource.com/Distribution/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}.zip}"
SONAR_SCANNER_ZIP_URL="${SONAR_SCANNER_ZIP_URL:-https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_SCANNER_PORTABLE_VERSION}.zip}"
SONAR_FLUTTER_PLUGIN_VERSION="${SONAR_FLUTTER_PLUGIN_VERSION:-0.5.2}"
SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL="${SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL:-}"
SONAR_FLUTTER_PLUGIN_VENDOR_JAR="${SONAR_FLUTTER_PLUGIN_VENDOR_JAR:-$SCRIPT_DIR/vendor/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN="${SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN:-1}"
SONAR_FLUTTER_PLUGIN_JAR="${SONAR_FLUTTER_PLUGIN_JAR:-$CACHE_DIR/plugins/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"

SONAR_LOCAL_PROJECT_KEY="${SONAR_LOCAL_PROJECT_KEY:-top-flutter-local}"
SONAR_LOCAL_TOKEN_DIR="${SONAR_LOCAL_TOKEN_DIR:-$HOME/.sonarqube-local}"
SONAR_LOCAL_TOKEN_FILE="${SONAR_LOCAL_TOKEN_FILE:-$SONAR_LOCAL_TOKEN_DIR/top-flutter-portable-${SONAR_LOCAL_PORT}.token}"
SONAR_LOCAL_READY_TIMEOUT="${SONAR_LOCAL_READY_TIMEOUT:-360}"

KEEP_SERVER=0
SONAR_PID=""

# ── Help ──────────────────────────────────────────────────────────────────────
show_help() {
  echo ""
  echo "Usage: bash scripts/quality/portable-local-sonar.sh [OPTIONS]"
  echo "       bash scripts/quality/local-quality.sh [OPTIONS]"
  echo ""
  echo "Starts a portable local SonarQube (zip, no Docker/Homebrew/admin),"
  echo "then runs the quality gate and generates reports."
  echo "Run first-time setup with: bash scripts/quality/setup.sh"
  echo ""
  echo "Requirements:"
  echo "  curl, unzip, python3   (usually pre-installed on macOS/Linux)"
  echo "  java (JDK 17)          SonarQube 10.7 requires JDK 17; set JAVA_HOME / PORTABLE_JAVA_HOME"
  echo ""
  echo "Options:"
  echo "  --keep-sonar-local    Keep SonarQube process running after scan"
  echo "  --keep-server         Alias for --keep-sonar-local"
  echo "  -d, --dup-threshold   Max duplication % (default: 3)"
  echo "  -c, --cov-threshold   Min coverage % (default: 80)"
  echo "  --focus AREAS         Focus on: coverage,duplication,smell"
  echo "  --minimal-focus       Shorthand for --focus coverage,duplication,smell"
  echo "  --focus-minimal       Alias for --minimal-focus"
  echo "  --smell-threshold N   Max code smells in focus mode (default: 0)"
  echo "  -h, --help            Show this help (no download)"
  echo ""
  echo "Environment overrides:"
  echo "  TOP_FLUTTER_QUALITY_CACHE   Cache dir (default: ~/.cache/top-flutter-quality)"
  echo "  SONAR_LOCAL_PORT            SonarQube port (default: 19102)"
  echo "  SONARQUBE_PORTABLE_VERSION  SonarQube version (default: $SONARQUBE_PORTABLE_VERSION)"
  echo "  SONAR_SCANNER_PORTABLE_VERSION  sonar-scanner version (default: $SONAR_SCANNER_PORTABLE_VERSION)"
  echo "  SONARQUBE_ZIP_URL           Override full SonarQube download URL"
  echo "  SONAR_SCANNER_ZIP_URL       Override full sonar-scanner download URL"
  echo "  SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN  default: 1 (set 0 to skip)"
  echo "  SONAR_FLUTTER_PLUGIN_VERSION  sonar-flutter plugin version (default: $SONAR_FLUTTER_PLUGIN_VERSION)"
  echo "  SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL  Optional explicit mirror URL for sonar-flutter plugin (no default)"
  echo "  SONAR_FLUTTER_PLUGIN_VENDOR_JAR  Repo-local vendor jar path (default: scripts/quality/vendor/sonar-flutter-plugin-<version>.jar)"
  echo "  SONAR_FLUTTER_PLUGIN_JAR    Cached plugin jar path (default: cache/plugins/sonar-flutter-plugin-<version>.jar)"
  echo "  PORTABLE_JAVA_HOME          Explicit JDK path (skips PATH/JAVA_HOME search)"
  echo "  SONAR_TOKEN                 Skip token auto-creation; use this token"
  echo "  SONAR_LOCAL_TOKEN_FILE      Token cache file (default: ~/.sonarqube-local/top-flutter-portable-<port>.token)"
  echo ""
  echo "Examples:"
  echo "  bash scripts/quality/setup.sh"
  echo "  bash scripts/quality/local-quality.sh"
  echo "  bash scripts/quality/local-quality.sh --keep-sonar-local"
  echo ""
  exit 0
}

# ── Arg parsing ───────────────────────────────────────────────────────────────
PASSTHROUGH_ARGS=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    -h|--help) show_help ;;
    --keep-sonar-local|--keep-server) KEEP_SERVER=1; shift ;;
    --portable-local) shift ;; # backward-compatible no-op; portable is the default entrypoint path
    *) PASSTHROUGH_ARGS+=("$1"); shift ;;
  esac
done

# ── Resolve paths ─────────────────────────────────────────────────────────────
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
QUALITY_CHECK="$SCRIPT_DIR/quality-check.sh"
REPORT_HTML="$PROJECT_ROOT/reports/quality/quality-report.html"
RUNTIME_DIR="$CACHE_DIR/runtime"

# ── Prerequisite checks ───────────────────────────────────────────────────────
for tool in curl unzip python3; do
  if ! command -v "$tool" >/dev/null 2>&1; then
    log_error "$tool is required but not found. Install it and retry."
    exit 1
  fi
done

# ── Java resolution ───────────────────────────────────────────────────────────
java_line_for_home() {
  "$1/bin/java" -version 2>&1 | python3 -c 'import sys; print(sys.stdin.readline().strip())' 2>/dev/null || true
}

java_major_for_home() {
  "$1/bin/java" -version 2>&1 | python3 -c 'import re,sys
text=sys.stdin.read()
m=re.search(r"version \"([^\"]+)\"", text)
if not m:
    sys.exit(0)
v=m.group(1)
print(v.split(".")[1] if v.startswith("1.") else v.split(".")[0])' 2>/dev/null || true
}

use_java_home() {
  export JAVA_HOME="$1"
  export PATH="$JAVA_HOME/bin:$PATH"
}

require_java17_home() {
  local candidate="$1"
  local label="$2"
  local major=""
  local line=""

  if [[ -z "$candidate" || ! -x "$candidate/bin/java" ]]; then
    log_error "$label does not point to an executable JDK: $candidate"
    exit 1
  fi
  major="$(java_major_for_home "$candidate")"
  line="$(java_line_for_home "$candidate")"
  if [[ "$major" != "17" ]]; then
    log_error "SonarQube ${SONARQUBE_PORTABLE_VERSION} requires Java 17; $label is ${line:-unknown}."
    echo "  Set PORTABLE_JAVA_HOME to a JDK 17 archive extracted under your home directory."
    exit 1
  fi
  use_java_home "$candidate"
}

resolve_java() {
  local mac_java17=""
  local path_java=""
  local path_home=""
  local path_major=""
  local path_line=""

  if [[ -n "${PORTABLE_JAVA_HOME:-}" ]]; then
    require_java17_home "$PORTABLE_JAVA_HOME" "PORTABLE_JAVA_HOME"
    return 0
  fi

  if [[ -n "${JAVA_HOME:-}" && -x "$JAVA_HOME/bin/java" ]]; then
    if [[ "$(java_major_for_home "$JAVA_HOME")" == "17" ]]; then
      use_java_home "$JAVA_HOME"
      return 0
    fi
  fi

  if [[ "$(uname -s)" == "Darwin" ]] && [[ -x /usr/libexec/java_home ]]; then
    mac_java17="$(/usr/libexec/java_home -v 17 2>/dev/null || true)"
    if [[ -n "$mac_java17" && -x "$mac_java17/bin/java" ]]; then
      use_java_home "$mac_java17"
      return 0
    fi
  fi

  if command -v java >/dev/null 2>&1; then
    path_java="$(command -v java)"
    path_home="$(cd "$(dirname "$path_java")/.." && pwd)"
    if [[ -x "$path_home/bin/java" ]]; then
      path_major="$(java_major_for_home "$path_home")"
      path_line="$(java_line_for_home "$path_home")"
      if [[ "$path_major" == "17" ]]; then
        use_java_home "$path_home"
        return 0
      fi
      log_error "SonarQube ${SONARQUBE_PORTABLE_VERSION} requires Java 17; PATH java is ${path_line:-unknown}."
      echo "  Install/extract JDK 17 and set PORTABLE_JAVA_HOME, or on macOS install a JDK 17 visible to /usr/libexec/java_home -v 17."
      exit 1
    fi
  fi

  log_error "Java 17 not found."
  echo ""
  echo "  To fix without admin/Homebrew:"
  echo "    1. Download a JDK archive from https://adoptium.net/temurin/releases/"
  echo "       (choose macOS/Linux, .tar.gz, JDK 17)"
  echo "    2. Extract it, e.g.: tar -xzf OpenJDK17*.tar.gz -C ~/.cache/jdk/"
  echo "    3. Re-run with: PORTABLE_JAVA_HOME=~/.cache/jdk/<extracted-dir> bash scripts/quality/local-quality.sh"
  echo ""
  exit 1
}
resolve_java
log_ok "java: $(java_line_for_home "$JAVA_HOME")"

# ── Cache directories ─────────────────────────────────────────────────────────
mkdir -p "$CACHE_DIR/sonarqube" "$CACHE_DIR/sonar-scanner" "$RUNTIME_DIR" "$CACHE_DIR/plugins"

validate_flutter_plugin_jar() {
  local jar_path="$1"

  [[ -f "$jar_path" ]] || return 1
  unzip -t "$jar_path" >/dev/null 2>&1
}

fail_invalid_flutter_plugin_download() {
  log_error "Mirror-provided sonar-flutter plugin is not a valid jar: $SONAR_FLUTTER_PLUGIN_JAR"
  echo ""
  echo "  The explicit mirror download may have been partial, corrupted, or replaced"
  echo "  by non-jar content from a proxy/corporate network."
  echo ""
  echo "  To fix:"
  echo "    1. Copy a valid sonar-flutter plugin jar to the repo-local vendor path:"
  echo "       $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
  echo "    2. Or prefill the cache with a valid jar at:"
  echo "       $SONAR_FLUTTER_PLUGIN_JAR"
  echo "    3. Or set SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted mirror."
  echo ""
  exit 1
}

fail_missing_flutter_plugin() {
  log_error "Missing valid sonar-flutter plugin jar."
  echo ""
  echo "  GitHub fallback downloads are disabled. Provide the plugin explicitly by"
  echo "  placing a valid sonar-flutter plugin jar at:"
  echo "    $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
  echo "  Or prefill the cache with a valid jar at:"
  echo "    $SONAR_FLUTTER_PLUGIN_JAR"
  echo "  Or set SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted non-default mirror."
  echo ""
  exit 1
}

fail_flutter_plugin_download_failed() {
  log_error "Could not download sonar-flutter plugin from explicit mirror: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL"
  echo ""
  echo "  Place a valid plugin jar at:"
  echo "    $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
  echo "  Or prefill the cache with a valid jar at:"
  echo "    $SONAR_FLUTTER_PLUGIN_JAR"
  echo ""
  exit 1
}

fail_invalid_flutter_plugin_vendor() {
  log_error "Repo-local sonar-flutter plugin vendor jar is not valid: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
  echo ""
  echo "  The vendor jar exists but failed validation, so this repository/package copy"
  echo "  is considered broken. Replace it with a valid sonar-flutter plugin jar."
  echo ""
  exit 1
}

ensure_flutter_plugin() {
  local plugin_dest_dir="$SQ_DIR/extensions/plugins"
  local plugin_dest="$plugin_dest_dir/$(basename "$SONAR_FLUTTER_PLUGIN_JAR")"

  if [[ "$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN" != "1" ]]; then
    log_warn "Skipping sonar-flutter plugin install (SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN)"
    return 0
  fi

  if [[ -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
    if validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
      log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
    else
      log_warn "Removing invalid cached sonar-flutter plugin jar: $SONAR_FLUTTER_PLUGIN_JAR"
      rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
    fi
  fi

  if [[ ! -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
    mkdir -p "$(dirname "$SONAR_FLUTTER_PLUGIN_JAR")"
    if [[ -f "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" ]]; then
      log_info "Using repo-local sonar-flutter plugin vendor jar: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR"
      if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR"; then
        fail_invalid_flutter_plugin_vendor
      fi
      cp "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" "$SONAR_FLUTTER_PLUGIN_JAR"
      if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
        log_error "Copied sonar-flutter plugin cache jar is invalid: $SONAR_FLUTTER_PLUGIN_JAR"
        exit 1
      fi
      log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
    elif [[ -n "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" ]]; then
      log_info "Fetching sonar-flutter plugin ${SONAR_FLUTTER_PLUGIN_VERSION} from explicit mirror..."
      log_info "URL: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL"
      if ! curl -fL --progress-bar "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" -o "$SONAR_FLUTTER_PLUGIN_JAR"; then
        rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
        fail_flutter_plugin_download_failed
      fi

      if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
        rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
        fail_invalid_flutter_plugin_download
      fi
      log_ok "sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR"
    else
      fail_missing_flutter_plugin
    fi
  fi

  mkdir -p "$plugin_dest_dir"
  if [[ -f "$plugin_dest" ]] && ! validate_flutter_plugin_jar "$plugin_dest"; then
    log_warn "Replacing invalid installed sonar-flutter plugin jar: $plugin_dest"
  fi

  if [[ "$SONAR_FLUTTER_PLUGIN_JAR" != "$plugin_dest" ]]; then
    cp "$SONAR_FLUTTER_PLUGIN_JAR" "$plugin_dest"
  fi
  if [[ ! -f "$plugin_dest" ]]; then
    log_error "sonar-flutter plugin was not installed at $plugin_dest"
    exit 1
  fi
  if ! validate_flutter_plugin_jar "$plugin_dest"; then
    log_error "sonar-flutter plugin installed jar is invalid: $plugin_dest"
    exit 1
  fi
  log_ok "sonar-flutter plugin installed: $plugin_dest"
}

# ── Download/extract SonarQube ────────────────────────────────────────────────
SQ_ZIP="$CACHE_DIR/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}.zip"
SQ_DIR="$CACHE_DIR/sonarqube/sonarqube-${SONARQUBE_PORTABLE_VERSION}"

if [[ ! -d "$SQ_DIR" ]]; then
  if [[ ! -f "$SQ_ZIP" ]]; then
    log_info "Downloading SonarQube ${SONARQUBE_PORTABLE_VERSION}..."
    log_info "URL: $SONARQUBE_ZIP_URL"
    curl -fL --progress-bar "$SONARQUBE_ZIP_URL" -o "$SQ_ZIP"
  fi
  log_info "Extracting SonarQube..."
  unzip -q "$SQ_ZIP" -d "$CACHE_DIR/sonarqube"
  # Normalize extracted dir name (zip may unpack as sonarqube-X.Y.Z.BUILD)
  if [[ ! -d "$SQ_DIR" ]]; then
    extracted="$(find "$CACHE_DIR/sonarqube" -maxdepth 1 -name "sonarqube-*" -type d | head -1)"
    if [[ -z "$extracted" ]]; then
      log_error "Could not find extracted SonarQube directory."
      exit 1
    fi
    mv "$extracted" "$SQ_DIR"
  fi
fi
log_ok "SonarQube cache: $SQ_DIR"
ensure_flutter_plugin

# ── Download/extract sonar-scanner ───────────────────────────────────────────
SS_ZIP="$CACHE_DIR/sonar-scanner/sonar-scanner-cli-${SONAR_SCANNER_PORTABLE_VERSION}.zip"
SS_DIR="$CACHE_DIR/sonar-scanner/sonar-scanner-${SONAR_SCANNER_PORTABLE_VERSION}"

if [[ ! -d "$SS_DIR" ]]; then
  if [[ ! -f "$SS_ZIP" ]]; then
    log_info "Downloading sonar-scanner ${SONAR_SCANNER_PORTABLE_VERSION}..."
    log_info "URL: $SONAR_SCANNER_ZIP_URL"
    curl -fL --progress-bar "$SONAR_SCANNER_ZIP_URL" -o "$SS_ZIP"
  fi
  log_info "Extracting sonar-scanner..."
  unzip -q "$SS_ZIP" -d "$CACHE_DIR/sonar-scanner"
  if [[ ! -d "$SS_DIR" ]]; then
    extracted="$(find "$CACHE_DIR/sonar-scanner" -maxdepth 1 -name "sonar-scanner-*" -type d | head -1)"
    if [[ -z "$extracted" ]]; then
      log_error "Could not find extracted sonar-scanner directory."
      exit 1
    fi
    mv "$extracted" "$SS_DIR"
  fi
fi
log_ok "sonar-scanner cache: $SS_DIR"

# Prepend portable sonar-scanner to PATH
export PATH="$SS_DIR/bin:$PATH"
export TOP_FLUTTER_PORTABLE_SCANNER=1

# ── Start SonarQube ───────────────────────────────────────────────────────────
SONAR_HOST_URL="http://localhost:${SONAR_LOCAL_PORT}"
SQ_LOG="$RUNTIME_DIR/sonarqube.log"
SQ_PID_FILE="$RUNTIME_DIR/sonarqube.pid"

# Detect OS for the correct sonar.sh wrapper
if [[ "$(uname -s)" == "Darwin" ]]; then
  SQ_OS="macosx-universal-64"
else
  SQ_OS="linux-x86-64"
fi
SQ_SCRIPT="$SQ_DIR/bin/$SQ_OS/sonar.sh"

if [[ ! -f "$SQ_SCRIPT" ]]; then
  # Fall back: find any sonar.sh in the bin tree
  SQ_SCRIPT="$(find "$SQ_DIR/bin" -name "sonar.sh" | head -1 || true)"
  if [[ -z "$SQ_SCRIPT" ]]; then
    log_error "Cannot find sonar.sh in $SQ_DIR/bin"
    exit 1
  fi
fi
chmod +x "$SQ_SCRIPT" 2>/dev/null || true

configure_sonar_properties() {
  local props="$SQ_DIR/conf/sonar.properties"
  local tmp
  tmp="$(mktemp "${props}.tmp.XXXXXX")"
  trap 'rm -f "$tmp"' RETURN

  if [[ ! -f "$props" ]]; then
    log_error "Missing SonarQube config file: $props"
    exit 1
  fi
  python3 - "$props" "$tmp" "$SONAR_LOCAL_PORT" <<'PY'
import sys
path, tmp, port = sys.argv[1:]
updates = {"sonar.web.port": port, "sonar.web.host": "127.0.0.1"}
seen = set()
out = []
with open(path, encoding="utf-8") as fh:
    for raw in fh:
        stripped = raw.lstrip()
        active = not stripped.startswith("#") and "=" in raw
        key = raw.split("=", 1)[0].strip() if active else ""
        if key in updates:
            out.append(f"{key}={updates[key]}\n")
            seen.add(key)
        else:
            out.append(raw)
for key, value in updates.items():
    if key not in seen:
        out.append(f"{key}={value}\n")
with open(tmp, "w", encoding="utf-8") as fh:
    fh.writelines(out)
PY
  mv "$tmp" "$props"
  log_ok "Configured SonarQube web bind: 127.0.0.1:${SONAR_LOCAL_PORT}"
}

configure_sonar_properties

# Check if already up
sonar_status() {
  curl -fsS "$SONAR_HOST_URL/api/system/status" 2>/dev/null \
    | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status","UNKNOWN"))' 2>/dev/null || true
}

sonar_version() {
  curl -fsS "$SONAR_HOST_URL/api/server/version" 2>/dev/null || true
}

port_diagnostic() {
  if command -v lsof >/dev/null 2>&1; then
    lsof -nP -iTCP:"$SONAR_LOCAL_PORT" -sTCP:LISTEN 2>/dev/null || true
  fi
}

SONAR_STARTED_BY_SCRIPT=0
existing_status="$(sonar_status)"
existing_version="$(sonar_version)"
if [[ "$existing_status" == "UP" ]]; then
  log_info "Existing SonarQube status at $SONAR_HOST_URL: $existing_status version=${existing_version:-unknown}"
  if [[ -n "$existing_version" && "$existing_version" != "$SONARQUBE_PORTABLE_VERSION" ]]; then
    log_error "Port $SONAR_LOCAL_PORT is serving SonarQube $existing_version, not expected portable $SONARQUBE_PORTABLE_VERSION."
    echo "  Choose a free port with SONAR_LOCAL_PORT=9103 or stop the other server."
    port_diagnostic
    exit 1
  fi
  log_ok "SonarQube already UP at $SONAR_HOST_URL"
else
  if port_diagnostic | grep -q .; then
    log_error "Port $SONAR_LOCAL_PORT is already occupied, but $SONAR_HOST_URL is not an UP SonarQube instance."
    port_diagnostic
    echo "  Choose a free port with SONAR_LOCAL_PORT=9103 or stop the conflicting process."
    exit 1
  fi
  log_info "Starting portable SonarQube on port $SONAR_LOCAL_PORT..."
  JAVA_HOME="${JAVA_HOME:-}" bash "$SQ_SCRIPT" console >> "$SQ_LOG" 2>&1 &
  SONAR_PID=$!
  SONAR_STARTED_BY_SCRIPT=1
  echo "$SONAR_PID" > "$SQ_PID_FILE"
  log_info "SonarQube PID $SONAR_PID — log: $SQ_LOG"
fi

# ── Cleanup trap ──────────────────────────────────────────────────────────────
cleanup_sonar() {
  if [[ "$SONAR_STARTED_BY_SCRIPT" == "1" && "$KEEP_SERVER" == "0" && -n "$SONAR_PID" ]]; then
    log_warn "Stopping portable SonarQube (PID $SONAR_PID)..."
    kill "$SONAR_PID" 2>/dev/null || true
    rm -f "$SQ_PID_FILE"
  fi
}
trap cleanup_sonar EXIT

# ── Wait for UP ───────────────────────────────────────────────────────────────
log_info "Waiting for SonarQube to be UP at $SONAR_HOST_URL ..."
deadline=$((SECONDS + SONAR_LOCAL_READY_TIMEOUT))
reached_up=0
while (( SECONDS < deadline )); do
  if [[ "$(sonar_status)" == "UP" ]]; then
    reached_up=1; break
  fi
  printf "."
  sleep 5
done
if [[ "$reached_up" == "0" ]]; then
  echo ""
  log_error "Timed out waiting for SonarQube. Check: $SQ_LOG"
  exit 1
fi
log_ok "SonarQube is UP"

# ── Token management ──────────────────────────────────────────────────────────
validate_token() {
  local token="$1"
  local valid=""

  if [[ -z "$token" ]]; then
    return 1
  fi

  valid="$(curl -fsS -H "Authorization: Bearer ${token}" \
    "$SONAR_HOST_URL/api/authentication/validate" 2>/dev/null \
    | python3 -c 'import json,sys; print("true" if json.load(sys.stdin).get("valid") is True else "false")' 2>/dev/null || true)"

  [[ "$valid" == "true" ]]
}

ensure_token() {
  if [[ -n "${SONAR_TOKEN:-}" ]]; then
    return 0
  fi
  mkdir -p "$SONAR_LOCAL_TOKEN_DIR"
  chmod 700 "$SONAR_LOCAL_TOKEN_DIR" 2>/dev/null || true
  if [[ -f "$SONAR_LOCAL_TOKEN_FILE" ]]; then
    SONAR_TOKEN="$(tr -d '[:space:]' < "$SONAR_LOCAL_TOKEN_FILE")"
    if validate_token "$SONAR_TOKEN"; then
      export SONAR_TOKEN
      log_ok "Using cached SonarQube token: $SONAR_LOCAL_TOKEN_FILE"
      return 0
    fi
    log_warn "Ignoring stale/invalid cached SonarQube token for $SONAR_HOST_URL: $SONAR_LOCAL_TOKEN_FILE"
    rm -f "$SONAR_LOCAL_TOKEN_FILE"
    SONAR_TOKEN=""
  fi
  log_info "Creating local SonarQube token (admin:${SONAR_LOCAL_ADMIN_PASSWORD:-admin})..."
  local token_name="top-flutter-portable-$(date +%Y%m%d%H%M%S)"
  SONAR_TOKEN="$(curl -fsS -u "admin:${SONAR_LOCAL_ADMIN_PASSWORD:-admin}" -X POST \
    --data-urlencode "name=${token_name}" \
    "$SONAR_HOST_URL/api/user_tokens/generate" \
    | python3 -c 'import json,sys; print(json.load(sys.stdin).get("token",""))' 2>/dev/null || true)"
  if [[ -z "$SONAR_TOKEN" ]]; then
    log_error "Could not auto-create token."
    echo "  Open $SONAR_HOST_URL, log in, create a token, then rerun with:"
    echo "  SONAR_TOKEN=<your-token> bash scripts/quality/local-quality.sh"
    exit 1
  fi
  if ! validate_token "$SONAR_TOKEN"; then
    log_error "Auto-created SonarQube token was not accepted by $SONAR_HOST_URL."
    echo "  Open $SONAR_HOST_URL, log in, create a token, then rerun with:"
    echo "  SONAR_TOKEN=<your-token> bash scripts/quality/local-quality.sh"
    SONAR_TOKEN=""
    exit 1
  fi
  printf '%s\n' "$SONAR_TOKEN" > "$SONAR_LOCAL_TOKEN_FILE"
  chmod 600 "$SONAR_LOCAL_TOKEN_FILE" 2>/dev/null || true
  export SONAR_TOKEN
  log_ok "Cached SonarQube token: $SONAR_LOCAL_TOKEN_FILE"
}
ensure_token

# ── Delegate to quality-check.sh (remote mode — no --sonar-local) ─────────────
echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN}  portable-local-sonar.sh  →  quality-check.sh${NC}"
echo -e "${CYAN}  URL: $SONAR_HOST_URL${NC}"
echo -e "${CYAN}  Project: $SONAR_LOCAL_PROJECT_KEY${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo ""

if [[ ! -x "$QUALITY_CHECK" ]]; then
  chmod +x "$QUALITY_CHECK" 2>/dev/null || true
fi

quality_exit=0
if [[ ${#PASSTHROUGH_ARGS[@]} -gt 0 ]]; then
  SONAR_HOST_URL="$SONAR_HOST_URL" \
    SONAR_TOKEN="$SONAR_TOKEN" \
    SONAR_PROJECT_KEY="$SONAR_LOCAL_PROJECT_KEY" \
    TOP_FLUTTER_PORTABLE_SCANNER=1 \
    bash "$QUALITY_CHECK" "${PASSTHROUGH_ARGS[@]}" || quality_exit=$?
else
  SONAR_HOST_URL="$SONAR_HOST_URL" \
    SONAR_TOKEN="$SONAR_TOKEN" \
    SONAR_PROJECT_KEY="$SONAR_LOCAL_PROJECT_KEY" \
    TOP_FLUTTER_PORTABLE_SCANNER=1 \
    bash "$QUALITY_CHECK" || quality_exit=$?
fi

# ── Final report hint ─────────────────────────────────────────────────────────
echo ""
if [[ -f "$REPORT_HTML" ]]; then
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
  echo -e "${GREEN}  Report ready: ${REPORT_HTML}${NC}"
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
fi

exit $quality_exit
quality-check.sh Internal SonarQube/SonarScanner engine
#!/bin/bash

##############################################
# Code Quality Check (SonarQube + Coverage)
# Duplication Threshold: 3% | Coverage Threshold: 80%
##############################################

set -euo pipefail

# Prefer Homebrew CLIs on Apple Silicon for the legacy Colima/Docker path, but do
# not let Homebrew's sonar-scanner override the portable scanner selected by
# portable-local-sonar.sh.
if [[ "${TOP_FLUTTER_PORTABLE_SCANNER:-0}" != "1" && -d /opt/homebrew/bin ]]; then
  export PATH="/opt/homebrew/bin:$PATH"
fi

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
REPORT_DIR="$PROJECT_ROOT/reports/quality"
SUMMARY_JSON="$REPORT_DIR/summary.json"
SONAR_PROPERTIES="$PROJECT_ROOT/sonar-project.properties"
DUP_THRESHOLD=3
COV_THRESHOLD=90
FOCUS_MODE=""
SMELL_THRESHOLD=0
SONAR_LOCAL=false
SONAR_LOCAL_KEEP_RUNNING="${SONAR_LOCAL_KEEP_RUNNING:-0}"
SONAR_LOCAL_CONTAINER="${SONAR_LOCAL_CONTAINER:-sonarqube-local}"
SONAR_LOCAL_IMAGE="${SONAR_LOCAL_IMAGE:-sonarqube:community}"
SONAR_LOCAL_PORT="${SONAR_LOCAL_PORT:-9000}"
SONAR_LOCAL_URL="http://localhost:${SONAR_LOCAL_PORT}"
SONAR_LOCAL_PROJECT_KEY="${SONAR_LOCAL_PROJECT_KEY:-top-flutter-local}"
SONAR_LOCAL_TOKEN_DIR="${SONAR_LOCAL_TOKEN_DIR:-$HOME/.sonarqube-local}"
SONAR_LOCAL_TOKEN_FILE="${SONAR_LOCAL_TOKEN_FILE:-$SONAR_LOCAL_TOKEN_DIR/top-flutter.token}"
SONAR_FLUTTER_PLUGIN_VERSION="${SONAR_FLUTTER_PLUGIN_VERSION:-0.5.2}"
SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL="${SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL:-}"
SONAR_FLUTTER_PLUGIN_VENDOR_JAR="${SONAR_FLUTTER_PLUGIN_VENDOR_JAR:-$SCRIPT_DIR/vendor/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
SONAR_FLUTTER_PLUGIN_JAR="${SONAR_FLUTTER_PLUGIN_JAR:-$SONAR_LOCAL_TOKEN_DIR/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar}"
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN="${SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN:-1}"
COLIMA_CPU="${COLIMA_CPU:-2}"
COLIMA_MEMORY="${COLIMA_MEMORY:-4}"
COLIMA_DISK="${COLIMA_DISK:-20}"
COLIMA_STARTED_BY_SCRIPT=0
SONAR_CONTAINER_STARTED_BY_SCRIPT=0

GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

show_help() {
  echo ""
  echo "Usage: $(basename "$0") [OPTIONS]"
  echo ""
  echo "Run the local quality gate with SonarScanner/SonarQube, then generate"
  echo "reports/quality/summary.json and reports/quality/quality-report.html."
  echo ""
  echo "Options:"
  echo "  --sonar-local         Start local SonarQube on demand via Colima/Docker,"
  echo "                        scan against localhost only, then stop what this script started"
  echo "  --keep-sonar-local    Keep the local SonarQube container/Colima running after scan"
  echo "  --keep-server         Alias for --keep-sonar-local"
  echo "  -h, --help            Show this help message"
  echo "  -d, --dup-threshold N Max duplication % fallback threshold (default: 3)"
  echo "  -c, --cov-threshold N Min coverage % fallback threshold (default: 90)"
  echo "  --focus AREAS         Focus report on specific quality areas: coverage,duplication,smell."
  echo "                        Gate is derived locally from these three thresholds; BUG/VULN/HOTSPOT"
  echo "                        issues are hidden in the report."
  echo "                        Example: --focus coverage,duplication,smell"
  echo "  --minimal-focus       Shorthand for --focus coverage,duplication,smell"
  echo "  --focus-minimal       Alias for --minimal-focus"
  echo "  --smell-threshold N   Max code smells allowed in focus gate (default: 0)"
  echo ""
  echo "Environment:"
  echo "  SONAR_HOST_URL        Required unless sonar.host.url is configured elsewhere"
  echo "  SONAR_TOKEN           Required for authenticated scanner/API access"
  echo "  SONAR_PROJECT_KEY     Required unless sonar.projectKey is in sonar-project.properties"
  echo ""
  echo "Local SonarQube harness environment:"
  echo "  SONAR_LOCAL_PORT      Local SonarQube port (default: 9000)"
  echo "  SONAR_LOCAL_IMAGE     SonarQube image (default: sonarqube:community)"
  echo "  SONAR_LOCAL_CONTAINER Container name (default: sonarqube-local)"
  echo "  SONAR_LOCAL_PROJECT_KEY Project key for local mode (default: top-flutter-local)"
  echo "  SONAR_LOCAL_KEEP_RUNNING=1 Keep local services running after scan"
  echo "  SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=0 Skip installing sonar-flutter plugin"
  echo "  SONAR_FLUTTER_PLUGIN_VERSION Flutter/Dart plugin version (default: 0.5.2)"
  echo "  SONAR_FLUTTER_PLUGIN_VENDOR_JAR Repo-local vendor jar path"
  echo "  SONAR_FLUTTER_PLUGIN_JAR Cached plugin jar path"
  echo "  SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL Optional explicit mirror URL (no default)"
  echo "  COLIMA_CPU=2 COLIMA_MEMORY=4 COLIMA_DISK=20 Resource limits when Colima is started"
  echo ""
  echo "Examples:"
  echo '  SONAR_HOST_URL=https://sonar.example.com SONAR_TOKEN=*** SONAR_PROJECT_KEY=top-flutter \'
  echo "    $(basename "$0")"
  echo "  $(basename "$0") --sonar-local"
  echo "  $(basename "$0") -d 5 -c 70"
  echo ""
  echo "Recommended portable local mode (no Homebrew/Docker/admin):"
  echo "  bash scripts/quality/setup.sh"
  echo "  bash scripts/quality/local-quality.sh"
  echo "  bash scripts/quality/local-quality.sh --keep-sonar-local"
  echo ""
  exit 0
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    -h|--help) show_help ;;
    --sonar-local)
      SONAR_LOCAL=true
      shift
      ;;
    --keep-sonar-local|--keep-server)
      SONAR_LOCAL_KEEP_RUNNING=1
      shift
      ;;
    -d|--dup-threshold)
      if [[ $# -lt 2 ]]; then
        echo -e "${RED}✗ Missing value for $1${NC}"
        exit 1
      fi
      DUP_THRESHOLD="$2"
      shift 2
      ;;
    -c|--cov-threshold)
      if [[ $# -lt 2 ]]; then
        echo -e "${RED}✗ Missing value for $1${NC}"
        exit 1
      fi
      COV_THRESHOLD="$2"
      shift 2
      ;;
    --focus)
      if [[ $# -lt 2 ]]; then
        echo -e "${RED}✗ Missing value for $1${NC}"
        exit 1
      fi
      FOCUS_MODE="$2"
      shift 2
      ;;
    --minimal-focus|--focus-minimal)
      FOCUS_MODE="coverage,duplication,smell"
      shift
      ;;
    --smell-threshold)
      if [[ $# -lt 2 ]]; then
        echo -e "${RED}✗ Missing value for $1${NC}"
        exit 1
      fi
      SMELL_THRESHOLD="$2"
      shift 2
      ;;
    *) echo -e "${RED}✗ Unknown option: $1${NC}"; exit 1 ;;
  esac
done

validate_threshold() {
  local name="$1"
  local value="$2"
  if ! [[ "$value" =~ ^([0-9]+([.][0-9]+)?|[.][0-9]+)$ ]]; then
    echo -e "${RED}✗ ${name} must be a non-negative number: ${value}${NC}"
    exit 1
  fi
}

property_value() {
  local key="$1"
  local file="$2"
  if [[ -f "$file" ]]; then
    python3 - "$key" "$file" <<'PY'
import sys
key, path = sys.argv[1:]
with open(path, encoding="utf-8") as fh:
    for raw in fh:
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        if k.strip() == key:
            print(v.strip())
            break
PY
  fi
}

is_local_sonar_url() {
  local url="$1"
  [[ "$url" == http://localhost:* || "$url" == http://127.0.0.1:* ]]
}

require_local_sonar_url() {
  local url="$1"
  if ! is_local_sonar_url "$url"; then
    echo -e "${RED}✗ Refusing to run local Sonar mode against non-local host: ${url}${NC}"
    echo -e "${YELLOW}  --sonar-local only allows http://localhost:* or http://127.0.0.1:*${NC}"
    exit 1
  fi
}

sonar_status() {
  local url="$1"
  curl -fsS "$url/api/system/status" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status", "UNKNOWN"))' 2>/dev/null || true
}

wait_for_local_sonar() {
  local url="$1"
  local timeout_seconds="${SONAR_LOCAL_READY_TIMEOUT:-360}"
  local deadline=$((SECONDS + timeout_seconds))
  local status="UNKNOWN"

  echo -e "${YELLOW}▸ Waiting for local SonarQube to be UP at ${url}...${NC}"
  local reached_up=0
  while (( SECONDS < deadline )); do
    if [[ "$(sonar_status "$url")" == "UP" ]]; then
      reached_up=1; break
    fi
    printf "."
    sleep 5
  done

  if [[ "$reached_up" == "0" ]]; then
    echo ""
    echo -e "${RED}✗ Timed out waiting for local SonarQube.${NC}"
    echo -e "${YELLOW}  Check logs with: docker logs --tail 100 ${SONAR_LOCAL_CONTAINER}${NC}"
    exit 1
  fi
  echo -e "${GREEN}✓ Local SonarQube is UP${NC}"
}

start_colima_if_needed() {
  if docker info >/dev/null 2>&1; then
    return 0
  fi

  if ! command -v colima >/dev/null 2>&1; then
    echo -e "${RED}✗ Docker is not running and Colima is not installed.${NC}"
    echo -e "${YELLOW}  Install lightweight runtime: brew install colima docker${NC}"
    exit 1
  fi

  echo -e "${YELLOW}▸ Starting Colima (${COLIMA_CPU} CPU, ${COLIMA_MEMORY}GB RAM, ${COLIMA_DISK}GB disk)...${NC}"
  colima start --cpu "$COLIMA_CPU" --memory "$COLIMA_MEMORY" --disk "$COLIMA_DISK"
  COLIMA_STARTED_BY_SCRIPT=1

  if ! docker info >/dev/null 2>&1; then
    echo -e "${RED}✗ Docker CLI still cannot reach a Docker engine after starting Colima.${NC}"
    exit 1
  fi
}

prepare_colima_kernel_limits() {
  if command -v colima >/dev/null 2>&1 && colima status >/dev/null 2>&1; then
    # SonarQube embeds a search engine that commonly needs this Linux VM setting.
    colima ssh -- sudo sysctl -w vm.max_map_count=262144 >/dev/null 2>&1 || true
  fi
}

container_is_running() {
  local name="$1"
  [[ "$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null || true)" == "true" ]]
}

container_exists() {
  local name="$1"
  docker container inspect "$name" >/dev/null 2>&1
}

validate_flutter_plugin_jar() {
  local jar_path="$1"

  [[ -f "$jar_path" ]] || return 1
  unzip -t "$jar_path" >/dev/null 2>&1
}

fail_missing_sonar_flutter_plugin() {
  echo -e "${RED}✗ Missing valid sonar-flutter plugin jar.${NC}"
  echo -e "${YELLOW}  GitHub fallback downloads are disabled. Provide the plugin explicitly at:${NC}"
  echo -e "${YELLOW}    $SONAR_FLUTTER_PLUGIN_VENDOR_JAR${NC}"
  echo -e "${YELLOW}  Or prefill the cache with a valid jar at:${NC}"
  echo -e "${YELLOW}    $SONAR_FLUTTER_PLUGIN_JAR${NC}"
  echo -e "${YELLOW}  Or set SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL to a trusted non-default mirror.${NC}"
  exit 1
}

ensure_sonar_flutter_plugin() {
  if [[ "$SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN" != "1" ]]; then
    return 0
  fi

  mkdir -p "$SONAR_LOCAL_TOKEN_DIR" "$(dirname "$SONAR_FLUTTER_PLUGIN_JAR")"
  chmod 700 "$SONAR_LOCAL_TOKEN_DIR" 2>/dev/null || true

  if [[ -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
    if validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
      echo -e "${GREEN}✓ sonar-flutter plugin cache: $SONAR_FLUTTER_PLUGIN_JAR${NC}"
    else
      echo -e "${YELLOW}⚠ Removing invalid cached sonar-flutter plugin jar: $SONAR_FLUTTER_PLUGIN_JAR${NC}"
      rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
    fi
  fi

  if [[ ! -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
    if [[ -f "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" ]]; then
      echo -e "${YELLOW}▸ Using repo-local sonar-flutter plugin vendor jar: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR${NC}"
      if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR"; then
        echo -e "${RED}✗ Repo-local sonar-flutter plugin vendor jar is not valid: $SONAR_FLUTTER_PLUGIN_VENDOR_JAR${NC}"
        exit 1
      fi
      cp "$SONAR_FLUTTER_PLUGIN_VENDOR_JAR" "$SONAR_FLUTTER_PLUGIN_JAR"
    elif [[ -n "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" ]]; then
      echo -e "${YELLOW}▸ Fetching sonar-flutter plugin ${SONAR_FLUTTER_PLUGIN_VERSION} from explicit mirror...${NC}"
      curl -fL "$SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL" -o "$SONAR_FLUTTER_PLUGIN_JAR" || {
        rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
        echo -e "${RED}✗ Could not download sonar-flutter plugin from explicit mirror: $SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL${NC}"
        exit 1
      }
    else
      fail_missing_sonar_flutter_plugin
    fi
  fi

  if ! validate_flutter_plugin_jar "$SONAR_FLUTTER_PLUGIN_JAR"; then
    rm -f "$SONAR_FLUTTER_PLUGIN_JAR"
    echo -e "${RED}✗ sonar-flutter plugin cache jar is invalid: $SONAR_FLUTTER_PLUGIN_JAR${NC}"
    exit 1
  fi

  echo -e "${YELLOW}▸ Installing sonar-flutter plugin into local SonarQube container...${NC}"
  docker cp "$SONAR_FLUTTER_PLUGIN_JAR" \
    "$SONAR_LOCAL_CONTAINER:/opt/sonarqube/extensions/plugins/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar"
}

ensure_local_sonar_container() {
  start_colima_if_needed
  prepare_colima_kernel_limits

  if ! command -v docker >/dev/null 2>&1; then
    echo -e "${RED}✗ docker CLI not found. Install with: brew install docker${NC}"
    exit 1
  fi

  if ! container_exists "$SONAR_LOCAL_CONTAINER"; then
    echo -e "${YELLOW}▸ Creating local SonarQube container: ${SONAR_LOCAL_CONTAINER}${NC}"
    docker create \
      --name "$SONAR_LOCAL_CONTAINER" \
      -p "127.0.0.1:${SONAR_LOCAL_PORT}:9000" \
      -v "${SONAR_LOCAL_CONTAINER}_data:/opt/sonarqube/data" \
      -v "${SONAR_LOCAL_CONTAINER}_extensions:/opt/sonarqube/extensions" \
      -v "${SONAR_LOCAL_CONTAINER}_logs:/opt/sonarqube/logs" \
      "$SONAR_LOCAL_IMAGE" >/dev/null
  fi

  if ! container_is_running "$SONAR_LOCAL_CONTAINER"; then
    echo -e "${YELLOW}▸ Starting local SonarQube container: ${SONAR_LOCAL_CONTAINER}${NC}"
    docker start "$SONAR_LOCAL_CONTAINER" >/dev/null
    SONAR_CONTAINER_STARTED_BY_SCRIPT=1
  fi

  wait_for_local_sonar "$SONAR_LOCAL_URL"
  ensure_sonar_flutter_plugin
}

read_cached_local_token() {
  if [[ -f "$SONAR_LOCAL_TOKEN_FILE" ]]; then
    tr -d '[:space:]' < "$SONAR_LOCAL_TOKEN_FILE"
  fi
}

validate_local_sonar_token() {
  local token="$1"
  local valid=""

  if [[ -z "$token" ]]; then
    return 1
  fi

  valid="$(curl -fsS -H "Authorization: Bearer ${token}" \
    "$SONAR_LOCAL_URL/api/authentication/validate" 2>/dev/null \
    | python3 -c 'import json,sys; print("true" if json.load(sys.stdin).get("valid") is True else "false")' 2>/dev/null || true)"

  [[ "$valid" == "true" ]]
}

generate_local_sonar_token() {
  local token_name="top-flutter-local-$(date +%Y%m%d%H%M%S)"
  mkdir -p "$SONAR_LOCAL_TOKEN_DIR"
  chmod 700 "$SONAR_LOCAL_TOKEN_DIR" 2>/dev/null || true

  curl -fsS -u "admin:${SONAR_LOCAL_ADMIN_PASSWORD:-admin}" -X POST \
    --data-urlencode "name=${token_name}" \
    "$SONAR_LOCAL_URL/api/user_tokens/generate" \
    | python3 -c 'import json,sys; print(json.load(sys.stdin).get("token", ""))' 2>/dev/null || true
}

ensure_local_sonar_token() {
  local cached_token=""

  if [[ -n "${SONAR_TOKEN:-}" ]]; then
    return 0
  fi

  cached_token="$(read_cached_local_token)"
  if [[ -n "$cached_token" ]]; then
    if validate_local_sonar_token "$cached_token"; then
      export SONAR_TOKEN="$cached_token"
      return 0
    fi
    echo -e "${YELLOW}⚠ Cached SonarQube token is stale/invalid; regenerating...${NC}"
    rm -f "$SONAR_LOCAL_TOKEN_FILE"
    cached_token=""
  fi

  echo -e "${YELLOW}▸ Creating local SonarQube token using default local admin credentials...${NC}"
  if ! SONAR_TOKEN="$(generate_local_sonar_token)" || [[ -z "$SONAR_TOKEN" ]]; then
    echo -e "${RED}✗ Could not auto-create a local SonarQube token.${NC}"
    echo -e "${YELLOW}  Open ${SONAR_LOCAL_URL}, login to the local instance, create a token, then rerun with:${NC}"
    echo -e "${YELLOW}  SONAR_TOKEN=<local-token> $(basename "$0") --sonar-local${NC}"
    exit 1
  fi

  (umask 077 && printf '%s\n' "$SONAR_TOKEN" > "$SONAR_LOCAL_TOKEN_FILE")
  export SONAR_TOKEN
}

setup_local_sonar_mode() {
  SONAR_LOCAL_URL="http://localhost:${SONAR_LOCAL_PORT}"
  require_local_sonar_url "$SONAR_LOCAL_URL"

  echo ""
  echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
  echo -e "${CYAN}  Local SonarQube On-Demand Harness${NC}"
  echo -e "${CYAN}  Runtime: Colima/Docker CLI${NC}"
  echo -e "${CYAN}  URL: ${SONAR_LOCAL_URL}${NC}"
  echo -e "${CYAN}  Container: ${SONAR_LOCAL_CONTAINER}${NC}"
  echo -e "${CYAN}══════════════════════════════════════════════════${NC}"

  ensure_local_sonar_container
  ensure_local_sonar_token

  export SONAR_HOST_URL="$SONAR_LOCAL_URL"
  export SONAR_PROJECT_KEY="${SONAR_PROJECT_KEY:-$SONAR_LOCAL_PROJECT_KEY}"
}

cleanup_local_sonar() {
  if [[ "$SONAR_LOCAL" != true || "$SONAR_LOCAL_KEEP_RUNNING" == "1" ]]; then
    return 0
  fi

  if [[ "$SONAR_CONTAINER_STARTED_BY_SCRIPT" == "1" ]] && command -v docker >/dev/null 2>&1; then
    echo -e "${YELLOW}▸ Stopping local SonarQube container...${NC}"
    docker stop "$SONAR_LOCAL_CONTAINER" >/dev/null 2>&1 || true
  fi

  if [[ "$COLIMA_STARTED_BY_SCRIPT" == "1" ]] && command -v colima >/dev/null 2>&1; then
    echo -e "${YELLOW}▸ Stopping Colima...${NC}"
    colima stop >/dev/null 2>&1 || true
  fi
}
trap cleanup_local_sonar EXIT INT TERM

validate_threshold "Duplicate threshold" "$DUP_THRESHOLD"
validate_threshold "Coverage threshold" "$COV_THRESHOLD"

SCAN_LABEL="Whole Project"
COVERAGE_FILE=""

LCOV_PATH="$PROJECT_ROOT/reports/coverage/lcov.info"
if [[ -f "$LCOV_PATH" ]]; then
  COVERAGE_FILE="$LCOV_PATH"
fi

if [[ ! -f "$SONAR_PROPERTIES" ]]; then
  echo -e "${RED}✗ Missing sonar-project.properties at project root${NC}"
  exit 1
fi

if [[ "$SONAR_LOCAL" == true ]]; then
  setup_local_sonar_mode
fi

if ! command -v sonar-scanner >/dev/null 2>&1; then
  echo -e "${RED}✗ sonar-scanner not found. Install SonarScanner CLI before running this gate.${NC}"
  echo -e "${YELLOW}  macOS: brew install sonar-scanner${NC}"
  exit 1
fi

PROJECT_KEY="${SONAR_PROJECT_KEY:-$(property_value sonar.projectKey "$SONAR_PROPERTIES")}"
PROJECT_KEY="${PROJECT_KEY//[[:space:]]/}"
HOST_URL="${SONAR_HOST_URL:-$(property_value sonar.host.url "$SONAR_PROPERTIES")}"
HOST_URL="${HOST_URL//[[:space:]]/}"
TOKEN="${SONAR_TOKEN:-${SONAR_LOGIN:-}}"

if [[ -z "$PROJECT_KEY" ]]; then
  echo -e "${RED}✗ SONAR_PROJECT_KEY is required because sonar.projectKey is not set in sonar-project.properties.${NC}"
  exit 1
fi

if [[ -z "$HOST_URL" ]]; then
  echo -e "${RED}✗ SONAR_HOST_URL is required because sonar.host.url is not set in sonar-project.properties.${NC}"
  exit 1
fi

if [[ "$SONAR_LOCAL" == true ]]; then
  require_local_sonar_url "$HOST_URL"
fi

if [[ -z "$TOKEN" ]]; then
  echo -e "${RED}✗ SONAR_TOKEN is required for local SonarScanner and SonarQube API access.${NC}"
  exit 1
fi

mkdir -p "$REPORT_DIR"
rm -f "$SUMMARY_JSON"

SONAR_ARGS=(
  "-Dproject.settings=$SONAR_PROPERTIES"
  "-Dsonar.projectKey=$PROJECT_KEY"
)

if [[ -n "$HOST_URL" ]]; then
  SONAR_ARGS+=("-Dsonar.host.url=$HOST_URL")
fi

if [[ -n "$COVERAGE_FILE" ]]; then
  rel_coverage="${COVERAGE_FILE#$PROJECT_ROOT/}"
  SONAR_ARGS+=("-Dsonar.flutter.coverage.reportPath=$rel_coverage")
fi

# Keep token out of echoed output and command-line arguments; sonar-scanner reads SONAR_TOKEN from env.
export SONAR_TOKEN="$TOKEN"
export SONAR_HOST_URL="$HOST_URL"

cd "$PROJECT_ROOT"

echo ""
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
echo -e "${CYAN}  SonarQube Quality Check${NC}"
echo -e "${CYAN}  Scope: ${SCAN_LABEL}${NC}"
echo -e "${CYAN}  Host: ${HOST_URL}${NC}"
echo -e "${CYAN}  Project: ${PROJECT_KEY}${NC}"
echo -e "${CYAN}  Duplication: ≤${DUP_THRESHOLD}% | Coverage: ≥${COV_THRESHOLD}%${NC}"
if [[ -n "$FOCUS_MODE" ]]; then
  echo -e "${CYAN}  Focus: ${FOCUS_MODE} | Smell threshold: ≤${SMELL_THRESHOLD}${NC}"
fi
echo -e "${CYAN}  Settings: sonar-project.properties${NC}"
echo -e "${CYAN}══════════════════════════════════════════════════${NC}"

echo ""
echo -e "${YELLOW}▸ [1/2] Running sonar-scanner...${NC}"
sonar-scanner "${SONAR_ARGS[@]}"

REPORT_TASK="$PROJECT_ROOT/.scannerwork/report-task.txt"
if [[ ! -f "$REPORT_TASK" ]]; then
  echo -e "${RED}✗ SonarScanner completed but .scannerwork/report-task.txt was not generated.${NC}"
  exit 1
fi

echo ""
echo -e "${YELLOW}▸ [2/2] Reading SonarQube quality gate and generating local report...${NC}"

REPORT_ARGS=(
  --report-task "$REPORT_TASK"
  --project-key "$PROJECT_KEY"
  --scope "$SCAN_LABEL"
  --dup-threshold "$DUP_THRESHOLD"
  --coverage-threshold "$COV_THRESHOLD"
)

if [[ -n "$COVERAGE_FILE" ]]; then
  REPORT_ARGS+=(--coverage "$COVERAGE_FILE")
fi

if [[ -n "$FOCUS_MODE" ]]; then
  REPORT_ARGS+=(--focus "$FOCUS_MODE")
  if [[ "$FOCUS_MODE" == *"smell"* ]]; then
    REPORT_ARGS+=(--smell-threshold "$SMELL_THRESHOLD")
  fi
fi

if ! python3 "$SCRIPT_DIR/generate-report.py" "${REPORT_ARGS[@]}"; then
  echo -e "${RED}✗ Report generation failed. Check output above.${NC}"
  exit 1
fi

echo ""
echo -e "${CYAN}▸ HTML Report: ${REPORT_DIR}/quality-report.html${NC}"
echo -e "${CYAN}▸ Summary:     ${SUMMARY_JSON}${NC}"

if [[ "${OSTYPE:-}" =~ ^darwin ]]; then
  open "$REPORT_DIR/quality-report.html" 2>/dev/null || true
fi

GATE_STATUS="$(python3 - "$SUMMARY_JSON" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as fh:
    print(json.load(fh).get("gate", "failed"))
PY
)"

if [[ "$GATE_STATUS" = "passed" ]]; then
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
  echo -e "${GREEN}  ✅ QUALITY GATE PASSED${NC}"
  echo -e "${GREEN}══════════════════════════════════════════════════${NC}"
  exit 0
fi

echo -e "${RED}══════════════════════════════════════════════════${NC}"
echo -e "${RED}  ❌ QUALITY GATE FAILED${NC}"
echo -e "${RED}══════════════════════════════════════════════════${NC}"
exit 1
generate-report.py Internal report generator
#!/usr/bin/env python3
"""Generate local quality reports from SonarQube/SonarScanner results.

The scanner is the source of truth. This script reads the scanner report-task.txt,
queries the configured SonarQube/SonarCloud API for quality gate status, measures,
and file-level problem data, then writes reports/quality/summary.json and
quality-report.html.
"""

from __future__ import annotations

import argparse
import base64
import collections
import datetime
import html
import json
import os
import sys
import time
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
REPORT_DIR = PROJECT_ROOT / "reports" / "quality"
OUTPUT_HTML = REPORT_DIR / "quality-report.html"
SUMMARY_JSON = REPORT_DIR / "summary.json"
TEMPLATE_HTML = Path(__file__).parent / "template.html"


class SonarApiError(RuntimeError):
    """Raised when SonarQube API data cannot be retrieved."""


_incomplete_endpoints: set[str] = set()


def percent_threshold(value: str) -> float:
    try:
        parsed = float(value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("must be a number") from exc
    if parsed < 0:
        raise argparse.ArgumentTypeError("must be non-negative")
    return parsed


def read_properties(path: Path) -> dict[str, str]:
    values: dict[str, str] = {}
    if not path.exists():
        return values
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        values[key.strip()] = value.strip()
    return values


def normalize_base_url(url: str) -> str:
    return url.rstrip("/")


def api_get(base_url: str, endpoint: str, params: dict[str, str], token: str) -> dict:
    query = urllib.parse.urlencode(params)
    url = f"{normalize_base_url(base_url)}{endpoint}"
    if query:
        url = f"{url}?{query}"

    request = urllib.request.Request(url)
    if token:
        auth = base64.b64encode(f"{token}:".encode("utf-8")).decode("ascii")
        request.add_header("Authorization", f"Basic {auth}")

    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            return json.loads(response.read().decode("utf-8"))
    except Exception as exc:  # noqa: BLE001 - preserve CLI-friendly error context
        raise SonarApiError(f"Failed to read {url}: {exc}") from exc


def api_get_optional(base_url: str, endpoint: str, params: dict[str, str], token: str) -> dict:
    try:
        return api_get(base_url, endpoint, params, token)
    except SonarApiError as exc:
        print(f"WARN: {exc}", file=sys.stderr)
        _incomplete_endpoints.add(endpoint)
        return {}


def wait_for_analysis(base_url: str, ce_task_id: str, token: str, timeout_seconds: int = 180) -> str | None:
    deadline = time.time() + timeout_seconds
    last_status = "UNKNOWN"
    while time.time() < deadline:
        task = api_get(base_url, "/api/ce/task", {"id": ce_task_id}, token).get("task", {})
        last_status = task.get("status", last_status)
        if last_status == "SUCCESS":
            return task.get("analysisId")
        if last_status in {"FAILED", "CANCELED"}:
            raise SonarApiError(f"SonarQube background task {ce_task_id} ended with status {last_status}")
        time.sleep(3)
    raise SonarApiError(f"Timed out waiting for SonarQube background task {ce_task_id}; last status: {last_status}")


def measure_float(measures: dict[str, float | None], key: str) -> float | None:
    value = measures.get(key)
    if value is None:
        return None
    return float(value)


def status_from_threshold(value: float | None, threshold: float, direction: str) -> bool | None:
    if value is None:
        return None
    if direction == "max":
        return value <= threshold
    return value >= threshold


def to_float(value: Any) -> float | None:
    if value in (None, ""):
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def component_path(component: dict[str, Any], project_key: str) -> str:
    path = component.get("path") or component.get("name") or component.get("key", "")
    if path.startswith(project_key + ":"):
        path = path.split(":", 1)[1]
    return str(path)


def folder_for(path: str) -> str:
    parent = str(Path(path).parent)
    return "." if parent == "." else parent


def sonar_component_link(base_url: str, project_key: str, path: str) -> str:
    selected = f"{project_key}:{path}"
    return f"{normalize_base_url(base_url)}/component_measures?id={urllib.parse.quote(project_key)}&selected={urllib.parse.quote(selected, safe='')}"


def sonar_issue_link(base_url: str, project_key: str, issue_key: str) -> str:
    return f"{normalize_base_url(base_url)}/project/issues?open={urllib.parse.quote(issue_key)}&id={urllib.parse.quote(project_key)}"


def paginate_issues(base_url: str, project_key: str, token: str, max_items: int = 1000, types_filter: str | None = None) -> list[dict[str, Any]]:
    issues: list[dict[str, Any]] = []
    page = 1
    page_size = 500
    while len(issues) < max_items:
        params: dict[str, str] = {
            "componentKeys": project_key,
            "resolved": "false",
            "ps": str(page_size),
            "p": str(page),
            "s": "FILE_LINE",
            "asc": "true",
        }
        if types_filter:
            params["types"] = types_filter
        payload = api_get_optional(
            base_url,
            "/api/issues/search",
            params,
            token,
        )
        batch = payload.get("issues", [])
        if not batch:
            break
        issues.extend(batch)
        paging = payload.get("paging", {})
        total = int(paging.get("total", len(issues)))
        if len(issues) >= total:
            break
        page += 1
    return issues[:max_items]


def fetch_file_measures(base_url: str, project_key: str, token: str, max_items: int = 5000) -> list[dict[str, Any]]:
    components: list[dict[str, Any]] = []
    page = 1
    page_size = 500
    metric_keys = "coverage,lines_to_cover,uncovered_lines,duplicated_lines_density,duplicated_lines,ncloc"
    while len(components) < max_items:
        payload = api_get_optional(
            base_url,
            "/api/measures/component_tree",
            {
                "component": project_key,
                "qualifiers": "FIL",
                "metricKeys": metric_keys,
                "ps": str(page_size),
                "p": str(page),
            },
            token,
        )
        batch = payload.get("components", [])
        if not batch:
            break
        components.extend(batch)
        paging = payload.get("paging", {})
        total = int(paging.get("total", len(components)))
        if len(components) >= total:
            break
        page += 1
    return components[:max_items]


def parse_measure_map(component: dict[str, Any]) -> dict[str, float | None]:
    values: dict[str, float | None] = {}
    for item in component.get("measures", []):
        values[str(item.get("metric"))] = to_float(item.get("value"))
    return values


def build_problem_map(
    base_url: str,
    project_key: str,
    token: str,
    coverage_threshold: float,
    focus_mode: bool = False,
) -> dict[str, Any]:
    issues = paginate_issues(base_url, project_key, token, types_filter="CODE_SMELL" if focus_mode else None)
    file_components = fetch_file_measures(base_url, project_key, token)

    files: dict[str, dict[str, Any]] = {}

    def ensure_file(path: str) -> dict[str, Any]:
        if path not in files:
            files[path] = {
                "path": path,
                "folder": folder_for(path),
                "issues": {"BUG": 0, "VULNERABILITY": 0, "CODE_SMELL": 0, "SECURITY_HOTSPOT": 0},
                "issueList": [],
                "coverage": None,
                "linesToCover": None,
                "uncoveredLines": None,
                "duplicatedLinesDensity": None,
                "duplicatedLines": None,
                "ncloc": None,
                "link": sonar_component_link(base_url, project_key, path),
            }
        return files[path]

    for issue in issues:
        component = str(issue.get("component", ""))
        path = component.split(":", 1)[1] if component.startswith(project_key + ":") else component
        if not path:
            continue
        record = ensure_file(path)
        issue_type = str(issue.get("type") or "CODE_SMELL")
        record["issues"][issue_type] = record["issues"].get(issue_type, 0) + 1
        record["issueList"].append(
            {
                "key": issue.get("key"),
                "type": issue_type,
                "severity": issue.get("severity"),
                "line": issue.get("line"),
                "message": issue.get("message"),
                "link": sonar_issue_link(base_url, project_key, str(issue.get("key", ""))),
            }
        )

    for component in file_components:
        path = component_path(component, project_key)
        if not path:
            continue
        measures = parse_measure_map(component)
        record = ensure_file(path)
        record["coverage"] = measures.get("coverage")
        record["linesToCover"] = measures.get("lines_to_cover")
        record["uncoveredLines"] = measures.get("uncovered_lines")
        record["duplicatedLinesDensity"] = measures.get("duplicated_lines_density")
        record["duplicatedLines"] = measures.get("duplicated_lines")
        record["ncloc"] = measures.get("ncloc")

    for record in files.values():
        issue_total = sum(int(v) for v in record["issues"].values())
        coverage = record.get("coverage")
        lines_to_cover = record.get("linesToCover") or 0
        duplicated = record.get("duplicatedLines") or 0
        duplicated_density = record.get("duplicatedLinesDensity") or 0
        record["issueTotal"] = issue_total
        record["coverageProblem"] = bool(lines_to_cover and coverage is not None and coverage < coverage_threshold)
        record["duplicationProblem"] = bool(duplicated > 0 or duplicated_density > 0)
        record["problemTotal"] = issue_total + (1 if record["coverageProblem"] else 0) + (1 if record["duplicationProblem"] else 0)

    problem_files = [item for item in files.values() if item["problemTotal"] > 0]
    problem_files.sort(
        key=lambda item: (
            -int(item["issueTotal"]),
            -(float(item.get("duplicatedLinesDensity") or 0)),
            float(item.get("coverage") if item.get("coverage") is not None else 101),
            item["path"],
        )
    )

    folders: dict[str, dict[str, Any]] = {}
    for record in problem_files:
        folder = record["folder"]
        if folder not in folders:
            folders[folder] = {
                "folder": folder,
                "files": [],
                "issueTotal": 0,
                "bugs": 0,
                "vulnerabilities": 0,
                "codeSmells": 0,
                "securityHotspots": 0,
                "coverageProblems": 0,
                "duplicationProblems": 0,
            }
        folder_record = folders[folder]
        folder_record["files"].append(record)
        folder_record["issueTotal"] += int(record["issueTotal"])
        folder_record["bugs"] += int(record["issues"].get("BUG", 0))
        folder_record["vulnerabilities"] += int(record["issues"].get("VULNERABILITY", 0))
        folder_record["codeSmells"] += int(record["issues"].get("CODE_SMELL", 0))
        folder_record["securityHotspots"] += int(record["issues"].get("SECURITY_HOTSPOT", 0))
        folder_record["coverageProblems"] += 1 if record["coverageProblem"] else 0
        folder_record["duplicationProblems"] += 1 if record["duplicationProblem"] else 0

    folder_list = sorted(
        folders.values(),
        key=lambda item: (
            -int(item["issueTotal"]),
            -int(item["duplicationProblems"]),
            -int(item["coverageProblems"]),
            item["folder"],
        ),
    )

    return {
        "issuesTotal": len(issues),
        "filesAnalyzed": len(file_components),
        "problemFilesTotal": len(problem_files),
        "foldersTotal": len(folder_list),
        "folders": folder_list,
        "topFiles": problem_files,
        "limited": len(issues) >= 1000,
    }


def fetch_file_issues(base_url: str, component_key: str, token: str) -> list[dict[str, Any]]:
    """Fetch issues for a specific file component from SonarQube."""
    payload = api_get_optional(
        base_url,
        "/api/issues/search",
        {"componentKeys": component_key, "resolved": "false", "ps": "500"},
        token,
    )
    issues = []
    for issue in payload.get("issues", []):
        issues.append({
            "line": issue.get("line"),
            "message": issue.get("message", ""),
            "severity": issue.get("severity", ""),
            "type": issue.get("type", ""),
            "rule": issue.get("rule", ""),
        })
    return issues


def fetch_file_duplications(base_url: str, component_key: str, token: str) -> list[dict[str, Any]]:
    """Fetch duplication blocks for a file from SonarQube /api/duplications/show."""
    payload = api_get_optional(
        base_url,
        "/api/duplications/show",
        {"key": component_key},
        token,
    )
    files_map = payload.get("files", {})
    result = []
    for dup in payload.get("duplications", []):
        blocks = []
        for block in dup.get("blocks", []):
            ref = str(block.get("_ref", ""))
            file_info = files_map.get(ref, {})
            blocks.append({
                "from": block.get("from", 0),
                "size": block.get("size", 0),
                "fileKey": file_info.get("key", ""),
                "fileName": file_info.get("name", ""),
            })
        if len(blocks) > 1:
            result.append({"blocks": blocks})
    return result


def parse_lcov(lcov_path: Path, project_root: Path | None = None) -> dict[str, dict[str, list[int]]]:
    """Parse lcov.info → {sf_path: {c: [covered lines], u: [uncovered lines]}}."""
    if not lcov_path or not lcov_path.exists():
        return {}

    _root = project_root or PROJECT_ROOT
    result: dict[str, dict[str, list[int]]] = {}
    current: dict[str, list[int]] | None = None

    for raw in lcov_path.read_text(encoding="utf-8", errors="replace").splitlines():
        line = raw.strip()
        if line.startswith("SF:"):
            sf = line[3:]
            if os.path.isabs(sf):
                try:
                    sf = str(Path(sf).relative_to(_root))
                except ValueError:
                    pass
            current = {"c": [], "u": []}
            result[sf] = current
        elif line.startswith("DA:") and current is not None:
            parts = line[3:].split(",")
            if len(parts) >= 2:
                try:
                    lineno = int(parts[0])
                    hits = int(parts[1])
                    (current["c"] if hits > 0 else current["u"]).append(lineno)
                except ValueError:
                    pass
        elif line == "end_of_record":
            current = None

    return result


def read_source_file(project_root: Path, file_path: str) -> list[str]:
    """Read source file from disk, return list of lines (up to 2000)."""
    full_path = project_root / file_path
    try:
        lines = full_path.read_text(encoding="utf-8", errors="replace").splitlines()
        return lines[:2000]
    except Exception:
        return []


def render_issue_badges(record: dict[str, Any]) -> str:
    labels = [
        ("BUG", "BUG", "sq-badge sq-badge-bug"),
        ("VULNERABILITY", "VULN", "sq-badge sq-badge-vuln"),
        ("CODE_SMELL", "SMELL", "sq-badge sq-badge-smell"),
        ("SECURITY_HOTSPOT", "HOTSPOT", "sq-badge sq-badge-hotspot"),
    ]
    badges = []
    for key, label, css in labels:
        count = int(record.get("issues", {}).get(key, 0) or 0)
        if count:
            badges.append(f'<span class="{css}">{html.escape(label)}</span>')
    if record.get("duplicationProblem"):
        density = record.get("duplicatedLinesDensity")
        duplicated = record.get("duplicatedLines")
        text = "DUP"
        if density is not None:
            text += f" {float(density):.1f}%"
        badges.append(f'<span class="sq-badge sq-badge-smell">{html.escape(text)}</span>')
    if record.get("coverageProblem"):
        coverage = record.get("coverage")
        text = "COV"
        if coverage is not None:
            text += f" {float(coverage):.1f}%"
        badges.append(f'<span class="sq-badge sq-badge-cov">{html.escape(text)}</span>')
    return " ".join(badges) or '<span style="color:var(--text-dim);font-size:11px">—</span>'


def fmt_percent(value: Any) -> str:
    parsed = to_float(value)
    return "N/A" if parsed is None else f"{parsed:.1f}%"


def render_problem_map(problem_map: dict[str, Any]) -> str:
    if not problem_map.get("problemFilesTotal"):
        return """
  <section class="section">
    <h2>Problem Structure</h2>
    <p class="muted">No file-level issues, duplication, or coverage gaps were returned by SonarQube.</p>
  </section>
"""

    top_rows = "".join(
        "<tr>"
        f"<td><a href=\"{html.escape(record['link'])}\">{html.escape(record['path'])}</a></td>"
        f"<td>{render_issue_badges(record)}</td>"
        f"<td>{fmt_percent(record.get('coverage'))}</td>"
        f"<td>{fmt_percent(record.get('duplicatedLinesDensity'))}</td>"
        "</tr>"
        for record in problem_map.get("topFiles", [])
    )

    folder_blocks = []
    for folder in problem_map.get("folders", []):
        rows = "".join(
            "<tr>"
            f"<td class=\"path\"><a href=\"{html.escape(record['link'])}\">{html.escape(Path(record['path']).name)}</a><br><small>{html.escape(record['path'])}</small></td>"
            f"<td>{render_issue_badges(record)}</td>"
            f"<td>{fmt_percent(record.get('coverage'))}</td>"
            f"<td>{fmt_percent(record.get('duplicatedLinesDensity'))}</td>"
            "</tr>"
            for record in folder.get("files", [])[:30]
        )
        extra = len(folder.get("files", [])) - 30
        if extra > 0:
            rows += f'<tr><td colspan="4" class="muted">+ {extra} more files in this folder</td></tr>'
        folder_blocks.append(
            f"""
    <details class="folder" open>
      <summary>
        <strong>{html.escape(folder['folder'])}</strong>
        <span class="muted">{len(folder.get('files', []))} files · {folder['issueTotal']} issues · {folder['duplicationProblems']} dup · {folder['coverageProblems']} coverage</span>
      </summary>
      <table>
        <thead><tr><th>File</th><th>Problems</th><th>Coverage</th><th>Duplication</th></tr></thead>
        <tbody>{rows}</tbody>
      </table>
    </details>
"""
        )

    limit_note = ""
    if problem_map.get("limited"):
        limit_note = '<p class="warn">Issue list was capped at 1000 unresolved issues for report size.</p>'

    return f"""
  <section class="section">
    <h2>Problem Structure / Full Quality Map</h2>
    <p class="muted">File/folder map from SonarQube APIs: unresolved issues + duplicated files + files below coverage threshold.</p>
    {limit_note}
    <div class="cards compact">
      <div class="card"><h3>Problem files</h3><div class="value small">{problem_map['problemFilesTotal']}</div></div>
      <div class="card"><h3>Folders</h3><div class="value small">{problem_map['foldersTotal']}</div></div>
      <div class="card"><h3>Unresolved issues</h3><div class="value small">{problem_map['issuesTotal']}</div></div>
      <div class="card"><h3>Files analyzed</h3><div class="value small">{problem_map['filesAnalyzed']}</div></div>
    </div>

    <h3>Top problem files</h3>
    <table>
      <thead><tr><th>File</th><th>Problems</th><th>Coverage</th><th>Duplication</th></tr></thead>
      <tbody>{top_rows}</tbody>
    </table>

    <h3>By folder</h3>
    {''.join(folder_blocks)}
  </section>
"""


def get_current_branch() -> str:
    import subprocess
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--abbrev-ref", "HEAD"],
            capture_output=True, text=True, timeout=5,
            cwd=str(PROJECT_ROOT),
        )
        branch = result.stdout.strip()
        return branch if branch and branch != "HEAD" else ""
    except Exception:
        return ""


def display_metric_key(key: Any) -> str:
    metric = str(key or "")
    labels = {
        "new_duplicated_lines_density": "Duplication",
        "duplicated_lines_density": "Duplication",
        "new_violations": "New Issues",
        "coverage": "Coverage",
    }
    return labels.get(metric, metric.replace("_", " ").title())


def short_path(path: Any, max_len: int = 42) -> str:
    text = str(path or "")
    if len(text) <= max_len:
        return text
    parts = text.split("/")
    if len(parts) >= 3:
        compact = f"{parts[0]}/…/{parts[-1]}"
        if len(compact) <= max_len:
            return compact
    return text[: max_len - 1].rstrip("/") + "…"


def describe_gate_condition(item: dict[str, Any]) -> str:
    metric_key = str(item.get("metricKey", ""))
    label = display_metric_key(metric_key)
    actual_raw = str(item.get("actualValue", ""))
    threshold_raw = str(item.get("errorThreshold", ""))
    actual = html.escape(actual_raw)
    threshold = html.escape(threshold_raw)
    comparator = str(item.get("comparator", "")).upper()
    status = str(item.get("status", "")).upper()
    failed = status not in ("OK", "PASS", "PASSED")

    if metric_key == "new_violations":
        return f"{actual} new issue{'s' if actual_raw != '1' else ''} exceed threshold {threshold}"
    if metric_key in {"coverage"}:
        return f"Coverage {actual}% is {'below' if failed else 'above'} threshold {threshold}%"
    if metric_key in {"new_duplicated_lines_density", "duplicated_lines_density"}:
        if failed or comparator in {"GT", ">"}:
            return f"Duplication {actual}% exceeds threshold {threshold}%"
        return f"Duplication {actual}% is within threshold {threshold}%"
    return f"{html.escape(label)} {actual} {'exceeds' if failed else 'meets'} threshold {threshold}"


def _top_file_rows(files: list[dict[str, Any]], base_url: str = "", project_key: str = "", focus_mode: bool = False) -> str:
    rows = ""
    for record in files:
        path = record.get("path", "")
        full_path = str(path)
        filename = html.escape(Path(full_path).name)
        filepath = html.escape(short_path(str(Path(full_path).parent), 64))
        full_path_escaped = html.escape(full_path)
        parent_title = html.escape(str(Path(full_path).parent))
        link = record.get("link", "")
        if not link and base_url and project_key:
            link = sonar_component_link(base_url, project_key, full_path)
        link = html.escape(link)

        issues = record.get("issues", {})
        bugs = int(issues.get("BUG", 0) or 0)
        vulns = int(issues.get("VULNERABILITY", 0) or 0)
        smells = int(issues.get("CODE_SMELL", 0) or 0)
        hotspots = int(issues.get("SECURITY_HOTSPOT", 0) or 0)
        is_dup = record.get("duplicationProblem", False)
        is_cov = record.get("coverageProblem", False)
        issue_total = int(record.get("issueTotal", 0) or 0)

        dup_density = to_float(record.get("duplicatedLinesDensity"))
        dup_lines = to_float(record.get("duplicatedLines"))
        cov_val = to_float(record.get("coverage"))
        if not is_dup and (dup_density or dup_lines):
            is_dup = True
        if not is_cov and cov_val is not None and cov_val == 0.0 and not issue_total and not is_dup:
            is_cov = True

        pills = []
        if bugs and not focus_mode:
            pills.append('<span class="pill pill-bug">BUG</span>')
        if vulns and not focus_mode:
            pills.append('<span class="pill pill-vuln">VULN</span>')
        if smells:
            pills.append('<span class="pill pill-smell">SMELL</span>')
        if hotspots and not focus_mode:
            pills.append('<span class="pill pill-hotspot">HOTSPOT</span>')
        if is_dup:
            pills.append('<span class="pill pill-dup">DUP</span>')
        if is_cov:
            pills.append('<span class="pill pill-cov">COV</span>')
        type_html = " ".join(pills) if pills else '<span class="muted">—</span>'

        details: list[str] = []
        if issue_total:
            details.append(f"{issue_total} issue{'s' if issue_total != 1 else ''}")
        if is_dup and dup_density is not None:
            details.append(f"{dup_density:.1f}% dup")
            if dup_lines:
                details.append(f"{int(dup_lines)} lines")
        if is_cov and cov_val is not None:
            details.append(f"{cov_val:.1f}% cov")
        detail_text = html.escape(" · ".join(details)) if details else "—"

        anchor_open = f'<a href="{link}" title="{full_path_escaped}">' if link else ""
        anchor_close = "</a>" if link else ""
        rows += (
            "<tr>"
            f'<td><div class="filename">{anchor_open}{filename}{anchor_close}</div>'
            f'<div class="path" title="{parent_title}">{filepath}</div></td>'
            f"<td>{type_html}</td>"
            f"<td>{detail_text}</td>"
            "</tr>\n"
        )
    return rows


def _reliability_grade(bugs: Any) -> str:
    b = int(bugs or 0)
    if b == 0: return "A"
    if b == 1: return "B"
    if b <= 5: return "C"
    if b <= 10: return "D"
    return "E"


def _security_grade(vulns: Any) -> str:
    return _reliability_grade(vulns)


def _maintainability_grade(smells: Any) -> str:
    s = int(smells or 0)
    if s == 0: return "A"
    if s <= 5: return "B"
    if s <= 20: return "C"
    if s <= 50: return "D"
    return "E"


def render_html(
    summary: dict,
    measures: dict[str, float | None],
    gate_conditions: list[dict],
    problem_map: dict[str, Any],
    branch: str = "",
    focus_mode: bool = False,
    code_smells_summary: dict | None = None,
    file_details: dict[str, Any] | None = None,
    lcov_coverage: dict | None = None,
) -> str:
    gate = summary["gate"]
    duplicate = summary["duplicate"]
    coverage = summary["coverage"]
    dashboard_url = summary.get("dashboardUrl") or ""
    is_passed = gate == "passed"
    project_key = html.escape(summary.get("projectKey", "top-flutter"))

    branch_or_scope = html.escape(branch) if branch else html.escape(summary.get("scope", ""))

    smell_cs = code_smells_summary or {}
    smell_count = smell_cs.get("count")
    smell_passed = smell_cs.get("passed", True)

    gate_class = "pass" if is_passed else "fail"
    gate_icon = "✓" if is_passed else "✗"
    gate_status_text = "Passed" if is_passed else "Failed"

    dup_pct = to_float(duplicate.get("percentage"))
    cov_pct = to_float(coverage.get("percentage"))
    dup_threshold = float(duplicate.get("threshold") or 3)
    cov_threshold = float(coverage.get("threshold") or 80)
    dup_passed = duplicate.get("passed")
    cov_passed = coverage.get("passed")

    # Lint results
    lint_data = summary.get("lint") or {}
    lint_errors = lint_data.get("errors")
    lint_warnings = lint_data.get("warnings")
    lint_mode = lint_data.get("mode", "strict")
    lint_passed_val = lint_data.get("passed")  # True/False/None

    files_analyzed = int(problem_map.get("filesAnalyzed", 0))
    issues_total = int(problem_map.get("issuesTotal", 0))
    problem_files_total = int(problem_map.get("problemFilesTotal", 0))
    folders_total = int(problem_map.get("foldersTotal", 0))

    def fmt_pct(v: Any) -> str:
        parsed = to_float(v)
        return "N/A" if parsed is None else f"{parsed:.1f}%"

    # Compute metric values from measures
    _bugs_raw = measures.get("bugs")
    bugs_count: int | None = int(_bugs_raw) if _bugs_raw is not None else None
    vulns_count = int(measures.get("vulnerabilities") or 0)
    _smells_raw = measures.get("code_smells")
    smells_count: int | None = int(_smells_raw) if _smells_raw is not None else None
    if smells_count is None:
        smells_count = int(smell_count) if smell_count is not None else None
    ncloc = int(measures.get("ncloc") or 0)
    ncloc_display = f"{ncloc:,}" if ncloc else "N/A"

    reliability_grade_val = _reliability_grade(bugs_count)
    security_grade_val = _security_grade(vulns_count)
    maintainability_grade_val = _maintainability_grade(smells_count)

    cov_circle = "circle-pass" if cov_passed is True else ("circle-fail" if cov_passed is False else "circle-none")
    dup_circle = "circle-pass" if dup_passed is True else ("circle-fail" if dup_passed is False else "circle-none")
    cov_val_class = "sq-metric-value"
    dup_val_class = "sq-metric-value"

    # Build table rows for problem files
    table_rows = ""
    for idx, record in enumerate(problem_map.get("topFiles", [])):
        path = str(record.get("path", ""))
        filename = html.escape(Path(path).name)
        path_escaped = html.escape(path)
        path_js = html.escape(json.dumps(path)[1:-1])
        badges = render_issue_badges(record)
        issue_total = int(record.get("issueTotal") or 0)
        details_parts = []
        if issue_total:
            details_parts.append(f'{issue_total} issue{"s" if issue_total != 1 else ""}')
        cov = to_float(record.get("coverage"))
        dup = to_float(record.get("duplicatedLinesDensity"))
        if cov is not None:
            details_parts.append(f"{cov:.1f}% cov")
        if dup is not None and dup > 0:
            details_parts.append(f"{dup:.1f}% dup")
        detail_text = html.escape(" · ".join(details_parts)) if details_parts else "—"
        table_rows += (
            f'<tr class="file-row" data-path="{path_escaped}">\n'
            f'  <td onclick="showDetail(\'{path_js}\')">\n'
            f'    <div class="sq-file-link">{filename}</div>\n'
            f'    <div class="sq-file-path">{path_escaped}</div>\n'
            f'  </td>\n'
            f'  <td>{badges}</td>\n'
            f'  <td class="sq-issues-count">{detail_text}</td>\n'
            f'</tr>\n'
        )
    if not table_rows:
        table_rows = '<tr><td colspan="3" style="padding:14px;color:var(--text-dim)">No problem files found.</td></tr>'

    # Gate conditions table rows
    def _cstatus_html(item: dict) -> str:
        s = str(item.get("status", "")).upper()
        cls = "sq-cond-pass" if s in ("OK", "PASS", "PASSED") else "sq-cond-fail"
        return f'<span class="{cls}">{html.escape(s)}</span>'

    if focus_mode:
        smell_threshold = smell_cs.get("threshold", 0)
        _focus_rows_data = [
            ("Coverage", fmt_pct(cov_pct), f">= {cov_threshold:g}%", "OK" if cov_passed else "ERROR"),
            ("Duplication", fmt_pct(dup_pct), f"<= {dup_threshold:g}%", "OK" if dup_passed else "ERROR"),
            ("Code Smells", str(smell_count) if smell_count is not None else "N/A", f"<= {smell_threshold}", "OK" if smell_passed else "ERROR"),
        ]
        condition_rows = "".join(
            "<tr>"
            f"<td>{html.escape(label)}</td>"
            f"<td>{html.escape(actual)}</td>"
            f"<td>{html.escape(threshold)}</td>"
            f"<td>{_cstatus_html({'status': status})}</td>"
            "</tr>"
            for label, actual, threshold, status in _focus_rows_data
        )
    else:
        condition_rows = "".join(
            "<tr>"
            f"<td>{html.escape(display_metric_key(item.get('metricKey', '')))}</td>"
            f"<td>{html.escape(str(item.get('actualValue', '')))}</td>"
            f"<td>{html.escape(str(item.get('comparator', '')))} {html.escape(str(item.get('errorThreshold', '')))}</td>"
            f"<td>{_cstatus_html(item)}</td>"
            "</tr>"
            for item in gate_conditions
        ) or '<tr><td colspan="4" style="color:var(--text-dim)">No quality gate conditions returned.</td></tr>'

    sq_open_btn = ""

    limit_note = ""
    if problem_map.get("limited"):
        limit_note = '<div class="sq-warn">Issue list capped at 1,000 unresolved issues.</div>'

    incomplete_banner = ""
    if summary.get("data_incomplete"):
        incomplete_banner = '<div class="sq-warn">&#x26A0; Some SonarQube API endpoints were unavailable. Report data may be incomplete.</div>'

    template = TEMPLATE_HTML.read_text(encoding="utf-8")

    now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
    bugs_card_attr = 'onclick="showFileList(\'bugs\')" style="cursor:pointer"' if bugs_count else 'style="cursor:default;opacity:0.5"'
    smells_card_attr = 'onclick="showFileList(\'smells\')" style="cursor:pointer"' if smells_count > 0 else 'style="cursor:default;opacity:0.5"'
    cov_card_attr = 'onclick="showFileList(\'coverage\')" style="cursor:pointer"' if cov_pct is not None else 'style="cursor:default;opacity:0.5"'
    dup_card_attr = 'onclick="showFileList(\'duplications\')" style="cursor:pointer"' if dup_pct is not None else 'style="cursor:default;opacity:0.5"'
    cov_pass_sub = f' · {"pass" if cov_passed else "fail"}' if cov_pct is not None else ''
    dup_pass_sub = f' · {"pass" if dup_passed else "fail"}' if dup_pct is not None else ''
    lint_circle = "circle-pass" if lint_passed_val is True else ("circle-fail" if lint_passed_val is False else "circle-none")
    has_lint_files = bool(lint_data and lint_data.get("files"))
    lint_card_attr = 'onclick="showFileList(\'lint\')" style="cursor:pointer"' if has_lint_files else 'style="cursor:default;opacity:0.5"'
    lint_errors_display = str(lint_errors) if lint_errors is not None else "N/A"
    lint_warn_display = str(lint_warnings) if lint_warnings is not None else ""
    lint_mode_display = lint_mode if lint_mode else "strict"
    lint_pass_sub = f'mode: {lint_mode_display} · {"pass" if lint_passed_val else "fail"}' if lint_passed_val is not None else "not run"

    body_html = f"""
<div class="sq-navbar">
  <span class="sq-logo">&#11044;</span>
  <span class="sq-project">{project_key}</span>
  {f'<span class="sq-branch-badge"><span class="pass">✓</span> {branch_or_scope}</span>' if branch_or_scope else ''}
  <div style="margin-left:auto;display:flex;align-items:center;gap:12px">
    <span style="font-size:12px;color:var(--text-dim)">{ncloc_display} Lines of Code</span>
    <span style="font-size:12px;color:var(--text-dim)">{now_str}</span>
  </div>
</div>

<div class="sq-tabs">
  <div class="sq-tab active">Overview</div>
  <div style="margin-left:auto;display:flex;align-items:center">
    <button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle colour theme">
      <span class="theme-toggle-icon" id="themeIcon">&#9790;</span>
      <span id="themeLabel">Dark</span>
    </button>
  </div>
</div>

<div class="sq-content">

  {incomplete_banner}
  {limit_note}

  <!-- View: Overview list -->
  <div id="view-list">

  <!-- Quality Gate Banner -->
  <div class="sq-gate">
    <div class="sq-gate-icon {gate_class}">{gate_icon}</div>
    <div>
      <div class="sq-gate-title">Quality Gate</div>
      <div class="sq-gate-status {gate_class}">{gate_status_text}</div>
      <div class="sq-gate-meta">Last analysis: {branch_or_scope or html.escape(summary.get("scope", ""))}</div>
    </div>
    {sq_open_btn}
    <div class="sq-gate-right">
      <div>{files_analyzed} files analyzed</div>
      <div>{folders_total} folders</div>
    </div>
  </div>

  <!-- Metric cards -->
  <div class="sq-metrics">
    <div class="sq-metric-card" {bugs_card_attr}>
      <div class="sq-metric-label">Bugs</div>
      <div class="sq-metric-body">
        <div class="sq-metric-value">{bugs_count if bugs_count is not None else "N/A"}</div>
      </div>
      <div class="sq-metric-sub">{bugs_count if bugs_count is not None else "N/A"} open issue{"s" if (bugs_count or 0) != 1 else ""}</div>
    </div>
    <div class="sq-metric-card" {smells_card_attr}>
      <div class="sq-metric-label">Code Smells</div>
      <div class="sq-metric-body">
        <div class="sq-metric-value">{smells_count if smells_count is not None else "N/A"}</div>
      </div>
      <div class="sq-metric-sub">{smells_count if smells_count is not None else "N/A"} open issue{"s" if (smells_count or 0) != 1 else ""}</div>
    </div>
    <div class="sq-metric-card" {cov_card_attr}>
      <div class="sq-metric-label">Coverage</div>
      <div class="sq-metric-body">
        <div class="{cov_val_class}">{fmt_pct(cov_pct)}</div>
        <div class="sq-metric-circle {cov_circle}"></div>
      </div>
      <div class="sq-metric-sub">threshold {cov_threshold:g}%{cov_pass_sub}</div>
    </div>
    <div class="sq-metric-card" {dup_card_attr}>
      <div class="sq-metric-label">Duplications</div>
      <div class="sq-metric-body">
        <div class="{dup_val_class}">{fmt_pct(dup_pct)}</div>
        <div class="sq-metric-circle {dup_circle}"></div>
      </div>
      <div class="sq-metric-sub">threshold {dup_threshold:g}%{dup_pass_sub}</div>
    </div>
    <div class="sq-metric-card" {lint_card_attr}>
      <div class="sq-metric-label">Lint</div>
      <div class="sq-metric-body">
        <div class="sq-metric-value">{lint_errors_display}</div>
        <div class="sq-metric-circle {lint_circle}"></div>
      </div>
      <div class="sq-metric-sub">{lint_pass_sub}{f" · {lint_warn_display} warn" if lint_warn_display else ""}</div>
    </div>
  </div>

  </div>

  <!-- View: File List (drill-down from metric card) -->
  <div id="view-files" style="display:none">
    <div class="detail-header">
      <button class="back-btn" onclick="goBack()">&#8592; Back</button>
      <div class="detail-file-info">
        <span class="detail-filename" id="view-files-title"></span>
        <span class="detail-filepath" id="view-files-count"></span>
      </div>
      <div class="view-toggle">
        <button id="btn-view-list" class="view-toggle-btn active" onclick="setFilesView('list')">&#9776; List</button>
        <button id="btn-view-tree" class="view-toggle-btn" onclick="setFilesView('tree')">&#9654; Tree</button>
      </div>
    </div>
    <div id="view-files-content"></div>
  </div>

  <!-- View: File Detail -->
  <div id="view-detail" style="display:none">
    <div class="detail-header">
      <button class="back-btn" onclick="goBack()">&#8592; Back</button>
      <div class="detail-file-info">
        <span class="detail-filename" id="detail-filename"></span>
        <span class="detail-filepath" id="detail-filepath"></span>
      </div>
      <div class="detail-badges" id="detail-badges"></div>
      <div class="detail-issue-count" id="detail-issue-count"></div>
    </div>
    <div id="detail-content"></div>
  </div>

<div style="padding:16px 0;font-size:11px;color:var(--text-dim);text-align:right">
    top-flutter · {project_key} · Source: SonarQube · {html.escape(summary.get("scope", ""))}
  </div>

</div>"""

    # Build FILE_DETAILS JS object (exclude scripts/quality to avoid injection marker conflicts)
    file_details_data: dict[str, Any] = {}
    if file_details:
        for path, detail in file_details.items():
            if path.startswith("scripts/"):
                continue
            file_details_data[path] = {
                "lines": detail.get("lines", []),
                "issues": detail.get("issues", []),
                "duplications": detail.get("duplications", []),
            }
    file_details_js = json.dumps(file_details_data, ensure_ascii=True).replace("</", "<\\/")
    coverage_data_js = json.dumps(lcov_coverage or {}, ensure_ascii=True).replace("</", "<\\/")

    lint_files_data = []
    if lint_data and lint_data.get("files"):
        for f in lint_data["files"]:
            lint_files_data.append({
                "path": f.get("path", ""),
                "errors": f.get("errors", 0),
                "warnings": f.get("warnings", 0),
                "info": f.get("info", 0),
                "issues": f.get("issues", []),
            })
    lint_files_js = json.dumps(lint_files_data, ensure_ascii=True).replace("</", "<\\/")

    # Serialize problem files for JS drill-down
    problem_files_data = []
    for rec in problem_map.get("topFiles", []):
        problem_files_data.append({
            "path": rec.get("path", ""),
            "issueTotal": int(rec.get("issueTotal") or 0),
            "coverage": rec.get("coverage"),
            "duplicatedLinesDensity": rec.get("duplicatedLinesDensity"),
            "coverageProblem": bool(rec.get("coverageProblem")),
            "duplicationProblem": bool(rec.get("duplicationProblem")),
            "issues": {
                "BUG": int((rec.get("issues") or {}).get("BUG") or 0),
                "VULNERABILITY": int((rec.get("issues") or {}).get("VULNERABILITY") or 0),
                "CODE_SMELL": int((rec.get("issues") or {}).get("CODE_SMELL") or 0),
                "SECURITY_HOTSPOT": int((rec.get("issues") or {}).get("SECURITY_HOTSPOT") or 0),
            },
        })
    problem_files_js = json.dumps(problem_files_data, ensure_ascii=True).replace("</", "<\\/")

    inject_js = f"const FILE_DETAILS = {file_details_js};\nconst PROBLEM_FILES = {problem_files_js};\nconst COVERAGE_DATA = {coverage_data_js};\nconst LINT_FILES = {lint_files_js};"

    html_out = template.replace("<!-- INJECT_BODY -->", body_html)
    html_out = html_out.replace("/*__REPORT_DATA__*/", inject_js)
    return html_out


def generate_report() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--report-task", required=True, help="Path to .scannerwork/report-task.txt")
    parser.add_argument("--project-key", required=True, help="SonarQube project key")
    parser.add_argument("--scope", default="Whole Project", help="Display label for this run")
    parser.add_argument("--coverage", help="Path to lcov.info used by scanner", default=None)
    parser.add_argument("--coverage-threshold", type=percent_threshold, default=80)
    parser.add_argument("--dup-threshold", type=percent_threshold, default=3)
    parser.add_argument("--focus", default="", help="Focus areas: coverage,duplication,smell")
    parser.add_argument("--smell-threshold", type=int, default=0, help="Max code smells allowed in focus gate")
    args = parser.parse_args()
    focus_mode = bool(args.focus)

    report_task = Path(args.report_task)
    task_values = read_properties(report_task)
    env_url = os.environ.get("SONAR_HOST_URL")
    task_url = task_values.get("serverUrl")
    if env_url and task_url and env_url.rstrip("/") != task_url.rstrip("/"):
        print(f"WARN: SONAR_HOST_URL ({env_url}) differs from serverUrl in report-task ({task_url}), using SONAR_HOST_URL", file=sys.stderr)
    base_url = env_url or task_url or task_values.get("dashboardUrl", "").split("/dashboard", 1)[0]
    ce_task_id = task_values.get("ceTaskId")
    token = os.environ.get("SONAR_TOKEN") or os.environ.get("SONAR_LOGIN") or ""

    if not base_url:
        raise SonarApiError("Could not determine SonarQube URL from SONAR_HOST_URL or report-task.txt")
    if not ce_task_id:
        raise SonarApiError(f"Missing ceTaskId in {report_task}")

    try:
        analysis_id = wait_for_analysis(base_url, ce_task_id, token)
    except SonarApiError:
        # Remove stale summary so quality-check.sh cannot read a false PASSED gate
        if SUMMARY_JSON.exists():
            SUMMARY_JSON.unlink()
        raise
    gate_params = {"analysisId": analysis_id} if analysis_id else {"projectKey": args.project_key}
    gate_payload = api_get(
        base_url,
        "/api/qualitygates/project_status",
        gate_params,
        token,
    )
    project_status = gate_payload.get("projectStatus", {})
    gate_status = str(project_status.get("status", "ERROR")).upper()
    gate_conditions = project_status.get("conditions", [])

    metric_keys = "duplicated_lines_density,duplicated_lines,coverage,ncloc,bugs,vulnerabilities,code_smells"
    measures_payload = api_get(
        base_url,
        "/api/measures/component",
        {"component": args.project_key, "metricKeys": metric_keys},
        token,
    )
    measures: dict[str, float | None] = {key: None for key in metric_keys.split(",")}
    for item in measures_payload.get("component", {}).get("measures", []):
        key = item.get("metric")
        value = item.get("value")
        if key in measures and value is not None:
            measures[key] = to_float(value)

    problem_map = build_problem_map(base_url, args.project_key, token, args.coverage_threshold, focus_mode=focus_mode)

    # Parse lcov for coverage highlighting (raw SF paths, matching done in JS)
    lcov_path = Path(args.coverage) if args.coverage else None
    lcov_coverage = parse_lcov(lcov_path)

    # Build file detail data for drill-down
    def _fetch_detail(args_tuple: tuple) -> tuple:
        fp, ck, bu, tk, dup = args_tuple
        issues = fetch_file_issues(bu, ck, tk)
        dups = fetch_file_duplications(bu, ck, tk) if dup > 0 else []
        return fp, issues, dups

    top_records = [r for r in problem_map.get("topFiles", [])[:500] if r.get("path")]
    fetch_args_list = [
        (r["path"], f"{args.project_key}:{r['path']}", base_url, token, to_float(r.get("duplicatedLines")) or 0)
        for r in top_records
    ]
    file_details: dict[str, Any] = {}
    with ThreadPoolExecutor(max_workers=10) as executor:
        futures = {executor.submit(_fetch_detail, a): a[0] for a in fetch_args_list}
        for future in as_completed(futures):
            fp, issues, dups = future.result()
            file_details[fp] = {
                "lines": read_source_file(PROJECT_ROOT, fp),
                "issues": issues,
                "duplications": dups,
                # TODO: add per-line coverage from /api/sources/lines?key=component when lcov absent
            }

    duplication_pct = measure_float(measures, "duplicated_lines_density")
    coverage_pct = measure_float(measures, "coverage")
    code_smells_count = measures.get("code_smells")

    dup_passed = status_from_threshold(duplication_pct, args.dup_threshold, "max")
    cov_passed = status_from_threshold(coverage_pct, args.coverage_threshold, "min")
    smell_passed = (code_smells_count is not None and int(code_smells_count) <= args.smell_threshold) if focus_mode else True
    code_smells_summary = {
        "count": int(code_smells_count) if code_smells_count is not None else None,
        "threshold": args.smell_threshold,
        "passed": smell_passed,
    }

    # Read lint results if available
    lint_summary: dict[str, Any] | None = None
    lint_json_path = PROJECT_ROOT / "reports" / "lint" / "lint-results.json"
    if lint_json_path.exists():
        try:
            lint_summary = json.loads(lint_json_path.read_text(encoding="utf-8"))
        except Exception:
            lint_summary = None
    lint_passed = lint_summary.get("passed") if lint_summary else None

    # Gate: SonarQube result + local thresholds (coverage, dup) + lint — all must pass
    sonar_ok = gate_status == "OK"
    cov_ok = cov_passed is True        # None (no data) = fail gate
    dup_ok = dup_passed is not False   # None (no data) = don't block
    lint_ok = lint_passed is not False  # None (not run) = don't block
    smell_ok = (code_smells_count is None or int(code_smells_count) <= args.smell_threshold) if focus_mode else True

    gate_passed = sonar_ok and cov_ok and dup_ok and lint_ok and smell_ok
    gate_display = "passed" if gate_passed else "failed"
    gate_source = "local"

    summary = {
        "scope": args.scope,
        "source": "sonarqube",
        "projectKey": args.project_key,
        "dashboardUrl": task_values.get("dashboardUrl"),
        "analysisId": analysis_id,
        "duplicate": {
            "percentage": duplication_pct,
            "threshold": args.dup_threshold,
            "passed": dup_passed,
        },
        "coverage": {
            "percentage": coverage_pct,
            "threshold": args.coverage_threshold,
            "passed": cov_passed,
            "reportPath": args.coverage,
        },
        "qualityGateStatus": gate_status,
        "gate": gate_display,
        "gateSource": gate_source,
        "focusMode": focus_mode,
        "data_incomplete": len(_incomplete_endpoints) > 0,
        "codeSmells": code_smells_summary,
        "lint": lint_summary,
        "problemMap": {
            "issuesTotal": problem_map["issuesTotal"],
            "filesAnalyzed": problem_map["filesAnalyzed"],
            "problemFilesTotal": problem_map["problemFilesTotal"],
            "foldersTotal": problem_map["foldersTotal"],
            "topFiles": [
                {
                    "path": item["path"],
                    "issueTotal": item["issueTotal"],
                    "coverage": item["coverage"],
                    "duplicatedLinesDensity": item["duplicatedLinesDensity"],
                    "duplicatedLines": item["duplicatedLines"],
                }
                for item in problem_map.get("topFiles", [])[:20]
            ],
        },
    }

    REPORT_DIR.mkdir(parents=True, exist_ok=True)
    SUMMARY_JSON.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
    OUTPUT_HTML.write_text(render_html(summary, measures, gate_conditions, problem_map, branch=get_current_branch(), focus_mode=focus_mode, code_smells_summary=code_smells_summary, file_details=file_details, lcov_coverage=lcov_coverage), encoding="utf-8")

    print(f"Summary generated: {SUMMARY_JSON}")
    print(f"Report generated: {OUTPUT_HTML}")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(generate_report())
    except SonarApiError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        raise SystemExit(1)
template.html Reference/design template
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code Quality Report</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-light">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-dark" disabled>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/dart.min.js"></script>
<style>
  /* ── Design tokens ──────────────────────────────────────────────
     Tokenized from the existing report styling. Legacy aliases (--bg,
     --surface, etc.) remain mapped so the visual design is preserved. */
  :root {
    /* Color primitives: dark theme (Catppuccin Mocha) */
    --color-canvas-dark: #1e1e2e;
    --color-surface-dark: #181825;
    --color-surface-raised-dark: #313244;
    --color-border-dark: #45475a;
    --color-text-dark: #cdd6f4;
    --color-text-muted-dark: #6c7086;
    --color-success-dark: #a6e3a1;
    --color-danger-dark: #f38ba8;
    --color-accent-dark: #89b4fa;
    --color-warning-dark: #f9e2af;
    --color-info-dark: #94e2d5;

    /* Semantic colors */
    --color-canvas: var(--color-canvas-dark);
    --color-surface: var(--color-surface-dark);
    --color-surface-raised: var(--color-surface-raised-dark);
    --color-border: var(--color-border-dark);
    --color-text: var(--color-text-dark);
    --color-text-muted: var(--color-text-muted-dark);
    --color-success: var(--color-success-dark);
    --color-danger: var(--color-danger-dark);
    --color-accent: var(--color-accent-dark);
    --color-warning: var(--color-warning-dark);
    --color-info: var(--color-info-dark);
    --color-code-bg: var(--color-canvas-dark);
    --color-on-badge: #1e1e2e;
    --color-gate-pass-bg: rgba(166,227,161,0.15);
    --color-gate-fail-bg: rgba(243,139,168,0.15);
    --color-filter-bg: rgba(137,180,250,0.15);
    --color-active-bg: rgba(137,180,250,0.2);
    --color-overlay: rgba(17,17,27,0.7);

    /* Typography, space, radius, motion, layout */
    --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
    --font-mono: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    --space-1: 4px; --space-2: 6px; --space-3: 8px; --space-4: 10px; --space-5: 12px; --space-6: 14px; --space-7: 16px; --space-8: 20px; --space-9: 24px;
    --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 10px; --radius-pill: 20px;
    --shadow-popover: 0 18px 60px rgba(0,0,0,0.35);
    --shadow-card: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08);
    --shadow-card-hover: 0 4px 12px rgba(0,0,0,0.20), 0 2px 4px rgba(0,0,0,0.12);
    --duration-fast: 0.15s;
    --duration-med: 0.2s;
    --layout-sidebar-width: 320px;

    /* Legacy aliases preserved for existing rules */
    --bg: var(--color-canvas);
    --surface: var(--color-surface);
    --surface2: var(--color-surface-raised);
    --border: var(--color-border);
    --text: var(--color-text);
    --text-dim: var(--color-text-muted);
    --green: var(--color-success);
    --red: var(--color-danger);
    --blue: var(--color-accent);
    --yellow: var(--color-warning);
    --cyan: var(--color-info);
    --sidebar-w: var(--layout-sidebar-width);
    --code-bg: var(--color-code-bg);
    --badge-text: var(--color-on-badge);
    --gate-pass-bg: var(--color-gate-pass-bg);
    --gate-fail-bg: var(--color-gate-fail-bg);
    --filter-bg: var(--color-filter-bg);
    --active-bg: var(--color-active-bg);
  }
  [data-theme="light"] {
    /* Catppuccin Latte */
    --color-canvas: #eff1f5;
    --color-surface: #e6e9ef;
    --color-surface-raised: #dce0e8;
    --color-border: #ccd0da;
    --color-text: #4c4f69;
    --color-text-muted: #9ca0b0;
    --color-success: #40a02b;
    --color-danger: #d20f39;
    --color-accent: #1e66f5;
    --color-warning: #df8e1d;
    --color-info: #179299;
    --color-code-bg: #e6e9ef;
    --color-on-badge: #eff1f5;
    --color-gate-pass-bg: rgba(64,160,43,0.1);
    --color-gate-fail-bg: rgba(210,15,57,0.1);
    --color-filter-bg: rgba(30,102,245,0.1);
    --color-active-bg: rgba(30,102,245,0.12);
    --color-overlay: rgba(76,79,105,0.4);
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: var(--bg); color: var(--text); margin: 0; font-size: 14px; }

  /* SonarQube-style layout */

  /* Top nav */
  .sq-navbar { background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; height: 48px; gap: 16px; }
  .sq-logo { font-weight: 700; font-size: 15px; color: var(--blue); }
  .sq-project { font-weight: 600; font-size: 14px; }
  .sq-branch-badge { background: var(--surface2); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 4px; }
  .sq-branch-badge .pass { color: var(--green); }

  /* Tab bar */
  .sq-tabs { background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; }
  .sq-tab { padding: 12px 16px; font-size: 13px; color: var(--text-dim); border-bottom: 2px solid transparent; cursor: pointer; }
  .sq-tab.active { color: var(--blue); border-bottom-color: var(--blue); font-weight: 500; }

  /* Content area */
  .sq-content { max-width: 1200px; margin: 0 auto; padding: 24px 20px; }
  .sq-meta { font-size: 13px; color: var(--text-dim); margin-bottom: 16px; }
  .sq-meta strong { color: var(--text); }

  /* Quality gate banner */
  .sq-gate { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px; display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
  .sq-gate-icon { width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
  .sq-gate-icon.pass { background: rgba(63,185,80,0.15); color: var(--green); }
  .sq-gate-icon.fail { background: rgba(248,81,73,0.15); color: var(--red); }
  .sq-gate-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin-bottom: 4px; }
  .sq-gate-status { font-size: 26px; font-weight: 700; }
  .sq-gate-status.pass { color: var(--green); }
  .sq-gate-status.fail { color: var(--red); }
  .sq-gate-meta { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
  .sq-gate-right { margin-left: auto; font-size: 12px; color: var(--text-dim); text-align: right; }

  /* Code tabs (New Code / Overall Code) */
  .sq-code-tabs { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
  .sq-code-tab { padding: 8px 16px; font-size: 13px; color: var(--text-dim); border-bottom: 2px solid transparent; cursor: pointer; }
  .sq-code-tab.active { color: var(--text); border-bottom-color: var(--text); font-weight: 500; }

  /* Metric cards grid */
  .sq-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 24px; }
  .sq-metric-card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 16px 18px; }
  .sq-metric-label { font-size: 12px; color: var(--text-dim); margin-bottom: 10px; font-weight: 500; }
  .sq-metric-body { display: flex; align-items: center; justify-content: space-between; }
  .sq-metric-value { font-size: 28px; font-weight: 700; color: var(--text); }
  .sq-metric-grade { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700; flex-shrink: 0; }
  .sq-metric-sub { font-size: 12px; color: var(--text-dim); margin-top: 6px; }
  /* Grade colors */
  .grade-A { background: rgba(63,185,80,0.2); color: var(--green); }
  .grade-B { background: rgba(57,210,192,0.2); color: var(--cyan); }
  .grade-C { background: rgba(210,153,22,0.2); color: var(--yellow); }
  .grade-D { background: rgba(248,81,73,0.15); color: var(--red); }
  .grade-E { background: rgba(248,81,73,0.2); color: var(--red); border: 1px solid var(--red); }
  /* Coverage/Dup circle */
  .sq-metric-circle { width: 28px; height: 28px; border-radius: 50%; border: 3px solid; flex-shrink: 0; }
  .circle-pass { border-color: var(--green); }
  .circle-fail { border-color: var(--red); }
  .circle-warn { border-color: var(--yellow); }
  .circle-none { border-color: var(--text-dim); opacity: 0.4; }

  /* Problem files section */
  .sq-section-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
  .sq-table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
  .sq-table th { text-align: left; padding: 10px 16px; font-size: 12px; font-weight: 600; color: var(--text-dim); background: var(--surface2); border-bottom: 1px solid var(--border); }
  .sq-table td { padding: 10px 16px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
  .sq-table tr:last-child td { border-bottom: none; }
  .sq-table tr:hover td { background: var(--surface2); }
  .sq-file-link { color: var(--blue); font-size: 12px; }
  .sq-file-path { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
  .sq-badge { display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 3px; margin-right: 3px; }
  .sq-badge-bug { background: rgba(248,81,73,0.15); color: var(--red); }
  .sq-badge-smell { background: rgba(210,153,22,0.15); color: var(--yellow); }
  .sq-badge-cov { background: rgba(88,166,255,0.15); color: var(--blue); }
  .sq-badge-vuln { background: rgba(203,166,247,0.15); color: var(--cyan); }
  .sq-badge-dup { background: rgba(210,153,22,0.15); color: var(--yellow); }
  .sq-badge-hotspot { background: rgba(203,166,247,0.15); color: var(--cyan); }
  .sq-issues-count { font-size: 12px; color: var(--text-dim); }

  /* Open SonarQube button */
  .sq-open-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; background: var(--blue); color: #fff; border-radius: 4px; font-size: 12px; font-weight: 500; text-decoration: none; }
  .sq-open-btn:hover { opacity: 0.9; text-decoration: none; color: #fff; }

  /* warn/incomplete banner */
  .sq-warn { background: rgba(249,226,175,0.15); color: var(--yellow); border: 1px solid var(--yellow); border-radius: 6px; padding: 10px 14px; font-size: 12px; margin-bottom: 16px; }
  [data-theme="light"] .sq-warn { background: rgba(223,142,29,0.12); }

  /* Gate conditions table */
  .sq-gate-table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; font-size: 13px; margin-top: 24px; }
  .sq-gate-table th { text-align: left; padding: 10px 16px; font-size: 12px; font-weight: 600; color: var(--text-dim); background: var(--surface2); border-bottom: 1px solid var(--border); }
  .sq-gate-table td { padding: 10px 16px; border-bottom: 1px solid var(--border); }
  .sq-gate-table tr:last-child td { border-bottom: none; }
  .sq-gate-table tr:hover td { background: var(--surface2); }
  .sq-cond-pass { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; display: inline-block; background: var(--color-gate-pass-bg); color: var(--green); }
  .sq-cond-fail { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; display: inline-block; background: var(--color-gate-fail-bg); color: var(--red); }

  @media (max-width: 900px) { .sq-metrics { grid-template-columns: repeat(2, 1fr); } }
  @media (max-width: 600px) { .sq-metrics { grid-template-columns: 1fr; } }

  /* Theme toggle — fixed top-right */
  .theme-toggle {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 12px 16px;
    background: none;
    border: none;
    cursor: pointer;
    font-size: 13px;
    color: var(--text-dim);
    transition: color 0.15s;
  }
  .theme-toggle:hover { color: var(--text); }
  .theme-toggle:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
  .theme-toggle-icon { font-size: 16px; }

  /* Relationship panel */
  .rel-panel {
    display: none;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 8px;
    margin-bottom: 16px;
    overflow: hidden;
  }
  .rel-panel.active { display: block; }
  .rel-header {
    padding: 12px 16px;
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    gap: 10px;
  }
  .rel-header-file {
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    font-size: 13px;
    font-weight: 600;
    color: var(--blue);
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .rel-header-stat {
    font-size: 12px;
    color: var(--text-dim);
    flex-shrink: 0;
  }
  .rel-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 12px;
  }
  .rel-table th {
    text-align: left;
    padding: 8px 12px;
    background: var(--surface2);
    color: var(--text-dim);
    font-weight: 500;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
  }
  .rel-table td {
    padding: 8px 12px;
    border-top: 1px solid var(--border);
  }
  .rel-table tr:hover { background: var(--surface2); cursor: pointer; }
  .rel-file-link {
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    color: var(--cyan);
    font-size: 12px;
  }
  .rel-count { color: var(--yellow); font-weight: 600; }
  .rel-lines { color: var(--text-dim); }

  .result-count { color: var(--text-dim); font-size: 12px; margin-bottom: 12px; }

  /* Clone cards */
  .clone-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 8px;
    margin-bottom: 6px;
    overflow: hidden;
    content-visibility: auto;
    contain-intrinsic-size: auto 60px;
  }
  .clone-card.highlight { border-color: var(--blue); }
  .clone-header {
    display: flex;
    align-items: center;
    padding: 10px 14px;
    cursor: pointer;
    gap: 10px;
    transition: background 0.15s;
  }
  .clone-header:hover { background: var(--surface2); }
  .clone-title { display: flex; align-items: center; gap: 6px; min-width: 120px; }
  .clone-badge {
    background: var(--yellow);
    color: var(--badge-text);
    padding: 1px 7px;
    border-radius: 4px;
    font-size: 11px;
    font-weight: 600;
  }
  .clone-lines { color: var(--text-dim); font-size: 11px; }
  .clone-type-badge {
    background: rgba(210,153,34,0.15);
    color: var(--yellow);
    border: 1px solid rgba(210,153,34,0.3);
    padding: 1px 6px;
    border-radius: 4px;
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 0.3px;
    white-space: nowrap;
  }
  .clone-files-row { flex: 1; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
  .file-tag {
    background: var(--surface2);
    padding: 2px 7px;
    border-radius: 4px;
    font-size: 11px;
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    color: var(--cyan);
  }
  .vs { color: var(--text-dim); font-size: 10px; }
  .toggle-icon { color: var(--text-dim); font-size: 11px; transition: transform 0.2s; }
  .toggle-icon.open { transform: rotate(90deg); }

  .clone-list-wrap {
    border: 1px solid var(--border);
    border-radius: 8px;
    overflow: auto;
    max-height: 70vh;
    padding: 8px;
    background: var(--bg);
  }
  .clone-list-wrap .clone-card:last-child { margin-bottom: 0; }

  .clone-body { border-top: 1px solid var(--border); }
  .code-compare {
    display: grid;
    grid-template-columns: 1fr 1fr;
  }
  .code-panel { overflow: auto; }
  .code-panel + .code-panel { border-left: 1px solid var(--border); }
  .panel-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 6px 10px;
    background: var(--surface2);
    border-bottom: 1px solid var(--border);
    font-size: 11px;
  }
  .panel-file {
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    color: var(--blue);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    flex: 1;
  }
  .panel-range { color: var(--text-dim); flex-shrink: 0; margin-left: 8px; }
  .code-wrapper {
    display: flex;
    overflow-x: auto;
    background: var(--code-bg);
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    font-size: 12px;
    line-height: 1.7;
  }
  .code-wrapper .line-numbers {
    flex-shrink: 0;
    padding: 8px 0;
    text-align: right;
    user-select: none;
    border-right: 1px solid var(--border);
  }
  .code-wrapper .line-numbers > div {
    padding: 0 10px 0 12px;
    color: var(--text-dim);
    font-size: 12px;
    line-height: 1.7;
    height: 1.7em;
  }
  .code-wrapper .line-numbers > div.hl {
    background: rgba(248, 81, 73, 0.18);
    color: var(--red);
    font-weight: 600;
  }
  .code-wrapper .code-content {
    flex: 1;
    margin: 0;
    padding: 8px 0;
    overflow-x: auto;
    background: transparent;
  }
  .code-wrapper .code-content code {
    display: block;
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    font-size: 12px;
  }
  .code-wrapper .code-content .code-line {
    display: block;
    padding: 0 12px;
    height: 1.7em;
    line-height: 1.7;
  }
  .code-wrapper .code-content .code-line.hl {
    background: rgba(248, 81, 73, 0.18);
    border-left: 3px solid var(--red);
    padding-left: 9px;
  }
  [data-theme="light"] .code-wrapper .code-content .code-line.hl {
    background: rgba(255, 220, 220, 0.7);
  }
  [data-theme="light"] .code-wrapper .line-numbers > div.hl {
    background: rgba(255, 220, 220, 0.7);
  }
  .code-panel code.hljs {
    background: transparent;
    padding: 0;
    line-height: 1.7;
  }

  /* Main tabs */
  .main-tabs {
    display: flex;
    border-bottom: 2px solid var(--border);
    margin-bottom: 20px;
    gap: 4px;
  }
  .main-tab {
    padding: 10px 20px;
    font-size: 13px;
    font-weight: 500;
    cursor: pointer;
    color: var(--text-dim);
    border-bottom: 2px solid transparent;
    margin-bottom: -2px;
    transition: all 0.15s;
    border-radius: 6px 6px 0 0;
  }
  .main-tab:hover { color: var(--text); background: var(--surface); }
  .main-tab.active { color: var(--blue); border-bottom-color: var(--blue); background: var(--surface); }
  .tab-panel { display: none; }
  .tab-panel.active { display: block; }

  /* Overview gauge */
  .overview-gates {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
    margin-bottom: 20px;
  }
  .gate-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 16px;
  }
  .gate-card-title {
    font-size: 12px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: var(--text-dim);
    margin-bottom: 8px;
  }
  .gate-card-value {
    font-size: 32px;
    font-weight: 700;
    margin-bottom: 4px;
  }
  .gate-card-bar {
    height: 6px;
    border-radius: 3px;
    background: var(--surface2);
    margin-top: 8px;
    overflow: hidden;
  }
  .gate-card-fill {
    height: 100%;
    border-radius: 3px;
    transition: width 0.3s;
  }
  .gate-card-detail {
    font-size: 11px;
    color: var(--text-dim);
    margin-top: 6px;
  }

  /* Coverage table */
  .cov-table-wrap {
    border: 1px solid var(--border);
    border-radius: 8px;
    overflow: auto;
    max-height: 70vh;
    margin-top: 12px;
  }
  .cov-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 12px;
  }
  .cov-table th {
    text-align: left;
    padding: 10px 12px;
    background: var(--surface2);
    color: var(--text-dim);
    font-weight: 600;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    cursor: pointer;
    user-select: none;
    position: sticky;
    top: 0;
    z-index: 10;
    border-bottom: 2px solid var(--border);
  }
  .cov-table th:hover { color: var(--text); }
  .cov-table td {
    padding: 7px 12px;
    border-top: 1px solid var(--border);
  }
  .cov-table tr:hover { background: var(--surface); }
  .cov-row { cursor: default; }
  .cov-file-action {
    display: inline-flex;
    max-width: 100%;
    padding: 0;
    border: 0;
    background: transparent;
    cursor: pointer;
    text-align: left;
  }
  .cov-file-action:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 3px; }
  .cov-file {
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    font-size: 11px;
    color: var(--cyan);
  }
  .cov-bar-wrap {
    width: 100%;
    height: 8px;
    background: var(--surface2);
    border-radius: 4px;
    overflow: hidden;
    min-width: 80px;
  }
  .cov-bar {
    height: 100%;
    border-radius: 4px;
  }

  /* Coverage sidebar */
  .cov-sidebar-file {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 5px 8px;
    border-radius: 6px;
    font-size: 12px;
    margin-bottom: 2px;
  }
  .cov-sidebar-file:hover { background: var(--surface2); }
  .cov-sidebar-name {
    flex: 1;
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    font-size: 11px;
    color: var(--text);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .cov-sidebar-bar {
    width: 50px;
    height: 6px;
    background: var(--surface2);
    border-radius: 3px;
    overflow: hidden;
    flex-shrink: 0;
  }
  .cov-sidebar-pct {
    font-size: 11px;
    font-weight: 600;
    min-width: 32px;
    text-align: right;
    flex-shrink: 0;
  }

  /* Coverage drill-down */
  .coverage-table-view.hidden, .coverage-detail-view.hidden { display: none; }
  .coverage-detail-view {
    border: 1px solid var(--border);
    border-radius: 8px;
    overflow: hidden;
    background: var(--surface);
  }
  .cov-detail-header {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 12px 14px;
    border-bottom: 1px solid var(--border);
    background: var(--surface2);
  }
  .cov-detail-title {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: var(--font-mono);
    color: var(--cyan);
    font-size: 13px;
    font-weight: 600;
  }
  .cov-detail-stats { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 14px; border-bottom: 1px solid var(--border); }
  .cov-stat-pill { padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; font-size: 12px; background: var(--bg); }
  .cov-legend { display: flex; gap: 10px; align-items: center; margin-left: auto; color: var(--text-dim); font-size: 11px; }
  .legend-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 4px; }
  .legend-covered { background: rgba(63,185,80,0.35); border: 1px solid var(--green); }
  .legend-uncovered { background: rgba(248,81,73,0.35); border: 1px solid var(--red); }
  .legend-neutral { background: var(--surface2); border: 1px solid var(--border); }
  .cov-source-wrap { max-height: 72vh; overflow: auto; background: var(--code-bg); }
  .cov-source-line { display: grid; grid-template-columns: 72px 1fr; min-height: 1.65em; font-family: var(--font-mono); font-size: 12px; line-height: 1.65; }
  .cov-source-line .ln { padding: 0 10px; text-align: right; color: var(--text-dim); user-select: none; border-right: 1px solid var(--border); }
  .cov-source-line .src { padding: 0 12px; white-space: pre; overflow: visible; }
  .cov-source-line.covered { background: rgba(63,185,80,0.10); }
  .cov-source-line.covered .ln { color: var(--green); }
  .cov-source-line.uncovered { background: rgba(248,81,73,0.16); }
  .cov-source-line.uncovered .ln { color: var(--red); font-weight: 600; }
  .cov-source-line.neutral { background: transparent; }

  /* Sidebar sections */
  .sidebar-section { display: none; }
  .sidebar-section.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; }

  /* Theme toggle */
  .theme-toggle {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 12px 16px;
    background: none;
    border: none;
    cursor: pointer;
    font-size: 13px;
    color: var(--text-dim);
    transition: color 0.15s;
  }
  .theme-toggle:hover { color: var(--text); }
  .theme-toggle:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
  .theme-toggle-icon { font-size: 16px; }

  /* Focus-visible for interactive elements */
  button:focus-visible, [role="button"]:focus-visible,
  .sidebar-tab:focus-visible, .folder-row:focus-visible, .tree-file:focus-visible {
    outline: 2px solid var(--blue);
    outline-offset: 2px;
  }

  /* ── Contextual Copilot Prompt Modal ── */
  .copilot-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    padding: 6px 12px;
    border-radius: var(--radius-md);
    border: 1px solid var(--blue);
    background: var(--filter-bg);
    color: var(--blue);
    cursor: pointer;
    font-size: 12px;
    font-weight: 600;
    min-height: 32px;
    white-space: nowrap;
    transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
  }
  .copilot-btn:hover { background: var(--blue); color: var(--badge-text); }
  .copilot-btn.compact { padding: 4px 9px; font-size: 11px; min-height: 32px; }
  .cov-list-item .copilot-btn { margin-top: 6px; width: 100%; }

  .prompt-modal-backdrop {
    position: fixed;
    inset: 0;
    z-index: 2000;
    display: none;
    align-items: center;
    justify-content: center;
    padding: var(--space-9);
    background: var(--color-overlay);
    pointer-events: none;
  }
  .prompt-modal-backdrop.active { display: flex; }
  .prompt-modal {
    width: min(860px, 96vw);
    pointer-events: auto;
    max-height: 86vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    box-shadow: var(--shadow-popover);
  }
  .prompt-modal-header {
    display: flex;
    align-items: center;
    gap: var(--space-5);
    padding: 14px 16px;
    border-bottom: 1px solid var(--border);
    background: var(--surface2);
  }
  .prompt-modal-title { font-size: 15px; font-weight: 700; color: var(--text); }
  .prompt-modal-file {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: var(--font-mono);
    font-size: 12px;
    color: var(--cyan);
  }
  .prompt-modal-close {
    border: 0;
    background: transparent;
    color: var(--text-dim);
    cursor: pointer;
    font-size: 22px;
    line-height: 1;
  }
  .prompt-modal-close:hover { color: var(--red); }
  .prompt-modal-body { padding: 16px; overflow: auto; background: var(--bg); }
  .prompt-modal-text {
    margin: 0;
    white-space: pre-wrap;
    word-break: break-word;
    color: var(--text);
    font-family: var(--font-mono);
    font-size: 12px;
    line-height: 1.65;
  }
  .prompt-modal-actions {
    display: flex;
    justify-content: flex-end;
    gap: var(--space-3);
    padding: 12px 16px;
    border-top: 1px solid var(--border);
    background: var(--surface);
  }
  .prompt-copy-btn {
    padding: 8px 14px;
    border-radius: var(--radius-md);
    border: 1px solid var(--blue);
    background: var(--blue);
    color: var(--badge-text);
    cursor: pointer;
    font-size: 12px;
    font-weight: 600;
  }
  .prompt-copy-btn.copied { background: var(--green); border-color: var(--green); }

  @media (max-width: 1000px) {
    .sidebar { width: 260px; min-width: 260px; }
    .code-compare { grid-template-columns: 1fr; }
    .code-panel + .code-panel { border-left: none; border-top: 1px solid var(--border); }
    .stats { grid-template-columns: repeat(2, 1fr); }
  }

  /* Extra tokens layered on top of template.html Catppuccin vars */
  :root { --strong: var(--color-text); }
  a { color: var(--blue); text-decoration: none; }
  a:hover { text-decoration: underline; }
  .file-row { cursor: pointer; }
  .file-row:hover td { background: var(--surface2); }
  /* SPA views */
  #view-list, #view-detail { width: 100%; }
  /* Detail header */
  .detail-header { display: flex; align-items: center; gap: 16px; padding: 12px 0 16px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
  .back-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; cursor: pointer; white-space: nowrap; }
  .back-btn:hover { background: var(--surface2); }
  .detail-file-info { flex: 1; min-width: 0; }
  .detail-filename { font-size: 15px; font-weight: 600; color: var(--text); display: block; }
  .detail-filepath { font-size: 11px; color: var(--text-dim); display: block; margin-top: 2px; word-break: break-all; }
  .detail-issue-count { font-size: 13px; color: var(--text-dim); white-space: nowrap; }
  /* Source code view */
  .code-view { overflow-x: auto; border: 1px solid var(--border); border-radius: 6px; }
  .code-table { width: 100%; border-collapse: collapse; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
  .code-table .ln { width: 52px; min-width: 52px; text-align: right; padding: 1px 12px; color: var(--text-dim); background: var(--surface); border-right: 1px solid var(--border); user-select: none; vertical-align: top; line-height: 1.6; }
  .code-table .lc { padding: 1px 16px; white-space: pre; color: var(--text); line-height: 1.6; }
  .code-table tr.issue-line .ln { background: rgba(248,81,73,0.12); color: var(--red); font-weight: 600; }
  .code-table tr.issue-line .lc { background: rgba(248,81,73,0.05); }
  .issue-inline { display: flex; align-items: flex-start; gap: 8px; padding: 4px 16px 6px 68px; background: rgba(248,81,73,0.06); border-bottom: 1px solid rgba(248,81,73,0.12); }
  .issue-inline .sev { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 3px; flex-shrink: 0; margin-top: 1px; }
  .issue-inline .msg { font-size: 12px; color: var(--text); line-height: 1.5; }
  .sev-BLOCKER, .sev-CRITICAL { background: rgba(248,81,73,0.2); color: var(--red); }
  .sev-MAJOR { background: rgba(210,153,22,0.2); color: var(--yellow); }
  .sev-MINOR, .sev-INFO { background: rgba(88,166,255,0.15); color: var(--blue); }
  .no-source { padding: 32px; text-align: center; color: var(--text-dim); font-size: 13px; }
  .code-table tr.dup-line .ln { background: rgba(210,153,22,0.12); color: var(--yellow); }
  .code-table tr.dup-line .lc { background: rgba(210,153,22,0.05); }
  .code-table tr.cov-line .ln { background: rgba(63,185,80,0.15); color: #3fb950; }
  .code-table tr.cov-line .lc { background: rgba(63,185,80,0.05); }
  .code-table tr.uncov-line .ln { background: rgba(248,81,73,0.15); color: var(--red); }
  .code-table tr.uncov-line .lc { background: rgba(248,81,73,0.05); }
  .code-table tr.lint-line .ln { background: rgba(188,140,255,0.15); color: #bc8cff; }
  .code-table tr.lint-line .lc { background: rgba(188,140,255,0.05); }
  .lint-inline { display: flex; align-items: flex-start; gap: 8px; padding: 4px 16px 6px 68px; background: rgba(188,140,255,0.06); border-bottom: 1px solid rgba(188,140,255,0.12); }
  .lint-rule { font-size: 10px; color: var(--text-dim); margin-left: auto; font-family: 'JetBrains Mono', monospace; flex-shrink: 0; }
  .dup-panel { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 8px 16px; background: rgba(210,153,22,0.07); border: 1px solid rgba(210,153,22,0.2); border-radius: 6px; margin-bottom: 8px; font-size: 12px; }
  .dup-label { color: var(--yellow); font-weight: 600; white-space: nowrap; }
  .dup-file { background: rgba(210,153,22,0.12); color: var(--text); border-radius: 4px; padding: 1px 8px; font-family: 'JetBrains Mono', monospace; }
  .view-toggle { display: flex; gap: 4px; margin-left: auto; }
  .view-toggle-btn { background: var(--surface); border: 1px solid var(--border); color: var(--text-dim); padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer; }
  .view-toggle-btn.active { background: var(--blue); border-color: var(--blue); color: #fff; }
  .tree-folder { width: 100%; }
  .tree-folder summary { display: flex; align-items: center; gap: 8px; padding: 6px 12px; cursor: pointer; background: var(--surface); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-dim); list-style: none; }
  .tree-folder summary::-webkit-details-marker { display: none; }
  .tree-folder[open] summary .tree-arrow { transform: rotate(90deg); }
  .tree-arrow { display: inline-block; font-size: 10px; transition: transform 0.15s; flex-shrink: 0; }
  .tree-folder-name { font-weight: 600; color: var(--text); font-size: 11px; }
  .tree-folder-meta { color: var(--text-dim); font-size: 11px; margin-left: auto; }
  .tree-file-row { display: flex; align-items: center; padding: 5px 12px 5px 28px; border-bottom: 1px solid var(--border); cursor: pointer; font-size: 12px; gap: 10px; }
  .tree-file-row:hover { background: var(--surface2); }
  .tree-file-name { color: var(--text); font-size: 11px; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .tree-file-badges { display: flex; gap: 4px; flex-shrink: 0; }
  .tree-file-detail { color: var(--text-dim); font-size: 11px; white-space: nowrap; flex-shrink: 0; }
</style>
</head>
<body>
<!-- INJECT_BODY -->
<script>
/*__REPORT_DATA__*/
</script>
<script>
function _renderDetail(path) {
  const detail = FILE_DETAILS[path];

  // Update header
  const parts = path.split('/');
  document.getElementById('detail-filename').textContent = parts[parts.length - 1];
  document.getElementById('detail-filepath').textContent = path;

  // Badges from PROBLEM_FILES
  const pf = PROBLEM_FILES.find(f => f.path === path);
  const badgesEl = document.getElementById('detail-badges');
  badgesEl.innerHTML = pf ? renderFileBadges(pf) : '';

  const issueCount = detail ? (detail.issues || []).length : 0;
  document.getElementById('detail-issue-count').textContent = issueCount + ' issue' + (issueCount !== 1 ? 's' : '');

  // Build source view
  const content = document.getElementById('detail-content');

  if (!detail || !detail.lines || detail.lines.length === 0) {
    content.innerHTML = '<div class="no-source">Source not available for this file.</div>';
  } else {
    const issuesByLine = {};
    (detail.issues || []).forEach(issue => {
      const ln = issue.line || 0;
      if (!issuesByLine[ln]) issuesByLine[ln] = [];
      issuesByLine[ln].push(issue);
    });
    const issueLines = new Set(Object.keys(issuesByLine).map(Number));

    const dupBlocks = detail.duplications || [];
    const dupLineSet = new Set();
    dupBlocks.forEach(dup => {
      dup.blocks.forEach(block => {
        const _normPath = path.replace(/\\/g, '/');
        const _isCurrentFile = (k) => !k || k === _normPath || k.endsWith('/' + _normPath) || k.endsWith(':' + _normPath);
        if (_isCurrentFile(block.fileKey)) {
          for (let i = block.from; i < block.from + block.size; i++) dupLineSet.add(i);
        }
      });
    });

    let dupHtml = '';
    if (dupBlocks.length > 0) {
      const _np = path.replace(/\\/g, '/');
      const _isCurrent = (k) => !k || k === _np || k.endsWith('/' + _np) || k.endsWith(':' + _np);
      const otherFiles = new Set();
      dupBlocks.forEach(dup => {
        dup.blocks.forEach(block => {
          if (block.fileName && !_isCurrent(block.fileKey)) {
            otherFiles.add(block.fileName);
          }
        });
      });
      if (otherFiles.size > 0) {
        dupHtml = '<div class="dup-panel"><span class="dup-label">Duplicated with:</span> ';
        dupHtml += [...otherFiles].map(f => '<span class="dup-file">' + escHtml(f) + '</span>').join(' ');
        dupHtml += '</div>';
      }
    }

    // Match coverage data by suffix (SF paths in lcov may be shorter than project paths)
    let covData = null;
    if (COVERAGE_DATA) {
      // Try progressively shorter suffixes of the file path
      const parts = path.split('/');
      for (let i = 0; i < parts.length && !covData; i++) {
        const suffix = parts.slice(i).join('/');
        if (COVERAGE_DATA[suffix]) covData = COVERAGE_DATA[suffix];
      }
    }
    const coveredSet = new Set(covData ? covData.c || [] : []);
    const uncoveredSet = new Set(covData ? covData.u || [] : []);
    const hasCoverage = coveredSet.size > 0 || uncoveredSet.size > 0;

    // Lint issues for this file
    const lintFile = (typeof LINT_FILES !== 'undefined' ? LINT_FILES : []).find(l => {
        const parts = path.split('/');
        for (let i = 0; i < parts.length; i++) {
            if (l.path === parts.slice(i).join('/') || l.path === path) return true;
        }
        return false;
    });
    const lintIssuesByLine = {};
    if (lintFile) {
        (lintFile.issues || []).forEach(issue => {
            if (!lintIssuesByLine[issue.line]) lintIssuesByLine[issue.line] = [];
            lintIssuesByLine[issue.line].push(issue);
        });
    }
    const lintLineSet = new Set(Object.keys(lintIssuesByLine).map(Number));

    let html = dupHtml + '<div class="code-view"><table class="code-table">';
    const fullSource = detail.lines.join('\n');
    const hlResult = hljs.highlight(fullSource, { language: 'dart', ignoreIllegals: true });
    const hlLines = hlResult.value.split('\n');
    detail.lines.forEach((line, idx) => {
      const ln = idx + 1;
      const isIssue = issueLines.has(ln);
      const isDup = dupLineSet.has(ln);
      const isCovered = hasCoverage && coveredSet.has(ln);
      const isUncovered = hasCoverage && uncoveredSet.has(ln);
      const isLint = lintLineSet.has(ln);
      const rowClass = isIssue ? 'issue-line' : (isDup ? 'dup-line' : (isCovered ? 'cov-line' : (isUncovered ? 'uncov-line' : (isLint ? 'lint-line' : ''))));
      html += '<tr' + (rowClass ? ' class="' + rowClass + '"' : '') + '>';
      html += '<td class="ln">' + ln + '</td>';
      html += '<td class="lc">' + (hlLines[idx] || ' ') + '</td>';
      html += '</tr>';
      if (isIssue && issuesByLine[ln]) {
        issuesByLine[ln].forEach(issue => {
          html += '<tr><td colspan="2" style="padding:0"><div class="issue-inline">';
          html += '<span class="sev sev-' + issue.severity + '">' + issue.severity + '</span>';
          html += '<span class="msg">' + escHtml(issue.message) + '</span>';
          html += '</div></td></tr>';
        });
      }
      if (isLint && lintIssuesByLine[ln]) {
        lintIssuesByLine[ln].forEach(issue => {
          html += '<tr><td colspan="2" style="padding:0"><div class="issue-inline lint-inline">';
          html += '<span class="sev sev-' + issue.severity.toUpperCase() + '">' + issue.severity + '</span>';
          html += '<span class="msg">' + escHtml(issue.message) + '</span>';
          html += '<span class="lint-rule">' + escHtml(issue.rule || '') + '</span>';
          html += '</div></td></tr>';
        });
      }
    });
    html += '</table></div>';
    content.innerHTML = html;

    if (detail.issues && detail.issues.length > 0) {
      const firstLine = Math.min(...detail.issues.map(i => i.line || 999999).filter(n => n < 999999));
      if (isFinite(firstLine) && firstLine > 0) {
        setTimeout(() => {
          const rows = document.querySelectorAll('.code-table tr');
          if (rows[firstLine - 1]) rows[firstLine - 1].scrollIntoView({block: 'center'});
        }, 50);
      }
    }
  }
}

function showDetail(path) {
  _renderDetail(path);
  _switchView('detail');
  history.pushState({view: 'detail', path: path}, '', '#file=' + encodeURIComponent(path));
}

// Set initial history state so browser back works from the first page
history.replaceState({view: 'list'}, '', location.pathname + location.search);

function _switchView(show) {
  document.getElementById('view-list').style.display = show === 'list' ? '' : 'none';
  document.getElementById('view-files').style.display = show === 'files' ? '' : 'none';
  document.getElementById('view-detail').style.display = show === 'detail' ? '' : 'none';
  window.scrollTo(0, 0);
}

function showList() {
  _switchView('list');
}

function goBack() {
  history.back();
}

function _renderFileList(category) {
  const _validCats = new Set(['bugs', 'vulns', 'smells', 'coverage', 'duplications', 'issues', 'lint']);
  if (!_validCats.has(category)) category = 'issues';
  const labels = {
    bugs: 'Bugs', vulns: 'Vulnerabilities', smells: 'Code Smells',
    coverage: 'Coverage Problems', duplications: 'Duplication Problems', issues: 'All Issues',
    lint: 'Lint Issues'
  };
  const filtered = PROBLEM_FILES.filter(f => {
    if (category === 'bugs') return f.issues.BUG > 0;
    if (category === 'vulns') return f.issues.VULNERABILITY > 0;
    if (category === 'smells') return f.issues.CODE_SMELL > 0;
    if (category === 'coverage') return f.coverageProblem;
    if (category === 'duplications') return f.duplicationProblem;
    if (category === 'issues') return f.issueTotal > 0;
    if (category === 'lint') {
      const lf = (typeof LINT_FILES !== 'undefined' ? LINT_FILES : []).find(l => l.path === f.path);
      return lf && (lf.errors > 0 || lf.warnings > 0);
    }
    return true;
  });
  let displayFiles;
  if (category === 'lint' && typeof LINT_FILES !== 'undefined') {
    displayFiles = LINT_FILES
      .filter(f => f.errors > 0 || f.warnings > 0)
      .map(f => ({
        path: f.path,
        issueTotal: f.errors + f.warnings,
        coverage: null,
        duplicatedLinesDensity: null,
        coverageProblem: false,
        duplicationProblem: false,
        issues: { BUG: 0, VULNERABILITY: 0, CODE_SMELL: 0, SECURITY_HOTSPOT: 0 },
        lintErrors: f.errors,
        lintWarnings: f.warnings,
      }));
  } else {
    displayFiles = filtered;
  }
  document.getElementById('view-files-title').textContent = labels[category] || category;
  document.getElementById('view-files-count').textContent = displayFiles.length + ' file' + (displayFiles.length !== 1 ? 's' : '');
  _currentFilteredFiles = displayFiles;
  document.getElementById('view-files-content').innerHTML =
    _filesViewMode === 'tree' ? buildTreeHtml(displayFiles) : buildListHtml(displayFiles);
}

function showFileList(category) {
  _renderFileList(category);
  _switchView('files');
  history.pushState({view: 'files', category: category, filesViewMode: _filesViewMode}, '', location.pathname + '#' + category);
}

function renderFileBadges(f) {
  // Lint file — show ERROR/WARNING badges only
  if (f.lintErrors !== undefined || f.lintWarnings !== undefined) {
    const badges = [];
    if (f.lintErrors > 0)   badges.push('<span class="sq-badge sq-badge-bug">' + f.lintErrors + ' ERROR</span>');
    if (f.lintWarnings > 0) badges.push('<span class="sq-badge sq-badge-smell">' + f.lintWarnings + ' WARN</span>');
    return badges.join(' ') || '<span style="color:var(--text-dim);font-size:11px">—</span>';
  }
  // SonarQube file
  const badges = [];
  const iss = f.issues || {};
  [['BUG','BUG','sq-badge-bug'],['VULNERABILITY','VULN','sq-badge-vuln'],['CODE_SMELL','SMELL','sq-badge-smell'],['SECURITY_HOTSPOT','HOTSPOT','sq-badge-hotspot']].forEach(([key,label,css]) => {
    const n = parseInt(iss[key] || 0);
    if (n > 0) badges.push('<span class="sq-badge ' + css + '">' + n + ' ' + label + '</span>');
  });
  if (!badges.length && f.duplicationProblem) badges.push('<span class="sq-badge sq-badge-dup">DUP</span>');
  if (!badges.length && f.coverageProblem) badges.push('<span class="sq-badge sq-badge-cov">COV</span>');
  return badges.join(' ') || '<span style="color:var(--text-dim);font-size:11px">—</span>';
}

// Handle browser back/forward
window.addEventListener('popstate', function(e) {
  if (e.state && e.state.view === 'detail') {
    if (typeof FILE_DETAILS !== 'undefined') {
      _renderDetail(e.state.path);
      _switchView('detail');
    }
  } else if (e.state && e.state.view === 'files') {
    if (typeof PROBLEM_FILES === 'undefined') return;
    if (e.state.filesViewMode) {
      _filesViewMode = e.state.filesViewMode;
      const btnList = document.getElementById('btn-view-list');
      const btnTree = document.getElementById('btn-view-tree');
      if (btnList) btnList.classList.toggle('active', _filesViewMode === 'list');
      if (btnTree) btnTree.classList.toggle('active', _filesViewMode === 'tree');
    }
    _renderFileList(e.state.category);
    _switchView('files');
  } else {
    _switchView('list');
  }
});

function escHtml(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

let _filesViewMode = 'list';
let _currentFilteredFiles = [];

function setFilesView(mode) {
  _filesViewMode = mode;
  document.getElementById('btn-view-list').classList.toggle('active', mode === 'list');
  document.getElementById('btn-view-tree').classList.toggle('active', mode === 'tree');
  document.getElementById('view-files-content').innerHTML =
    mode === 'tree' ? buildTreeHtml(_currentFilteredFiles) : buildListHtml(_currentFilteredFiles);
}

function buildListHtml(files) {
  if (!files.length) return '<div style="padding:32px;text-align:center;color:var(--text-dim);font-size:13px">No files found.</div>';
  let html = '<table class="sq-table"><thead><tr><th>File</th><th>Type</th><th>Detail</th></tr></thead><tbody>';
  files.forEach(f => {
    const parts = [];
    if (f.issueTotal) parts.push(f.issueTotal + ' issue' + (f.issueTotal !== 1 ? 's' : ''));
    if (f.coverage != null) parts.push(f.coverage.toFixed(1) + '% cov');
    if (f.duplicatedLinesDensity != null && f.duplicatedLinesDensity > 0) parts.push(f.duplicatedLinesDensity.toFixed(1) + '% dup');
    const detail = parts.join(' · ') || '—';
    const filename = f.path.split('/').pop();
    const pathJs = f.path.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
    html += '<tr class="file-row" data-path="' + escHtml(f.path) + '">';
    html += '<td onclick="showDetail(\'' + pathJs + '\')">';
    html += '<div class="sq-file-link">' + escHtml(filename) + '</div>';
    html += '<div class="sq-file-path">' + escHtml(f.path) + '</div></td>';
    html += '<td>' + renderFileBadges(f) + '</td>';
    html += '<td class="sq-issues-count">' + escHtml(detail) + '</td>';
    html += '</tr>';
  });
  html += '</tbody></table>';
  return html;
}

function buildTreeHtml(files) {
  if (!files.length) return '<div style="padding:32px;text-align:center;color:var(--text-dim);font-size:13px">No files found.</div>';

  const root = {};
  files.forEach(f => {
    const parts = f.path.split('/');
    let node = root;
    parts.forEach((part, i) => {
      if (i === parts.length - 1) {
        if (!node.__files) node.__files = [];
        node.__files.push({name: part, file: f});
      } else {
        if (!node[part]) node[part] = {};
        node = node[part];
      }
    });
  });

  function countFiles(n) {
    let c = (n.__files || []).length;
    Object.keys(n).filter(k => k !== '__files').forEach(k => { c += countFiles(n[k]); });
    return c;
  }

  function renderNode(node, depth) {
    let html = '';
    const indent = depth * 16;
    Object.keys(node).filter(k => k !== '__files').sort().forEach(folderName => {
      const child = node[folderName];
      const fc = countFiles(child);
      html += '<details class="tree-folder"' + (depth === 0 ? ' open' : '') + '>';
      html += '<summary style="padding-left:' + (12 + indent) + 'px">' +
        '<span class="tree-arrow">&#9654;</span>' +
        '<span class="tree-folder-name">&#128193; ' + escHtml(folderName) + '</span>' +
        '<span class="tree-folder-meta">' + fc + ' file' + (fc !== 1 ? 's' : '') + '</span>' +
        '</summary>';
      html += renderNode(child, depth + 1);
      html += '</details>';
    });
    (node.__files || []).sort((a, b) => a.name.localeCompare(b.name)).forEach(({name, file}) => {
      const pathJs = file.path.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
      const parts = [];
      if (file.issueTotal) parts.push(file.issueTotal + ' issue' + (file.issueTotal !== 1 ? 's' : ''));
      if (file.coverage != null) parts.push(file.coverage.toFixed(1) + '% cov');
      if (file.duplicatedLinesDensity != null && file.duplicatedLinesDensity > 0) parts.push(file.duplicatedLinesDensity.toFixed(1) + '% dup');
      const detail = parts.join(' · ') || '';
      html += '<div class="tree-file-row" style="padding-left:' + (28 + indent) + 'px" onclick="showDetail(\'' + pathJs + '\')">';
      html += '<span class="tree-file-name">&#128220; ' + escHtml(name) + '</span>';
      html += '<span class="tree-file-badges">' + renderFileBadges(file) + '</span>';
      if (detail) html += '<span class="tree-file-detail">' + escHtml(detail) + '</span>';
      html += '</div>';
    });
    return html;
  }

  return '<div style="border:1px solid var(--border);border-radius:6px;overflow:hidden">' + renderNode(root, 0) + '</div>';
}
</script>
<script>
// Theme toggle
function setHljsTheme(theme) {
  document.getElementById('hljs-light').disabled = (theme === 'dark');
  document.getElementById('hljs-dark').disabled = (theme !== 'dark');
}
function toggleTheme() {
  const html = document.documentElement;
  const isLight = html.getAttribute('data-theme') === 'light';
  const next = isLight ? 'dark' : 'light';
  html.setAttribute('data-theme', next);
  document.getElementById('themeIcon').innerHTML = isLight ? '&#9788;' : '&#9790;';
  document.getElementById('themeLabel').textContent = isLight ? 'Light' : 'Dark';
  setHljsTheme(next);
  localStorage.setItem('dup-report-theme', next);
}
(function() {
  const saved = localStorage.getItem('dup-report-theme');
  const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
  const theme = saved || (prefersDark ? 'dark' : 'light');
  document.documentElement.setAttribute('data-theme', theme);
  const icon = document.getElementById('themeIcon');
  const label = document.getElementById('themeLabel');
  if (icon && label) {
    icon.innerHTML = theme === 'dark' ? '&#9788;' : '&#9790;';
    label.textContent = theme === 'dark' ? 'Light' : 'Dark';
  }
  setHljsTheme(theme);
})();

// Syntax highlighting + line wrapping
document.addEventListener('DOMContentLoaded', function() {
  document.querySelectorAll('code.language-dart').forEach(el => {
    const mask = (el.dataset.hl || '').split(',');
    const rawText = el.textContent;
    const result = hljs.highlight(rawText, { language: 'dart', ignoreIllegals: true });
    const lines = result.value.split('\n');
    el.innerHTML = lines.map((line, idx) => {
      const isHl = mask[idx] === '1';
      return '<span class="code-line' + (isHl ? ' hl' : '') + '">' + (line || ' ') + '</span>';
    }).join('');
    el.classList.add('hljs');
  });
});

// Tab switching
function switchMainTab(tab) {
  closePromptModal();
  document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
  document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
  event.currentTarget.classList.add('active');
  document.getElementById('tab-' + tab).classList.add('active');
  const sidebar = document.querySelector('.sidebar');
  const hasSidebar = (tab === 'duplicates' || tab === 'coverage');
  if (sidebar) {
    sidebar.style.display = hasSidebar ? 'flex' : 'none';
    document.querySelectorAll('.sidebar-section').forEach(s => s.classList.remove('active'));
    const section = document.getElementById('sidebar-' + tab);
    if (section) section.classList.add('active');
  }
}

// Coverage sidebar tab switch
function switchCovTab(el, panel) {
  const section = document.getElementById('sidebar-coverage');
  section.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
  section.querySelectorAll('.sidebar-panel').forEach(p => p.classList.remove('active'));
  el.classList.add('active');
  document.getElementById('panel-' + panel).classList.add('active');
}

// Coverage sidebar filter
function filterCovSidebar() {
  const q = document.getElementById('covSidebarSearch').value.toLowerCase();
  const section = document.getElementById('sidebar-coverage');
  section.querySelectorAll('.tree-file').forEach(el => {
    const title = (el.querySelector('.file-name').title || '').toLowerCase();
    el.style.display = (!q || title.includes(q)) ? 'flex' : 'none';
  });
  section.querySelectorAll('.cov-list-item').forEach(el => {
    const text = el.textContent.toLowerCase();
    el.style.display = (!q || text.includes(q)) ? 'block' : 'none';
  });
  if (q) {
    section.querySelectorAll('.folder-children').forEach(c => c.style.display = 'block');
    section.querySelectorAll('.folder-toggle').forEach(t => t.classList.add('open'));
    section.querySelectorAll('.folder-icon').forEach(i => i.innerHTML = '&#128194;');
  }
}

// Contextual Copilot prompts
let currentPromptId = null;

function openPromptModal(id) {
  const prompt = PROMPTS_DATA[id];
  if (!prompt) return;
  currentPromptId = id;
  const modal = document.getElementById('promptModal');
  const title = document.getElementById('promptModalTitle');
  const file = document.getElementById('promptModalFile');
  const text = document.getElementById('promptModalText');
  const copyBtn = document.getElementById('promptCopyBtn');
  title.textContent = prompt.type === 'coverage' ? 'Coverage Copilot Prompt' : 'Duplicate Refactor Copilot Prompt';
  file.textContent = prompt.file || '';
  file.title = prompt.file || '';
  text.textContent = prompt.text || '';
  copyBtn.innerHTML = '&#128203; Copy for Copilot';
  copyBtn.classList.remove('copied');
  modal.classList.add('active');
  modal.setAttribute('aria-hidden', 'false');
}

function closePromptModal() {
  const modal = document.getElementById('promptModal');
  modal.classList.remove('active');
  modal.setAttribute('aria-hidden', 'true');
  currentPromptId = null;
}

document.addEventListener('click', function(e) {
  const modal = document.getElementById('promptModal');
  if (!modal || !modal.classList.contains('active')) return;

  const tabAtPoint = Array.from(document.querySelectorAll('.main-tab')).find(tab => {
    const rect = tab.getBoundingClientRect();
    return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
  });
  if (tabAtPoint) {
    e.preventDefault();
    e.stopPropagation();
    closePromptModal();
    tabAtPoint.click();
    return;
  }

  if (e.target.closest('.prompt-modal')) return;
  closePromptModal();
}, true);

document.addEventListener('keydown', function(e) {
  if (e.key === 'Escape') closePromptModal();
});

function copyPrompt(id, btn) {
  const text = (PROMPTS_DATA[id] || {}).text || '';
  if (!text) return;
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(() => flashCopied(btn));
  } else {
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.opacity = '0';
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    document.body.removeChild(ta);
    flashCopied(btn);
  }
}

function flashCopied(btn) {
  const orig = btn.innerHTML;
  btn.innerHTML = '&#10003; Copied!';
  btn.classList.add('copied');
  setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('copied'); }, 1800);
}

function showCoverageTable(options) {
  const opts = options || {};
  const tableView = document.getElementById('coverageTableView');
  const detailView = document.getElementById('coverageDetailView');
  if (tableView) tableView.classList.remove('hidden');
  if (detailView) detailView.classList.add('hidden');
  document.querySelectorAll('.cov-row.active, .cov-tree-file.active, .cov-list-item.active').forEach(el => el.classList.remove('active'));
  if (opts.scroll !== false && tableView) tableView.scrollIntoView({ block: 'start' });
}

function setText(id, value) {
  const el = document.getElementById(id);
  if (el) el.textContent = value;
}

function openCoverageDetail(filePath) {
  const detail = COVERAGE_DETAILS[filePath];
  if (!detail) return;
  const tableView = document.getElementById('coverageTableView');
  const detailView = document.getElementById('coverageDetailView');
  const sourceWrap = document.getElementById('covSourceWrap');
  if (!tableView || !detailView || !sourceWrap) return;

  document.querySelectorAll('.main-tab').forEach(t => t.classList.remove('active'));
  document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
  const coverageTab = document.getElementById('tab-coverage');
  if (coverageTab) coverageTab.classList.add('active');
  const tabs = Array.from(document.querySelectorAll('.main-tab'));
  const covTabButton = tabs.find(t => t.textContent.includes('Coverage'));
  if (covTabButton) covTabButton.classList.add('active');

  const sidebar = document.querySelector('.sidebar');
  if (sidebar) {
    sidebar.style.display = 'flex';
    document.querySelectorAll('.sidebar-section').forEach(s => s.classList.remove('active'));
    const section = document.getElementById('sidebar-coverage');
    if (section) section.classList.add('active');
  }

  tableView.classList.add('hidden');
  detailView.classList.remove('hidden');
  setText('covDetailFile', detail.path || filePath);
  const title = document.getElementById('covDetailFile');
  if (title) title.title = detail.path || filePath;
  const missed = Math.max((detail.total || 0) - (detail.hit || 0), 0);
  setText('covDetailPct', 'Coverage: ' + Number(detail.pct || 0).toFixed(1) + '%');
  setText('covDetailHit', (detail.hit || 0) + ' covered / ' + (detail.total || 0) + ' executable lines');
  setText('covDetailMiss', missed + ' uncovered executable line' + (missed === 1 ? '' : 's'));

  const promptBtn = document.getElementById('covDetailPromptBtn');
  if (promptBtn) {
    promptBtn.onclick = function(e) { e.stopPropagation(); openPromptModal(detail.promptId); };
  }

  sourceWrap.textContent = '';
  const lineHits = detail.lines || {};
  (detail.source || []).forEach((line, idx) => {
    const lineNumber = idx + 1;
    const key = String(lineNumber);
    const executable = Object.prototype.hasOwnProperty.call(lineHits, key);
    const hits = executable ? Number(lineHits[key]) : 0;
    const row = document.createElement('div');
    row.className = 'cov-source-line ' + (executable ? (hits > 0 ? 'covered' : 'uncovered') : 'neutral');
    if (executable) row.title = hits + ' hit' + (hits === 1 ? '' : 's');

    const gutter = document.createElement('span');
    gutter.className = 'ln';
    gutter.textContent = executable ? (lineNumber + ' ' + (hits > 0 ? '✓' : '✕')) : String(lineNumber);

    const src = document.createElement('span');
    src.className = 'src';
    src.textContent = line || ' ';

    row.appendChild(gutter);
    row.appendChild(src);
    sourceWrap.appendChild(row);
  });

  document.querySelectorAll('.cov-row.active, .cov-tree-file.active, .cov-list-item.active').forEach(el => el.classList.remove('active'));
  document.querySelectorAll('.cov-row').forEach(row => {
    const fileCell = row.querySelector('.cov-file');
    if (fileCell && fileCell.textContent === filePath) row.classList.add('active');
  });
  document.querySelectorAll('.cov-tree-file, .cov-list-item').forEach(el => {
    const label = el.querySelector('.file-name, .list-file-name');
    if (label && label.title === filePath) el.classList.add('active');
  });
  const main = document.querySelector('.main');
  if (main) main.scrollTo({ top: 0, behavior: 'smooth' });
}

// Coverage table filter
function filterCovTable() {
  const q = document.getElementById('covSearch').value.toLowerCase();
  const rows = document.querySelectorAll('#covBody tr');
  let visible = 0;
  rows.forEach(row => {
    const text = row.textContent.toLowerCase();
    const show = !q || text.includes(q);
    row.style.display = show ? '' : 'none';
    if (show) visible++;
  });
  const el = document.getElementById('covCount');
  if (el) el.textContent = 'Showing ' + visible + ' files';
}

let covSortCol = -1, covSortAsc = true;
function sortCovTable(col) {
  const tbody = document.getElementById('covBody');
  if (!tbody) return;
  const rows = Array.from(tbody.querySelectorAll('tr'));
  if (covSortCol === col) { covSortAsc = !covSortAsc; } else { covSortCol = col; covSortAsc = true; }
  rows.sort((a, b) => {
    let va = a.cells[col].textContent.trim();
    let vb = b.cells[col].textContent.trim();
    const na = parseFloat(va.replace('%',''));
    const nb = parseFloat(vb.replace('%',''));
    if (!isNaN(na) && !isNaN(nb)) return covSortAsc ? na - nb : nb - na;
    return covSortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
  });
  rows.forEach(r => tbody.appendChild(r));
}


function toggleClone(i) {
  const body = document.getElementById('body-' + i);
  const icon = document.getElementById('icon-' + i);
  if (body.style.display === 'none') {
    body.style.display = 'block';
    icon.classList.add('open');
  } else {
    body.style.display = 'none';
    icon.classList.remove('open');
  }
}

function expandAll() {
  document.querySelectorAll('.clone-card').forEach(c => {
    if (c.style.display !== 'none') {
      c.querySelector('.clone-body').style.display = 'block';
      c.querySelector('.toggle-icon').classList.add('open');
    }
  });
}

function collapseAll() {
  document.querySelectorAll('.clone-body').forEach(b => b.style.display = 'none');
  document.querySelectorAll('.toggle-icon').forEach(i => i.classList.remove('open'));
}

function filterClones() {
  const q = document.getElementById('mainSearch').value.toLowerCase();
  let visible = 0;
  document.querySelectorAll('.clone-card').forEach(card => {
    if (card.dataset.hidden === 'true') return;
    const text = card.textContent.toLowerCase();
    const show = !q || text.includes(q);
    card.style.display = show ? 'block' : 'none';
    if (show) visible++;
  });
  document.getElementById('resultCount').textContent = 'Showing ' + visible + ' clones';
}

function renderRelPanel(filePath) {
  const rels = FILE_RELATIONS[filePath];
  const panel = document.getElementById('relPanel');
  if (!rels || Object.keys(rels).length === 0) {
    panel.classList.remove('active');
    return;
  }
  const fname = filePath.split('/').pop();
  const relFiles = Object.keys(rels);
  const totalLines = relFiles.reduce((s, k) => s + rels[k].lines, 0);
  document.getElementById('relFileName').textContent = fname;
  document.getElementById('relFileName').title = filePath;
  document.getElementById('relStat').textContent = relFiles.length + ' related file' + (relFiles.length > 1 ? 's' : '') + ' · ' + totalLines + ' duplicate lines';

  const tbody = document.getElementById('relBody');
  tbody.innerHTML = '';
  relFiles.forEach(other => {
    const info = rels[other];
    const otherName = other.split('/').pop();
    const tr = document.createElement('tr');
    tr.onclick = function() {
      // Filter to show only clones between these two files
      document.querySelectorAll('.clone-card').forEach(card => {
        const idx = parseInt(card.dataset.index);
        if (info.clones.includes(idx)) {
          card.style.display = 'block';
          card.dataset.hidden = 'false';
          card.classList.add('highlight');
        } else {
          card.style.display = 'none';
          card.dataset.hidden = 'true';
          card.classList.remove('highlight');
        }
      });
      document.getElementById('resultCount').textContent = 'Showing ' + info.clones.length + ' of ' + TOTAL + ' clones (' + fname + ' vs ' + otherName + ')';
    };
    const fileTd = document.createElement('td');
    const fileSpan = document.createElement('span');
    fileSpan.className = 'rel-file-link';
    fileSpan.title = other;
    fileSpan.textContent = otherName;
    fileTd.appendChild(fileSpan);

    const countTd = document.createElement('td');
    const countSpan = document.createElement('span');
    countSpan.className = 'rel-count';
    countSpan.textContent = info.clones.length;
    countTd.appendChild(countSpan);

    const linesTd = document.createElement('td');
    const linesSpan = document.createElement('span');
    linesSpan.className = 'rel-lines';
    linesSpan.textContent = info.lines;
    linesTd.appendChild(linesSpan);

    tr.appendChild(fileTd);
    tr.appendChild(countTd);
    tr.appendChild(linesTd);
    tbody.appendChild(tr);
  });
  panel.classList.add('active');
}

function filterByFile(filePath, cloneIds) {
  // Clear highlights
  document.querySelectorAll('.tree-file.active, .list-file.active').forEach(el => el.classList.remove('active'));
  // Highlight clicked item
  event.currentTarget.classList.add('active');

  document.querySelectorAll('.clone-card').forEach(card => {
    const idx = parseInt(card.dataset.index);
    if (cloneIds.includes(idx)) {
      card.style.display = 'block';
      card.dataset.hidden = 'false';
      card.classList.add('highlight');
    } else {
      card.style.display = 'none';
      card.dataset.hidden = 'true';
      card.classList.remove('highlight');
    }
  });

  renderRelPanel(filePath);

  const fname = filePath.split('/').pop();
  document.getElementById('filterName').textContent = fname;
  document.getElementById('filterName').title = filePath;
  document.getElementById('filterTag').classList.add('active');
  document.getElementById('resultCount').textContent = 'Showing ' + cloneIds.length + ' of ' + TOTAL + ' clones';
  document.getElementById('mainSearch').value = '';
}

function showAll() {
  document.querySelectorAll('.clone-card').forEach(card => {
    card.style.display = 'block';
    card.dataset.hidden = 'false';
    card.classList.remove('highlight');
  });
  document.querySelectorAll('.tree-file.active, .list-file.active').forEach(el => el.classList.remove('active'));
  document.getElementById('filterTag').classList.remove('active');
  document.getElementById('relPanel').classList.remove('active');
  document.getElementById('resultCount').textContent = 'Showing ' + TOTAL + ' clones';
  document.getElementById('mainSearch').value = '';
}

function switchTab(tab) {
  document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
  document.querySelectorAll('.sidebar-panel').forEach(p => p.classList.remove('active'));
  event.currentTarget.classList.add('active');
  document.getElementById('panel-' + tab).classList.add('active');
}

function toggleFolder(row) {
  const toggle = row.querySelector('.folder-toggle');
  const icon = row.querySelector('.folder-icon');
  toggle.classList.toggle('open');
  const children = row.parentElement.querySelector('.folder-children');
  const isOpen = children.style.display !== 'none';
  children.style.display = isOpen ? 'none' : 'block';
  icon.innerHTML = isOpen ? '&#128193;' : '&#128194;';
}

function filterSidebar() {
  const q = document.getElementById('sidebarSearch').value.toLowerCase();
  // Filter tree files
  document.querySelectorAll('.tree-file').forEach(el => {
    const name = el.querySelector('.file-name').textContent.toLowerCase();
    const title = (el.querySelector('.file-name').title || '').toLowerCase();
    el.style.display = (!q || name.includes(q) || title.includes(q)) ? 'flex' : 'none';
  });
  // Filter list files
  document.querySelectorAll('.list-file').forEach(el => {
    const text = el.textContent.toLowerCase();
    el.style.display = (!q || text.includes(q)) ? 'block' : 'none';
  });
  if (q) {
    document.querySelectorAll('.folder-children').forEach(c => c.style.display = 'block');
    document.querySelectorAll('.folder-toggle').forEach(t => t.classList.add('open'));
    document.querySelectorAll('.folder-icon').forEach(i => i.innerHTML = '&#128194;');
  }
}

let sortAsc = false;
function sortByLines() {
  const list = document.getElementById('cloneList');
  const cards = Array.from(list.querySelectorAll('.clone-card'));
  sortAsc = !sortAsc;
  cards.sort((a, b) => {
    const la = parseInt(a.dataset.lines);
    const lb = parseInt(b.dataset.lines);
    return sortAsc ? la - lb : lb - la;
  });
  cards.forEach(c => list.appendChild(c));
  document.getElementById('sortBtn').innerHTML = 'Sort: Lines ' + (sortAsc ? '&#8593;' : '&#8595;');
}
</script>
</body>
</html>
README.md Documentation
# Quality Check

Local and CI quality gate for the Flutter monorepo. **SonarScanner and SonarQube are the source of truth** for analysis, duplication, coverage metrics, and the quality gate result. The local script runs the same scanner engine used in CI, then writes a small HTML report and `summary.json` from SonarQube API results.

## Files

```text
scripts/quality/
├── setup.sh              # First-run setup for portable local SonarQube/scanner (no Homebrew/Docker/admin)
├── local-quality.sh      # Recommended entrypoint; defaults to portable local SonarQube
├── portable-local-sonar.sh # Portable local SonarQube runner used by local-quality.sh
├── quality-check.sh      # Core engine: runs SonarScanner, then generates local reports
├── generate-report.py    # Reads scanner task metadata and SonarQube API metrics
├── template.html         # Self-contained HTML/CSS/JS template for quality-report.html
├── DESIGN.md             # Design tokens and UI spec — start here for visual changes
└── README.md
```

Output goes to `reports/quality/`:

| File | Description |
|------|-------------|
| `quality-report.html` | Local HTML summary plus Full Quality Map by folder/file from SonarQube APIs |
| `summary.json` | Machine-readable quality gate result and top problem files for CI/local automation |

## Prerequisites

Recommended local flow uses portable zip-based tooling and does **not** require Homebrew, Docker, Colima, sudo, or admin access.

Required for setup/run:

- `curl`
- `unzip`
- `python3` — stdlib only, no extra packages needed
- Java **JDK 17** on `PATH`, `JAVA_HOME`, or `PORTABLE_JAVA_HOME` (SonarQube 10.7 is run with Java 17; on macOS the scripts prefer `/usr/libexec/java_home -v 17`)

Optional coverage file generated before running the check:

- `reports/coverage/clean_combined_lcov.info` (auto-copied from `coverage/lcov.info`)

Remote/team SonarQube mode also needs:

- **SonarQube/SonarCloud access** via `SONAR_HOST_URL` or `sonar.host.url`
- **Authentication token** in `SONAR_TOKEN`

## Recommended local flow

### 1) First-run setup

```bash
bash scripts/quality/setup.sh
```

This creates `~/.cache/top-flutter-quality/`, downloads/extracts the configured SonarQube and sonar-scanner zips, installs a valid cache/vendor/mirror-provided sonar-flutter plugin into the portable SonarQube, verifies `sonar.sh`, `sonar-scanner`, and plugin placement, and prints the next command. SonarQube/scanner downloads are cached for later runs.

Environment overrides are shared with `portable-local-sonar.sh`:

```bash
TOP_FLUTTER_QUALITY_CACHE=/tmp/top-flutter-quality bash scripts/quality/setup.sh
SONARQUBE_PORTABLE_VERSION=10.7.0.96327 bash scripts/quality/setup.sh
SONAR_SCANNER_PORTABLE_VERSION=6.2.1.4610 bash scripts/quality/setup.sh
SONAR_FLUTTER_PLUGIN_VERSION=0.5.2 bash scripts/quality/setup.sh
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=0 bash scripts/quality/setup.sh  # skip plugin install
```

Use `bash scripts/quality/setup.sh --help` to see all setup overrides without downloading anything.

### 2) Daily run

```bash
bash scripts/quality/local-quality.sh
```

`local-quality.sh` now defaults to portable local SonarQube:

1. Uses cached SonarQube and sonar-scanner under `~/.cache/top-flutter-quality/`.
2. Configures the portable SonarQube bind address to `127.0.0.1:${SONAR_LOCAL_PORT:-19102}` (default port `19102`, override with `SONAR_LOCAL_PORT`).
3. Ensures a valid cache/vendor/mirror-provided sonar-flutter plugin is installed under SonarQube `extensions/plugins/`.
4. Starts SonarQube from the zip using its bundled launcher — no Docker/Colima.
5. Creates/caches and validates a portable local token in `~/.sonarqube-local/top-flutter-portable-<port>.token` when `SONAR_TOKEN` is not provided (override with `SONAR_LOCAL_TOKEN_FILE`).
6. Runs the same `quality-check.sh` engine with the portable sonar-scanner first on `PATH`.
7. Writes `reports/quality/summary.json` and `reports/quality/quality-report.html`.
8. Stops SonarQube unless you keep it running.

Common examples:

```bash
# Keep SonarQube running after the scan (inspect dashboard at http://localhost:19102)
bash scripts/quality/local-quality.sh --keep-sonar-local

# Minimal focus: coverage >= 80%, duplication <= 3%, code smells <= 0
bash scripts/quality/local-quality.sh --minimal-focus
bash scripts/quality/local-quality.sh --focus-minimal  # alias

# Point to a different JDK without changing system settings
PORTABLE_JAVA_HOME=~/.cache/jdk/jdk-17 bash scripts/quality/local-quality.sh

# Override cache location
TOP_FLUTTER_QUALITY_CACHE=/tmp/sq-cache bash scripts/quality/local-quality.sh

# Override the portable SonarQube port if 19102 is already in use
SONAR_LOCAL_PORT=9103 bash scripts/quality/local-quality.sh
```

`--portable-local` is still accepted as a backward-compatible no-op, but it is no longer needed.

**Options:**

| Flag | Default | Description |
|------|---------|-------------|
| `--keep-sonar-local` | off | Keep portable local SonarQube running after the scan |
| `--keep-server` | off | Alias for `--keep-sonar-local` |
| `-d, --dup-threshold N` | `3` | Max allowed duplication percentage used in local summary fields |
| `-c, --cov-threshold N` | `80` | Min required coverage percentage used in local summary fields |
| `--focus AREAS` | — | Focus report on `coverage,duplication,smell` — gate derived locally from those three thresholds; BUG/VULN/HOTSPOT hidden |
| `--minimal-focus` | — | Shorthand for `--focus coverage,duplication,smell` |
| `--focus-minimal` | — | Alias for `--minimal-focus` |
| `--smell-threshold N` | `0` | Max code smells allowed when `--focus` is active |
| `--legacy-local` | off | Optional legacy Homebrew + Colima/Docker local mode |
| `-h, --help` | — | Show help |

Portable local SonarQube installs the Flutter/Dart analyzer plugin by default, matching legacy mode:

```bash
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=1
SONAR_FLUTTER_PLUGIN_VERSION=0.5.2
SONAR_FLUTTER_PLUGIN_VENDOR_JAR=scripts/quality/vendor/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar
SONAR_FLUTTER_PLUGIN_JAR=~/.cache/top-flutter-quality/plugins/sonar-flutter-plugin-${SONAR_FLUTTER_PLUGIN_VERSION}.jar
# Optional only when explicitly set to a trusted internal/non-default mirror:
SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL=
```

Plugin acquisition order is: valid cache jar, valid repo-local vendor jar, then an explicit `SONAR_FLUTTER_PLUGIN_DOWNLOAD_URL` mirror only if you set it. There is no default plugin download URL. Place a valid plugin jar at `scripts/quality/vendor/sonar-flutter-plugin-<version>.jar` (or set `SONAR_FLUTTER_PLUGIN_VENDOR_JAR` to another path) before running setup/local quality. If the vendor jar exists but is invalid, the script fails instead of silently downloading, so a broken packaged artifact is visible.

Set `SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=0` only if you are intentionally running without Dart/Flutter-specific Sonar rules.

## Direct usage (advanced)

```bash
# Whole project against a remote/team SonarQube host
SONAR_HOST_URL=https://sonar.example.com SONAR_TOKEN=*** SONAR_PROJECT_KEY=top-flutter \
  bash scripts/quality/quality-check.sh

# Custom fallback thresholds for the local summary
bash scripts/quality/quality-check.sh -d 5 -c 70
```

## Focus / Minimal mode

Use focus mode to narrow the quality gate to only three areas — **Coverage**, **Duplication**, and **Code Smell** — and hide BUG/VULN/HOTSPOT issues in the HTML report. The gate is derived locally from those three thresholds, not from SonarQube's configured gate.

```bash
# Minimal focus through the recommended local entrypoint
bash scripts/quality/local-quality.sh --minimal-focus

# Same with a looser smell threshold for a legacy package
bash scripts/quality/local-quality.sh --minimal-focus --smell-threshold 20 packages/features/home

# Direct engine usage against an already configured SonarQube host
bash scripts/quality/quality-check.sh --focus coverage,duplication,smell --smell-threshold 5
```

In focus mode the HTML report shows:
- **FOCUS MODE** badge in the header
- 3-card metrics row: Coverage · Duplication · Code Smells
- "Focus Gate Passed/Failed" banner (not the Sonar gate)
- Gate conditions table with the three local thresholds
- Only CODE_SMELL pills visible in the problem-file rows

`summary.json` gains three extra fields when focus mode is active:

```json
{
  "gate": "passed",
  "gateSource": "focus",
  "focusMode": true,
  "codeSmells": { "count": 3, "threshold": 5, "passed": true }
}
```

`gateSource` is `"focus"` (local threshold check) or `"sonar"` (Sonar's configured quality gate). The raw `qualityGateStatus` from SonarQube is still present and unchanged.

## Legacy Homebrew/Docker local mode (optional)

The previous local path is still available, but it is no longer the recommended default. Use it only if you specifically want Colima/Docker-managed SonarQube:

```bash
bash scripts/quality/local-quality.sh --legacy-local
```

Legacy mode requires Homebrew and will install missing packages (`colima`, `docker`, `sonar-scanner`, `jq`, `python`) via `brew install`. You can also call the core engine directly:

```bash
bash scripts/quality/quality-check.sh --sonar-local
```

Legacy flow:

```text
1. Start Colima if Docker is not already available
2. Create/start sonarqube-local container if needed
3. Install a valid cache/vendor/mirror-provided sonar-flutter plugin into the local container unless disabled
4. Wait until http://localhost:9000/api/system/status = UP
5. Create/cache a local token in ~/.sonarqube-local/top-flutter.token when SONAR_TOKEN is not provided
6. Run sonar-scanner against localhost only
7. Generate reports/quality/summary.json and reports/quality/quality-report.html
8. Stop the SonarQube container and Colima if this script started them
```

The legacy local mode hard-guards the host URL and refuses anything except:

```text
http://localhost:*
http://127.0.0.1:*
```

This prevents accidental scans against the team SonarQube server.

### Legacy resource knobs

Defaults are conservative for a resource-limited Mac:

```bash
COLIMA_CPU=2
COLIMA_MEMORY=4
COLIMA_DISK=20
SONAR_LOCAL_PORT=9000
SONAR_LOCAL_IMAGE=sonarqube:community
SONAR_LOCAL_CONTAINER=sonarqube-local
SONAR_LOCAL_PROJECT_KEY=top-flutter-local
SONAR_LOCAL_INSTALL_FLUTTER_PLUGIN=1
SONAR_FLUTTER_PLUGIN_VERSION=0.5.2
```

Examples:

```bash
# Try a slightly smaller VM. If SonarQube fails to start, go back to 4GB.
COLIMA_MEMORY=3 COLIMA_DISK=15 bash scripts/quality/local-quality.sh --legacy-local

# Keep the Docker-backed local server open after the scan.
bash scripts/quality/local-quality.sh --legacy-local --keep-sonar-local

# Equivalent env switch.
SONAR_LOCAL_KEEP_RUNNING=1 bash scripts/quality/local-quality.sh --legacy-local
```

First legacy run will pull `sonarqube:community`, require a valid cache/vendor/mirror-provided `sonar-flutter-plugin`, and create Docker volumes. Later runs reuse the local container/volumes.

## Coverage setup

The script does not run tests. Generate coverage first when coverage should be included in the scanner analysis.

```bash
bash scripts/quality/local-quality.sh   # auto-discovers coverage/lcov.info
```

## Quality gate

`summary.json` schema:

```json
{
  "scope": "packages/features/create_rm",
  "source": "sonarqube",
  "projectKey": "top-flutter",
  "dashboardUrl": "https://sonar.example.com/dashboard?id=top-flutter",
  "analysisId": "...",
  "duplicate": { "percentage": 4.73, "threshold": 3, "passed": false },
  "coverage": { "percentage": 75.42, "threshold": 80, "passed": false, "reportPath": ".../lcov.info" },
  "qualityGateStatus": "ERROR",
  "gate": "failed",
  "gateSource": "sonar",
  "focusMode": false,
  "codeSmells": { "count": 12, "threshold": 0, "passed": false }
}
```

- `gate` is `"passed"` or `"failed"`. In default mode it mirrors SonarQube's gate status. In focus mode it is computed locally from the three focus thresholds.
- `gateSource` is `"sonar"` (default) or `"focus"` (when `--focus`/`--minimal-focus` is used).
- `qualityGateStatus` is always the raw SonarQube gate status string and is never overridden.
- `duplicate` and `coverage` percentages come from SonarQube measures, not a separate local analyzer.
- `problemMap` contains a compact file/folder quality map from SonarQube APIs:
  - unresolved issues from `/api/issues/search`
  - duplicated files from `/api/measures/component_tree`
  - coverage gaps from `/api/measures/component_tree`
- Exit code is `1` when the SonarQube quality gate fails.

## Configuration

Analysis scope, duplication behavior, coverage input, and exclusions live in the repository root `sonar-project.properties`. Keep CI and local runs aligned by changing that file rather than adding separate local quality rules.

## UI customisation

The generated `quality-report.html` is fully self-contained — all CSS and JS are inlined, no build step required.

> **Current source of truth for the generated HTML: `generate-report.py`.**
> The `render_html()` function in `generate-report.py` owns the inline CSS, JS, and HTML structure written to `quality-report.html`. `template.html` is a reference/design document and is **not** used by `quality-check.sh` at runtime. To wire `template.html` into generation, update `render_html()` to read and substitute into it.

Key points:

- **Dark/light mode** — the report ships with a toggle button (top-right, `class="theme-toggle"`). Default follows `prefers-color-scheme`; preference saved to `localStorage` key `dup-report-theme`. CSS variables for both themes live in `render_html()` `:root` (dark default) and `[data-theme="light"]` blocks.
- **CSS variables** — layout colors are tokens (`--bg`, `--surface`, `--surface2`, `--border`, `--text`, `--muted`, `--green`, `--red`, `--blue`, `--yellow`, `--cyan`, `--gate-pass-bg`, `--gate-fail-bg`). Badge `.pill` variants have `[data-theme="light"]` overrides.
- **`template.html` / `DESIGN.md`** — document design tokens and a richer sidebar/main layout that can replace the current inline HTML when `render_html()` is updated to use it.
sonar-project.properties SonarQube project config (example)
sonar.projectKey=top-flutter
# Config file report path
sonar.flutter.coverage.reportPath=reports/coverage/lcov.info
# Encoding of the source code. Default is default system encoding.
sonar.sourceEncoding=UTF-8
# Use existing options to perform dartanalyzer analysis
sonar.dart.analyzer.options.override=false
# Set dart pattern
sonar.lang.patterns.dart=**/*.dart
# Scan dart files only
sonar.inclusions=**/*.dart
# Excluding Files
sonar.exclusions=web/**/*.js/,apps/**/**/**/*.js,packages/**/**/*.js,packages/**/**/test/**/*.mocks.dart,packages/**/test/**/*.mocks.dart,apps/tep/lib/**/*.g.dart,apps/tep/lib/**/**/*.g.dart,apps/tep/android/app/src/**/google-services.json,apps/tep/lib/**/**/**/*.g.dart,apps/tep/lib/**/*.config.dart,apps/tep_cc/lib/**/*.g.dart,apps/tep_cc/lib/**/**/*.g.dart,apps/tep_cc/android/app/src/**/google-services.json,apps/tep_cc/lib/**/**/**/*.g.dart,apps/tep_cc/lib/**/*.config.dart,packages/features/feature_template/**,packages/core/application/**,packages/core/configurations/**,packages/core/constants/**,packages/core/enum/**,packages/core/firebase/**,packages/core/localization/**,packages/core/log/**,packages/core/route/**,packages/core/ui/**,packages/core/utils/**,packages/core/widgets/**,packages/core/**,packages/**/**/lib/**/*.g.dart,packages/**/**/lib/**/**/*.g.dart,packages/**/**/lib/**/**/**/*.g.dart,packages/**/**/lib/**/*.config.dart,packages/**/**/libs/**/**/*.java,packages/**/**/java/**/**/*.java,packages/**/**/kotlin/**/**/*.java,packages/design_system/lib/**,packages/design_system/lib/**/**,packages/design_system/lib/**/**/**,**/test/**,packages/**/**/lib/**/*.freezed.dart,packages/**/**/lib/**/**/*.freezed.dart,packages/**/**/lib/**/**/**/*.freezed.dart,packages/**/**/lib/**/**/**/**/*.freezed.dart,packages/**/**/lib/widget/**,packages/**/**/lib/widgets/**,packages/**/**/lib/ui/widget/**,packages/**/**/lib/ui/widgets/**,packages/**/**/lib/ui/**/widget/**,packages/**/**/lib/ui/**/widgets/**,packages/**/**/lib/src/ui/**,packages/**/**/lib/src/widget/**,packages/**/**/lib/src/widgets/**,packages/**/**/lib/src/ui/widget/**,packages/**/**/lib/src/ui/widgets/**,packages/**/**/lib/src/ui/**/widget/**,packages/**/**/lib/src/ui/**/widgets/**,packages/**/**/lib/**/ui/**,packages/plugins/face_recognition/**
sonar.coverage.exclusions=packages/**/**/lib/constants/**,packages/**/**/lib/presentation/**/*selector.dart,packages/**/**/lib/data/model/**,packages/**/**/lib/data/model/**/**,packages/**/**/lib/data/model/**/**/**,app/assets/js/base_webview_injector.js,packages/**/**/lib/presentation/**/*listener.dart,packages/**/**/lib/src/constants/**,packages/**/**/lib/src/presentation/**/*selector.dart,packages/**/**/lib/src/data/model/**,packages/**/**/lib/src/data/model/**/**,packages/**/**/lib/src/data/model/**/**/**,app/assets/js/base_webview_injector.js,packages/**/**/lib/src/presentation/**/*listener.dart,packages/**/common_application/lib/domain/mapper/additional_mapper/**,scripts/**
sonar.cpd.dart.ignoreImports=true
sonar.html.file.suffixes=