package main

import (
	"context"
	"database/sql"
	"flag"
	"fmt"
	"os"
	"os/signal"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"syscall"
	"time"
	"unicode/utf8"

	"github.com/gdamore/tcell/v2"
	"golang.org/x/term"
	_ "modernc.org/sqlite"

	"huntr/internal/buffer"
	"huntr/internal/daemon"
	"huntr/internal/database"
	"huntr/internal/engine"
	"huntr/internal/loader"
	"huntr/internal/pidfile"
	"huntr/internal/stats"
)

const Version = "1.0.0"

// getAppRoot returns the project root directory where huntr.db, logs/, and targets/ live.
// 3-step resolution handles all launch contexts:
//   1. Linux/Windows subfolder: exe in Huntr/Linux/ or Huntr/Windows/ → go up one level to Huntr/
//   2. Recon-suite fallback: exe in /usr/local/bin/ or other system path → use ~/Documents/recon-suite/Huntr/
//   3. CWD fallback: use current working directory
func getAppRoot() string {
	exe, err := os.Executable()
	if err == nil {
		exe, err = filepath.EvalSymlinks(exe)
		if err == nil {
			exeDir := filepath.Dir(exe)
			dirName := strings.ToUpper(filepath.Base(exeDir))
			// Step 1: Binary inside Linux/ or Windows/ subfolder → project root is one level up
			if dirName == "LINUX" || dirName == "WINDOWS" {
				return filepath.Dir(exeDir)
			}
		}
	}

	// Step 2: Recon-suite fallback (Kali system install at /usr/local/bin/)
	if home, err := os.UserHomeDir(); err == nil {
		reconDir := filepath.Join(home, "Documents", "recon-suite", "Huntr")
		if info, err := os.Stat(reconDir); err == nil && info.IsDir() {
			return reconDir
		}
	}

	// Step 3: Fallback to working directory
	cwd, err := os.Getwd()
	if err != nil {
		return "."
	}
	return cwd
}

// ============================================================================
// COLOR SCHEME - Crimson/Red theme
// ============================================================================

var (
	ColorBackground = tcell.ColorBlack
	ColorText       = tcell.ColorWhite
	ColorPrimary    = tcell.NewRGBColor(255, 50, 50)   // Bright red
	ColorSuccess    = tcell.NewRGBColor(50, 205, 50)    // Green (found creds)
	ColorWarning    = tcell.NewRGBColor(255, 200, 50)   // Gold
	ColorDanger     = tcell.NewRGBColor(255, 0, 0)      // Pure red
	ColorInfo       = tcell.NewRGBColor(255, 100, 100)  // Light red
	ColorBorder     = tcell.NewRGBColor(200, 30, 30)    // Dark red border
	ColorLogo       = tcell.NewRGBColor(255, 30, 30)    // Logo red
	ColorDim        = tcell.NewRGBColor(150, 60, 60)    // Dim red
	ColorCred       = tcell.NewRGBColor(255, 255, 0)    // Yellow for credentials
)

// ============================================================================
// SPLASH SCREEN ASCII ART
// ============================================================================

var huntrLogo = []string{
	`__/\\\________/\\\__/\\\________/\\\__/\\\\\_____/\\\__/\\\\\\\\\\\\\\\_______________`,
	` _\/\\\_______\/\\\_\/\\\_______\/\\\_\/\\\\\\___\/\\\_\///////\\\/////________________`,
	`  _\/\\\_______\/\\\_\/\\\_______\/\\\_\/\\\/\\\__\/\\\_______\/\\\_____________________`,
	`   _\/\\\\\\\\\\\\\\\_\/\\\_______\/\\\_\/\\\//\\\_\/\\\_______\/\\\________/\\/\\\\\\\__`,
	`    _\/\\\/////////\\\_\/\\\_______\/\\\_\/\\\\//\\\\/\\\_______\/\\\_______\/\\\/////\\\_`,
	`     _\/\\\_______\/\\\_\/\\\_______\/\\\_\/\\\_\//\\\/\\\_______\/\\\_______\/\\\___\///__`,
	`      _\/\\\_______\/\\\_\//\\\______/\\\__\/\\\__\//\\\\\\_______\/\\\_______\/\\\_________`,
	`       _\/\\\_______\/\\\__\///\\\\\\\\\/___\/\\\___\//\\\\\_______\/\\\_______\/\\\_________`,
	`        _\///________\///_____\/////////_____\///_____\/////________\///________\///__________`,
}

// ============================================================================
// DATABASE LAYER
// ============================================================================

type FindingRecord struct {
	ID          int64
	Domain      string
	Path        string
	StatusCode  int
	ContentType string
	BodySnippet string
	Patterns    string // comma-separated matched patterns
	FoundAt     string
}

type DB struct {
	conn *sql.DB
}

func NewDB(dbPath string) (*DB, error) {
	conn, err := sql.Open("sqlite", dbPath)
	if err != nil {
		return nil, fmt.Errorf("open db: %w", err)
	}

	// Create tables
	schema := `
	CREATE TABLE IF NOT EXISTS findings (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		domain TEXT NOT NULL,
		path TEXT NOT NULL,
		status_code INTEGER,
		content_type TEXT,
		body_snippet TEXT,
		patterns TEXT,
		found_at DATETIME DEFAULT CURRENT_TIMESTAMP,
		UNIQUE(domain, path)
	);
	CREATE INDEX IF NOT EXISTS idx_findings_domain ON findings(domain);
	CREATE INDEX IF NOT EXISTS idx_findings_path ON findings(path);

	CREATE TABLE IF NOT EXISTS scans (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		domain TEXT NOT NULL,
		paths_checked INTEGER DEFAULT 0,
		findings_count INTEGER DEFAULT 0,
		started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
		completed_at DATETIME
	);
	CREATE INDEX IF NOT EXISTS idx_scans_domain ON scans(domain);
	`
	_, err = conn.Exec(schema)
	if err != nil {
		conn.Close()
		return nil, fmt.Errorf("create schema: %w", err)
	}

	return &DB{conn: conn}, nil
}

func (db *DB) InsertFinding(domain, path string, statusCode int, contentType, bodySnippet, patterns string) error {
	_, err := db.conn.Exec(
		`INSERT OR REPLACE INTO findings (domain, path, status_code, content_type, body_snippet, patterns, found_at)
		 VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
		domain, path, statusCode, contentType, bodySnippet, patterns,
	)
	return err
}

func (db *DB) InsertScan(domain string, pathsChecked, findingsCount int) error {
	_, err := db.conn.Exec(
		`INSERT INTO scans (domain, paths_checked, findings_count, completed_at)
		 VALUES (?, ?, ?, datetime('now'))`,
		domain, pathsChecked, findingsCount,
	)
	return err
}

func (db *DB) GetStats() (totalFindings, totalDomains, totalScans int64, err error) {
	err = db.conn.QueryRow(`SELECT COUNT(*) FROM findings`).Scan(&totalFindings)
	if err != nil {
		return
	}
	err = db.conn.QueryRow(`SELECT COUNT(DISTINCT domain) FROM findings`).Scan(&totalDomains)
	if err != nil {
		return
	}
	err = db.conn.QueryRow(`SELECT COUNT(*) FROM scans`).Scan(&totalScans)
	return
}

func (db *DB) GetFindings(limit int) ([]FindingRecord, error) {
	rows, err := db.conn.Query(
		`SELECT id, domain, path, status_code, content_type, body_snippet, patterns, found_at
		 FROM findings ORDER BY found_at DESC LIMIT ?`, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var findings []FindingRecord
	for rows.Next() {
		var f FindingRecord
		if err := rows.Scan(&f.ID, &f.Domain, &f.Path, &f.StatusCode, &f.ContentType, &f.BodySnippet, &f.Patterns, &f.FoundAt); err != nil {
			continue
		}
		findings = append(findings, f)
	}
	return findings, nil
}

func (db *DB) ExportCSV(path string) error {
	findings, err := db.GetFindings(100000)
	if err != nil {
		return err
	}
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()

	fmt.Fprintln(f, "domain,path,status_code,content_type,patterns,found_at")
	for _, rec := range findings {
		fmt.Fprintf(f, "%q,%q,%d,%q,%q,%q\n",
			rec.Domain, rec.Path, rec.StatusCode, rec.ContentType, rec.Patterns, rec.FoundAt)
	}
	return nil
}

func (db *DB) Close() {
	if db.conn != nil {
		db.conn.Close()
	}
}

// ============================================================================
// TUI - COMMAND STATES
// ============================================================================

type CommandState int

const (
	StateMenu     CommandState = iota
	StateInput                         // waiting for user text input
	StateRunning                       // scan in progress
	StateComplete                      // scan finished
	StateSettings                      // settings menu
	StateSelect                        // arrow-key selection list
)

// ============================================================================
// TUI STRUCT
// ============================================================================

type TUI struct {
	screen      tcell.Screen
	width       int
	height      int
	running     bool
	showSplash   bool
	splashFrame  float64
	selectedItem int // highlighted menu item index

	// Input
	inputBuffer  string
	inputPrompt  string
	commandState CommandState
	pendingCmd   int // which command triggered input mode

	// Selection list (arrow-key navigation)
	selectItems  []string // display labels
	selectPaths  []string // resolved file paths (empty string = custom path option)
	selectIndex  int      // current highlighted index

	// Output panel - uses ring buffer for bounded memory
	outputBuffer *buffer.RingBuffer[string]
	outputScroll int
	outputMutex  sync.Mutex
	renderMutex  sync.Mutex

	// Scan control
	cancelFunc context.CancelFunc

	// Stats
	scanned    int64
	found      int64
	errors     int64
	totalPaths int64

	// Session management
	currentSession  *database.Session          // Active session (nil if none)
	checkpoint      *database.CheckpointManager // Checkpoint manager for active session
	sessionStats    *stats.SessionStats         // Aggregate stats with rate/ETA
	typeStats       *stats.TypeStats            // Per-vulnerability-type counters
	checkpointFlash time.Time                   // When to stop showing checkpoint indicator

	// Graceful shutdown
	shutdownOnce sync.Once // Prevents multiple shutdown attempts

	// Configuration
	workers     int  // Concurrent workers count (displayed in header)
	forceRescan bool // Skip deduplication if true

	// Components
	db           *DB                     // Legacy DB struct for reads (GetStats, GetFindings, ExportCSV, InsertScan)
	dbConn       *sql.DB                 // Database connection for queries
	writer       *database.Writer        // Async writer for findings
	writerCancel context.CancelFunc      // Writer shutdown control
	eng          *engine.Engine
	logger       *Logger
	appDir       string
	logsDir      string
}

// ============================================================================
// TUI CONSTRUCTOR
// ============================================================================

func NewTUI(workers int, timeout time.Duration, forceRescan bool) (*TUI, error) {
	screen, err := tcell.NewScreen()
	if err != nil {
		return nil, fmt.Errorf("create screen: %w", err)
	}
	if err := screen.Init(); err != nil {
		return nil, fmt.Errorf("init screen: %w", err)
	}

	screen.SetStyle(tcell.StyleDefault.
		Background(ColorBackground).
		Foreground(ColorText))
	screen.Clear()

	w, h := screen.Size()

	// App directory setup — uses project root (parent of exe folder)
	appDir := getAppRoot()
	logsDir := filepath.Join(appDir, "logs")
	os.MkdirAll(logsDir, 0755)

	// Logger
	logger := NewLogger(logsDir)
	logger.Info("Huntr v%s starting up", Version)
	logger.Info("Working directory: %s", appDir)

	// Database - using new database package with WAL mode
	dbPath := filepath.Join(appDir, "huntr.db")
	dbConn, err := database.Open(database.Config{Path: dbPath})
	if err != nil {
		screen.Fini()
		logger.Error("Failed to open database: %v", err)
		return nil, fmt.Errorf("open database: %w", err)
	}
	if err := database.EnsureSchema(dbConn); err != nil {
		dbConn.Close()
		screen.Fini()
		logger.Error("Failed to initialize schema: %v", err)
		return nil, fmt.Errorf("init schema: %w", err)
	}
	logger.Info("Database initialized with WAL mode: %s", dbPath)

	// Create Writer for async finding inserts
	writerCtx, writerCancel := context.WithCancel(context.Background())
	writer := database.NewWriter(writerCtx, dbConn, database.WriterConfig{
		BatchSize:   250,
		ChannelSize: 1000,
		MaxRetries:  3,
	})

	// Start writer in background goroutine
	go writer.Start()
	logger.Info("Database writer started (batch size: 250, channel: 1000)")

	// Legacy DB struct for read operations
	db := &DB{conn: dbConn}
	logger.Info("Screen initialized: %dx%d", w, h)

	// Initialize session stats (will be reset when scan starts)
	typeStats := stats.NewTypeStats()
	sessionStats := stats.NewSessionStats(0)

	tui := &TUI{
		screen:       screen,
		width:        w,
		height:       h,
		running:      true,
		showSplash:   true,
		splashFrame:  0.0,
		commandState: StateMenu,
		outputBuffer: buffer.NewRingBuffer[string](1000),
		typeStats:    typeStats,
		sessionStats: sessionStats,
		workers:      workers,
		forceRescan:  forceRescan,
		db:           db,
		dbConn:       dbConn,
		writer:       writer,
		writerCancel: writerCancel,
		logger:       logger,
		appDir:       appDir,
		logsDir:      logsDir,
	}

	// Create engine with TUI as ProgressReporter
	cfg := engine.DefaultConfig()
	cfg.Workers = workers
	cfg.Timeout = timeout
	eng, err := engine.New(cfg, tui)
	if err != nil {
		screen.Fini()
		db.Close()
		logger.Error("Failed to initialize engine: %v", err)
		return nil, fmt.Errorf("init engine: %w", err)
	}
	tui.eng = eng
	logger.Info("Engine initialized: %d workers, %v timeout", workers, timeout)

	return tui, nil
}

// ============================================================================
// DRAWING HELPERS
// ============================================================================

func (tui *TUI) drawText(x, y int, style tcell.Style, text string) {
	for _, r := range text {
		if x >= tui.width {
			break
		}
		tui.screen.SetContent(x, y, r, nil, style)
		x++
	}
}

func (tui *TUI) drawTextClip(x, y, maxWidth int, style tcell.Style, text string) {
	col := 0
	for _, r := range text {
		if col >= maxWidth || x+col >= tui.width {
			break
		}
		tui.screen.SetContent(x+col, y, r, nil, style)
		col++
	}
}

func (tui *TUI) drawBox(x1, y1, x2, y2 int, style tcell.Style) {
	// Corners
	tui.screen.SetContent(x1, y1, '┌', nil, style)
	tui.screen.SetContent(x2, y1, '┐', nil, style)
	tui.screen.SetContent(x1, y2, '└', nil, style)
	tui.screen.SetContent(x2, y2, '┘', nil, style)

	// Horizontal
	for x := x1 + 1; x < x2; x++ {
		tui.screen.SetContent(x, y1, '─', nil, style)
		tui.screen.SetContent(x, y2, '─', nil, style)
	}

	// Vertical
	for y := y1 + 1; y < y2; y++ {
		tui.screen.SetContent(x1, y, '│', nil, style)
		tui.screen.SetContent(x2, y, '│', nil, style)
	}
}

func (tui *TUI) fillRect(x1, y1, x2, y2 int, style tcell.Style) {
	for y := y1; y <= y2; y++ {
		for x := x1; x <= x2; x++ {
			tui.screen.SetContent(x, y, ' ', nil, style)
		}
	}
}

// ============================================================================
// SPLASH SCREEN
// ============================================================================

func (tui *TUI) renderSplashScreen() {
	bgStyle := tcell.StyleDefault.Background(ColorBackground)
	tui.fillRect(0, 0, tui.width-1, tui.height-1, bgStyle)

	logoStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorLogo).Bold(true)
	infoStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorPrimary)
	dimStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorDim)

	// Calculate total characters in logo
	totalChars := 0
	for _, line := range huntrLogo {
		totalChars += utf8.RuneCountInString(line)
	}

	// Phase 1: reveal logo character by character (frame 0.0 to 1.0)
	visibleChars := int(tui.splashFrame * float64(totalChars))

	startY := (tui.height / 2) - (len(huntrLogo) / 2) - 3

	charCount := 0
	for i, line := range huntrLogo {
		startX := (tui.width - utf8.RuneCountInString(line)) / 2
		x := startX
		for _, r := range line {
			charCount++
			if charCount <= visibleChars {
				tui.screen.SetContent(x, startY+i, r, nil, logoStyle)
			}
			x++
		}
	}

	// Phase 2: info text fade in (frame 1.0 to 2.0)
	if tui.splashFrame > 1.0 {
		infoY := startY + len(huntrLogo) + 1

		tagline := "Exposed Credential & Misconfiguration Scanner"
		tui.drawText((tui.width-len(tagline))/2, infoY, infoStyle, tagline)

		author := "by Ringmast4r & Tikket1"
		tui.drawText((tui.width-len(author))/2, infoY+2, dimStyle, author)

		ver := fmt.Sprintf("v%s", Version)
		tui.drawText((tui.width-len(ver))/2, infoY+3, dimStyle, ver)

		hint := "Press any key to continue..."
		tui.drawText((tui.width-len(hint))/2, infoY+5, dimStyle, hint)
	}

	// Advance animation
	if tui.splashFrame < 2.0 {
		tui.splashFrame += 0.02
	}
}

// ============================================================================
// MAIN RENDER
// ============================================================================

func (tui *TUI) Render() {
	tui.renderMutex.Lock()
	defer tui.renderMutex.Unlock()

	tui.screen.Clear()

	if tui.showSplash {
		tui.renderSplashScreen()
		tui.screen.Show()
		return
	}

	tui.renderHeader()
	tui.renderMenu()
	tui.renderOutput()
	tui.renderInput()
	tui.renderStatusBar()

	tui.screen.Show()
}

// ============================================================================
// HEADER BAR
// ============================================================================

func (tui *TUI) renderHeader() {
	headerStyle := tcell.StyleDefault.Background(ColorBorder).Foreground(ColorText)
	highlightStyle := tcell.StyleDefault.Background(ColorBorder).Foreground(ColorPrimary)

	// Fill header row
	for x := 0; x < tui.width; x++ {
		tui.screen.SetContent(x, 0, ' ', nil, headerStyle)
	}

	// Title with optional session name
	var title string
	if tui.currentSession != nil {
		title = fmt.Sprintf(" HUNTR v%s | Session: %s ", Version, tui.currentSession.Name)
	} else {
		title = fmt.Sprintf(" HUNTR v%s ", Version)
	}
	tui.drawText(0, 0, highlightStyle, title)

	// Stats on the right side - include writer stats and worker count
	totalFindings, totalDomains, _, _ := tui.db.GetStats()
	writerStats := tui.writer.Stats()
	// Format: "W: 150/5q" (150 written, 5 queued) or just "W: 150" if queue empty
	var writerInfo string
	if writerStats.QueueDepth > 0 {
		writerInfo = fmt.Sprintf("W: %d/%dq", writerStats.TotalWrites, writerStats.QueueDepth)
	} else {
		writerInfo = fmt.Sprintf("W: %d", writerStats.TotalWrites)
	}

	// Checkpoint indicator (brief flash when checkpoint saved)
	checkpointIndicator := ""
	if time.Now().Before(tui.checkpointFlash) {
		checkpointIndicator = " [CP]"
	}

	statsText := fmt.Sprintf("Workers: %d | DB: %d findings, %d domains | %s%s ",
		tui.workers, totalFindings, totalDomains, writerInfo, checkpointIndicator)
	tui.drawText(tui.width-len(statsText), 0, headerStyle, statsText)

	// Second header row for per-type breakdown during scan
	if tui.currentSession != nil && tui.commandState == StateRunning {
		for x := 0; x < tui.width; x++ {
			tui.screen.SetContent(x, 1, ' ', nil, headerStyle)
		}

		// Per-type breakdown
		typeSnapshot := tui.typeStats.Snapshot()
		if len(typeSnapshot) > 0 {
			var typeParts []string
			for vulnType, count := range typeSnapshot {
				typeParts = append(typeParts, fmt.Sprintf("%s:%d", vulnType, count))
			}
			// Join them
			typeStr := " Types: " + strings.Join(typeParts, " | ")
			if len(typeStr) > tui.width-2 {
				typeStr = typeStr[:tui.width-2]
			}
			tui.drawText(0, 1, headerStyle, typeStr)
		}
	}
}

// ============================================================================
// LEFT MENU PANEL
// ============================================================================

func (tui *TUI) renderMenu() {
	menuWidth := 40
	if tui.width < 80 {
		menuWidth = tui.width / 3
	}

	borderStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorBorder)
	titleStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorPrimary).Bold(true)
	itemStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorText)
	dimStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorDim)

	// Menu box
	tui.drawBox(0, 1, menuWidth, tui.height-4, borderStyle)

	// Title
	tui.drawText(2, 1, titleStyle, "─ COMMANDS ")

	// Menu items
	y := 3
	menuItems := []struct {
		key  string
		desc string
	}{
		{"1", "Scan single target"},
		{"2", "Bulk scan from file"},
		{"3", "View findings"},
		{"4", "Export results (CSV)"},
		{"5", "Database stats"},
		{"6", "Resume session"},
		{"7", "Clear output"},
		{"Q", "Quit"},
	}

	for i, item := range menuItems {
		if i == tui.selectedItem {
			// Highlighted row: fill background, then draw text
			selBg := tcell.StyleDefault.Background(ColorBorder).Foreground(ColorText).Bold(true)
			selKey := tcell.StyleDefault.Background(ColorBorder).Foreground(ColorSuccess).Bold(true)
			tui.fillRect(1, y, menuWidth-1, y, selBg)
			tui.drawText(3, y, selKey, fmt.Sprintf("[%s]", item.key))
			tui.drawTextClip(7, y, menuWidth-9, selBg, item.desc)
		} else {
			keyStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorPrimary).Bold(true)
			tui.drawText(3, y, keyStyle, fmt.Sprintf("[%s]", item.key))
			tui.drawTextClip(7, y, menuWidth-9, itemStyle, item.desc)
		}
		y++
	}

	// Separator
	y += 1
	tui.drawText(3, y, dimStyle, "─── Scan Checks ───")
	y++
	checks := []string{
		"• .env / config exposure",
		"• .git repository leak",
		"• Database dumps",
		"• Debug/info pages",
		"• API config endpoints",
		"• Cloud credentials",
		"• Backup files",
		"• Admin panels",
	}
	for _, check := range checks {
		if y >= tui.height-5 {
			break
		}
		tui.drawTextClip(3, y, menuWidth-5, dimStyle, check)
		y++
	}

	// Path count
	y = tui.height - 5
	pathInfo := fmt.Sprintf("%d paths loaded", len(engine.CredentialPaths))
	tui.drawTextClip(3, y, menuWidth-5, dimStyle, pathInfo)
}

// ============================================================================
// RIGHT OUTPUT PANEL
// ============================================================================

func (tui *TUI) renderOutput() {
	tui.outputMutex.Lock()
	defer tui.outputMutex.Unlock()

	menuWidth := 40
	if tui.width < 80 {
		menuWidth = tui.width / 3
	}

	outputX := menuWidth + 1
	outputWidth := tui.width - menuWidth - 2

	borderStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorBorder)
	titleStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorPrimary).Bold(true)

	// Output box
	tui.drawBox(menuWidth, 1, tui.width-1, tui.height-4, borderStyle)

	// Title with dropped count indicator
	droppedCount := tui.outputBuffer.DroppedCount()
	if droppedCount > 0 {
		titleText := fmt.Sprintf("─ OUTPUT (%d lines dropped) ", droppedCount)
		tui.drawText(menuWidth+2, 1, titleStyle, titleText)
	} else {
		tui.drawText(menuWidth+2, 1, titleStyle, "─ OUTPUT ")
	}

	// Get snapshot of current buffer lines
	outputLines := tui.outputBuffer.Snapshot()

	// Scrollbar info
	if len(outputLines) > 0 {
		scrollInfo := fmt.Sprintf(" %d lines ", len(outputLines))
		tui.drawText(tui.width-len(scrollInfo)-2, 1, tcell.StyleDefault.Background(ColorBackground).Foreground(ColorDim), scrollInfo)
	}

	// Visible area
	visibleHeight := tui.height - 7
	if visibleHeight < 1 {
		visibleHeight = 1
	}

	startLine := tui.outputScroll
	if startLine < 0 {
		startLine = 0
	}
	if startLine > len(outputLines)-visibleHeight {
		startLine = len(outputLines) - visibleHeight
	}
	if startLine < 0 {
		startLine = 0
	}

	// If in selection mode, render the selection list instead of output
	if tui.commandState == StateSelect && len(tui.selectItems) > 0 {
		selectedBg := tcell.StyleDefault.Background(ColorBorder).Foreground(ColorText).Bold(true)
		selectedArrow := tcell.StyleDefault.Background(ColorBorder).Foreground(ColorSuccess).Bold(true)
		normalStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorDim)
		headerStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorPrimary).Bold(true)

		// Override title
		tui.drawText(menuWidth+2, 1, titleStyle, "─ SELECT TARGET FILE ")

		y := 3
		tui.drawTextClip(outputX+2, y, outputWidth-2, headerStyle, "Select a target file:")
		y += 2

		for i, item := range tui.selectItems {
			if y >= tui.height-5 {
				break
			}
			rowX := outputX + 1
			rowWidth := outputWidth - 2
			if i == tui.selectIndex {
				// Fill entire row with highlight background
				tui.fillRect(rowX, y, rowX+rowWidth, y, selectedBg)
				tui.drawTextClip(rowX+1, y, 3, selectedArrow, " ► ")
				tui.drawTextClip(rowX+4, y, rowWidth-5, selectedBg, item)
			} else {
				tui.drawTextClip(rowX+1, y, rowWidth-2, normalStyle, "   "+item)
			}
			y++
		}
		return
	}

	for i := 0; i < visibleHeight; i++ {
		lineIdx := startLine + i
		if lineIdx >= len(outputLines) {
			break
		}
		line := outputLines[lineIdx]
		style := tui.getLineStyle(line)
		tui.drawTextClip(outputX+1, 2+i, outputWidth-1, style, line)
	}
}

func (tui *TUI) getLineStyle(line string) tcell.Style {
	base := tcell.StyleDefault.Background(ColorBackground)

	switch {
	case strings.HasPrefix(line, "[+]") || strings.Contains(line, "[FOUND]") || strings.Contains(line, "[EXPOSED]"):
		return base.Foreground(ColorSuccess).Bold(true)
	case strings.HasPrefix(line, "[-]") || strings.Contains(line, "error") || strings.Contains(line, "Error"):
		return base.Foreground(ColorDanger)
	case strings.HasPrefix(line, "[*]") || strings.HasPrefix(line, "[INFO]"):
		return base.Foreground(ColorInfo)
	case strings.HasPrefix(line, "[!]") || strings.Contains(line, "[WARN]"):
		return base.Foreground(ColorWarning)
	case strings.HasPrefix(line, "    "):
		return base.Foreground(ColorDim)
	case strings.Contains(line, "CRED:") || strings.Contains(line, "KEY:"):
		return base.Foreground(ColorCred).Bold(true)
	default:
		return base.Foreground(ColorText)
	}
}

// ============================================================================
// INPUT BAR
// ============================================================================

func (tui *TUI) renderInput() {
	inputY := tui.height - 3
	borderStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorBorder)
	promptStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorPrimary)
	textStyle := tcell.StyleDefault.Background(ColorBackground).Foreground(ColorText)

	// Input box
	for x := 0; x < tui.width; x++ {
		tui.screen.SetContent(x, inputY, '─', nil, borderStyle)
	}

	if tui.commandState == StateInput {
		tui.drawText(1, inputY+1, promptStyle, tui.inputPrompt)
		tui.drawText(1+len(tui.inputPrompt), inputY+1, textStyle, tui.inputBuffer+"_")
	} else if tui.commandState == StateSelect {
		hint := "↑↓: Navigate | Enter: Select | F5: Refresh | ESC: Cancel"
		tui.drawText(1, inputY+1, promptStyle, hint)
	} else if tui.commandState == StateRunning {
		tui.drawText(1, inputY+1, promptStyle, "Scanning... Press ESC to cancel | Ctrl+C to save & exit")
	} else {
		hint := "↑↓: Navigate | Enter: Select | F5: Refresh | Q: Quit | Ctrl+C: Exit"
		tui.drawText(1, inputY+1, tcell.StyleDefault.Background(ColorBackground).Foreground(ColorDim), hint)
	}
}

// ============================================================================
// STATUS BAR
// ============================================================================

func (tui *TUI) renderStatusBar() {
	statusY := tui.height - 1
	statusStyle := tcell.StyleDefault.Background(ColorBorder).Foreground(ColorText)

	// Fill status bar
	for x := 0; x < tui.width; x++ {
		tui.screen.SetContent(x, statusY, ' ', nil, statusStyle)
	}

	// Use session stats if available and scanning, otherwise legacy counters
	var status string
	if tui.currentSession != nil && tui.commandState == StateRunning {
		snap := tui.sessionStats.Snapshot()

		// Format rate
		rateStr := fmt.Sprintf("%.1f/s", snap.Rate)

		// Format ETA
		etaStr := "--:--"
		if snap.ETA >= 0 {
			if snap.ETA < time.Hour {
				etaStr = fmt.Sprintf("%dm%ds", int(snap.ETA.Minutes()), int(snap.ETA.Seconds())%60)
			} else {
				etaStr = fmt.Sprintf("%dh%dm", int(snap.ETA.Hours()), int(snap.ETA.Minutes())%60)
			}
		}

		// Format progress
		progressStr := ""
		if snap.Progress >= 0 {
			progressStr = fmt.Sprintf(" %.1f%%", snap.Progress)
		}

		// Format elapsed
		elapsedStr := fmt.Sprintf("%dm%ds", int(snap.Elapsed.Minutes()), int(snap.Elapsed.Seconds())%60)

		status = fmt.Sprintf(" Scanned: %d/%d%s | Found: %d | Errors: %d | Rate: %s | ETA: %s | Elapsed: %s",
			snap.Scanned, snap.TotalTargets, progressStr, snap.Found, snap.Errors, rateStr, etaStr, elapsedStr)
	} else {
		// Legacy display (non-session scans or idle)
		scanned := atomic.LoadInt64(&tui.scanned)
		found := atomic.LoadInt64(&tui.found)
		errors := atomic.LoadInt64(&tui.errors)
		total := atomic.LoadInt64(&tui.totalPaths)

		status = fmt.Sprintf(" Checked: %d/%d | Found: %d | Errors: %d | Paths DB: %d",
			scanned, total, found, errors, len(engine.CredentialPaths))
	}

	// Add rate limit indicator if applicable
	if tui.eng != nil && tui.eng.IsRateLimited() {
		status = status + " [RATE LIMITED]"
	}

	tui.drawText(0, statusY, statusStyle, status)
}

// ============================================================================
// OUTPUT HELPERS
// ============================================================================

func (tui *TUI) addOutput(line string) {
	tui.outputMutex.Lock()
	defer tui.outputMutex.Unlock()

	// Word wrap
	menuWidth := 40
	if tui.width < 80 {
		menuWidth = tui.width / 3
	}
	maxWidth := tui.width - menuWidth - 4
	if maxWidth < 20 {
		maxWidth = 20
	}

	wrapped := tui.wrapText(line, maxWidth)
	for _, w := range wrapped {
		tui.outputBuffer.Push(w)
	}

	// Auto scroll to bottom
	visibleHeight := tui.height - 7
	bufLen := tui.outputBuffer.Len()
	if bufLen > visibleHeight {
		tui.outputScroll = bufLen - visibleHeight
	}
}

func (tui *TUI) wrapText(text string, maxWidth int) []string {
	if len(text) <= maxWidth {
		return []string{text}
	}

	var lines []string
	for len(text) > maxWidth {
		// Try to break at a space
		breakIdx := maxWidth
		for i := maxWidth; i > maxWidth/2; i-- {
			if text[i] == ' ' {
				breakIdx = i
				break
			}
		}
		lines = append(lines, text[:breakIdx])
		text = "    " + strings.TrimLeft(text[breakIdx:], " ")
	}
	if len(text) > 0 {
		lines = append(lines, text)
	}
	return lines
}

// getLoader returns the appropriate Loader based on file extension.
// CSV files (.csv) use CSVLoader with Product Hunt dataset defaults.
// All other files use PlainTextLoader (one target per line).
func getLoader(filePath string) loader.Loader {
	ext := strings.ToLower(filepath.Ext(filePath))
	if ext == ".csv" {
		return loader.NewCSVLoader(loader.DefaultCSVConfig())
	}
	return loader.NewPlainTextLoader()
}

// ============================================================================
// COMMAND HANDLERS
// ============================================================================

func (tui *TUI) handleScanSingle() {
	tui.logger.UserAction("Menu: Single scan selected")
	tui.commandState = StateInput
	tui.inputPrompt = "Target domain: "
	tui.inputBuffer = ""
	tui.pendingCmd = 1
}

func (tui *TUI) handleBulkScan() {
	tui.logger.UserAction("Menu: Bulk scan selected")
	tui.refreshTargetFiles()
	tui.commandState = StateSelect
	tui.pendingCmd = 2
}

func (tui *TUI) handleViewFindings() {
	tui.logger.UserAction("Menu: View findings")
	tui.addOutput("")
	tui.addOutput("[*] === Recent Findings ===")

	findings, err := tui.db.GetFindings(50)
	if err != nil {
		tui.addOutput(fmt.Sprintf("[-] Error loading findings: %v", err))
		tui.logger.Error("Failed to load findings from DB: %v", err)
		return
	}

	if len(findings) == 0 {
		tui.addOutput("[*] No findings yet. Run a scan first.")
		tui.logger.Info("View findings: no findings in database")
		return
	}

	for _, f := range findings {
		tui.addOutput(fmt.Sprintf("[+] [FOUND] %s%s [%d] patterns: %s", f.Domain, f.Path, f.StatusCode, f.Patterns))
	}
	tui.addOutput(fmt.Sprintf("[*] Showing %d findings", len(findings)))
	tui.logger.Info("Displayed %d findings", len(findings))
}

func (tui *TUI) handleExport() {
	tui.logger.UserAction("Menu: Export CSV")
	exportPath := filepath.Join(tui.appDir, fmt.Sprintf("huntr_export_%s.csv", time.Now().Format("20060102_150405")))
	err := tui.db.ExportCSV(exportPath)
	if err != nil {
		tui.addOutput(fmt.Sprintf("[-] Export failed: %v", err))
		tui.logger.Error("CSV export failed: %v", err)
		return
	}
	tui.addOutput(fmt.Sprintf("[+] Exported to: %s", exportPath))
	tui.logger.Info("CSV exported to: %s", exportPath)
}

func (tui *TUI) handleStats() {
	tui.logger.UserAction("Menu: Database stats")
	totalFindings, totalDomains, totalScans, err := tui.db.GetStats()
	if err != nil {
		tui.addOutput(fmt.Sprintf("[-] Error loading stats: %v", err))
		tui.logger.Error("Failed to load DB stats: %v", err)
		return
	}

	tui.addOutput("")
	tui.addOutput("[*] === Database Stats ===")
	tui.addOutput(fmt.Sprintf("[*] Total findings:  %d", totalFindings))
	tui.addOutput(fmt.Sprintf("[*] Unique domains:  %d", totalDomains))
	tui.addOutput(fmt.Sprintf("[*] Total scans:     %d", totalScans))
	tui.addOutput(fmt.Sprintf("[*] Credential paths: %d", len(engine.CredentialPaths)))
	tui.logger.Info("Stats queried: %d findings, %d domains, %d scans", totalFindings, totalDomains, totalScans)
}

func (tui *TUI) handleClear() {
	tui.logger.UserAction("Menu: Clear output")
	tui.outputMutex.Lock()
	tui.outputBuffer.Clear()
	tui.outputScroll = 0
	tui.outputMutex.Unlock()
	tui.addOutput("[*] Output cleared.")
}

func (tui *TUI) handleResumeSession() {
	tui.logger.UserAction("Menu: Resume session selected")

	// Get incomplete sessions
	sessions, err := database.GetIncompleteSessions(tui.dbConn)
	if err != nil {
		tui.addOutput(fmt.Sprintf("[!] Failed to load sessions: %v", err))
		tui.logger.Error("Failed to load sessions: %v", err)
		return
	}

	if len(sessions) == 0 {
		tui.addOutput("[*] No incomplete sessions to resume")
		return
	}

	// Display sessions
	tui.addOutput("[*] === Incomplete Sessions ===")
	for i, s := range sessions {
		progress := ""
		if s.TotalTargets > 0 {
			pct := float64(s.CompletedTargets) / float64(s.TotalTargets) * 100
			progress = fmt.Sprintf(" (%.1f%%)", pct)
		}
		tui.addOutput(fmt.Sprintf("  %d. %s - %d/%d targets%s - %s",
			i+1, s.Name, s.CompletedTargets, s.TotalTargets, progress, s.Status))
	}
	tui.addOutput("")
	tui.addOutput("[*] Enter session number to resume (or ESC to cancel)")

	// Set up input mode for session selection
	tui.commandState = StateInput
	tui.inputPrompt = "Session number: "
	tui.inputBuffer = ""
	tui.pendingCmd = 6 // Resume session command
}

func (tui *TUI) executeResumeSession(sessionID int64) {
	cm, session, completedDomains, err := database.ResumeSession(tui.dbConn, sessionID, database.DefaultCheckpointConfig())
	if err != nil {
		tui.addOutput(fmt.Sprintf("[!] Failed to resume session: %v", err))
		tui.logger.Error("Resume session failed: %v", err)
		tui.commandState = StateMenu
		return
	}

	// Check if this was a single-domain scan (no targets file)
	if session.TargetsFile == "" {
		tui.addOutput("[!] Cannot resume: single-domain scan (no targets file)")
		tui.addOutput("[*] Single domain scans cannot be resumed. Start a new scan.")
		tui.commandState = StateMenu
		return
	}

	// Load targets file - try stored path first, then fall back to local targets/ dir
	targetsPath := session.TargetsFile
	data, err := os.ReadFile(targetsPath)
	if err != nil {
		// Fall back: look for same filename in local targets/ directory
		baseName := filepath.Base(targetsPath)
		localPath := filepath.Join(tui.appDir, "targets", baseName)
		data, err = os.ReadFile(localPath)
		if err != nil {
			tui.addOutput(fmt.Sprintf("[!] Cannot read targets file: %s", baseName))
			tui.addOutput(fmt.Sprintf("[*] Tried: %s", targetsPath))
			tui.addOutput(fmt.Sprintf("[*] Tried: %s", localPath))
			tui.addOutput("[*] Copy the targets file to your local targets/ folder to resume")
			tui.logger.Error("Resume failed - targets file missing: %s and %s", targetsPath, localPath)
			tui.commandState = StateMenu
			return
		}
		tui.addOutput(fmt.Sprintf("[*] Using local targets file: %s", localPath))
	}

	// Parse targets
	lines := strings.Split(strings.TrimSpace(string(data)), "\n")
	var allTargets []string
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		line = strings.TrimPrefix(line, "http://")
		line = strings.TrimPrefix(line, "https://")
		line = strings.TrimRight(line, "/")
		allTargets = append(allTargets, line)
	}

	// Filter out completed domains
	var remainingTargets []string
	for _, target := range allTargets {
		if !completedDomains[target] {
			remainingTargets = append(remainingTargets, target)
		}
	}

	if len(remainingTargets) == 0 {
		tui.addOutput("[*] Session already complete - no remaining targets")
		cm.Complete()
		tui.commandState = StateMenu
		return
	}

	// Set up session state
	tui.currentSession = session
	tui.checkpoint = cm
	tui.sessionStats.Reset(session.TotalTargets)
	tui.typeStats.Reset()

	// Restore progress counter
	tui.sessionStats.Scanned.Store(int64(session.CompletedTargets))

	tui.commandState = StateRunning
	tui.addOutput(fmt.Sprintf("[*] Resuming session: %s", session.Name))
	tui.addOutput(fmt.Sprintf("[*] Progress: %d/%d completed, %d remaining",
		session.CompletedTargets, session.TotalTargets, len(remainingTargets)))
	tui.addOutput(fmt.Sprintf("[*] Targets file: %s", session.TargetsFile))
	tui.addOutput("")

	// Reset legacy counters for display
	atomic.StoreInt64(&tui.scanned, int64(session.CompletedTargets))
	atomic.StoreInt64(&tui.found, 0)
	atomic.StoreInt64(&tui.errors, 0)
	atomic.StoreInt64(&tui.totalPaths, int64(len(allTargets)*len(engine.CredentialPaths)))

	tui.logger.Section(fmt.Sprintf("RESUME: %s", session.Name))
	tui.logger.Info("Session ID: %d", session.ID)
	tui.logger.Info("Targets file: %s", session.TargetsFile)
	tui.logger.Info("Total targets: %d, Completed: %d, Remaining: %d",
		session.TotalTargets, session.CompletedTargets, len(remainingTargets))

	ctx, cancel := context.WithCancel(context.Background())
	tui.cancelFunc = cancel
	resumeStart := time.Now()

	go func() {
		defer func() {
			tui.commandState = StateMenu
			cancel()
		}()

		// Scan only remaining targets
		err := tui.eng.Scan(ctx, remainingTargets)

		duration := time.Since(resumeStart)
		foundCount := atomic.LoadInt64(&tui.found)

		tui.addOutput("")
		if err == context.Canceled {
			tui.addOutput("[!] Scan cancelled (session paused for resume)")
			tui.logger.Cancelled("Resume scan cancelled by user")
		} else if err != nil {
			tui.addOutput(fmt.Sprintf("[-] Scan error: %v", err))
			tui.logger.Error("Resume scan error: %v", err)
		} else {
			tui.addOutput(fmt.Sprintf("[*] Session complete: %s", session.Name))
			tui.addOutput(fmt.Sprintf("[*] Total targets: %d | New findings: %d", session.TotalTargets, foundCount))
		}

		// Show database summary
		tui.showDatabaseSummary()

		// Mark session complete or pause (depending on how it ended)
		if tui.checkpoint != nil {
			if err == context.Canceled {
				// ESC pressed - pause for resume
				// (If Ctrl+C, already marked paused by gracefulShutdown)
				tui.checkpoint.Pause()
			} else if err == nil {
				// Normal completion
				tui.checkpoint.Complete()
				tui.addOutput(fmt.Sprintf("[*] Session %s completed", session.Name))
			}
			// If other error, leave session as-is (paused)
		}
		tui.currentSession = nil
		tui.checkpoint = nil

		tui.logger.Info("Resume scan finished in %v", duration)
	}()
}

// ============================================================================
// SCAN EXECUTION
// ============================================================================

func (tui *TUI) executeScan(target string) {
	tui.commandState = StateRunning

	target = strings.TrimSpace(target)
	target = strings.TrimPrefix(target, "http://")
	target = strings.TrimPrefix(target, "https://")
	target = strings.TrimRight(target, "/")

	if target == "" {
		tui.addOutput("[-] Empty target. Returning to menu.")
		tui.logger.Warn("Empty target submitted, returning to menu")
		tui.commandState = StateMenu
		return
	}

	tui.logger.UserAction(fmt.Sprintf("Single scan requested: %s", target))

	tui.addOutput("")
	tui.addOutput(fmt.Sprintf("[*] Starting scan: %s", target))
	tui.addOutput(fmt.Sprintf("[*] Checking %d credential paths...", len(engine.CredentialPaths)))
	tui.addOutput("")

	atomic.StoreInt64(&tui.scanned, 0)
	atomic.StoreInt64(&tui.found, 0)
	atomic.StoreInt64(&tui.errors, 0)
	atomic.StoreInt64(&tui.totalPaths, int64(len(engine.CredentialPaths)))

	// Create session with domain name (empty targets file for single domain)
	sessionName := target // Single domain uses domain as name
	cm, session, err := database.StartSession(tui.dbConn, sessionName, 1, "", database.DefaultCheckpointConfig())
	if err != nil {
		tui.addOutput(fmt.Sprintf("[!] Failed to create session: %v", err))
		tui.logger.Error("Session creation failed: %v", err)
		// Continue without session (legacy behavior)
	} else {
		tui.currentSession = session
		tui.checkpoint = cm
		tui.addOutput(fmt.Sprintf("[*] Session: %s (ID: %d)", session.Name, session.ID))
		tui.logger.Info("Created session: %s (ID: %d)", session.Name, session.ID)
	}

	// Reset session stats
	tui.sessionStats.Reset(1)
	tui.typeStats.Reset()

	tui.logger.ScanStart(target, len(engine.CredentialPaths))

	ctx, cancel := context.WithCancel(context.Background())
	tui.cancelFunc = cancel
	scanStart := time.Now()

	go func() {
		defer func() {
			tui.commandState = StateComplete
			cancel()
		}()

		// Use engine.Scan() - progress/findings/errors via ProgressReporter callbacks
		err := tui.eng.Scan(ctx, []string{target})

		duration := time.Since(scanStart)
		scanned := atomic.LoadInt64(&tui.scanned)
		foundCount := atomic.LoadInt64(&tui.found)
		errCount := atomic.LoadInt64(&tui.errors)

		// Save scan record
		dbErr := tui.db.InsertScan(target, int(scanned), int(foundCount))
		if dbErr != nil {
			tui.logger.Error("DB insert scan record failed: %v", dbErr)
		} else {
			tui.logger.DBEvent("INSERT", fmt.Sprintf("scan record saved: %s", target))
		}

		tui.addOutput("")
		if err == context.Canceled {
			tui.addOutput(fmt.Sprintf("[!] Scan cancelled: %s", target))
		} else if err != nil {
			tui.addOutput(fmt.Sprintf("[-] Scan error: %v", err))
			tui.logger.Error("Scan error: %v", err)
		} else {
			tui.addOutput(fmt.Sprintf("[*] Scan complete: %s", target))
		}
		tui.addOutput(fmt.Sprintf("[*] Paths checked: %d | Exposed: %d", scanned, foundCount))

		// Show database summary
		tui.showDatabaseSummary()

		// Mark session complete only if scan finished normally (not cancelled)
		if tui.checkpoint != nil {
			if err == nil {
				// Normal completion
				if err := tui.checkpoint.Complete(); err != nil {
					tui.logger.Error("Failed to complete session: %v", err)
				}
				tui.addOutput(fmt.Sprintf("[*] Session %s completed", tui.currentSession.Name))
			}
			// If err == context.Canceled, session already marked paused by gracefulShutdown
		}
		tui.currentSession = nil
		tui.checkpoint = nil

		tui.logger.ScanComplete(target, int(scanned), int(foundCount), int(errCount), duration)

		tui.commandState = StateMenu
	}()
}

// showDatabaseSummary displays writer statistics after scan completion.
// Shows batches committed, findings written, and any failed writes.
func (tui *TUI) showDatabaseSummary() {
	stats := tui.writer.Stats()

	// Only show if there's something to report
	if stats.TotalWrites > 0 || stats.FailedWrites > 0 {
		tui.addOutput("")
		tui.addOutput("[*] === Database Summary ===")
		tui.addOutput(fmt.Sprintf("[*] Findings written: %d", stats.TotalWrites))
		tui.addOutput(fmt.Sprintf("[*] Batches committed: %d", stats.TotalBatches))

		// Show warning if any writes failed
		if stats.FailedWrites > 0 {
			tui.addOutput(fmt.Sprintf("[!] WARNING: %d findings failed to write after retries", stats.FailedWrites))
			tui.logger.Warn("Database write failures: %d findings lost", stats.FailedWrites)
		}
	}
}

func (tui *TUI) executeBulkScan(filePath string) {
	filePath = strings.TrimSpace(filePath)

	tui.logger.UserAction(fmt.Sprintf("Bulk scan requested: %s", filePath))

	// Get appropriate loader based on file extension
	l := getLoader(filePath)

	// Load and validate targets
	result, err := l.Load(filePath)
	if err != nil {
		tui.addOutput(fmt.Sprintf("[-] Error loading file: %v", err))
		tui.logger.Error("Failed to load targets file: %s -> %v", filePath, err)
		tui.commandState = StateMenu
		return
	}

	// Show loading summary
	tui.addOutput(fmt.Sprintf("[*] File: %s", filePath))
	tui.addOutput(fmt.Sprintf("[*] Rows processed: %d", result.TotalRows))
	if result.Filtered > 0 {
		tui.addOutput(fmt.Sprintf("[*] Filtered: %d (status=Down, invalid, private IP)", result.Filtered))
	}
	if result.Duplicates > 0 {
		tui.addOutput(fmt.Sprintf("[*] Duplicates removed: %d", result.Duplicates))
	}
	if result.InvalidURLs > 0 {
		tui.addOutput(fmt.Sprintf("[*] Invalid URLs skipped: %d", result.InvalidURLs))
	}

	targets := result.Targets

	tui.logger.FileLoaded(filePath, result.TotalRows, len(targets), result.Filtered, result.Duplicates, result.InvalidURLs)

	if len(targets) == 0 {
		tui.addOutput("[-] No valid targets found in file.")
		tui.addOutput("[*] Check: Are URLs in external_link/resolved_url columns? Is status='Up'?")
		tui.logger.Warn("No valid targets in file: %s", filePath)
		tui.commandState = StateMenu
		return
	}

	// Deduplication: skip targets already scanned in completed sessions
	var dedupSkippedCount int
	if !tui.forceRescan {
		completedDomains, err := database.GetAllCompletedDomains(tui.dbConn)
		if err != nil {
			tui.addOutput(fmt.Sprintf("[!] Warning: deduplication check failed: %v", err))
			tui.logger.Warn("Deduplication query failed: %v", err)
			// Continue without dedup on error (graceful degradation)
		} else if len(completedDomains) > 0 {
			var newTargets []string
			for _, target := range targets {
				normalized := database.NormalizeDomain(target)
				if completedDomains[normalized] {
					dedupSkippedCount++
					continue
				}
				newTargets = append(newTargets, target)
			}
			targets = newTargets

			if dedupSkippedCount > 0 {
				tui.addOutput(fmt.Sprintf("[*] Skipped: %d (already scanned in previous sessions)", dedupSkippedCount))
				tui.logger.Info("Deduplication: skipped %d previously scanned targets", dedupSkippedCount)
			}
		}
	}

	if len(targets) == 0 {
		tui.addOutput("[*] All targets already scanned. Use --force to re-scan.")
		tui.commandState = StateMenu
		return
	}

	tui.commandState = StateRunning
	tui.addOutput(fmt.Sprintf("[*] Loaded %d targets from: %s", len(targets), filePath))
	tui.addOutput("")

	atomic.StoreInt64(&tui.scanned, 0)
	atomic.StoreInt64(&tui.found, 0)
	atomic.StoreInt64(&tui.errors, 0)
	atomic.StoreInt64(&tui.totalPaths, int64(len(targets)*len(engine.CredentialPaths)))

	// Derive session name from filename (not full path)
	sessionName := filepath.Base(filePath)
	// Remove extension
	if idx := strings.LastIndex(sessionName, "."); idx > 0 {
		sessionName = sessionName[:idx]
	}

	// Pass filePath so session stores targets file location for resume
	cm, session, err := database.StartSession(tui.dbConn, sessionName, len(targets), filePath, database.DefaultCheckpointConfig())
	if err != nil {
		tui.addOutput(fmt.Sprintf("[!] Failed to create session: %v", err))
		tui.logger.Error("Session creation failed: %v", err)
	} else {
		tui.currentSession = session
		tui.checkpoint = cm
		tui.addOutput(fmt.Sprintf("[*] Session: %s (ID: %d) - %d targets", session.Name, session.ID, len(targets)))
		tui.logger.Info("Created session: %s (ID: %d, targets: %d)", session.Name, session.ID, len(targets))
	}

	// Reset session stats
	tui.sessionStats.Reset(len(targets))
	tui.typeStats.Reset()

	tui.logger.BulkStart(filePath, len(targets))

	ctx, cancel := context.WithCancel(context.Background())
	tui.cancelFunc = cancel
	bulkStart := time.Now()

	go func() {
		defer func() {
			tui.commandState = StateMenu
			cancel()
		}()

		// Use engine.Scan() with all targets - progress/findings/errors via ProgressReporter
		err := tui.eng.Scan(ctx, targets)

		duration := time.Since(bulkStart)
		foundCount := atomic.LoadInt64(&tui.found)

		tui.addOutput("")
		if err == context.Canceled {
			tui.addOutput("[!] Bulk scan cancelled.")
			tui.logger.Cancelled("Bulk scan cancelled by user")
		} else if err != nil {
			tui.addOutput(fmt.Sprintf("[-] Bulk scan error: %v", err))
			tui.logger.Error("Bulk scan error: %v", err)
		} else {
			tui.addOutput(fmt.Sprintf("[*] Bulk scan complete: %d targets | %d findings", len(targets), foundCount))
		}

		// Show database summary
		tui.showDatabaseSummary()

		// Mark session complete only if scan finished normally (not cancelled)
		if tui.checkpoint != nil {
			if err == nil {
				// Normal completion
				if err := tui.checkpoint.Complete(); err != nil {
					tui.logger.Error("Failed to complete session: %v", err)
				}
				tui.addOutput(fmt.Sprintf("[*] Session %s completed", tui.currentSession.Name))
			}
			// If err == context.Canceled, session already marked paused by gracefulShutdown
		}
		tui.currentSession = nil
		tui.checkpoint = nil

		tui.logger.BulkComplete(len(targets), int(foundCount), duration)
	}()
}

func (tui *TUI) cancelScan() {
	// Use engine.StopAll() to cancel all active scans
	incomplete := tui.eng.StopAll()
	if len(incomplete) > 0 {
		tui.addOutput(fmt.Sprintf("[!] Scan cancelled, %d targets in-flight", len(incomplete)))
		tui.logger.Cancelled(fmt.Sprintf("Scan cancelled by user via ESC, %d targets in-flight", len(incomplete)))
	} else {
		tui.addOutput("[!] Scan cancelled by user.")
		tui.logger.Cancelled("Scan cancelled by user via ESC")
	}
	tui.commandState = StateMenu
}

// ============================================================================
// PROGRESS REPORTER INTERFACE IMPLEMENTATION
// ============================================================================

// OnProgress handles scan progress updates from the engine.
// Implements engine.ProgressReporter interface.
// Thread-safe: can be called from multiple worker goroutines.
func (tui *TUI) OnProgress(update engine.ProgressUpdate) {
	tui.renderMutex.Lock()
	defer tui.renderMutex.Unlock()

	switch update.Status {
	case engine.StatusStarted:
		tui.logger.Info("Scan started: %s", update.Target)
	case engine.StatusCompleted:
		atomic.AddInt64(&tui.scanned, 1)
		// Record domain completion for checkpoint and session stats
		tui.sessionStats.RecordScanned()
		if tui.checkpoint != nil {
			checkpointSaved := tui.checkpoint.RecordDomainComplete(update.Target)
			if checkpointSaved {
				// Set flash indicator for 500ms
				tui.checkpointFlash = time.Now().Add(500 * time.Millisecond)
			}
		}
	case engine.StatusFound:
		// Also record scanned for StatusFound (domain was scanned and had findings)
		atomic.AddInt64(&tui.scanned, 1)
		tui.sessionStats.RecordScanned()
		if tui.checkpoint != nil {
			checkpointSaved := tui.checkpoint.RecordDomainComplete(update.Target)
			if checkpointSaved {
				tui.checkpointFlash = time.Now().Add(500 * time.Millisecond)
			}
		}
	case engine.StatusError:
		atomic.AddInt64(&tui.errors, 1)
		tui.sessionStats.RecordError()
	}
}

// OnError handles error reports from the engine.
// Implements engine.ProgressReporter interface.
// Thread-safe: can be called from multiple worker goroutines.
func (tui *TUI) OnError(errInfo engine.ErrorInfo) {
	atomic.AddInt64(&tui.errors, 1)
	tui.sessionStats.RecordError()
	tui.addOutput(fmt.Sprintf("[-] Error: %s%s: %v", errInfo.Target, errInfo.Path, errInfo.Err))
	tui.logger.Error("Scan error: %s%s: %v", errInfo.Target, errInfo.Path, errInfo.Err)
}

// OnFinding handles discovered credential exposures from the engine.
// Implements engine.ProgressReporter interface.
// Thread-safe: can be called from multiple worker goroutines.
// Uses Writer.QueueFinding() for async batch inserts (never blocks workers).
func (tui *TUI) OnFinding(finding engine.Finding) {
	atomic.AddInt64(&tui.found, 1)
	patternsStr := strings.Join(finding.Patterns, ", ")
	tui.addOutput(fmt.Sprintf("[+] [EXPOSED] %s%s [%d] -> %s",
		finding.Domain, finding.Path, finding.StatusCode, patternsStr))

	// Record per-type stats
	vulnType := engine.VulnTypeFromPath(finding.Path)
	tui.typeStats.Record(vulnType)
	tui.sessionStats.RecordFound()

	// Queue finding for async batch write (never blocks scan workers)
	err := tui.writer.QueueFinding(finding)
	if err != nil {
		// Only fails if writer is shutting down
		tui.addOutput(fmt.Sprintf("[!] DB queue failed (shutdown): %s%s", finding.Domain, finding.Path))
		tui.logger.Error("DB queue failed for %s%s: %v", finding.Domain, finding.Path, err)
	} else {
		tui.logger.DBEvent("QUEUE", fmt.Sprintf("finding queued: %s%s", finding.Domain, finding.Path))
	}

	tui.logger.Found("Exposure: %s%s [%d] patterns: %s", finding.Domain, finding.Path, finding.StatusCode, patternsStr)
}

// ============================================================================
// LOGGER - Comprehensive debug logging
// ============================================================================

type Logger struct {
	mu      sync.Mutex
	file    *os.File
	logDir  string
	session string
}

func NewLogger(logDir string) *Logger {
	os.MkdirAll(logDir, 0755)
	session := time.Now().Format("20060102_150405")

	logPath := filepath.Join(logDir, fmt.Sprintf("%s_session.txt", session))
	f, err := os.Create(logPath)
	if err != nil {
		return &Logger{logDir: logDir, session: session}
	}

	l := &Logger{
		file:    f,
		logDir:  logDir,
		session: session,
	}

	l.writeHeader()
	return l
}

func (l *Logger) writeHeader() {
	l.raw("════════════════════════════════════════════════════════════════")
	l.raw(" HUNTR v%s - Debug Session Log", Version)
	l.raw("════════════════════════════════════════════════════════════════")
	l.raw(" Session:    %s", l.session)
	l.raw(" Started:    %s", time.Now().Format("2006-01-02 15:04:05 MST"))
	l.raw(" Platform:   %s", fmt.Sprintf("%s", os.Getenv("OS")))
	l.raw(" Workers:    configured at startup")
	l.raw(" Cred Paths: %d loaded", len(engine.CredentialPaths))
	l.raw("════════════════════════════════════════════════════════════════")
	l.raw("")
}

func (l *Logger) raw(format string, args ...interface{}) {
	if l.file == nil {
		return
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	fmt.Fprintf(l.file, format+"\n", args...)
}

func (l *Logger) log(level, format string, args ...interface{}) {
	if l.file == nil {
		return
	}
	ts := time.Now().Format("15:04:05.000")
	msg := fmt.Sprintf(format, args...)
	l.mu.Lock()
	defer l.mu.Unlock()
	fmt.Fprintf(l.file, "[%s] [%s] %s\n", ts, level, msg)
}

func (l *Logger) Info(format string, args ...interface{})  { l.log("INFO ", format, args...) }
func (l *Logger) Debug(format string, args ...interface{}) { l.log("DEBUG", format, args...) }
func (l *Logger) Warn(format string, args ...interface{})  { l.log("WARN ", format, args...) }
func (l *Logger) Error(format string, args ...interface{}) { l.log("ERROR", format, args...) }
func (l *Logger) Found(format string, args ...interface{}) { l.log("FOUND", format, args...) }

func (l *Logger) Section(title string) {
	l.raw("")
	l.raw("──────────────────────────────────────────────────")
	l.raw(" %s", title)
	l.raw("──────────────────────────────────────────────────")
}

func (l *Logger) ScanStart(target string, pathCount int) {
	l.Section(fmt.Sprintf("SCAN: %s", target))
	l.Info("Target: %s", target)
	l.Info("Paths to check: %d", pathCount)
	l.Info("Scan started at: %s", time.Now().Format("2006-01-02 15:04:05"))
}

func (l *Logger) ScanPathResult(domain, path string, statusCode int, contentType string, exposed bool, patterns []string, err error) {
	url := fmt.Sprintf("%s%s", domain, path)
	if err != nil {
		l.Error("FAIL %s → connection error: %v", url, err)
		return
	}
	if exposed {
		l.Found("HIT  %s → [%d] %s | patterns: %s", url, statusCode, contentType, strings.Join(patterns, ", "))
	} else if statusCode == 200 {
		l.Debug("OK   %s → [%d] %s | no credentials detected", url, statusCode, contentType)
	} else if statusCode == 403 {
		l.Debug("DENY %s → [%d] forbidden", url, statusCode)
	} else if statusCode == 404 {
		l.Debug("MISS %s → [%d] not found", url, statusCode)
	} else if statusCode == 301 || statusCode == 302 {
		l.Debug("RDIR %s → [%d] redirect", url, statusCode)
	} else if statusCode == 500 || statusCode == 502 || statusCode == 503 {
		l.Warn("SERR %s → [%d] server error", url, statusCode)
	} else {
		l.Debug("RESP %s → [%d] %s", url, statusCode, contentType)
	}
}

func (l *Logger) ScanComplete(target string, checked, found, errors int, duration time.Duration) {
	l.raw("")
	l.Info("Scan complete: %s", target)
	l.Info("  Paths checked:  %d", checked)
	l.Info("  Findings:       %d", found)
	l.Info("  Errors:         %d", errors)
	l.Info("  Duration:       %s", duration.Round(time.Millisecond))
	l.Info("  Paths/sec:      %.1f", float64(checked)/duration.Seconds())
}

func (l *Logger) BulkStart(filePath string, targetCount int) {
	l.Section("BULK SCAN")
	l.Info("Targets file: %s", filePath)
	l.Info("Total targets: %d", targetCount)
	l.Info("Total paths per target: %d", len(engine.CredentialPaths))
	l.Info("Total requests planned: %d", targetCount*len(engine.CredentialPaths))
}

func (l *Logger) BulkTargetStart(index, total int, target string) {
	l.raw("")
	l.Info("── Target [%d/%d]: %s ──", index, total, target)
}

func (l *Logger) BulkComplete(targetCount, totalFound int, duration time.Duration) {
	l.Section("BULK SCAN COMPLETE")
	l.Info("Targets scanned: %d", targetCount)
	l.Info("Total findings:  %d", totalFound)
	l.Info("Total duration:  %s", duration.Round(time.Millisecond))
	if targetCount > 0 {
		l.Info("Avg per target:  %s", (duration / time.Duration(targetCount)).Round(time.Millisecond))
	}
}

func (l *Logger) DBEvent(action string, details string) {
	l.Info("DB %s: %s", action, details)
}

func (l *Logger) FileLoaded(filePath string, totalRows, targets, filtered, duplicates, invalid int) {
	l.Section("FILE LOADED")
	l.Info("Path: %s", filePath)
	l.Info("Type: %s", filepath.Ext(filePath))
	l.Info("Total rows: %d", totalRows)
	l.Info("Valid targets: %d", targets)
	l.Info("Filtered: %d", filtered)
	l.Info("Duplicates: %d", duplicates)
	l.Info("Invalid URLs: %d", invalid)
}

func (l *Logger) UserAction(action string) {
	l.Info("USER: %s", action)
}

func (l *Logger) Cancelled(context string) {
	l.Warn("CANCELLED: %s", context)
}

func (l *Logger) SessionEnd() {
	l.raw("")
	l.Section("SESSION END")
	l.Info("Session ended at: %s", time.Now().Format("2006-01-02 15:04:05"))
	l.raw("════════════════════════════════════════════════════════════════")
	if l.file != nil {
		l.file.Close()
	}
}

func (l *Logger) GetPath() string {
	if l.file != nil {
		return l.file.Name()
	}
	return ""
}

// ============================================================================
// INPUT HANDLER
// ============================================================================

func (tui *TUI) HandleInput() {
	for tui.running {
		ev := tui.screen.PollEvent()

		switch ev := ev.(type) {
		case *tcell.EventResize:
			tui.width, tui.height = ev.Size()
			tui.screen.Sync()

		case *tcell.EventKey:
			// Splash screen - any key dismisses
			if tui.showSplash {
				if tui.splashFrame >= 2.0 {
					tui.showSplash = false
				} else {
					tui.splashFrame = 2.0 // skip to end
				}
				continue
			}

			switch tui.commandState {
			case StateMenu:
				tui.handleMenuKey(ev)
			case StateInput:
				tui.handleInputKey(ev)
			case StateRunning:
				switch ev.Key() {
				case tcell.KeyEscape:
					tui.cancelScan()
				case tcell.KeyCtrlC:
					// Ctrl+C during scan triggers graceful shutdown
					tui.logger.Info("Ctrl+C pressed during scan, triggering graceful shutdown")
					tui.gracefulShutdown()
					tui.running = false
				case tcell.KeyUp:
					tui.scrollUp(1)
				case tcell.KeyDown:
					tui.scrollDown(1)
				case tcell.KeyPgUp:
					tui.scrollUp(10)
				case tcell.KeyPgDn:
					tui.scrollDown(10)
				}
			case StateSelect:
				tui.handleSelectKey(ev)
			case StateComplete:
				tui.commandState = StateMenu
			}
		}
	}
}

func (tui *TUI) handleSelectKey(ev *tcell.EventKey) {
	switch ev.Key() {
	case tcell.KeyCtrlC:
		tui.commandState = StateMenu
	case tcell.KeyEscape:
		tui.commandState = StateMenu
	case tcell.KeyF5:
		tui.refreshTargetFiles()
		return
	case tcell.KeyUp:
		if tui.selectIndex > 0 {
			tui.selectIndex--
		}
	case tcell.KeyDown:
		if tui.selectIndex < len(tui.selectItems)-1 {
			tui.selectIndex++
		}
	case tcell.KeyEnter:
		if tui.selectIndex >= 0 && tui.selectIndex < len(tui.selectPaths) {
			selected := tui.selectPaths[tui.selectIndex]
			if selected == "" {
				// "Type or drag" option — switch to text input
				tui.commandState = StateInput
				tui.inputPrompt = "File path: "
				tui.inputBuffer = ""
				// pendingCmd is already 2
			} else {
				tui.commandState = StateMenu
				tui.executeBulkScan(selected)
			}
		}
	}
}

// refreshTargetFiles re-scans the targets/ folder and returns the count found.
func (tui *TUI) refreshTargetFiles() int {
	targetsDir := filepath.Join(tui.appDir, "targets")
	tui.selectItems = nil
	tui.selectPaths = nil
	tui.selectIndex = 0

	entries, err := os.ReadDir(targetsDir)
	if err == nil {
		for _, e := range entries {
			if e.IsDir() {
				continue
			}
			ext := strings.ToLower(filepath.Ext(e.Name()))
			if ext == ".txt" || ext == ".csv" || ext == ".list" {
				fullPath := filepath.Join(targetsDir, e.Name())
				label := e.Name()
				if info, err := os.Stat(fullPath); err == nil {
					kb := info.Size() / 1024
					if kb > 1024 {
						label += fmt.Sprintf("  (%.1f MB)", float64(info.Size())/1024/1024)
					} else {
						label += fmt.Sprintf("  (%d KB)", kb)
					}
				}
				tui.selectItems = append(tui.selectItems, label)
				tui.selectPaths = append(tui.selectPaths, fullPath)
			}
		}
	}

	// Always add custom path option at the end
	tui.selectItems = append(tui.selectItems, "Type or drag a file path...")
	tui.selectPaths = append(tui.selectPaths, "")

	return len(tui.selectItems) - 1 // exclude the custom option
}

const menuItemCount = 8 // total menu items (1-7 + Q)

func (tui *TUI) executeMenuItem(index int) {
	switch index {
	case 0:
		tui.handleScanSingle()
	case 1:
		tui.handleBulkScan()
	case 2:
		tui.handleViewFindings()
	case 3:
		tui.handleExport()
	case 4:
		tui.handleStats()
	case 5:
		tui.handleResumeSession()
	case 6:
		tui.handleClear()
	case 7:
		tui.logger.UserAction("Menu: Quit")
		tui.running = false
	}
}

func (tui *TUI) handleMenuKey(ev *tcell.EventKey) {
	switch ev.Key() {
	case tcell.KeyCtrlC:
		tui.logger.UserAction("Menu: Ctrl+C pressed (quit)")
		tui.running = false
		return
	case tcell.KeyF5:
		count := tui.refreshTargetFiles()
		tui.addOutput("")
		tui.addOutput(fmt.Sprintf("[*] Target list refreshed: %d file(s) found in targets/", count))
		tui.logger.Info("F5: Target list refreshed, %d files found", count)
		return
	case tcell.KeyUp:
		if tui.selectedItem > 0 {
			tui.selectedItem--
		}
		return
	case tcell.KeyDown:
		if tui.selectedItem < menuItemCount-1 {
			tui.selectedItem++
		}
		return
	case tcell.KeyEnter:
		tui.executeMenuItem(tui.selectedItem)
		return
	case tcell.KeyPgUp:
		tui.scrollUp(10)
		return
	case tcell.KeyPgDn:
		tui.scrollDown(10)
		return
	}

	// Number key shortcuts still work
	r := ev.Rune()
	switch r {
	case '1':
		tui.selectedItem = 0
		tui.handleScanSingle()
	case '2':
		tui.selectedItem = 1
		tui.handleBulkScan()
	case '3':
		tui.selectedItem = 2
		tui.handleViewFindings()
	case '4':
		tui.selectedItem = 3
		tui.handleExport()
	case '5':
		tui.selectedItem = 4
		tui.handleStats()
	case '6':
		tui.selectedItem = 5
		tui.handleResumeSession()
	case '7':
		tui.selectedItem = 6
		tui.handleClear()
	case 'q', 'Q':
		tui.selectedItem = 7
		tui.logger.UserAction("Menu: Quit")
		tui.running = false
	}
}

func (tui *TUI) handleInputKey(ev *tcell.EventKey) {
	switch ev.Key() {
	case tcell.KeyCtrlC:
		// Ctrl+C during input cancels and returns to menu
		tui.logger.UserAction("Input: Ctrl+C pressed (cancel)")
		tui.commandState = StateMenu
		tui.inputBuffer = ""
		return
	case tcell.KeyEscape:
		tui.commandState = StateMenu
		tui.inputBuffer = ""
	case tcell.KeyEnter:
		input := tui.inputBuffer
		tui.inputBuffer = ""
		switch tui.pendingCmd {
		case 1:
			tui.executeScan(input)
		case 2:
			// Strip surrounding quotes from drag-and-drop paths
			input = strings.Trim(input, "\"'")
			input = strings.TrimSpace(input)
			tui.executeBulkScan(input)
		case 6: // Resume session
			// Parse session number
			num, err := strconv.Atoi(input)
			if err != nil || num < 1 {
				tui.addOutput("[!] Invalid session number")
				tui.commandState = StateMenu
				return
			}

			sessions, _ := database.GetIncompleteSessions(tui.dbConn)
			if num > len(sessions) {
				tui.addOutput("[!] Session not found")
				tui.commandState = StateMenu
				return
			}

			session := sessions[num-1]
			tui.executeResumeSession(session.ID)
		}
	case tcell.KeyBackspace, tcell.KeyBackspace2:
		if len(tui.inputBuffer) > 0 {
			tui.inputBuffer = tui.inputBuffer[:len(tui.inputBuffer)-1]
		}
	default:
		if ev.Rune() != 0 {
			tui.inputBuffer += string(ev.Rune())
		}
	}
}

func (tui *TUI) scrollUp(n int) {
	tui.outputMutex.Lock()
	defer tui.outputMutex.Unlock()
	tui.outputScroll -= n
	if tui.outputScroll < 0 {
		tui.outputScroll = 0
	}
}

func (tui *TUI) scrollDown(n int) {
	tui.outputMutex.Lock()
	defer tui.outputMutex.Unlock()
	visibleHeight := tui.height - 7
	maxScroll := tui.outputBuffer.Len() - visibleHeight
	tui.outputScroll += n
	if tui.outputScroll > maxScroll {
		tui.outputScroll = maxScroll
	}
	if tui.outputScroll < 0 {
		tui.outputScroll = 0
	}
}

// gracefulShutdown handles signal-triggered shutdown.
// Stops active scans, flushes checkpoint, marks session as paused, closes database.
// Thread-safe: uses shutdownOnce to prevent multiple shutdown attempts.
func (tui *TUI) gracefulShutdown() {
	tui.shutdownOnce.Do(func() {
		tui.logger.Info("Graceful shutdown initiated...")

		// Display shutdown message in TUI
		tui.addOutput("")
		tui.addOutput("[!] ═══════════════════════════════════════════")
		tui.addOutput("[!] Signal received, saving progress...")
		tui.addOutput("[!] ═══════════════════════════════════════════")

		// Stop active scans
		if tui.eng != nil {
			incomplete := tui.eng.StopAll()
			if len(incomplete) > 0 {
				tui.logger.Info("Stopped scans: %d targets in-flight", len(incomplete))
				tui.addOutput(fmt.Sprintf("[*] Stopping scan... %d targets in-flight", len(incomplete)))
			}
		}

		// Flush checkpoint and mark session as paused
		if tui.checkpoint != nil && tui.currentSession != nil {
			tui.logger.Info("Saving checkpoint for session: %s (ID: %d)", tui.currentSession.Name, tui.currentSession.ID)
			tui.addOutput(fmt.Sprintf("[*] Saving checkpoint for session: %s", tui.currentSession.Name))

			// Flush any pending checkpoint data first
			tui.checkpoint.Flush()
			tui.logger.Info("Checkpoint flushed, completed count: %d", tui.checkpoint.CompletedCount())

			// Mark session as paused
			if err := tui.checkpoint.Pause(); err != nil {
				tui.logger.Error("Failed to pause session: %v", err)
				tui.addOutput(fmt.Sprintf("[-] Error pausing session: %v", err))
			} else {
				completedCount := tui.checkpoint.CompletedCount()
				totalTargets := tui.currentSession.TotalTargets
				tui.logger.Info("Session paused successfully: status='paused', completed=%d", completedCount)
				tui.addOutput(fmt.Sprintf("[+] Checkpoint saved: %d/%d targets completed", completedCount, totalTargets))

				// Calculate progress percentage
				if totalTargets > 0 {
					progress := float64(completedCount) / float64(totalTargets) * 100
					tui.addOutput(fmt.Sprintf("[*] Progress: %.1f%% complete", progress))
				}

				// Resume instructions
				tui.addOutput("")
				tui.addOutput(fmt.Sprintf("[*] Session '%s' paused successfully", tui.currentSession.Name))
				tui.addOutput("[*] To resume: Select option 6 (Resume session) from main menu")
			}
		} else {
			// Log why we didn't save checkpoint
			if tui.checkpoint == nil {
				tui.logger.Warn("Checkpoint is nil during shutdown - cannot save session state")
			}
			if tui.currentSession == nil {
				tui.logger.Warn("CurrentSession is nil during shutdown - cannot save session state")
			}
		}

		// Drain writer queue
		if tui.writerCancel != nil {
			tui.logger.Info("Draining database queue...")
			tui.addOutput("[*] Flushing database writes...")
			tui.writerCancel()
			// Give writer time to drain queue
			time.Sleep(100 * time.Millisecond)
			tui.logger.Info("Database queue drained")
		}

		// Close database with WAL checkpoint
		if tui.dbConn != nil {
			tui.logger.Info("Closing database...")
			tui.addOutput("[*] Closing database...")
			// Final WAL checkpoint before close
			tui.dbConn.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
			tui.dbConn.Close()
		}

		tui.addOutput("[+] Shutdown complete - all progress saved")
		tui.addOutput("[!] ═══════════════════════════════════════════")
		tui.addOutput("")

		// Final render to show shutdown messages before screen closes
		tui.Render()
		time.Sleep(2 * time.Second) // Give user time to see shutdown messages

		tui.logger.SessionEnd()
	})
}

// ============================================================================
// MAIN LOOP
// ============================================================================

func (tui *TUI) Run(ctx context.Context) {
	// Show welcome message (visible after splash)
	tui.addOutput(fmt.Sprintf("[*] Huntr v%s", Version))
	tui.addOutput("[*] Exposed Credential & Misconfiguration Scanner")
	tui.addOutput("")
	tui.addOutput("[*] Instructions:")
	tui.addOutput("    1. Enter a target domain to scan")
	tui.addOutput("    2. Or load a file of targets for bulk scanning")
	tui.addOutput("    3. Findings are saved to huntr.db automatically")
	tui.addOutput("")
	tui.addOutput(fmt.Sprintf("[*] Credential paths loaded: %d", len(engine.CredentialPaths)))
	tui.addOutput(fmt.Sprintf("[*] Targets folder: %s", filepath.Join(tui.appDir, "targets")))
	tui.addOutput(fmt.Sprintf("[*] Database: %s", filepath.Join(tui.appDir, "huntr.db")))
	tui.addOutput(fmt.Sprintf("[*] Log file: %s", tui.logger.GetPath()))
	tui.addOutput("")

	// Render loop
	tui.Render()

	go tui.HandleInput()

	ticker := time.NewTicker(50 * time.Millisecond) // ~20 FPS
	defer ticker.Stop()

	for tui.running {
		select {
		case <-ctx.Done():
			// Signal received - trigger graceful shutdown
			tui.logger.Info("Signal received, saving progress...")
			tui.gracefulShutdown()
			tui.running = false
		case <-ticker.C:
			// Auto-dismiss splash after animation completes
			if tui.showSplash && tui.splashFrame >= 2.0 {
				// Wait an extra beat then dismiss
				tui.splashFrame += 0.02
				if tui.splashFrame >= 2.5 {
					tui.showSplash = false
				}
			}

			tui.Render()
		}
	}

	tui.screen.Fini()

	// If gracefulShutdown wasn't called by signal, do final cleanup
	if ctx.Err() == nil {
		tui.eng.Close()
		tui.writerCancel()
		tui.dbConn.Close()
		tui.logger.SessionEnd()
	}
}

// ============================================================================
// MODE SELECTION
// ============================================================================

// RunMode determines whether the application runs in TUI or daemon mode.
type RunMode int

const (
	// ModeTUI runs the interactive terminal UI (default when TTY is available).
	ModeTUI RunMode = iota
	// ModeDaemon runs headless with JSONL output to stdout.
	ModeDaemon
)

// selectMode determines which mode to run based on CLI flags and TTY detection.
// If --daemon is set, returns ModeDaemon. If --tui is set, returns ModeTUI.
// If neither flag is set, auto-detects: ModeTUI when stdout is a terminal,
// otherwise prints an error to stderr and exits (requires explicit --daemon).
func selectMode(daemonFlag, tuiFlag bool) RunMode {
	if daemonFlag {
		return ModeDaemon
	}
	if tuiFlag {
		return ModeTUI
	}

	// Auto-detect: check if stdout is a terminal
	if term.IsTerminal(int(os.Stdout.Fd())) {
		return ModeTUI
	}

	// Not a terminal and no explicit flag -- user must opt in
	fmt.Fprintln(os.Stderr, "Error: stdout is not a terminal.")
	fmt.Fprintln(os.Stderr, "  Use --daemon for headless JSONL output")
	fmt.Fprintln(os.Stderr, "  Use --tui    to force interactive mode")
	os.Exit(1)
	return ModeTUI // unreachable, satisfies compiler
}

// ============================================================================
// MAIN ENTRY POINT
// ============================================================================

func main() {
	// Subcommand routing: check os.Args BEFORE flag.Parse() to intercept
	// subcommands that don't use the standard flag set.
	if len(os.Args) > 1 {
		switch os.Args[1] {
		case "status":
			runStatus(os.Args[2:])
			return
		case "stop":
			runStop()
			return
		}
	}

	// CLI flag parsing (for scan modes: TUI and daemon)
	var workers int
	var forceRescan bool
	var daemonFlag bool
	var tuiFlag bool
	flag.IntVar(&workers, "w", engine.DefaultWorkers, "number of concurrent workers (3-1000)")
	flag.IntVar(&workers, "workers", engine.DefaultWorkers, "number of concurrent workers (3-1000)")
	flag.BoolVar(&forceRescan, "force", false, "re-scan targets even if previously scanned")
	flag.BoolVar(&daemonFlag, "daemon", false, "run in headless daemon mode with JSONL output")
	flag.BoolVar(&tuiFlag, "tui", false, "force interactive TUI mode")
	flag.Parse()

	// Mutual exclusion: --daemon and --tui cannot both be set
	if daemonFlag && tuiFlag {
		fmt.Fprintln(os.Stderr, "Error: --daemon and --tui are mutually exclusive")
		os.Exit(1)
	}

	// Clamp workers to valid range with warnings
	if workers < engine.MinWorkers {
		fmt.Fprintf(os.Stderr, "Warning: workers clamped to minimum (%d)\n", engine.MinWorkers)
		workers = engine.MinWorkers
	}
	if workers > engine.MaxWorkers {
		fmt.Fprintf(os.Stderr, "Warning: workers clamped to maximum (%d)\n", engine.MaxWorkers)
		workers = engine.MaxWorkers
	}

	timeout := 5 * time.Second

	// Determine execution mode
	mode := selectMode(daemonFlag, tuiFlag)

	switch mode {
	case ModeTUI:
		runTUI(workers, timeout, forceRescan)
	case ModeDaemon:
		runDaemon(workers, timeout, forceRescan)
	}
}

// runTUI launches the interactive terminal UI. This is the original execution
// path, now wrapped in a function with PID file management.
func runTUI(workers int, timeout time.Duration, forceRescan bool) {
	// Create PID file (prevents concurrent instances)
	if err := pidfile.Create(pidfile.DefaultPath, forceRescan); err != nil {
		fmt.Fprintf(os.Stderr, "Fatal: %s\n", err)
		os.Exit(1)
	}
	defer pidfile.Remove(pidfile.DefaultPath)

	tui, err := NewTUI(workers, timeout, forceRescan)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal: %s\n", err)
		os.Exit(1)
	}

	// Set up signal handling for graceful shutdown
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()

	tui.Run(ctx)
}

// runDaemon launches the headless daemon mode with JSONL output to stdout.
// Targets are provided as positional arguments (domains or file paths).
// All status messages go to stderr; stdout is reserved exclusively for JSONL
// finding events produced by DaemonReporter.
//
// The daemon follows the same engine/writer/session pattern as the TUI path
// but replaces the interactive display with DaemonReporter for JSONL output.
func runDaemon(workers int, timeout time.Duration, forceRescan bool) {
	// -- PID file --
	if err := pidfile.Create(pidfile.DefaultPath, forceRescan); err != nil {
		fmt.Fprintf(os.Stderr, "Fatal: %s\n", err)
		os.Exit(1)
	}
	defer pidfile.Remove(pidfile.DefaultPath)

	// -- Targets --
	targets := flag.Args()
	if len(targets) == 0 {
		fmt.Fprintln(os.Stderr, "Usage: huntr --daemon [flags] <target1> [target2] ...")
		fmt.Fprintln(os.Stderr, "  Targets can be domains (example.com) or file paths (targets.txt)")
		os.Exit(1)
	}

	// Expand file-based targets: if a target looks like a file path, load it
	var expandedTargets []string
	for _, t := range targets {
		if info, err := os.Stat(t); err == nil && !info.IsDir() {
			// It's a file -- load targets from it
			l := getLoader(t)
			result, err := l.Load(t)
			if err != nil {
				fmt.Fprintf(os.Stderr, "Error loading %s: %v\n", t, err)
				os.Exit(1)
			}
			fmt.Fprintf(os.Stderr, "Loaded %d targets from %s (filtered: %d, dupes: %d)\n",
				len(result.Targets), t, result.Filtered, result.Duplicates)
			expandedTargets = append(expandedTargets, result.Targets...)
		} else {
			// Treat as a domain
			domain := strings.TrimSpace(t)
			domain = strings.TrimPrefix(domain, "http://")
			domain = strings.TrimPrefix(domain, "https://")
			domain = strings.TrimRight(domain, "/")
			if domain != "" {
				expandedTargets = append(expandedTargets, domain)
			}
		}
	}

	if len(expandedTargets) == 0 {
		fmt.Fprintln(os.Stderr, "Error: no valid targets found")
		os.Exit(1)
	}

	// -- Database setup (same pattern as NewTUI) --
	appDir := getAppRoot()
	dbPath := filepath.Join(appDir, "huntr.db")
	dbConn, err := database.Open(database.Config{Path: dbPath})
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal: open database: %v\n", err)
		os.Exit(1)
	}
	if err := database.EnsureSchema(dbConn); err != nil {
		dbConn.Close()
		fmt.Fprintf(os.Stderr, "Fatal: init schema: %v\n", err)
		os.Exit(1)
	}

	// -- Writer setup --
	writerCtx, writerCancel := context.WithCancel(context.Background())
	writer := database.NewWriter(writerCtx, dbConn, database.WriterConfig{
		BatchSize:   250,
		ChannelSize: 1000,
		MaxRetries:  3,
	})
	go writer.Start()

	// -- Deduplication --
	if !forceRescan {
		completedDomains, err := database.GetAllCompletedDomains(dbConn)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Warning: deduplication check failed: %v\n", err)
		} else if len(completedDomains) > 0 {
			var filtered []string
			skipped := 0
			for _, t := range expandedTargets {
				normalized := database.NormalizeDomain(t)
				if completedDomains[normalized] {
					skipped++
					continue
				}
				filtered = append(filtered, t)
			}
			if skipped > 0 {
				fmt.Fprintf(os.Stderr, "Skipped %d previously scanned targets\n", skipped)
			}
			expandedTargets = filtered
		}
	}

	if len(expandedTargets) == 0 {
		fmt.Fprintln(os.Stderr, "All targets already scanned. Use --force to re-scan.")
		writerCancel()
		dbConn.Close()
		os.Exit(0)
	}

	// -- DaemonReporter (JSONL to stdout) --
	reporter := daemon.NewDaemonReporter(os.Stdout, len(expandedTargets))

	// -- Engine setup --
	cfg := engine.DefaultConfig()
	cfg.Workers = workers
	cfg.Timeout = timeout
	eng, err := engine.New(cfg, &daemonFindingHandler{
		reporter: reporter,
		writer:   writer,
	})
	if err != nil {
		fmt.Fprintf(os.Stderr, "Fatal: init engine: %v\n", err)
		writerCancel()
		dbConn.Close()
		os.Exit(1)
	}

	// -- Session management --
	sessionName := fmt.Sprintf("daemon-%s", time.Now().Format("20060102-150405"))
	targetsFile := "" // daemon mode uses CLI args, not a file path for resume
	if len(targets) == 1 {
		if info, err := os.Stat(targets[0]); err == nil && !info.IsDir() {
			absPath, _ := filepath.Abs(targets[0])
			targetsFile = absPath
			// Use filename sans extension as session name
			base := filepath.Base(targets[0])
			if idx := strings.LastIndex(base, "."); idx > 0 {
				sessionName = base[:idx]
			}
		} else {
			sessionName = targets[0] // single domain as session name
		}
	}

	cm, _, err := database.StartSession(dbConn, sessionName, len(expandedTargets), targetsFile, database.DefaultCheckpointConfig())
	if err != nil {
		fmt.Fprintf(os.Stderr, "Warning: session creation failed: %v\n", err)
		// Continue without session (non-fatal)
	}

	// -- Signal handling --
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer cancel()

	fmt.Fprintf(os.Stderr, "huntr v%s daemon mode: %d targets, %d workers\n", Version, len(expandedTargets), workers)
	scanStart := time.Now()

	// -- Run scan --
	scanErr := eng.Scan(ctx, expandedTargets)

	// -- Shutdown sequence --
	duration := time.Since(scanStart)

	// 1. Flush checkpoint
	if cm != nil {
		cm.Flush()
		if scanErr == context.Canceled {
			cm.Pause()
		} else if scanErr == nil {
			cm.Complete()
		}
	}

	// 2. Drain writer
	writerCancel()
	time.Sleep(100 * time.Millisecond) // Allow writer to drain

	// 3. WAL checkpoint
	dbConn.Exec("PRAGMA wal_checkpoint(PASSIVE)")
	dbConn.Close()

	// 4. Close engine
	eng.Close()

	// -- Final stats to stderr --
	stats := reporter.Stats()
	fmt.Fprintf(os.Stderr, "\n")
	if scanErr == context.Canceled {
		fmt.Fprintf(os.Stderr, "Scan interrupted (progress saved)\n")
	} else if scanErr != nil {
		fmt.Fprintf(os.Stderr, "Scan error: %v\n", scanErr)
	} else {
		fmt.Fprintf(os.Stderr, "Scan complete\n")
	}
	fmt.Fprintf(os.Stderr, "  Targets:  %d/%d scanned\n", stats.Scanned, stats.TotalTargets)
	fmt.Fprintf(os.Stderr, "  Findings: %d\n", stats.Found)
	fmt.Fprintf(os.Stderr, "  Errors:   %d\n", stats.Errors)
	fmt.Fprintf(os.Stderr, "  Duration: %s\n", duration.Round(time.Millisecond))

	writerStats := writer.Stats()
	if writerStats.FailedWrites > 0 {
		fmt.Fprintf(os.Stderr, "  WARNING: %d findings failed to write to DB\n", writerStats.FailedWrites)
	}

	if scanErr != nil && scanErr != context.Canceled {
		os.Exit(1)
	}
}

// daemonFindingHandler wraps DaemonReporter and Writer for dual-write:
// findings are both written as JSONL to stdout (via reporter) and persisted
// to the database (via writer). It implements engine.ProgressReporter by
// delegating progress/error tracking to DaemonReporter and adding DB writes
// for findings.
type daemonFindingHandler struct {
	reporter *daemon.DaemonReporter
	writer   *database.Writer
}

// OnProgress delegates to DaemonReporter for stats tracking.
func (h *daemonFindingHandler) OnProgress(update engine.ProgressUpdate) {
	h.reporter.OnProgress(update)
}

// OnError delegates to DaemonReporter for error counting.
func (h *daemonFindingHandler) OnError(errInfo engine.ErrorInfo) {
	h.reporter.OnError(errInfo)
}

// OnFinding writes the finding as JSONL to stdout via DaemonReporter
// and queues it for database persistence via Writer.
func (h *daemonFindingHandler) OnFinding(finding engine.Finding) {
	h.reporter.OnFinding(finding)
	h.writer.QueueFinding(finding) //nolint:errcheck // best-effort DB write
}

// ============================================================================
// STATUS SUBCOMMAND
// ============================================================================

// runStatus queries the database for scan progress and displays status.
// Works whether a scan is running or not. Does NOT create a PID file
// or start any scan -- purely a read-only query.
//
// Usage: huntr status [--json]
func runStatus(args []string) {
	jsonOutput := false
	for _, arg := range args {
		if arg == "--json" || arg == "-json" {
			jsonOutput = true
		}
	}

	// -- Database setup (read-only, no writer needed) --
	appDir := getAppRoot()
	dbPath := filepath.Join(appDir, "huntr.db")
	dbConn, err := database.Open(database.Config{Path: dbPath})
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error: cannot open database: %v\n", err)
		os.Exit(1)
	}
	defer dbConn.Close()

	if err := database.EnsureSchema(dbConn); err != nil {
		fmt.Fprintf(os.Stderr, "Error: database schema: %v\n", err)
		os.Exit(1)
	}

	// -- Query status --
	pidPath := filepath.Join(appDir, pidfile.DefaultPath)
	info, err := daemon.QueryStatus(dbConn, pidPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error: query status: %v\n", err)
		os.Exit(1)
	}

	// -- Output --
	if jsonOutput {
		if err := daemon.PrintJSONStatus(info); err != nil {
			fmt.Fprintf(os.Stderr, "Error: encode JSON: %v\n", err)
			os.Exit(1)
		}
	} else {
		daemon.PrintHumanStatus(info)
	}
}

// ============================================================================
// STOP SUBCOMMAND
// ============================================================================

// runStop reads the PID file and sends SIGTERM for graceful shutdown.
// If no instance is running, prints a message and exits.
//
// Usage: huntr stop
func runStop() {
	appDir := getAppRoot()
	pidPath := filepath.Join(appDir, pidfile.DefaultPath)

	// Read PID from file
	pid, err := pidfile.ReadPID(pidPath)
	if err != nil {
		fmt.Println("No running instance found (no PID file)")
		return
	}

	// Check if process is actually running
	if !pidfile.IsProcessRunning(pid) {
		fmt.Printf("Stale PID file (process %d not running). Removing.\n", pid)
		pidfile.Remove(pidPath)
		return
	}

	// Send SIGTERM for graceful shutdown
	process, err := os.FindProcess(pid)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error: cannot find process %d: %v\n", pid, err)
		os.Exit(1)
	}

	if err := process.Signal(syscall.SIGTERM); err != nil {
		fmt.Fprintf(os.Stderr, "Error: cannot signal process %d: %v\n", pid, err)
		os.Exit(1)
	}

	fmt.Printf("Sent SIGTERM to huntr (PID %d). Graceful shutdown initiated.\n", pid)
}
