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: portable-local-sonar.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
# plus the sonar-flutter plugin into a user cache 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; }

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_URL="${SONAR_FLUTTER_PLUGIN_URL:-https://github.com/insideapp-oss/sonar-flutter/releases/download/${SONAR_FLUTTER_PLUGIN_VERSION}/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, sonar-scanner, and sonar-flutter plugin into a user cache."
  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_URL        default sonar-flutter plugin URL"
  echo "  SONAR_FLUTTER_PLUGIN_JAR        default: cache/plugins/sonar-flutter-plugin-<version>.jar"
  echo ""
  echo "After setup run:"
  echo "  bash scripts/quality/local-quality.sh"
  echo ""
}

for arg in "$@"; do
  case "$arg" in
    -h|--help) show_help; exit 0 ;;
    *) 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.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"
  }

  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
}

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

  download_file_if_missing "$SONAR_FLUTTER_PLUGIN_URL" "$SONAR_FLUTTER_PLUGIN_JAR" "sonar-flutter plugin ${SONAR_FLUTTER_PLUGIN_VERSION}"
  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
  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}"

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"

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 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] [PACKAGE_PATH]"
  echo ""
  echo "Default mode:"
  echo "  Portable local SonarQube from ~/.cache/top-flutter-quality."
  echo "  Default URL: http://localhost:19102 (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 "  bash scripts/quality/local-quality.sh packages/features/create_rm"
  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
REMAINING_ARGS=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --legacy-local)
      LEGACY_MODE=true
      shift
      ;;
    --portable-local)
      # Backward-compatible no-op: portable local is now the default.
      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}"
  echo -e "${CYAN}══════════════════════════════════════════════════${NC}"
  echo ""
  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
portable-local-sonar.sh ✦ Updated 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] [PACKAGE_PATH]
#   (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; }

# ── 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}"

# Derive short version (e.g. 10.7.0.96327 → 10.7.0) for directory name patterns
SQ_SHORT="${SONARQUBE_PORTABLE_VERSION%%.*}" # major only – used later via glob

# 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_URL="${SONAR_FLUTTER_PLUGIN_URL:-https://github.com/insideapp-oss/sonar-flutter/releases/download/${SONAR_FLUTTER_PLUGIN_VERSION}/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] [PACKAGE_PATH]"
  echo "       bash scripts/quality/local-quality.sh [OPTIONS] [PACKAGE_PATH]"
  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_URL    Override full sonar-flutter plugin URL"
  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 "  bash scripts/quality/local-quality.sh packages/features/create_rm"
  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 ─────────────────────────────────────────────────────────────
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"
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 "Downloaded sonar-flutter plugin is not a valid jar: $SONAR_FLUTTER_PLUGIN_JAR"
  echo ""
  echo "  The download from GitHub may have been partial, corrupted, or replaced by"
  echo "  non-jar content from a proxy/corporate network."
  echo ""
  echo "  To fix:"
  echo "    1. Re-run after checking network/proxy access to:"
  echo "       $SONAR_FLUTTER_PLUGIN_URL"
  echo "    2. Or copy a valid sonar-flutter plugin jar to:"
  echo "       $SONAR_FLUTTER_PLUGIN_JAR"
  echo "    3. Or set SONAR_FLUTTER_PLUGIN_URL to a trusted mirror/alternate release."
  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")"
    log_info "Downloading sonar-flutter plugin ${SONAR_FLUTTER_PLUGIN_VERSION}..."
    log_info "URL: $SONAR_FLUTTER_PLUGIN_URL"
    curl -fL --progress-bar "$SONAR_FLUTTER_PLUGIN_URL" -o "$SONAR_FLUTTER_PLUGIN_JAR"

    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"
  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="$props.tmp.$$"

  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))
while (( SECONDS < deadline )); do
  status="$(sonar_status)"
  if [[ "$status" == "UP" ]]; then
    log_ok "SonarQube is UP"
    break
  fi
  printf "."
  sleep 5
done
if [[ "$(sonar_status)" != "UP" ]]; then
  echo ""
  log_error "Timed out waiting for SonarQube. Check: $SQ_LOG"
  exit 1
fi

# ── 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:admin)..."
  local token_name="top-flutter-portable-$(date +%Y%m%d%H%M%S)"
  SONAR_TOKEN="$(curl -fsS -u admin: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=80
FOCUS_MODE=""
SMELL_THRESHOLD=0
PACKAGE_PATH=""
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_URL="${SONAR_FLUTTER_PLUGIN_URL:-https://github.com/insideapp-oss/sonar-flutter/releases/download/${SONAR_FLUTTER_PLUGIN_VERSION}/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] [PACKAGE_PATH]"
  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 "Arguments:"
  echo "  PACKAGE_PATH          Optional package label and package lcov source."
  echo "                        SonarScanner still reads sonar-project.properties."
  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: 80)"
  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 "  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") --sonar-local packages/features/create_rm"
  echo "  $(basename "$0") -d 5 -c 70 packages/features/home"
  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
      ;;
    *) PACKAGE_PATH="$1"; shift ;;
  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}"
  while (( SECONDS < deadline )); do
    status="$(sonar_status "$url")"
    if [[ "$status" == "UP" ]]; then
      echo -e "${GREEN}✓ Local SonarQube is UP${NC}"
      return 0
    fi
    printf "."
    sleep 5
  done

  echo ""
  echo -e "${RED}✗ Timed out waiting for local SonarQube. Last status: ${status}${NC}"
  echo -e "${YELLOW}  Check logs with: docker logs --tail 100 ${SONAR_LOCAL_CONTAINER}${NC}"
  exit 1
}

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
}

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

  mkdir -p "$SONAR_LOCAL_TOKEN_DIR"
  chmod 700 "$SONAR_LOCAL_TOKEN_DIR" 2>/dev/null || true

  if [[ ! -f "$SONAR_FLUTTER_PLUGIN_JAR" ]]; then
    echo -e "${YELLOW}▸ Downloading sonar-flutter plugin ${SONAR_FLUTTER_PLUGIN_VERSION}...${NC}"
    curl -fL "$SONAR_FLUTTER_PLUGIN_URL" -o "$SONAR_FLUTTER_PLUGIN_JAR"
  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
    ensure_sonar_flutter_plugin
    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"
}

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

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: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", ""))'
}

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
    export SONAR_TOKEN="$cached_token"
    return 0
  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

  printf '%s\n' "$SONAR_TOKEN" > "$SONAR_LOCAL_TOKEN_FILE"
  chmod 600 "$SONAR_LOCAL_TOKEN_FILE" 2>/dev/null || true
  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

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

SCAN_LABEL="Whole Project"
COVERAGE_FILE=""

if [[ -n "$PACKAGE_PATH" ]]; then
  PACKAGE_PATH="${PACKAGE_PATH%/}"
  if [[ ! -d "$PROJECT_ROOT/$PACKAGE_PATH" ]]; then
    echo -e "${RED}✗ Package not found: $PACKAGE_PATH${NC}"
    exit 1
  fi
  SCAN_LABEL="$PACKAGE_PATH"
  LCOV_PATH="$PROJECT_ROOT/$PACKAGE_PATH/coverage/lcov.info"
  if [[ -f "$LCOV_PATH" ]]; then
    COVERAGE_FILE="$LCOV_PATH"
  fi
else
  COMBINED_LCOV="$PROJECT_ROOT/reports/coverage/clean_combined_lcov.info"
  if [[ -f "$COMBINED_LCOV" ]]; then
    COVERAGE_FILE="$COMBINED_LCOV"
  fi
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" --smell-threshold "$SMELL_THRESHOLD")
fi

python3 "$SCRIPT_DIR/generate-report.py" "${REPORT_ARGS[@]}"

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 html
import json
import os
import sys
import time
import urllib.parse
import urllib.request
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"


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


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)
        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 render_issue_badges(record: dict[str, Any]) -> str:
    labels = [
        ("BUG", "Bug", "bug"),
        ("VULNERABILITY", "Vuln", "vuln"),
        ("CODE_SMELL", "Smell", "smell"),
        ("SECURITY_HOTSPOT", "Hotspot", "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="pill {css}">{html.escape(label)} {count}</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}%"
        if duplicated:
            text += f" / {int(duplicated)} lines"
        badges.append(f'<span class="pill dup">{html.escape(text)}</span>')
    if record.get("coverageProblem"):
        coverage = record.get("coverage")
        uncovered = record.get("uncoveredLines")
        text = "Cov"
        if coverage is not None:
            text += f" {float(coverage):.1f}%"
        if uncovered:
            text += f" / {int(uncovered)} uncovered"
        badges.append(f'<span class="pill cov">{html.escape(text)}</span>')
    return " ".join(badges) or '<span class="muted">No problem markers</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 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,
) -> str:
    gate = summary["gate"]
    duplicate = summary["duplicate"]
    coverage = summary["coverage"]
    dashboard_url = summary.get("dashboardUrl") or ""
    is_passed = gate == "passed"

    gate_badge_class = "badge-pass" if is_passed else "badge-fail"
    gate_badge_text = "✓ GATE PASSED" if is_passed else "✗ GATE FAILED"

    branch_or_scope = html.escape(branch) if branch else html.escape(summary.get("scope", ""))
    focus_badge = '<span style="background:#0d2a2a;border:1px solid #39d2c0;color:#39d2c0;border-radius:4px;padding:2px 8px;font-size:11px;font-weight:600">FOCUS MODE</span>' if focus_mode else ""

    smell_cs = code_smells_summary or {}
    smell_count = smell_cs.get("count")
    smell_threshold = smell_cs.get("threshold", 0)
    smell_passed = smell_cs.get("passed", True)
    smell_card_extra = "pass" if smell_passed else "fail"
    smell_color = "green" if smell_passed else "red"

    if is_passed:
        gate_banner_extra = "pass"
        gate_banner_icon = "✓"
        gate_banner_title = "Focus Gate Passed" if focus_mode else "Quality Gate Passed"
        gate_banner_sub = f"All {len(gate_conditions)} condition{'s' if len(gate_conditions) != 1 else ''} met"
    else:
        gate_banner_extra = ""
        gate_banner_icon = "✗"
        gate_banner_title = "Focus Gate Failed" if focus_mode else "Quality Gate Failed"
        failed = [c for c in gate_conditions if str(c.get("status", "")).upper() not in ("OK", "PASS", "PASSED")]
        sub_parts = [describe_gate_condition(c) for c in failed[:3]]
        gate_banner_sub = " · ".join(sub_parts) if sub_parts else "See gate conditions below"

    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")

    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}%"

    def bar_w(v: Any) -> int:
        parsed = to_float(v)
        return 0 if parsed is None else max(0, min(100, int(parsed)))

    dup_color = "green" if dup_passed else "red"
    cov_color = "green" if cov_passed else "red"
    dup_card_extra = "pass" if dup_passed else "fail"
    cov_card_extra = "pass" if cov_passed else "fail"

    # Sidebar gate check rows
    sidebar_gate = ""
    if focus_mode:
        _focus_checks = [
            ("Coverage", cov_passed),
            ("Duplication", dup_passed),
            ("Code Smells", smell_passed),
        ]
        for clabel_full, cpassed in _focus_checks:
            clabel = html.escape(clabel_full)
            count_style = 'style="color:#3fb950;background:#1a3d2b"' if cpassed else ""
            count_text = "✓ pass" if cpassed else "✗ fail"
            fail_cls = "" if cpassed else " fail"
            sidebar_gate += f'<div class="sidebar-item{fail_cls}" role="button" tabindex="0" onclick="drillTo(\'sec-gate-conds\')"><span class="sidebar-label" title="{clabel}">{clabel}</span><span class="count" {count_style}>{count_text}</span></div>\n'
    else:
        for cond in gate_conditions:
            cstatus = str(cond.get("status", "")).upper()
            cpassed = cstatus in ("OK", "PASS", "PASSED")
            clabel_full = display_metric_key(cond.get("metricKey", ""))
            clabel = html.escape(clabel_full)
            count_style = 'style="color:#3fb950;background:#1a3d2b"' if cpassed else ""
            count_text = "✓ pass" if cpassed else "✗ fail"
            fail_cls = "" if cpassed else " fail"
            sidebar_gate += f'<div class="sidebar-item{fail_cls}" role="button" tabindex="0" onclick="drillTo(\'sec-gate-conds\')"><span class="sidebar-label" title="{clabel}">{clabel}</span><span class="count" {count_style}>{count_text}</span></div>\n'
    if not sidebar_gate:
        sidebar_gate = '<div class="sidebar-item muted">No conditions</div>\n'

    sidebar_folders = ""
    for i, folder in enumerate(problem_map.get("folders", [])[:6]):
        folder_id = f"folder-{i}"
        fname_full = str(folder.get("folder", "?"))
        fname = html.escape(short_path(fname_full, 34))
        fname_title = html.escape(fname_full)
        fcount = int(folder.get("issueTotal", 0))
        sidebar_folders += f'<div class="sidebar-item" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\', \'{folder_id}\')"><span class="sidebar-label" title="{fname_title}">{fname}</span><span class="count">{fcount}</span></div>\n'

    # All problem files (list view)
    top_files_all = problem_map.get("topFiles", [])
    top_rows = _top_file_rows(top_files_all, focus_mode=focus_mode)
    if not top_rows:
        top_rows = '<tr><td colspan="3" class="muted" style="padding:14px">No problem files found.</td></tr>'

    # Folder detail sections
    folder_sections = ""
    for i, folder in enumerate(problem_map.get("folders", [])):
        folder_id = f"folder-{i}"
        fname_full = str(folder.get("folder", ""))
        fname = html.escape(short_path(fname_full, 70))
        fname_title = html.escape(fname_full)
        ffiles = folder.get("files", [])
        fissues = int(folder.get("issueTotal", 0))
        fdup = int(folder.get("duplicationProblems", 0))
        fcov = int(folder.get("coverageProblems", 0))
        file_rows = _top_file_rows(ffiles[:30], focus_mode=focus_mode)
        extra = len(ffiles) - 30
        if extra > 0:
            file_rows += f'<tr><td colspan="3" class="muted">+ {extra} more files</td></tr>\n'
        folder_sections += f"""    <details id="{folder_id}" class="folder">
      <summary><span class="folder-name" title="{fname_title}">{fname}</span><span>{len(ffiles)} files · {fissues} issues · {fdup} dup · {fcov} cov</span></summary>
      <div class="issues-table" style="border-radius:0;border-left:0;border-right:0;border-bottom:0">
        <table><thead><tr><th>File</th><th>Type</th><th>Detail</th></tr></thead>
        <tbody>{file_rows}</tbody></table>
      </div>
    </details>\n"""

    if not folder_sections:
        folder_sections = '<p class="muted" style="padding:14px 0">No folder-level data available.</p>'

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

    if focus_mode:
        _focus_rows_data = [
            ("Coverage", f"{fmt_pct(cov_pct)}", f">= {cov_threshold:g}%", "OK" if cov_passed else "ERROR"),
            ("Duplication", f"{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" class="muted">No quality gate conditions returned.</td></tr>'

    dashboard_button = ""
    if dashboard_url:
        dashboard_button = f'<a class="button" href="{html.escape(dashboard_url)}">Open SonarQube Dashboard →</a>'

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

    if focus_mode:
        _smell_val = str(smell_count) if smell_count is not None else "N/A"
        _smell_bar = min(100, int((smell_count or 0) / max(smell_threshold, 1) * 100)) if smell_threshold > 0 else (0 if (smell_count or 0) == 0 else 100)
        _smell_sub = "threshold <=" + str(smell_threshold) + " - " + ("pass" if smell_passed else "fail")
        metrics_row_html = (
            '<div class="metrics-row" style="grid-template-columns:repeat(3,1fr)">'
            f'<div class="metric-card {cov_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to coverage by folder">'
            '<div class="label">Coverage</div>'
            f'<div class="value">{fmt_pct(cov_pct)}</div>'
            f'<div class="sub">threshold {cov_threshold:g}% - {"pass" if cov_passed else "fail"}</div>'
            f'<div class="progress-bar"><div class="progress-fill {cov_color}" style="width:{bar_w(cov_pct)}%"></div></div>'
            "</div>"
            f'<div class="metric-card {dup_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to duplication by folder">'
            '<div class="label">Duplication</div>'
            f'<div class="value">{fmt_pct(dup_pct)}</div>'
            f'<div class="sub">threshold {dup_threshold:g}% - {"pass" if dup_passed else "fail"}</div>'
            f'<div class="progress-bar"><div class="progress-fill {dup_color}" style="width:{bar_w(dup_pct)}%"></div></div>'
            "</div>"
            f'<div class="metric-card {smell_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'list\')" title="Drill down to problem files">'
            '<div class="label">Code Smells</div>'
            f'<div class="value">{_smell_val}</div>'
            f'<div class="sub">{_smell_sub}</div>'
            f'<div class="progress-bar"><div class="progress-fill {smell_color}" style="width:{_smell_bar}%"></div></div>'
            "</div>"
            "</div>"
        )
    else:
        metrics_row_html = (
            '<div class="metrics-row">'
            '<div class="metric-card" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to folder breakdown">'
            '<div class="label">Files Analyzed</div>'
            f'<div class="value">{files_analyzed}</div>'
            f'<div class="sub">across {folders_total} folders</div>'
            "</div>"
            f'<div class="metric-card {"fail" if issues_total > 0 else ""}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'list\')" title="Drill down to problem files">'
            '<div class="label">Total Issues</div>'
            f'<div class="value">{issues_total}</div>'
            f'<div class="sub">in {problem_files_total} problem files</div>'
            "</div>"
            f'<div class="metric-card {dup_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to duplication by folder">'
            '<div class="label">Duplication</div>'
            f'<div class="value">{fmt_pct(dup_pct)}</div>'
            f'<div class="sub">threshold {dup_threshold:g}% · {"pass" if dup_passed else "fail"}</div>'
            f'<div class="progress-bar"><div class="progress-fill {dup_color}" style="width:{bar_w(dup_pct)}%"></div></div>'
            "</div>"
            f'<div class="metric-card {cov_card_extra}" role="button" tabindex="0" onclick="drillTo(\'sec-problem-files\', \'folder\')" title="Drill down to coverage by folder">'
            '<div class="label">Coverage</div>'
            f'<div class="value">{fmt_pct(cov_pct)}</div>'
            f'<div class="sub">threshold {cov_threshold:g}% · {"pass" if cov_passed else "fail"}</div>'
            f'<div class="progress-bar"><div class="progress-fill {cov_color}" style="width:{bar_w(cov_pct)}%"></div></div>'
            "</div>"
            "</div>"
        )

    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>top-flutter / Quality Report</title>
  <style>
    * {{ box-sizing: border-box; margin: 0; padding: 0; }}
    :root {{ --bg:#0d1117; --surface:#161b22; --surface2:#21262d; --border:#30363d; --text:#c9d1d9; --strong:#f0f6fc; --muted:#8b949e; --blue:#58a6ff; --red:#f85149; --green:#3fb950; --yellow:#e3b341; --cyan:#39d2c0; }}
    [data-theme="light"] {{ --bg:#f6f8fa; --surface:#ffffff; --surface2:#f1f4f8; --border:#d0d7de; --text:#24292f; --strong:#0d1117; --muted:#57606a; --blue:#0969da; --red:#cf222e; --green:#1a7f37; --yellow:#9a6700; --cyan:#0550ae; }}
    body {{ background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; font-size: 13px; line-height: 1.5; }}
    a {{ color: #58a6ff; text-decoration: none; }}
    a:hover {{ text-decoration: underline; }}
    .muted {{ color: #8b949e; font-size: 11px; }}
    .warn {{ background: #2d2414; color: #e3b341; border: 1px solid #e3b341; border-radius: 6px; padding: 10px 14px; font-size: 12px; }}
    .button {{ display: inline-block; padding: 6px 14px; border-radius: 6px; background: #21262d; border: 1px solid #30363d; color: #58a6ff; font-size: 12px; font-weight: 500; margin-top: 8px; }}
    .button:hover {{ border-color: #58a6ff; text-decoration: none; }}
    /* App shell */
    .app {{ display: flex; flex-direction: column; min-height: 100vh; }}
    .app-header {{ background: #161b22; border-bottom: 1px solid #30363d; padding: 11px 24px; display: flex; align-items: center; gap: 12px; }}
    .tab-bar {{ background: #161b22; border-bottom: 1px solid #30363d; padding: 0 24px; display: flex; }}
    .tab {{ padding: 10px 18px; font-size: 12px; border-bottom: 2px solid transparent; color: #8b949e; }}
    .tab.active {{ color: #f0f6fc; border-bottom-color: #1f6feb; }}
    .app-header h1 {{ font-size: 14px; font-weight: 600; color: #f0f6fc; white-space: nowrap; }}
    .branch {{ background: #21262d; border: 1px solid #30363d; border-radius: 4px; padding: 2px 8px; font-size: 11px; color: #8b949e; white-space: nowrap; }}
    .badge-fail {{ background: #3d1414; border: 1px solid #f85149; color: #f85149; border-radius: 4px; padding: 2px 8px; font-size: 11px; font-weight: 600; white-space: nowrap; }}
    .badge-pass {{ background: #1a3d2b; border: 1px solid #3fb950; color: #3fb950; border-radius: 4px; padding: 2px 8px; font-size: 11px; font-weight: 600; white-space: nowrap; }}
    .header-right {{ margin-left: auto; font-size: 11px; color: #8b949e; display: flex; align-items: center; gap: 12px; white-space: nowrap; }}
    .theme-toggle {{ min-height: 32px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; padding: 6px 14px; color: #8b949e; cursor: pointer; font-size: 11px; font-weight: 500; }}
    .theme-toggle:hover {{ border-color: #58a6ff; color: #58a6ff; }}
    /* Layout */
    .content {{ padding: 20px 24px 40px; flex: 1; }}
    .polish-layout {{ display: grid; grid-template-columns: 220px 1fr; gap: 20px; }}
    /* Sidebar */
    .sidebar {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px 16px; align-self: start; }}
    .sidebar h3 {{ font-size: 10px; color: #8b949e; text-transform: uppercase; letter-spacing: .08em; margin: 14px 0 8px; }}
    .sidebar h3:first-child {{ margin-top: 0; }}
    .sidebar-item {{ padding: 5px 8px; border-radius: 4px; margin-bottom: 2px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; overflow: hidden; font-size: 12px; color: #c9d1d9; }}
    .sidebar-label {{ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
    .sidebar-item.active {{ background: rgba(31,111,235,.13); color: #58a6ff; }}
    .sidebar-item.fail {{ color: #f85149; }}
    .sidebar-item .count {{ background: #21262d; border-radius: 10px; padding: 1px 6px; font-size: 10px; color: #8b949e; flex-shrink: 0; white-space: nowrap; }}
    .sidebar-item.fail .count {{ background: #3d1414; color: #f85149; }}
    .sidebar-divider {{ border: none; border-top: 1px solid #30363d; margin: 10px 0; }}
    /* Main report */
    .main-report {{ display: flex; flex-direction: column; gap: 16px; }}
    /* Gate banner */
    .gate-banner {{ background: #3d1414; border: 1px solid #f85149; border-radius: 8px; padding: 14px 18px; display: flex; align-items: flex-start; gap: 12px; }}
    .gate-banner.pass {{ background: #1a3d2b; border-color: #3fb950; }}
    .gate-banner .icon {{ font-size: 18px; line-height: 1.4; flex-shrink: 0; }}
    .gate-banner .title {{ font-weight: 600; color: #f85149; font-size: 14px; }}
    .gate-banner.pass .title {{ color: #3fb950; }}
    .gate-banner .sub {{ font-size: 12px; color: #c9d1d9; margin-top: 3px; }}
    /* Metrics row */
    .metrics-row {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }}
    .metric-card {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px; }}
    .metric-card .label {{ font-size: 10px; color: #8b949e; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px; }}
    .metric-card .value {{ font-size: 22px; font-weight: 700; color: #f0f6fc; }}
    .metric-card .sub {{ font-size: 11px; color: #8b949e; margin-top: 4px; }}
    .metric-card.pass .value {{ color: #3fb950; }}
    .metric-card.fail .value {{ color: #f85149; }}
    .metric-card[role="button"] {{ cursor: pointer; transition: border-color .15s, background .15s; }}
    .metric-card[role="button"]:hover {{ border-color: #58a6ff; background: #1c2128; }}
    .metric-card[role="button"]:focus {{ outline: 2px solid #58a6ff; outline-offset: 2px; }}
    .sidebar-item[role="button"] {{ cursor: pointer; transition: background .12s; }}
    .sidebar-item[role="button"]:hover {{ background: rgba(88,166,255,.1); color: #58a6ff; }}
    .sidebar-item[role="button"]:focus {{ outline: 2px solid #58a6ff; outline-offset: -2px; }}
    .drill-highlight {{ animation: drillpulse .6s ease-out; }}
    @keyframes drillpulse {{ 0%,100% {{ box-shadow: none; }} 40% {{ box-shadow: 0 0 0 3px rgba(88,166,255,.45); }} }}
    .progress-bar {{ height: 5px; background: #21262d; border-radius: 3px; overflow: hidden; margin-top: 8px; }}
    .progress-fill {{ height: 100%; border-radius: 3px; }}
    .progress-fill.green {{ background: #3fb950; }}
    .progress-fill.red {{ background: #f85149; }}
    /* Section */
    .section-title {{ font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 10px; }}
    /* Issues table */
    .issues-table {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }}
    .issues-table table {{ width: 100%; border-collapse: collapse; }}
    .issues-table th {{ background: #21262d; padding: 8px 14px; text-align: left; font-size: 11px; color: #8b949e; font-weight: 500; border-bottom: 1px solid #30363d; }}
    .issues-table td {{ padding: 8px 14px; font-size: 12px; border-bottom: 1px solid #21262d; vertical-align: top; }}
    .issues-table tr:last-child td {{ border-bottom: none; }}
    .issues-table tr:hover td {{ background: #1c2128; }}
    .filename {{ color: #c9d1d9; }}
    .path {{ color: #8b949e; font-size: 11px; font-family: monospace; margin-top: 2px; max-width: 520px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
    /* Pills */
    .pill {{ border-radius: 10px; padding: 2px 7px; font-size: 10px; font-weight: 600; display: inline-block; margin: 1px; }}
    .pill-bug {{ background: #3d1414; color: #f85149; }}
    .pill-vuln {{ background: #2d1a00; color: #e3b341; }}
    .pill-smell {{ background: #1a2535; color: #58a6ff; }}
    .pill-hotspot {{ background: #0d2a2a; color: #39d2c0; }}
    .pill-dup {{ background: #2d2414; color: #e3b341; }}
    .pill-cov {{ background: #1a2535; color: #58a6ff; }}
    /* Folder details */
    details.folder {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }}
    details.folder summary {{ cursor: pointer; padding: 11px 16px; font-size: 13px; font-weight: 600; list-style: none; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; color: #f0f6fc; }}
    details.folder summary::-webkit-details-marker {{ display: none; }}
    details.folder summary::before {{ content: "▶"; font-size: 9px; color: #8b949e; transition: transform .15s; flex-shrink: 0; }}
    details[open].folder summary::before {{ transform: rotate(90deg); }}
    .folder-name {{ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #f0f6fc; }}
    details.folder summary span:not(.folder-name) {{ color: #8b949e; font-size: 12px; font-weight: 400; white-space: nowrap; }}
    /* Gate conditions table */
    .gate-table {{ width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; font-size: 12px; }}
    .gate-table th {{ background: #21262d; padding: 8px 14px; text-align: left; font-size: 11px; color: #8b949e; font-weight: 500; border-bottom: 1px solid #30363d; }}
    .gate-table td {{ padding: 8px 14px; border-bottom: 1px solid #21262d; }}
    .gate-table tr:last-child td {{ border-bottom: none; }}
    .gate-status {{ font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 10px; display: inline-block; }}
    .gate-status.pass {{ background: #1a3d2b; color: #3fb950; }}
    .gate-status.fail {{ background: #3d1414; color: #f85149; }}
    /* Footer */
    .app-footer {{ padding: 12px 24px; border-top: 1px solid #30363d; background: #161b22; font-size: 11px; color: #8b949e; display: flex; justify-content: space-between; align-items: center; }}
    /* Problem files tabs */
    .pf-tabs {{ display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 12px; background: var(--surface); border-radius: 8px 8px 0 0; padding: 0 4px; }}
    .panel-tab {{ padding: 9px 18px; font-size: 12px; font-weight: 500; color: var(--muted); background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; transition: color .15s, border-color .15s; line-height: 1; }}
    .panel-tab.active {{ color: var(--strong); border-bottom-color: #1f6feb; }}
    .panel-tab:hover:not(.active) {{ color: var(--text); }}
    .panel-tab:focus {{ outline: 2px solid #58a6ff; outline-offset: -2px; border-radius: 3px; }}
    @media (max-width: 900px) {{
      .app-header {{ flex-wrap: wrap; }}
      .header-right {{ width: 100%; margin-left: 0; justify-content: space-between; }}
      .polish-layout {{ grid-template-columns: 1fr; }}
      .metrics-row {{ grid-template-columns: 1fr 1fr; }}
    }}
  </style>
  <script>
    (function() {{
      var t = localStorage.getItem('qr-theme') || 'dark';
      document.documentElement.setAttribute('data-theme', t);
      document.addEventListener('DOMContentLoaded', function() {{
        var label = document.getElementById('themeLbl');
        if (label) label.textContent = t === 'light' ? 'Dark' : 'Light';
      }});
    }})();
    function toggleTheme() {{
      var t = document.documentElement.getAttribute('data-theme') || localStorage.getItem('qr-theme') || 'dark';
      var next = t === 'dark' ? 'light' : 'dark';
      document.documentElement.setAttribute('data-theme', next);
      localStorage.setItem('qr-theme', next);
      document.getElementById('themeLbl').textContent = next === 'light' ? 'Dark' : 'Light';
    }}
    function showProblemView(view, folderId) {{
      var pvList = document.getElementById('pv-list');
      var pvFolder = document.getElementById('pv-folder');
      var tabList = document.getElementById('tab-pf-list');
      var tabFolder = document.getElementById('tab-pf-folder');
      if (!pvList || !pvFolder) return;
      var toFolder = (view === 'folder');
      pvList.style.display = toFolder ? 'none' : '';
      pvFolder.style.display = toFolder ? '' : 'none';
      if (tabList) {{ tabList.classList.toggle('active', !toFolder); tabList.setAttribute('aria-selected', String(!toFolder)); }}
      if (tabFolder) {{ tabFolder.classList.toggle('active', toFolder); tabFolder.setAttribute('aria-selected', String(toFolder)); }}
      if (toFolder && folderId) {{
        setTimeout(function() {{
          var el = document.getElementById(folderId);
          if (el) {{ el.open = true; el.scrollIntoView({{behavior: 'smooth', block: 'start'}}); }}
        }}, 60);
      }}
    }}
    function drillTo(id, view, folderId) {{
      if (view) showProblemView(view, folderId);
      var el = document.getElementById(id);
      if (!el) return;
      if (el.tagName === 'DETAILS') {{ el.open = true; }}
      el.scrollIntoView({{behavior: 'smooth', block: 'start'}});
      el.classList.add('drill-highlight');
      setTimeout(function() {{ el.classList.remove('drill-highlight'); }}, 1200);
    }}
    document.addEventListener('keydown', function(e) {{
      if ((e.key === 'Enter' || e.key === ' ') && e.target.getAttribute('role') === 'button') {{
        e.preventDefault(); e.target.click();
      }}
    }});
  </script>
</head>
<body>
<div class="app">

  <div class="app-header">
    <h1>top-flutter / Quality Report</h1>
    {f'<span class="branch">{branch_or_scope}</span>' if branch_or_scope else ''}
    <span class="{gate_badge_class}">{gate_badge_text}</span>
    {focus_badge}
    <div class="header-right">
      <span>{files_analyzed} files · {issues_total} issues · {problem_files_total} problem files</span>
      <button class="theme-toggle" onclick="toggleTheme()"><span id="themeLbl">Light</span></button>
    </div>
  </div>

  <div class="tab-bar"><div class="tab active">Option A — Polish Current Report</div></div>

  <div class="content">
    <div class="polish-layout">

      <div class="sidebar">
        <h3>Navigation</h3>
        <div class="sidebar-item active" role="button" tabindex="0" onclick="drillTo('sec-gate')"><span class="sidebar-label">Overview</span></div>
        <hr class="sidebar-divider">
        <h3>Gate Checks</h3>
        {sidebar_gate}
        <hr class="sidebar-divider">
        <h3>Issues</h3>
        <div class="sidebar-item" role="button" tabindex="0" onclick="drillTo('sec-problem-files', 'list')"><span class="sidebar-label">By File</span><span class="count">{problem_files_total}</span></div>
        <div class="sidebar-item" role="button" tabindex="0" onclick="drillTo('sec-problem-files', 'list')"><span class="sidebar-label">All Issues</span><span class="count">{issues_total}</span></div>
        <hr class="sidebar-divider">
        <h3>Folders</h3>
        {sidebar_folders if sidebar_folders else '<div class="sidebar-item muted">No folder data</div>'}
      </div>

      <div class="main-report">

        <div id="sec-gate" class="gate-banner {gate_banner_extra}">
          <div class="icon">{gate_banner_icon}</div>
          <div>
            <div class="title">{html.escape(gate_banner_title)}</div>
            <div class="sub">{gate_banner_sub}</div>
            {f'<div style="margin-top:8px">{dashboard_button}</div>' if dashboard_button else ''}
          </div>
        </div>

        {metrics_row_html}

        {limit_note}

        <div id="sec-problem-files">
          <div class="section-title">Problem Files</div>
          <div class="pf-tabs" role="tablist" aria-label="Problem files view">
            <button id="tab-pf-list" class="panel-tab active" role="tab" aria-selected="true" onclick="showProblemView('list')">List View</button>
            <button id="tab-pf-folder" class="panel-tab" role="tab" aria-selected="false" onclick="showProblemView('folder')">Folder View</button>
          </div>
          <div id="pv-list">
            <div class="issues-table">
              <table>
                <thead><tr><th>File</th><th>Type</th><th>Detail</th></tr></thead>
                <tbody>{top_rows}</tbody>
              </table>
            </div>
          </div>
          <div id="pv-folder" style="display:none">
            {folder_sections}
          </div>
        </div>

        <div id="sec-gate-conds">
          <div class="section-title">Quality Gate Conditions</div>
          <table class="gate-table">
            <thead><tr><th>Metric</th><th>Actual</th><th>Threshold</th><th>Status</th></tr></thead>
            <tbody>{condition_rows}</tbody>
          </table>
        </div>

      </div>
    </div>
  </div>

  <div class="app-footer">
    <span>top-flutter · {html.escape(summary.get('projectKey', ''))} · Quality Report</span>
    <span>Source: SonarQube · {html.escape(summary.get('scope', ''))}</span>
  </div>

</div>
</body>
</html>
"""


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)
    base_url = os.environ.get("SONAR_HOST_URL") or task_values.get("serverUrl") 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}")

    analysis_id = wait_for_analysis(base_url, ce_task_id, token)
    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)

    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 None or 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,
    }

    if focus_mode:
        focus_gate_passed = dup_passed and cov_passed and smell_passed
        gate_display = "passed" if focus_gate_passed else "failed"
        gate_source = "focus"
    else:
        gate_display = "passed" if gate_status == "OK" else "failed"
        gate_source = "sonar"

    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,
        "codeSmells": code_smells_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), 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 */
    --color-canvas-dark: #0d1117;
    --color-surface-dark: #161b22;
    --color-surface-raised-dark: #21262d;
    --color-border-dark: #30363d;
    --color-text-dark: #e6edf3;
    --color-text-muted-dark: #8b949e;
    --color-success-dark: #3fb950;
    --color-danger-dark: #f85149;
    --color-accent-dark: #58a6ff;
    --color-warning-dark: #d29922;
    --color-info-dark: #39d2c0;

    /* 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: #0d1117;
    --color-gate-pass-bg: rgba(63,185,80,0.1);
    --color-gate-fail-bg: rgba(248,81,73,0.1);
    --color-filter-bg: rgba(88,166,255,0.1);
    --color-active-bg: rgba(88,166,255,0.15);
    --color-overlay: rgba(1,4,9,0.62);

    /* 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"] {
    --color-canvas: #ffffff;
    --color-surface: #f6f8fa;
    --color-surface-raised: #e8ecf0;
    --color-border: #d0d7de;
    --color-text: #1f2328;
    --color-text-muted: #656d76;
    --color-success: #1a7f37;
    --color-danger: #cf222e;
    --color-accent: #0969da;
    --color-warning: #9a6700;
    --color-info: #0550ae;
    --color-code-bg: #f6f8fa;
    --color-on-badge: #ffffff;
    --color-gate-pass-bg: rgba(26,127,55,0.08);
    --color-gate-fail-bg: rgba(207,34,46,0.08);
    --color-filter-bg: rgba(9,105,218,0.08);
    --color-active-bg: rgba(9,105,218,0.1);
    --color-overlay: rgba(31,35,40,0.45);
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
    background: var(--bg);
    color: var(--text);
    line-height: 1.5;
    display: flex;
    height: 100vh;
    overflow: hidden;
  }

  /* ── Sidebar ── */
  .sidebar {
    width: var(--sidebar-w);
    min-width: var(--sidebar-w);
    background: var(--surface);
    border-right: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    height: 100vh;
    overflow: hidden;
  }
  .sidebar-header {
    padding: 16px;
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
  }
  .sidebar-header h2 { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
  .sidebar-search {
    width: 100%;
    padding: 8px 10px;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--text);
    font-size: 12px;
    outline: none;
  }
  .sidebar-search:focus { border-color: var(--blue); }
  .sidebar-search::placeholder { color: var(--text-dim); }

  .sidebar-tabs {
    display: flex;
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
  }
  .sidebar-tab {
    flex: 1;
    padding: 8px;
    text-align: center;
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    color: var(--text-dim);
    border-bottom: 2px solid transparent;
    transition: all 0.15s;
  }
  .sidebar-tab:hover { color: var(--text); }
  .sidebar-tab.active { color: var(--blue); border-bottom-color: var(--blue); }

  .sidebar-content {
    flex: 1;
    overflow-y: auto;
    padding: 8px;
    scrollbar-width: thin;
    scrollbar-color: var(--border) transparent;
  }
  .sidebar-content::-webkit-scrollbar { width: 6px; }
  .sidebar-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }

  .sidebar-panel { display: none; }
  .sidebar-panel.active { display: block; }

  .show-all-btn {
    width: 100%;
    padding: 8px;
    margin-bottom: 8px;
    background: var(--surface2);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--blue);
    cursor: pointer;
    font-size: 12px;
    font-weight: 500;
  }
  .show-all-btn:hover { background: var(--border); }

  /* Tree view */
  .tree-folder { padding: 1px 0; }
  .folder-row {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 5px 8px;
    border-radius: 6px;
    cursor: pointer;
    transition: background 0.1s;
  }
  .folder-row:hover { background: var(--surface2); }
  .folder-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 16px;
    height: 16px;
    font-size: 9px;
    color: var(--text-dim);
    transition: transform 0.15s;
    flex-shrink: 0;
  }
  .folder-toggle.open { transform: rotate(90deg); }
  .folder-icon { font-size: 12px; flex-shrink: 0; }
  .folder-name { font-size: 11px; font-weight: 500; color: var(--text); }
  .folder-children {
    margin-left: 12px;
    padding-left: 8px;
    border-left: 1px solid var(--border);
  }

  .tree-file {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 5px 8px;
    border-radius: 6px;
    cursor: pointer;
    font-size: 11px;
    transition: background 0.1s;
  }
  .tree-file:hover { background: var(--surface2); }
  .tree-file.active { background: var(--blue); color: var(--bg); }
  .tree-file.active .file-name { color: var(--bg); }
  .tree-file.active .file-count { background: var(--bg); color: var(--blue); }
  .file-icon { font-size: 12px; flex-shrink: 0; }
  .file-name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    font-size: 11px;
    color: var(--text);
  }
  .file-count {
    background: var(--surface2);
    color: var(--text-dim);
    padding: 1px 7px;
    border-radius: 10px;
    font-size: 11px;
    font-weight: 500;
    flex-shrink: 0;
  }

  /* List view */
  .list-file {
    padding: 6px 8px;
    border-radius: 4px;
    cursor: pointer;
    margin-bottom: 2px;
  }
  .list-file:hover { background: var(--surface2); }
  .list-file.active { background: var(--active-bg); }
  .list-file-name {
    display: block;
    font-size: 11px;
    font-weight: 500;
    font-family: 'JetBrains Mono', 'SF Mono', SFMono-Regular, Consolas, monospace;
    color: var(--cyan);
  }
  .list-file-path {
    display: block;
    font-size: 10px;
    color: var(--text-dim);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    margin-top: 1px;
  }
  .list-file-count {
    display: inline-block;
    background: var(--surface2);
    color: var(--text-dim);
    padding: 0 6px;
    border-radius: 10px;
    font-size: 10px;
    margin-top: 2px;
  }

  /* ── Main ── */
  .main {
    flex: 1;
    overflow-y: auto;
    padding: 24px;
  }
  .main::-webkit-scrollbar { width: 8px; }
  .main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }

  .main-header {
    text-align: center;
    padding-bottom: 20px;
    border-bottom: 1px solid var(--border);
    margin-bottom: 20px;
  }
  .main-header h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
  .main-header .subtitle { color: var(--text-dim); font-size: 13px; }

  .gate {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 12px;
    padding: 14px 24px;
    border-radius: 10px;
    margin-bottom: 20px;
    font-size: 16px;
    font-weight: 600;
  }
  .gate.passed { background: var(--gate-pass-bg); border: 1px solid var(--green); color: var(--green); }
  .gate.failed { background: var(--gate-fail-bg); border: 1px solid var(--red); color: var(--red); }

  .stats {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 12px;
    margin-bottom: 20px;
  }
  .stat-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 12px;
    text-align: center;
    transition: box-shadow var(--duration-fast), border-color var(--duration-fast);
  }
  .stat-card:hover { box-shadow: var(--shadow-card-hover); border-color: var(--blue); }
  .stat-value { font-size: 24px; font-weight: 700; color: var(--blue); }
  .stat-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }

  .toolbar {
    display: flex;
    gap: 8px;
    align-items: center;
    margin-bottom: 12px;
    padding: 8px 0;
  }
  .toolbar-search {
    flex: 1;
    padding: 8px 12px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--text);
    font-size: 13px;
    outline: none;
  }
  .toolbar-search:focus { border-color: var(--blue); }
  .toolbar-search::placeholder { color: var(--text-dim); }
  .btn {
    padding: 8px 12px;
    background: var(--surface2);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--text);
    cursor: pointer;
    font-size: 12px;
    white-space: nowrap;
  }
  .btn:hover { background: var(--border); }

  .filter-tag {
    display: none;
    align-items: center;
    gap: 6px;
    padding: 6px 12px;
    background: var(--filter-bg);
    border: 1px solid var(--blue);
    border-radius: 6px;
    margin-bottom: 12px;
    font-size: 12px;
    color: var(--blue);
  }
  .filter-tag.active { display: inline-flex; }
  .filter-clear {
    cursor: pointer;
    font-size: 14px;
    font-weight: bold;
    margin-left: 4px;
  }
  .filter-clear:hover { color: var(--red); }

  /* 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 {
    position: fixed;
    top: 16px;
    right: 24px;
    z-index: 999;
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 14px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 20px;
    cursor: pointer;
    font-size: 13px;
    color: var(--text);
    transition: all 0.2s;
  }
  .theme-toggle:hover { background: var(--surface2); }
  .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); }
  }
</style>
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle colour theme">
  <span class="theme-toggle-icon" id="themeIcon">&#9788;</span>
  <span id="themeLabel">Light</span>
</button>
<!-- INJECT_BODY -->
<script>
// INJECT_DATA
// 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 ? '&#9790;' : '&#9788;';
  document.getElementById('themeLabel').textContent = isLight ? 'Dark' : 'Light';
  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);
  if (theme === 'dark') {
    document.getElementById('themeIcon').innerHTML = '&#9790;';
    document.getElementById('themeLabel').textContent = '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:

- Whole repo: `reports/coverage/clean_combined_lcov.info`
- Package: `<package>/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, downloads/installs the configured `insideapp-oss/sonar-flutter` plugin into the portable SonarQube, verifies `sonar.sh`, `sonar-scanner`, and plugin placement, and prints the next command. 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 the cached `insideapp-oss/sonar-flutter` plugin is installed under SonarQube `extensions/plugins/` (downloads it if setup was skipped).
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

# Single package label and package coverage source
bash scripts/quality/local-quality.sh packages/features/create_rm

# 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_URL=https://github.com/insideapp-oss/sonar-flutter/releases/download/${SONAR_FLUTTER_PLUGIN_VERSION}/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
```

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

# Single package 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 packages/features/create_rm

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

## 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
bash scripts/quality/quality-check.sh --sonar-local packages/features/create_rm
```

Legacy flow:

```text
1. Start Colima if Docker is not already available
2. Create/start sonarqube-local container if needed
3. Download/install `insideapp-oss/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`, download `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.

**Single package:**

```bash
cd packages/features/create_rm
very_good test --coverage
cd -
bash scripts/quality/local-quality.sh packages/features/create_rm
```

**Whole project:**

```bash
bash scripts/combine-coverage.sh   # writes reports/coverage/clean_combined_lcov.info
bash scripts/quality/local-quality.sh
```

## 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.