package main

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"text/tabwriter"

	"fn-registry/functions/infra"
)

func cmdDoctor(args []string) {
	jsonOut := false
	emitClaudeMd := false
	sub := ""
	for _, a := range args {
		switch a {
		case "--json":
			jsonOut = true
		case "--emit-claude-md":
			emitClaudeMd = true
		case "-h", "--help":
			doctorUsage()
			return
		default:
			if sub == "" {
				sub = a
			}
		}
	}

	r := root()

	switch sub {
	case "", "all":
		doctorAll(r, jsonOut)
	case "artefacts":
		doctorArtefacts(r, jsonOut)
	case "services":
		doctorServices(r, jsonOut)
	case "services-spec":
		doctorServicesSpec(r, jsonOut)
	case "sync":
		doctorSync(r, jsonOut)
	case "uses-functions":
		doctorUsesFunctions(r, jsonOut)
	case "unused":
		doctorUnused(r, jsonOut)
	case "cpp-apps":
		doctorCppApps(r, jsonOut)
	case "ml":
		doctorML(r, jsonOut)
	case "vaults":
		doctorVaults(r, jsonOut)
	case "copied-code":
		doctorCopiedCode(r, jsonOut)
	case "capabilities":
		if emitClaudeMd {
			doctorCapabilitiesEmitMd(r)
		} else {
			doctorCapabilities(r, jsonOut)
		}
	case "app-location":
		doctorAppLocation(r, jsonOut)
	case "modules":
		doctorModules(r, jsonOut)
	case "dod":
		doctorDod(r, jsonOut)
	case "e2e-coverage":
		doctorE2ECoverage(r, jsonOut)
	case "projects":
		doctorProjects(r, jsonOut)
	default:
		fmt.Fprintf(os.Stderr, "unknown doctor subcommand: %s\n", sub)
		doctorUsage()
		os.Exit(1)
	}
}

func doctorUsage() {
	fmt.Println(`fn doctor — diagnostico read-only del registry y artefactos

Usage:
  fn doctor [subcommand] [--json]

Subcommands:
  (none)|all      Corre todos los checks
  artefacts       Salud de apps y analyses (git, venv, app.md, upstream)
  services        Estado de apps con tag 'service' (systemd + puerto)
  services-spec   Audit del bloque service: en app.md de apps tag 'service' (issue 0105)
  sync            Drift entre pc_locations BD y disco
  uses-functions  Audit imports reales vs uses_functions del app.md
  unused          Funciones del registry sin consumidores
  cpp-apps        Conformidad de apps C++ con cpp/PATTERNS.md (cfg.about, dockspace, menubar)
  ml              Entorno ML: GPUs NVIDIA, CUDA toolkit, venv Python, paquetes torch/diffusers, CLIs y vault
  vaults          Salud de vaults: directorio, layout, índice, staleness, drift
  copied-code     Detecta cuerpos de funcion del registry copiados en apps sin import (issue 0085k)
  capabilities    Drift entre docs/capabilities/INDEX.md, tags de funciones, y paginas <grupo>.md (issue 0086)
  app-location    Detecta artefactos (apps/analysis) en carpetas de lenguaje (cpp/apps/, etc.) - issue 0096
  modules         Drift entre uses_modules (app.md) y fn_module_<x> link calls (CMakeLists.txt) - issue 0097
  dod             Audita bloque dod_evidence_schema en dev/issues/ y dev/flows/ (issue 0114)
  e2e-coverage    Porcentaje de apps con e2e_checks declarado en su app.md (issue 0121b)
  projects        Cobertura de projects vs sub-repos Gitea (repo propio + hijos clonables) (issue 0171)

Flags:
  --json              Salida JSON (para scripting/agentes)
  --emit-claude-md    (solo capabilities) Genera bloque markdown para CLAUDE.md`)
}

func doctorAll(root string, jsonOut bool) {
	if jsonOut {
		all := map[string]any{}
		if v, err := infra.ArtefactDoctor(root); err == nil {
			all["artefacts"] = v
		} else {
			all["artefacts_error"] = err.Error()
		}
		if v, err := infra.ServicesStatus(root); err == nil {
			all["services"] = v
		} else {
			all["services_error"] = err.Error()
		}
		if v, err := infra.PcLocationsDrift(root, ""); err == nil {
			all["sync"] = v
		} else {
			all["sync_error"] = err.Error()
		}
		if v, err := infra.AuditUsesFunctions(root); err == nil {
			all["uses_functions"] = v
		} else {
			all["uses_functions_error"] = err.Error()
		}
		if v, err := infra.FindUnusedFunctions(root); err == nil {
			all["unused"] = v
		} else {
			all["unused_error"] = err.Error()
		}
		if v, err := infra.AuditCppApps(root); err == nil {
			all["cpp_apps"] = v
		} else {
			all["cpp_apps_error"] = err.Error()
		}
		if v, err := infra.AuditCppTableMigration(root); err == nil {
			all["cpp_table_migration"] = v
		} else {
			all["cpp_table_migration_error"] = err.Error()
		}
		if v, err := infra.AuditMlEnv(root); err == nil {
			all["ml"] = v
		} else {
			all["ml_error"] = err.Error()
		}
		if v, err := infra.VaultDoctor(root); err == nil {
			all["vaults"] = v
		} else {
			all["vaults_error"] = err.Error()
		}
		if v, err := infra.AuditCapabilityGroups(root); err == nil {
			all["capabilities"] = v
		} else {
			all["capabilities_error"] = err.Error()
		}
		emit(all)
		return
	}

	fmt.Println("=== Artefacts ===")
	doctorArtefacts(root, false)
	fmt.Println("\n=== Services ===")
	doctorServices(root, false)
	fmt.Println("\n=== Sync (pc_locations drift) ===")
	doctorSync(root, false)
	fmt.Println("\n=== uses_functions audit ===")
	doctorUsesFunctions(root, false)
	fmt.Println("\n=== Unused functions ===")
	doctorUnused(root, false)
	fmt.Println("\n=== C++ apps standard conformance ===")
	doctorCppApps(root, false)
	fmt.Println("\n=== ML environment ===")
	doctorML(root, false)
	fmt.Println("\n=== Vaults ===")
	doctorVaults(root, false)
	fmt.Println("\n=== Capability groups ===")
	doctorCapabilities(root, false)
}

func doctorCppApps(root string, jsonOut bool) {
	audits, err := infra.AuditCppApps(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	tableAudits, err2 := infra.AuditCppTableMigration(root)
	if err2 != nil {
		fmt.Fprintf(os.Stderr, "warning: table migration audit failed: %v\n", err2)
		tableAudits = nil
	}

	if jsonOut {
		emit(map[string]any{
			"conformance":       audits,
			"table_migration":   tableAudits,
		})
		return
	}

	// Conformance section.
	bad := 0
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "STATUS\tAPP\tISSUES")
	for _, a := range audits {
		status := "OK"
		issues := "-"
		if !a.OK {
			status = "FAIL"
			issues = strings.Join(a.Issues, "; ")
			bad++
		}
		fmt.Fprintf(w, "%s\t%s\t%s\n", status, a.AppID, issues)
	}
	w.Flush()
	fmt.Printf("\n%d/%d C++ apps conform.\n", len(audits)-bad, len(audits))

	// BeginTable migration section.
	if len(tableAudits) == 0 {
		return
	}
	hasMigrationNotes := false
	for _, t := range tableAudits {
		if t.Status != "clean" {
			hasMigrationNotes = true
			break
		}
	}
	if !hasMigrationNotes {
		return
	}
	fmt.Println("\n--- BeginTable migration (issue 0081) ---")
	tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "STATUS\tAPP\tTABLES\tMESSAGE")
	for _, t := range tableAudits {
		if t.Status == "clean" {
			continue
		}
		fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", strings.ToUpper(t.Status), t.AppID, t.BeginTableCount, t.Message)
	}
	tw.Flush()
}

func doctorArtefacts(root string, jsonOut bool) {
	checks, err := infra.ArtefactDoctor(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(checks)
		return
	}
	bad := 0
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "STATUS\tTYPE\tID\tISSUES")
	for _, c := range checks {
		status := "OK"
		issues := "-"
		if !c.OK {
			status = "FAIL"
			issues = strings.Join(c.Issues, "; ")
			bad++
		}
		fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", status, c.Type, c.ID, issues)
	}
	w.Flush()
	fmt.Printf("\n%d/%d artefacts healthy.\n", len(checks)-bad, len(checks))
}

func doctorServices(root string, jsonOut bool) {
	statuses, err := infra.ServicesStatus(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(statuses)
		return
	}
	if len(statuses) == 0 {
		fmt.Println("No services registered (no apps with tag 'service').")
		return
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "NAME\tUNIT\tACTIVE\tPORT\tLISTENING")
	for _, s := range statuses {
		port := "-"
		listen := "-"
		if s.Port > 0 {
			port = fmt.Sprintf("%d", s.Port)
			listen = fmt.Sprintf("%v", s.PortListening)
		}
		fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", s.Name, s.UnitName, s.UnitActive, port, listen)
	}
	w.Flush()
}

func doctorServicesSpec(root string, jsonOut bool) {
	audits, err := infra.AuditServicesSpec(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(audits)
		return
	}
	if len(audits) == 0 {
		fmt.Println("No services declared (no apps with tag 'service').")
		return
	}
	bad := 0
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "STATUS\tAPP\tRUNTIME\tPORT\tHEALTH\tUNIT\tTARGETS\tISSUES")
	for _, a := range audits {
		status := "OK"
		issues := "-"
		if !a.OK {
			status = "FAIL"
			issues = strings.Join(a.Issues, "; ")
			bad++
		}
		port := "-"
		if a.Port > 0 {
			port = fmt.Sprintf("%d", a.Port)
		}
		health := a.HealthPath
		if health == "" {
			health = "-"
		}
		unit := a.SystemdUnit
		if unit == "" {
			unit = "-"
		}
		targets := strings.Join(a.PCTargets, ",")
		if targets == "" {
			targets = "-"
		}
		runtime := a.Runtime
		if runtime == "" {
			runtime = "-"
		}
		fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
			status, a.Name, runtime, port, health, unit, targets, issues)
	}
	w.Flush()
	fmt.Printf("\n%d/%d services with complete service: block.\n", len(audits)-bad, len(audits))
}

func doctorSync(root string, jsonOut bool) {
	drifts, err := infra.PcLocationsDrift(root, "")
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(drifts)
		return
	}
	if len(drifts) == 0 {
		fmt.Println("No drift detected: pc_locations matches disk.")
		return
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "TYPE\tID\tDIR\tSTATUS\tISSUE")
	for _, d := range drifts {
		fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.EntityType, d.EntityID, d.DirPath, d.Status, d.Issue)
	}
	w.Flush()
	fmt.Printf("\n%d drift(s) detected.\n", len(drifts))
}

func doctorUsesFunctions(root string, jsonOut bool) {
	audits, err := infra.AuditUsesFunctions(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(audits)
		return
	}
	bad := 0
	for _, a := range audits {
		if len(a.Missing) == 0 && len(a.Unused) == 0 {
			continue
		}
		bad++
		fmt.Printf("\n%s (%s)\n", a.AppID, a.Lang)
		if len(a.Missing) > 0 {
			fmt.Printf("  missing in app.md: %s\n", strings.Join(a.Missing, ", "))
		}
		if len(a.Unused) > 0 {
			fmt.Printf("  declared but unused: %s\n", strings.Join(a.Unused, ", "))
		}
	}
	if bad == 0 {
		fmt.Println("All apps have matching uses_functions vs imports.")
	} else {
		fmt.Printf("\n%d/%d apps have drift.\n", bad, len(audits))
	}
}

func doctorUnused(root string, jsonOut bool) {
	unused, err := infra.FindUnusedFunctions(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(unused)
		return
	}
	if len(unused) == 0 {
		fmt.Println("No unused functions.")
		return
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "ID\tLANG\tDOMAIN\tAGE_DAYS")
	for _, u := range unused {
		fmt.Fprintf(w, "%s\t%s\t%s\t%d\n", u.ID, u.Lang, u.Domain, u.AgeDays)
	}
	w.Flush()
	fmt.Printf("\n%d unused functions (candidates to remove).\n", len(unused))
}

func doctorVaults(root string, jsonOut bool) {
	entries, err := infra.VaultDoctor(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(entries)
		return
	}
	if len(entries) == 0 {
		fmt.Println("No vaults declared (no projects/*/vaults/vault.yaml found).")
		return
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "NAME\tSTATUS\tFILES\tINDEXED\tISSUES")
	ok := 0
	for _, e := range entries {
		issues := "-"
		if len(e.Issues) > 0 {
			issues = strings.Join(e.Issues, "; ")
		}
		fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
			e.VaultName, e.Status, e.DiskFiles, e.IndexedFiles, issues)
		if e.Status == "ok" {
			ok++
		}
	}
	w.Flush()
	fmt.Printf("\n%d/%d vaults healthy.\n", ok, len(entries))
}

func doctorML(root string, jsonOut bool) {
	report, err := infra.AuditMlEnv(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(report)
		return
	}

	fmt.Printf("GPUs detected: %d\n", len(report.Gpus))
	for _, g := range report.Gpus {
		fmt.Printf("  [%d] %s  VRAM: %d/%d MiB  Driver: %s  CUDA: %s\n",
			g.Index, g.Name, g.VramFreeMb, g.VramTotalMb, g.DriverVersion, g.CudaVersion)
	}
	fmt.Println()

	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "CHECK\tSTATUS\tVERSION\tDETAIL")
	for _, c := range report.Checks {
		version := c.Version
		if version == "" {
			version = "-"
		}
		detail := c.Detail
		if len(detail) > 60 {
			detail = detail[:60] + "..."
		}
		if detail == "" {
			detail = "-"
		}
		fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.Name, c.Status, version, detail)
	}
	w.Flush()

	overall := "OK"
	if !report.OverallOK {
		overall = "INCOMPLETE"
	}
	fmt.Printf("\nOverall ML environment: %s\n", overall)
}

func doctorProjects(root string, jsonOut bool) {
	rows, err := infra.AuditProjectsCoverage(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	orphans, oerr := infra.FindOrphanProjectRefs(root)
	if oerr != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", oerr)
		os.Exit(1)
	}
	if jsonOut {
		emit(map[string]any{
			"coverage":        rows,
			"orphan_project_ids": orphans,
		})
		return
	}
	fmt.Print(infra.FormatProjectsCoverage(rows))
	fmt.Println("\n--- Check inverso: project_id huérfanos (apps/analysis sin project declarado) ---")
	fmt.Print(infra.FormatOrphanProjectRefs(orphans))
}

func emit(v any) {
	b, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		fmt.Fprintf(os.Stderr, "json error: %v\n", err)
		os.Exit(1)
	}
	fmt.Println(string(b))
}

func doctorCapabilities(root string, jsonOut bool) {
	audits, err := infra.AuditCapabilityGroups(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(audits)
		return
	}
	bad := 0
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "STATUS\tGROUP\tIN_INDEX\tDOC\tFN_COUNT\tISSUES")
	for _, a := range audits {
		status := "OK"
		issues := "-"
		if !a.OK {
			status = "FAIL"
			issues = strings.Join(a.Issues, "; ")
			bad++
		}
		doc := "no"
		if a.DocExists {
			doc = "yes"
		}
		inIdx := "no"
		if a.DeclaredInIndex {
			inIdx = "yes"
		}
		fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n", status, a.Group, inIdx, doc, a.FunctionCount, issues)
	}
	w.Flush()
	fmt.Printf("\n%d/%d capability groups healthy.\n", len(audits)-bad, len(audits))
}

func doctorCapabilitiesEmitMd(root string) {
	result, err := infra.EmitCapabilitiesMd(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	fmt.Print(infra.RenderCapabilitiesMd(result))
}

func doctorCopiedCode(root string, jsonOut bool) {
	entries, err := infra.AuditCopiedCode(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(entries)
		return
	}
	if len(entries) == 0 {
		fmt.Println("No copied-code detected (exact_copy match).")
		return
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "KIND\tSIMILARITY\tAPP_FILE\tAPP_FUNCTION\tREGISTRY_ID")
	for _, e := range entries {
		fmt.Fprintf(w, "%s\t%.2f\t%s\t%s\t%s\n",
			e.Kind, e.Similarity, e.AppFile, e.AppFunction, e.RegistryID)
	}
	w.Flush()
	fmt.Printf("\n%d suspected copy match(es).\n", len(entries))
}

func doctorAppLocation(root string, jsonOut bool) {
	violations, err := infra.AuditAppLocation(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(violations)
		return
	}
	if len(violations) == 0 {
		fmt.Println("OK: no artefacts under language-named folders.")
		return
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "KIND\tLANG\tPATH")
	for _, v := range violations {
		fmt.Fprintf(w, "%s\t%s\t%s\n", v.Kind, v.Lang, v.Path)
	}
	w.Flush()
	fmt.Printf("\n%d violation(s): move artefact to apps/<name>/ or projects/<p>/apps/<name>/ (issue 0096).\n", len(violations))
}

func doctorModules(root string, jsonOut bool) {
	checks, err := infra.AuditModulesDrift(root)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}

	if jsonOut {
		emit(checks)
		return
	}

	bad := 0
	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "STATUS\tAPP\tDECLARED\tLINKED\tMISSING\tEXTRA")
	for _, c := range checks {
		status := "OK"
		if !c.OK {
			status = "DRIFT"
			bad++
		}
		decl := strings.Join(c.Declared, ",")
		if decl == "" {
			decl = "-"
		}
		link := strings.Join(c.Linked, ",")
		if link == "" {
			link = "-"
		}
		missing := strings.Join(c.MissingLinks, ",")
		if missing == "" {
			missing = "-"
		}
		extra := strings.Join(c.ExtraLinks, ",")
		if extra == "" {
			extra = "-"
		}
		fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", status, c.AppID, decl, link, missing, extra)
	}
	w.Flush()
	fmt.Printf("\n%d/%d apps with module drift.\n", bad, len(checks))
	if bad > 0 {
		fmt.Println("Fix: align uses_modules in app.md with target_link_libraries(fn_module_*) in CMakeLists.txt.")
	}
}

func doctorDod(root string, jsonOut bool) {
	issuesDir := filepath.Join(root, "dev", "issues")
	flowsDir := filepath.Join(root, "dev", "flows")
	report, err := infra.AuditDodSchema(issuesDir, flowsDir)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(report)
		return
	}
	fmt.Println("=== DoD Schema Audit ===")
	fmt.Printf("files scanned:  %d\n", report.TotalFiles)
	fmt.Printf("with schema:    %d\n", report.FilesWithItems)
	fmt.Printf("total items:    %d\n", report.TotalItems)
	fmt.Printf("invalid items:  %d\n", report.InvalidItems)
	if report.InvalidItems == 0 {
		fmt.Println("\nAll DoD schemas valid.")
		return
	}
	fmt.Println()
	rel := func(p string) string {
		if r, err := filepath.Rel(root, p); err == nil {
			return r
		}
		return p
	}
	for _, f := range report.Files {
		if len(f.Errors) == 0 {
			continue
		}
		for _, e := range f.Errors {
			fmt.Printf("%s : %s\n", rel(f.Path), e)
		}
	}
}

func doctorE2ECoverage(root string, jsonOut bool) {
	roots := []string{
		filepath.Join(root, "apps"),
		filepath.Join(root, "cpp", "apps"),
		filepath.Join(root, "projects"),
	}
	report, err := infra.AuditE2ECoverage(roots)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	if jsonOut {
		emit(report)
		return
	}

	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(w, "METRIC\tVALUE")
	fmt.Fprintf(w, "total\t%d\n", report.Total)
	fmt.Fprintf(w, "with_checks\t%d\n", report.WithChecks)
	fmt.Fprintf(w, "missing\t%d\n", len(report.Missing))
	fmt.Fprintf(w, "coverage_pct\t%.2f%%\n", report.CoveragePct)
	w.Flush()

	if len(report.Missing) > 0 {
		fmt.Println("\nApps without e2e_checks:")
		rel := func(p string) string {
			if r, err := filepath.Rel(root, p); err == nil {
				return r
			}
			return p
		}
		for _, m := range report.Missing {
			fmt.Printf("  %s\n", rel(m))
		}
	}
}
