/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
:                                                                               :
:   █▀ █ █▀▀   ·   Blazing-fast pentesting suite                                :
:   ▄█ █ █▀    ·   BSD 3-Clause License                                         :
:                                                                               :
:   (c) 2022-2026 vmfunc, xyzeva,                                               :
:                 lunchcat alumni & contributors                                :
:                                                                               :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/

// Package sif provides the main functionality for the SIF (Security Information Finder) tool.
// It handles the initialization, configuration, and execution of various security scanning modules.

package sif

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"strings"

	"github.com/charmbracelet/log"
	"github.com/dropalldatabases/sif/internal/config"
	"github.com/dropalldatabases/sif/internal/dnsx"
	"github.com/dropalldatabases/sif/internal/finding"
	"github.com/dropalldatabases/sif/internal/httpx"
	"github.com/dropalldatabases/sif/internal/logger"
	"github.com/dropalldatabases/sif/internal/modules"
	"github.com/dropalldatabases/sif/internal/notify"
	"github.com/dropalldatabases/sif/internal/output"
	"github.com/dropalldatabases/sif/internal/report"
	"github.com/dropalldatabases/sif/internal/scan"
	"github.com/dropalldatabases/sif/internal/scan/builtin"
	"github.com/dropalldatabases/sif/internal/scan/frameworks"
	jsscan "github.com/dropalldatabases/sif/internal/scan/js"
	"github.com/dropalldatabases/sif/internal/store"
)

// App represents the main application structure for sif.
// It encapsulates the configuration settings, target URLs, and logging information.
type App struct {
	settings *config.Settings
	targets  []string
	logFiles []string
}

// Version is set by main to the resolved build version and shown on the banner.
var Version = "dev"

// reportFileMode is the permission applied to written report files: owner
// read/write, group/other read. reports aren't secret but may name targets.
const reportFileMode = 0o644

type UrlResult struct {
	Url     string `json:"url"`
	Results []ModuleResult
}

type ModuleResult struct {
	Id   string      `json:"id"`
	Data interface{} `json:"data"`
}

// ScanResult is the interface that all scan result types must implement.
// This mirrors the definition in pkg/scan/result.go for use by the main package.
type ScanResult interface {
	ResultType() string
}

// NewModuleResult creates a ModuleResult with compile-time type safety.
// The data parameter must implement ScanResult, which is enforced at compile time.
func NewModuleResult[T ScanResult](data T) ModuleResult {
	return ModuleResult{
		Id:   data.ResultType(),
		Data: data,
	}
}

// New creates a new App struct by parsing the configuration options,
// figuring out the targets from list or file, etc.
//
// Errors if no targets are supplied through URLs or File.
func New(settings *config.Settings) (*App, error) {
	app := &App{settings: settings}

	// -silent reroutes all chrome to stderr (and suppresses spinners) before the
	// banner prints, so stdout carries nothing but findings even on the banner.
	if settings.Silent {
		output.SetSilent(true)
	}

	if !settings.ApiMode {
		fmt.Fprintln(output.Writer(), output.Box.Render("   █▀ █ █▀▀\n  ▄█ █ █▀ "))
		tagline := "blazing-fast pentesting suite"
		if Version != "dev" {
			tagline += " · v" + Version
		}
		fmt.Fprintln(output.Writer(), output.Subheading.Render("\n"+tagline+"\n\nbsd 3-clause · (c) 2022-2026 vmfunc, xyzeva & contributors\n"))
	} else {
		output.SetAPIMode(true)
	}

	// Skip target requirement if just listing modules
	if settings.ListModules {
		return app, nil
	}

	// -u and -f are explicit; stdin is additive so `subfinder | sif -u extra`
	// still works. order: flags first, then piped lines appended.
	app.targets = append(app.targets, settings.URLs...)

	if settings.File != "" {
		if _, err := os.Stat(settings.File); err != nil {
			return nil, err
		}

		data, err := os.Open(settings.File)
		if err != nil {
			return nil, err
		}
		defer data.Close()

		scanner := bufio.NewScanner(data)
		scanner.Split(bufio.ScanLines)
		for scanner.Scan() {
			app.targets = append(app.targets, scanner.Text())
		}
	}

	// when stdin is a pipe (not a terminal), drain it for targets so sif slots
	// into a unix pipeline: `subfinder -d x | sif -silent | notify`. keyed off
	// stdin's mode, never stdout - a redirected stdout (>file) is not a pipe in.
	piped, err := stdinPipedFn()
	if err != nil {
		return nil, err
	}
	if piped {
		stdinTargets, err := readTargets(stdinReader)
		if err != nil {
			return nil, fmt.Errorf("reading targets from stdin: %w", err)
		}
		app.targets = append(app.targets, stdinTargets...)
	}

	if len(app.targets) == 0 {
		return nil, fmt.Errorf("target(s) must be supplied with -u, -f, or stdin\n\nSee 'sif -h' for more information")
	}

	// normalize every target in place: a naked host gains a default scheme, an
	// explicit scheme is kept, genuinely invalid input is rejected early.
	for i := 0; i < len(app.targets); i++ {
		normalized, err := normalizeTarget(app.targets[i])
		if err != nil {
			return nil, err
		}
		app.targets[i] = normalized
	}

	return app, nil
}

// defaultScheme is prepended to scheme-less targets. https is the safer default
// for recon: it's what modern hosts serve and avoids a cleartext first hop.
const defaultScheme = "https://"

// stdin ingestion is wired through two seams so it's hermetically testable: the
// pipe check and the reader can be swapped in tests without touching real fds.
var (
	stdinPipedFn           = stdinPiped
	stdinReader  io.Reader = os.Stdin
)

// stdinPiped reports whether stdin is a pipe/redirect rather than a terminal.
// a char device (the tty) means interactive with no piped input; anything else
// (pipe, file redirect) is treated as a target stream.
func stdinPiped() (bool, error) {
	info, err := os.Stdin.Stat()
	if err != nil {
		return false, fmt.Errorf("stat stdin: %w", err)
	}
	return info.Mode()&os.ModeCharDevice == 0, nil
}

// readTargets scans one target per line from r, dropping blank lines and
// trimming surrounding whitespace. shared by the stdin path; the file path keeps
// its own scanner since it preserves lines verbatim for back-compat.
func readTargets(r io.Reader) ([]string, error) {
	var out []string
	scanner := bufio.NewScanner(r)
	scanner.Split(bufio.ScanLines)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}
		out = append(out, line)
	}
	if err := scanner.Err(); err != nil {
		return nil, fmt.Errorf("scanning targets: %w", err)
	}
	return out, nil
}

// normalizeTarget canonicalizes a single target. a scheme-less host gets the
// default scheme; an http:// or https:// target is kept as-is. an empty string
// or a non-http(s) scheme (ftp://, file://, ...) is rejected so junk can't slip
// into the scan loop.
func normalizeTarget(target string) (string, error) {
	target = strings.TrimSpace(target)
	if target == "" {
		return "", fmt.Errorf("empty target provided")
	}
	if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
		return target, nil
	}
	// reject anything that carries some other scheme; "://" present but not
	// http(s) is a deliberate non-web target, not a naked host.
	if strings.Contains(target, "://") {
		return "", fmt.Errorf("target %s must use http:// or https:// scheme", target)
	}
	// a bare "host:port" or path-only token would also be ambiguous; require at
	// least a host-looking first segment (no spaces) before defaulting a scheme.
	if strings.ContainsAny(target, " \t") {
		return "", fmt.Errorf("invalid target %q", target)
	}
	return defaultScheme + target, nil
}

// Run runs the pentesting suite, with the targets specified, according to the
// settings specified.
func (app *App) Run() error {
	// Handle --list-modules before any other processing
	if app.settings.ListModules {
		loader, err := modules.NewLoader()
		if err != nil {
			return fmt.Errorf("failed to create module loader: %w", err)
		}
		if err := loader.LoadAll(); err != nil {
			log.Warnf("Failed to load modules: %v", err)
		}

		// Register built-in Go modules
		builtin.Register()

		fmt.Println("Available modules:")
		for _, m := range modules.All() {
			info := m.Info()
			fmt.Printf("  %-25s %s [%s]\n", info.ID, info.Name, strings.Join(info.Tags, ", "))
		}
		return nil
	}

	if app.settings.Debug {
		log.SetLevel(log.DebugLevel)
	}

	if app.settings.ApiMode {
		log.SetLevel(5)
	}

	if app.settings.LogDir != "" {
		if err := logger.Init(app.settings.LogDir); err != nil {
			return err
		}
		defer func() {
			if err := logger.Close(); err != nil {
				log.Errorf("closing logger: %v", err)
			}
		}()
	}

	// wire proxy/headers/cookie/rate-limit into the shared http client once,
	// before any scanner runs. a bad proxy/header shouldn't kill the run -
	// scanners fall back to a plain client if this fails.
	if err := httpx.Configure(httpx.Options{
		Proxy:     app.settings.Proxy,
		Headers:   app.settings.Header,
		Cookie:    app.settings.Cookie,
		RateLimit: app.settings.RateLimit,
		Threads:   app.settings.Threads,
	}); err != nil {
		log.Warnf("http client config failed, continuing with defaults: %v", err)
	}

	// target expansion - securitytrails discovers new domains before scanning
	if app.settings.SecurityTrails {
		expanded := app.expandTargets()
		if len(expanded) > 0 {
			output.Info("SecurityTrails discovered %d additional targets", len(expanded))
			app.targets = append(app.targets, expanded...)
		}
	}

	scansRun := make([]string, 0, 16)

	// accumulate every module result across targets so the report writers can
	// serialize the full run after the loop. only collected when an export flag
	// is set, so the common path pays nothing.
	wantReport := app.settings.SARIF != "" || app.settings.Markdown != ""
	reportResults := make([]report.Result, 0, 16)

	// normalized findings for the whole run; the single Flatten-driven view that
	// notify and diff consume. collected alongside the report so both describe the
	// same scanners from one pass.
	allFindings := make([]finding.Finding, 0, 16)

	// resolve the snapshot dir once when diff mode is on; a bad default isn't
	// fatal - diff just no-ops for the run rather than killing the scan.
	storeDir := ""
	if app.settings.Diff {
		dir, err := app.resolveStoreDir()
		if err != nil {
			log.Warnf("diff disabled: %v", err)
		} else {
			storeDir = dir
		}
	}

	for _, url := range app.targets {
		output.Info("Starting scan on %s", output.Highlight.Render(url))

		moduleResults := make([]ModuleResult, 0, 16)

		if app.settings.LogDir != "" {
			if err := logger.CreateFile(&app.logFiles, url, app.settings.LogDir); err != nil {
				return err
			}
		}

		if !app.settings.NoScan {
			scan.Scan(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			scansRun = append(scansRun, "Basic Scan")
		}

		if app.settings.Framework {
			result, err := frameworks.DetectFramework(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running framework detection: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Framework Detection")
			}
		}

		if app.settings.Dirlist != "none" {
			result, err := scan.Dirlist(app.settings.Dirlist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir, scan.DirlistOptions{
				MatchCodes:  app.settings.DirMatchCodes,
				FilterCodes: app.settings.DirFilterCodes,
				FilterSizes: app.settings.DirFilterSizes,
				FilterWords: app.settings.DirFilterWords,
				FilterRegex: app.settings.DirFilterRegex,
				Calibrate:   app.settings.DirCalibrate,
				Wordlist:    app.settings.DirWordlist,
				Extensions:  app.settings.DirExtensions,
			})
			if err != nil {
				log.Errorf("Error while running directory scan: %s", err)
			} else {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Directory Listing")
			}
		}

		var dnsResults []string

		if app.settings.Dnslist != "none" {
			result, err := scan.Dnslist(app.settings.Dnslist, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir, dnsx.ParseResolvers(app.settings.Resolvers))
			if err != nil {
				log.Errorf("Error while running dns scan: %s", err)
			} else {
				moduleResults = append(moduleResults, ModuleResult{"dnslist", result})
				dnsResults = result // Store the DNS results
				scansRun = append(scansRun, "DNS Scan")
			}

			// Only run subdomain takeover check if DNS scan is enabled
			if app.settings.SubdomainTakeover {
				result, err := scan.SubdomainTakeover(url, dnsResults, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
				if err != nil {
					log.Errorf("Error while running Subdomain Takeover Vulnerability Check: %s", err)
				} else {
					moduleResults = append(moduleResults, ModuleResult{"subdomain_takeover", result})
					scansRun = append(scansRun, "Subdomain Takeover")
				}
			}
		} else if app.settings.SubdomainTakeover {
			log.Warnf("Subdomain Takeover check is enabled but DNS scan is disabled. Skipping Subdomain Takeover check.")
		}

		if app.settings.Dorking {
			result, err := scan.Dork(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running Dork module: %s", err)
			} else {
				moduleResults = append(moduleResults, ModuleResult{"dork", result})
				scansRun = append(scansRun, "Dork")
			}
		}

		if app.settings.Ports != "none" {
			result, err := scan.Ports(context.Background(), app.settings.Ports, url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running port scan: %s", err)
			} else {
				moduleResults = append(moduleResults, ModuleResult{"portscan", result})
				scansRun = append(scansRun, "Port Scan")
			}
		}

		if app.settings.Whois {
			scan.Whois(url, app.settings.LogDir)
			scansRun = append(scansRun, "Whois")
		}

		if app.settings.Git {
			result, err := scan.Git(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running Git module: %s", err)
			} else {
				moduleResults = append(moduleResults, ModuleResult{"git", result})
				scansRun = append(scansRun, "Git")
			}
		}

		if app.settings.Nuclei {
			result, err := scan.Nuclei(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running Nuclei module: %s", err)
			} else {
				moduleResults = append(moduleResults, ModuleResult{"nuclei", result})
				scansRun = append(scansRun, "Nuclei")
			}
		}

		if app.settings.JavaScript {
			result, err := jsscan.JavascriptScan(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running JS module: %s", err)
			} else {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "JS")
			}
		}

		if app.settings.CMS {
			result, err := scan.CMS(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running CMS detection: %s", err)
				scansRun = append(scansRun, "CMS")
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
			}
		}

		if app.settings.Headers {
			result, err := scan.Headers(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running HTTP Header Analysis: %s", err)
			} else {
				moduleResults = append(moduleResults, ModuleResult{"headers", result})
				scansRun = append(scansRun, "HTTP Headers")
			}
		}

		if app.settings.SecurityHeaders {
			result, err := scan.SecurityHeaders(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running Security Header Analysis: %s", err)
			} else {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Security Headers")
			}
		}

		if app.settings.CloudStorage {
			result, err := scan.CloudStorage(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running C3 Scan: %s", err)
			} else {
				moduleResults = append(moduleResults, ModuleResult{"cloudstorage", result})
				scansRun = append(scansRun, "Cloud Storage")
			}
		}

		if app.settings.Shodan {
			result, err := scan.Shodan(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running Shodan lookup: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Shodan")
			}
		}

		if app.settings.SQL {
			result, err := scan.SQL(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running SQL reconnaissance: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "SQL Recon")
			}
		}

		if app.settings.LFI {
			result, err := scan.LFI(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running LFI reconnaissance: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "LFI Recon")
			}
		}

		if app.settings.JWT {
			result, err := scan.JWT(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running JWT analysis: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "JWT")
			}
		}

		if app.settings.OpenAPI {
			result, err := scan.OpenAPI(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running OpenAPI probe: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "OpenAPI")
			}
		}

		if app.settings.Favicon {
			result, err := scan.Favicon(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running favicon fingerprint: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Favicon")
			}
		}

		if app.settings.CORS {
			result, err := scan.CORS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running CORS probe: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "CORS")
			}
		}

		if app.settings.Redirect {
			result, err := scan.Redirect(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running open redirect probe: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Open Redirect")
			}
		}

		if app.settings.XSS {
			result, err := scan.XSS(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running reflected XSS probe: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Reflected XSS")
			}
		}

		if app.settings.Crawl {
			result, err := scan.Crawl(url, app.settings.CrawlDepth, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running web crawl: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Crawl")
			}
		}

		if app.settings.Passive {
			result, err := scan.Passive(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running passive discovery: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Passive")
			}
		}

		if app.settings.Probe {
			result, err := scan.Probe(url, app.settings.Timeout, app.settings.LogDir)
			if err != nil {
				log.Errorf("Error while running probe: %s", err)
			} else if result != nil {
				moduleResults = append(moduleResults, NewModuleResult(result))
				scansRun = append(scansRun, "Probe")
			}
		}

		// Load and run modules
		if app.settings.AllModules || app.settings.Modules != "" || app.settings.ModuleTags != "" {
			loader, err := modules.NewLoader()
			if err != nil {
				log.Warnf("Failed to create module loader: %v", err)
			} else {
				if err := loader.LoadAll(); err != nil {
					log.Warnf("Failed to load modules: %v", err)
				}

				// Register built-in Go modules
				builtin.Register()

				// Determine which modules to run
				var toRun []modules.Module
				switch {
				case app.settings.AllModules:
					toRun = modules.All()
				case app.settings.ModuleTags != "":
					for _, tag := range strings.Split(app.settings.ModuleTags, ",") {
						toRun = append(toRun, modules.ByTag(strings.TrimSpace(tag))...)
					}
				case app.settings.Modules != "":
					for _, id := range strings.Split(app.settings.Modules, ",") {
						if m, ok := modules.Get(strings.TrimSpace(id)); ok {
							toRun = append(toRun, m)
						} else {
							log.Warnf("Module not found: %s", id)
						}
					}
				}

				// Execute modules
				opts := modules.Options{
					Timeout: app.settings.Timeout,
					Threads: app.settings.Threads,
					LogDir:  app.settings.LogDir,
				}

				for _, m := range toRun {
					modLog := output.Module(m.Info().ID)
					modLog.Start()
					result, err := m.Execute(context.Background(), url, opts)
					if err != nil {
						modLog.Error("failed: %v", err)
						continue
					}
					if result != nil && len(result.Findings) > 0 {
						moduleResults = append(moduleResults, NewModuleResult(result))
						modLog.Complete(len(result.Findings), "findings")
					} else {
						modLog.Complete(0, "findings")
					}
				}
			}
		}

		if app.settings.ApiMode {
			result := UrlResult{
				Url:     url,
				Results: moduleResults,
			}

			marshalled, err := json.Marshal(result)
			if err != nil {
				log.Errorf("failed to marshal result: %s", err)
				continue
			}
			fmt.Println(string(marshalled))
		}

		targetFindings := collectFindings(url, moduleResults)
		allFindings = append(allFindings, targetFindings...)

		// diff mode is per-target: load this target's last snapshot, surface only
		// the delta, then overwrite the snapshot so the next run diffs against now.
		// storeDir is "" when diff is off or the dir couldn't resolve, in which
		// case this is a no-op and behavior is unchanged.
		if storeDir != "" {
			app.diffTarget(storeDir, url, targetFindings)
		}

		// the report carries raw blobs and is only built when an export flag is
		// set, so the common path skips the marshalling entirely.
		if wantReport {
			reportResults = append(reportResults, collectReportResults(url, moduleResults)...)
		}
	}

	// the normalized findings are the handoff point for notify/diff; surface the
	// count now so the path is live and observable without changing output.
	log.Debugf("normalized %d findings across %d targets", len(allFindings), len(app.targets))

	// notify: ship the severity-filtered findings to any configured provider.
	// kept as an isolated block so it merges cleanly with the diff-store bundle.
	if app.settings.Notify {
		if err := app.notifyFindings(context.Background(), allFindings); err != nil {
			log.Errorf("notify: %v", err)
		}
	}

	// -silent: stdout is the findings stream, one terse line each. all chrome
	// already went to stderr via the rerouted sink, so this is the only thing a
	// downstream pipe sees.
	if app.settings.Silent {
		printFindings(allFindings)
	}

	if wantReport {
		if err := app.writeReports(reportResults); err != nil {
			return err
		}
	}

	if !app.settings.ApiMode {
		output.PrintSummary(scansRun, app.logFiles)
	}

	return nil
}

// printFindings writes one normalized finding per line to stdout for the
// -silent plain sink. a single Builder over the run avoids interleaving with
// any stray stderr chrome and keeps the write to one syscall.
func printFindings(findings []finding.Finding) {
	var b strings.Builder
	for i := 0; i < len(findings); i++ {
		b.WriteString(findings[i].Line())
		b.WriteByte('\n')
	}
	fmt.Print(b.String())
}

// collectFindings normalizes one target's module results through finding.Flatten
// - the single normalization path that notify and diff build on. every scan
// result struct collapses to flat, severity-ranked findings here so a scanner is
// described once, not once per consumer.
func collectFindings(target string, moduleResults []ModuleResult) []finding.Finding {
	out := make([]finding.Finding, 0, len(moduleResults))
	for _, mr := range moduleResults {
		out = append(out, finding.Flatten(target, mr.Id, mr.Data)...)
	}
	return out
}

// resolveStoreDir picks the snapshot directory for diff mode. precedence: an
// explicit -store wins; else the run's log dir is reused (snapshots live next to
// logs); else the per-user default under <user-config>/sif/state. returns an
// error only when no usable location exists, so the caller can disable diff
// without failing the scan.
func (app *App) resolveStoreDir() (string, error) {
	if app.settings.Store != "" {
		return app.settings.Store, nil
	}
	if app.settings.LogDir != "" {
		return app.settings.LogDir, nil
	}
	dir, err := store.DefaultDir()
	if err != nil {
		return "", fmt.Errorf("resolving snapshot dir: %w", err)
	}
	return dir, nil
}

// diffTarget loads target's previous snapshot, prints the added/removed delta
// against the current findings, then overwrites the snapshot so the next run
// diffs against this one. a load failure surfaces but doesn't abort the run -
// the new snapshot is still written so a corrupt baseline self-heals. always
// saves, even when the delta is empty, to advance the baseline.
func (app *App) diffTarget(dir, target string, current []finding.Finding) {
	previous, err := store.Load(dir, target)
	if err != nil {
		log.Warnf("diff: reading snapshot for %s, treating as fresh: %v", target, err)
		previous = nil
	}

	added, removed := store.Diff(previous, current)
	printDiff(target, added, removed)

	if err := store.Save(dir, target, current); err != nil {
		log.Warnf("diff: saving snapshot for %s: %v", target, err)
	}
}

// printDiff renders a target's diff: each added finding marked "+ new", each
// removed one "- gone", with a one-line note when nothing changed. routed
// through the shared output sink so -silent keeps it on stderr alongside the
// other chrome. a single Builder keeps the block from interleaving.
func printDiff(target string, added, removed []finding.Finding) {
	if len(added) == 0 && len(removed) == 0 {
		output.Info("diff %s: no changes since last snapshot", target)
		return
	}

	var b strings.Builder
	fmt.Fprintf(&b, "diff %s: %d new, %d gone\n", target, len(added), len(removed))
	for i := 0; i < len(added); i++ {
		fmt.Fprintf(&b, "  + new  %s\n", added[i].Line())
	}
	for i := 0; i < len(removed); i++ {
		fmt.Fprintf(&b, "  - gone %s\n", removed[i].Line())
	}
	fmt.Fprint(output.Writer(), b.String())
}

// collectReportResults flattens one target's module results into the report
// model, carrying each finding as raw json so the report package stays free of
// scan types. a result that won't marshal is skipped rather than failing the run.
func collectReportResults(target string, moduleResults []ModuleResult) []report.Result {
	out := make([]report.Result, 0, len(moduleResults))
	for _, mr := range moduleResults {
		data, err := json.Marshal(mr.Data)
		if err != nil {
			log.Warnf("report: skipping %s result for %s: %v", mr.Id, target, err)
			continue
		}
		out = append(out, report.Result{Target: target, Module: mr.Id, Data: data})
	}
	return out
}

// writeReports serializes the collected results to the requested export files.
// each writer runs independently so a bad path for one format doesn't suppress
// the other.
func (app *App) writeReports(results []report.Result) error {
	if path := app.settings.SARIF; path != "" {
		data, err := report.SARIF(results)
		if err != nil {
			return fmt.Errorf("build sarif report: %w", err)
		}
		if err := os.WriteFile(path, data, reportFileMode); err != nil {
			return fmt.Errorf("write sarif report %q: %w", path, err)
		}
		output.Success("sarif report written to %s", path)
	}

	if path := app.settings.Markdown; path != "" {
		data := report.Markdown(results)
		if err := os.WriteFile(path, data, reportFileMode); err != nil {
			return fmt.Errorf("write markdown report %q: %w", path, err)
		}
		output.Success("markdown report written to %s", path)
	}

	return nil
}

// notifyFindings filters the run's findings to the -notify-severity floor and
// ships the survivors to every configured provider. an unrecognized severity
// string parses to SeverityUnknown, which would let everything through; guard
// against that by defaulting to medium so a typo can't flood a channel with
// info noise. an empty filtered set makes notify.Send a no-op.
func (app *App) notifyFindings(ctx context.Context, findings []finding.Finding) error {
	floor := finding.ParseSeverity(app.settings.NotifySeverity)
	if floor == finding.SeverityUnknown {
		log.Warnf("notify: unknown severity %q, defaulting to medium", app.settings.NotifySeverity)
		floor = finding.SeverityMedium
	}

	filtered := make([]finding.Finding, 0, len(findings))
	for i := 0; i < len(findings); i++ {
		if findings[i].Severity.AtLeast(floor) {
			filtered = append(filtered, findings[i])
		}
	}

	return notify.Send(ctx, filtered, notify.Options{
		Timeout:    app.settings.Timeout,
		ConfigPath: app.settings.NotifyConfig,
	})
}

// expandTargets queries SecurityTrails for each original target and returns
// newly discovered domains (subdomains + associated) for target expansion
func (app *App) expandTargets() []string {
	seen := make(map[string]struct{})
	for _, t := range app.targets {
		seen[t] = struct{}{}
	}

	// snapshot original targets - don't expand discovered ones
	originals := make([]string, len(app.targets))
	copy(originals, app.targets)

	var expanded []string

	for _, url := range originals {
		result, err := scan.SecurityTrails(url, app.settings.Timeout, app.settings.LogDir)
		if err != nil {
			log.Errorf("SecurityTrails error for %s: %v", url, err)
			continue
		}
		if result == nil {
			continue
		}

		for _, d := range result.DiscoveredURLs() {
			if _, exists := seen[d]; !exists {
				seen[d] = struct{}{}
				expanded = append(expanded, d)
			}
		}
	}

	return expanded
}
