// sentry-probe is a read-only diagnostic CLI that runs one cycle of
// sentry data-volume collection against one or more TiDB databases and
// prints a report to stdout. Optionally posts a combined daily-report
// summary to a Lark webhook.
//
// Intended for smoke-testing and for driving a daily cron/EventBridge
// scheduled report without wiring up the full sentry service.
//
// All queries are read-only (information_schema + SELECT COUNT/MAX).
// No persistence. No state across runs.
//
// Target configuration — two modes:
//
//  1. Multi-target via JSON file (preferred). Set PROBE_TARGETS_FILE to a
//     path containing a JSON array:
//     [
//     {"name": "prod",    "host": "...", "port": 4000, "user": "...",
//     "password": "...", "database": "nexuscrm"},
//     {"name": "staging", "host": "...", "port": 4000, "user": "...",
//     "password": "...", "database": "nexuscrm"}
//     ]
//
//  2. Single-target via env vars (backwards compatible):
//     DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE
//
// Other env vars:
//
//	TIDB_TLS=true          (default: true)
//	PROBE_WORKERS=8
//	PROBE_TIMEOUT=5s       (per-table query timeout)
//	PROBE_TOP_N=10         (top tables to show per target)
//
// Optional LLM translation for fired alerts:
//
//	QWEN_API_KEY, QWEN_API_URL, QWEN_MODEL, QWEN_TIMEOUT
//
// Optional Lark delivery of the combined report:
//
//	LARK_WEBHOOK_URL, LARK_SIGNING_SECRET
//
// Recommended daily-report cron (9 AM local):
//
//	0 9 * * *  /usr/local/bin/sentry-probe
package main

import (
	"bytes"
	"context"
	"crypto/tls"
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"sort"
	"strconv"
	"time"

	"github.com/go-sql-driver/mysql"

	"github.com/nexuscrm/sentry/internal/collector"
	"github.com/nexuscrm/sentry/internal/llm"
	"github.com/nexuscrm/sentry/internal/notify"
	"github.com/nexuscrm/sentry/internal/rules"
	"github.com/nexuscrm/sentry/internal/types"
)

// Target is one TiDB database to probe.
type Target struct {
	Name     string `json:"name"`
	Host     string `json:"host"`
	Port     int    `json:"port"`
	User     string `json:"user"`
	Password string `json:"password"`
	Database string `json:"database"`
}

// targetReport is the per-target result used to build the combined summary.
type targetReport struct {
	Target       Target
	Err          error
	TableCount   int
	ExactCount   int
	EstCount     int
	TotalRows    int64
	Duration     time.Duration
	TopTables    []types.TableRow
	SlowTables   []types.TableRow
	Alerts       []types.RuleResult
	LLMOutputs   map[string]string // ruleID+entity -> text
}

func main() {
	targets, err := loadTargets()
	if err != nil {
		log.Fatalf("probe: load targets: %v", err)
	}
	if len(targets) == 0 {
		log.Fatal("probe: no targets configured")
	}

	useTLS := getenvDefault("TIDB_TLS", "true") == "true"
	workers := getIntDefault("PROBE_WORKERS", 8)
	timeout := getDurDefault("PROBE_TIMEOUT", 5*time.Second)
	topN := getIntDefault("PROBE_TOP_N", 10)

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
	defer cancel()

	// Optional Qwen translator shared across all targets
	var qwen *llm.QwenClient
	if key := os.Getenv("QWEN_API_KEY"); key != "" {
		url := getenvDefault("QWEN_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
		model := getenvDefault("QWEN_MODEL", "qwen-max")
		qto := getDurDefault("QWEN_TIMEOUT", 60*time.Second)
		qwen = llm.NewQwenClient(url, key, model, qto)
	}

	fmt.Printf("═══ Sentry Probe — Daily Report ═══\n")
	fmt.Printf("Started:  %s\n", time.Now().UTC().Format(time.RFC3339))
	fmt.Printf("Targets:  %d\n", len(targets))
	if qwen != nil {
		fmt.Printf("Qwen:     enabled (%s)\n", getenvDefault("QWEN_MODEL", "qwen-max"))
	}
	fmt.Println()

	// ── Probe every target ──────────────────────────────────────────
	var reports []*targetReport
	for _, tgt := range targets {
		rep := probeOne(ctx, tgt, useTLS, workers, timeout, topN, qwen)
		reports = append(reports, rep)
		printTargetReport(rep, topN)
	}

	// ── Print combined summary ──────────────────────────────────────
	printCombinedSummary(reports)

	// ── Optional: send combined report to Lark ─────────────────────
	if url := os.Getenv("LARK_WEBHOOK_URL"); url != "" {
		secret := os.Getenv("LARK_SIGNING_SECRET")
		client := notify.NewLarkClient(url, secret, 15*time.Second)
		msg := buildLarkMessage(reports, qwen != nil)
		sctx, scancel := context.WithTimeout(ctx, 30*time.Second)
		err := client.SendText(sctx, msg)
		scancel()
		if err != nil {
			fmt.Printf("\n⚠️  Lark delivery failed: %v\n", err)
		} else {
			fmt.Printf("\n✓ Daily report sent to Lark webhook\n")
		}
	}
}

// probeOne runs a snapshot cycle against a single target and returns the
// collected data plus any errors. Never panics; errors are attached to the
// report.
func probeOne(
	ctx context.Context,
	tgt Target,
	useTLS bool,
	workers int,
	timeout time.Duration,
	topN int,
	qwen *llm.QwenClient,
) *targetReport {
	rep := &targetReport{Target: tgt, LLMOutputs: map[string]string{}}

	db, err := openDB(tgt, useTLS, workers)
	if err != nil {
		rep.Err = fmt.Errorf("open db: %w", err)
		return rep
	}
	defer db.Close()

	pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
	err = db.PingContext(pingCtx)
	pingCancel()
	if err != nil {
		rep.Err = fmt.Errorf("ping: %w", err)
		return rep
	}

	c := collector.NewTiDBCollector(db, workers, timeout)
	start := time.Now()

	tables, err := c.DiscoverTables(ctx)
	if err != nil {
		rep.Err = fmt.Errorf("discover: %w", err)
		return rep
	}

	rowsRaw, err := c.Snapshot(ctx, tables, nil)
	if err != nil {
		rep.Err = fmt.Errorf("snapshot: %w", err)
		return rep
	}
	rep.Duration = time.Since(start)
	rep.TableCount = len(rowsRaw)

	for _, r := range rowsRaw {
		rep.TotalRows += r.RowCount
		if r.Source == types.SourceInfoSchemaEst {
			rep.EstCount++
			rep.SlowTables = append(rep.SlowTables, r)
		} else {
			rep.ExactCount++
		}
	}

	// Sort descending by row count, keep topN
	sorted := make([]types.TableRow, len(rowsRaw))
	copy(sorted, rowsRaw)
	sort.Slice(sorted, func(i, j int) bool { return sorted[i].RowCount > sorted[j].RowCount })
	n := topN
	if n > len(sorted) {
		n = len(sorted)
	}
	rep.TopTables = sorted[:n]

	// Evaluate rules (delta rules skip with nil history)
	snap := types.Snapshot{
		SnapshotTs: time.Now().UTC(),
		EpochID:    0,
		Version:    "probe",
		Tables:     rowsRaw,
	}
	engine := rules.NewEngine(rules.AllDataRules())
	rep.Alerts = engine.Evaluate(snap, nilHistory{})

	// Optional LLM translation for each alert
	if qwen != nil && len(rep.Alerts) > 0 {
		for _, a := range rep.Alerts {
			qctx, qcancel := context.WithTimeout(ctx, 90*time.Second)
			text, err := qwen.Translate(qctx, a)
			qcancel()
			key := a.RuleID + "|" + a.Entity
			if err != nil {
				rep.LLMOutputs[key] = fmt.Sprintf("[LLM failed: %v]", err)
			} else {
				rep.LLMOutputs[key] = text
			}
		}
	}

	return rep
}

// openDB creates a *sql.DB for the target. Registers a unique TLS config
// per target so multiple targets on the same host don't collide.
func openDB(tgt Target, useTLS bool, workers int) (*sql.DB, error) {
	tlsKey := fmt.Sprintf("tidbprobe-%s", tgt.Name)
	if useTLS {
		if err := mysql.RegisterTLSConfig(tlsKey, &tls.Config{
			MinVersion: tls.VersionTLS12,
			ServerName: tgt.Host,
		}); err != nil {
			return nil, fmt.Errorf("register tls: %w", err)
		}
	}

	cfg := mysql.NewConfig()
	cfg.User = tgt.User
	cfg.Passwd = tgt.Password
	cfg.Net = "tcp"
	cfg.Addr = fmt.Sprintf("%s:%d", tgt.Host, tgt.Port)
	cfg.DBName = tgt.Database
	cfg.ParseTime = true
	cfg.Params = map[string]string{"charset": "utf8mb4"}
	if useTLS {
		cfg.TLSConfig = tlsKey
	}

	db, err := sql.Open("mysql", cfg.FormatDSN())
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(workers * 2)
	db.SetMaxIdleConns(workers)
	db.SetConnMaxLifetime(5 * time.Minute)
	return db, nil
}

func loadTargets() ([]Target, error) {
	if path := os.Getenv("PROBE_TARGETS_FILE"); path != "" {
		b, err := os.ReadFile(path)
		if err != nil {
			return nil, fmt.Errorf("read targets file: %w", err)
		}
		var out []Target
		if err := json.Unmarshal(b, &out); err != nil {
			return nil, fmt.Errorf("parse targets file: %w", err)
		}
		for i, t := range out {
			if t.Name == "" || t.Host == "" || t.User == "" || t.Database == "" {
				return nil, fmt.Errorf("targets[%d] is missing required field", i)
			}
			if t.Port == 0 {
				out[i].Port = 4000
			}
		}
		return out, nil
	}
	if j := os.Getenv("PROBE_TARGETS_JSON"); j != "" {
		var out []Target
		if err := json.Unmarshal([]byte(j), &out); err != nil {
			return nil, fmt.Errorf("parse PROBE_TARGETS_JSON: %w", err)
		}
		return out, nil
	}
	// Single-target fallback from plain env vars
	host := os.Getenv("DB_HOST")
	if host == "" {
		return nil, nil
	}
	port := getIntDefault("DB_PORT", 4000)
	return []Target{{
		Name:     getenvDefault("DB_NAME_LABEL", "default"),
		Host:     host,
		Port:     port,
		User:     os.Getenv("DB_USERNAME"),
		Password: os.Getenv("DB_PASSWORD"),
		Database: getenvDefault("DB_DATABASE", "nexuscrm"),
	}}, nil
}

// ── print helpers ─────────────────────────────────────────────────

func printTargetReport(rep *targetReport, topN int) {
	fmt.Printf("─── Target: %s ───\n", rep.Target.Name)
	fmt.Printf("    %s@%s:%d/%s\n", rep.Target.User, rep.Target.Host, rep.Target.Port, rep.Target.Database)

	if rep.Err != nil {
		fmt.Printf("    ✗ FAILED: %v\n\n", rep.Err)
		return
	}

	fmt.Printf("    ▸ Tables: %d (%d exact, %d estimated)\n",
		rep.TableCount, rep.ExactCount, rep.EstCount)
	fmt.Printf("    ▸ Total rows: %s\n", commaInt(rep.TotalRows))
	fmt.Printf("    ▸ Duration: %s\n", rep.Duration.Round(time.Millisecond))

	fmt.Printf("    ▸ Top %d tables by rows:\n", topN)
	for i, r := range rep.TopTables {
		updated := "-"
		if r.MaxUpdatedAt != nil {
			updated = r.MaxUpdatedAt.Format("2006-01-02 15:04:05")
		}
		src := "exact"
		if r.Source == types.SourceInfoSchemaEst {
			src = "est"
		}
		fmt.Printf("       %2d. %-40s  %15s  %-22s  %s\n",
			i+1, truncate(r.Name, 40), commaInt(r.RowCount), updated, src)
	}

	if len(rep.SlowTables) > 0 {
		fmt.Printf("    ▸ Slow tables (degraded to TiDB estimate): %d\n", len(rep.SlowTables))
	}

	if len(rep.Alerts) == 0 {
		fmt.Printf("    ▸ Rule evaluation: ✓ No fires\n")
	} else {
		fmt.Printf("    ▸ Rule evaluation: %d alert(s)\n", len(rep.Alerts))
		for _, a := range rep.Alerts {
			icon := "🟡"
			if a.Level == "CRIT" {
				icon = "🔴"
			}
			fmt.Printf("       %s %-6s %-32s entity=%s\n", icon, a.Level, a.RuleID, a.Entity)
			if text, ok := rep.LLMOutputs[a.RuleID+"|"+a.Entity]; ok {
				for _, line := range splitLines(text) {
					fmt.Printf("         %s\n", line)
				}
			}
		}
	}
	fmt.Println()
}

func printCombinedSummary(reports []*targetReport) {
	fmt.Printf("═══ Combined Summary ═══\n")
	var totalTables, totalAlerts int
	var totalRows int64
	var failures []string
	for _, r := range reports {
		if r.Err != nil {
			failures = append(failures, fmt.Sprintf("%s: %v", r.Target.Name, r.Err))
			continue
		}
		totalTables += r.TableCount
		totalRows += r.TotalRows
		totalAlerts += len(r.Alerts)
	}
	fmt.Printf("  Targets probed: %d\n", len(reports))
	fmt.Printf("  Total tables:   %d\n", totalTables)
	fmt.Printf("  Total rows:     %s\n", commaInt(totalRows))
	fmt.Printf("  Total alerts:   %d\n", totalAlerts)
	if len(failures) > 0 {
		fmt.Printf("  Failures:\n")
		for _, f := range failures {
			fmt.Printf("    - %s\n", f)
		}
	}
	fmt.Println()
}

// buildLarkMessage produces the multi-target digest sent to the Lark webhook.
func buildLarkMessage(reports []*targetReport, qwenEnabled bool) string {
	var buf bytes.Buffer
	fmt.Fprintf(&buf, "🛰️ Sentry — Daily Ops Report\n")
	fmt.Fprintf(&buf, "Generated: %s UTC\n", time.Now().UTC().Format("2006-01-02 15:04"))
	fmt.Fprintf(&buf, "Targets: %d    Qwen: %v\n\n", len(reports), qwenEnabled)

	overallHealthy := true
	for _, rep := range reports {
		fmt.Fprintf(&buf, "═══ %s ═══\n", rep.Target.Name)
		if rep.Err != nil {
			overallHealthy = false
			fmt.Fprintf(&buf, "✗ PROBE FAILED: %v\n\n", rep.Err)
			continue
		}
		fmt.Fprintf(&buf, "• Tables: %d (%d exact, %d est)\n",
			rep.TableCount, rep.ExactCount, rep.EstCount)
		fmt.Fprintf(&buf, "• Total rows: %s\n", commaInt(rep.TotalRows))
		fmt.Fprintf(&buf, "• Snapshot: %s\n", rep.Duration.Round(time.Millisecond))

		// Top 5 tables
		top := 5
		if top > len(rep.TopTables) {
			top = len(rep.TopTables)
		}
		fmt.Fprintf(&buf, "• Top %d tables:\n", top)
		for i := 0; i < top; i++ {
			t := rep.TopTables[i]
			fmt.Fprintf(&buf, "   %d. %s — %s rows\n", i+1, t.Name, commaInt(t.RowCount))
		}

		if len(rep.Alerts) == 0 {
			fmt.Fprintf(&buf, "• Alerts: ✓ none\n")
		} else {
			overallHealthy = false
			fmt.Fprintf(&buf, "• Alerts: %d\n", len(rep.Alerts))
			for _, a := range rep.Alerts {
				icon := "🟡"
				if a.Level == "CRIT" {
					icon = "🔴"
				}
				fmt.Fprintf(&buf, "   %s %s %s entity=%s\n", icon, a.Level, a.RuleID, a.Entity)
				if text, ok := rep.LLMOutputs[a.RuleID+"|"+a.Entity]; ok && text != "" {
					// Include only the first line of the LLM output for Lark brevity
					for _, line := range splitLines(text) {
						if line != "" {
							fmt.Fprintf(&buf, "     ▸ %s\n", line)
							break
						}
					}
				}
			}
		}
		fmt.Fprintln(&buf)
	}

	if overallHealthy {
		fmt.Fprintf(&buf, "🟢 All monitored databases healthy.\n")
	} else {
		fmt.Fprintf(&buf, "🟡 Issues detected — see above for details.\n")
	}
	return buf.String()
}

// ── misc helpers ──────────────────────────────────────────────────

// nilHistory is a HistoryFinder that always returns no-data.
type nilHistory struct{}

func (nilHistory) FindClosest(_ time.Time, _ int64) (types.Snapshot, bool) {
	return types.Snapshot{}, false
}

func getenvDefault(k, d string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return d
}

func getIntDefault(k string, d int) int {
	if v := os.Getenv(k); v != "" {
		if n, err := strconv.Atoi(v); err == nil {
			return n
		}
	}
	return d
}

func getDurDefault(k string, d time.Duration) time.Duration {
	if v := os.Getenv(k); v != "" {
		if n, err := time.ParseDuration(v); err == nil {
			return n
		}
	}
	return d
}

func commaInt(n int64) string {
	s := strconv.FormatInt(n, 10)
	if len(s) <= 3 {
		return s
	}
	var out []byte
	pre := len(s) % 3
	if pre > 0 {
		out = append(out, s[:pre]...)
		if len(s) > pre {
			out = append(out, ',')
		}
	}
	for i := pre; i < len(s); i += 3 {
		out = append(out, s[i:i+3]...)
		if i+3 < len(s) {
			out = append(out, ',')
		}
	}
	return string(out)
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n-1] + "…"
}

func splitLines(s string) []string {
	var out []string
	start := 0
	for i := 0; i < len(s); i++ {
		if s[i] == '\n' {
			out = append(out, s[start:i])
			start = i + 1
		}
	}
	if start < len(s) {
		out = append(out, s[start:])
	}
	if len(out) == 0 {
		out = []string{""}
	}
	return out
}
