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

package frameworks

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"sort"
	"strings"
	"sync"
	"time"

	charmlog "github.com/charmbracelet/log"
	"github.com/vmfunc/sif/internal/httpx"
	"github.com/vmfunc/sif/internal/logger"
	"github.com/vmfunc/sif/internal/output"
)

// detectionThreshold is the minimum confidence for a detection to be reported.
const detectionThreshold = 0.5

// maxBodySize limits response body to prevent memory exhaustion.
const maxBodySize = 5 * 1024 * 1024

// detectionResult holds the result from a single detector.
type detectionResult struct {
	name       string
	confidence float32
	version    string
}

// loadCustomOnce loads the user signature directory the first time a scan runs,
// so config-defined detectors join the registry without a per-target re-read.
var loadCustomOnce sync.Once

// gatherDetections fetches the target once and runs every registered detector
// against the response, returning each detector's raw confidence. it owns the
// spinner so the single- and multi-result entry points share one fetch. an
// empty result slice means no detectors were registered.
func gatherDetections(url string, timeout time.Duration) ([]detectionResult, string, error) {
	spin := output.NewSpinner("Detecting frameworks")
	spin.Start()
	defer spin.Stop()

	client := httpx.Client(timeout)

	req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, http.NoBody)
	if err != nil {
		return nil, "", err
	}
	resp, err := client.Do(req) //nolint:bodyclose // closed via defer below
	if err != nil {
		return nil, "", err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
	if err != nil {
		return nil, "", err
	}
	bodyStr := string(body)

	detectors := GetDetectors()
	if len(detectors) == 0 {
		return nil, bodyStr, nil
	}

	// Run all detectors concurrently
	results := make(chan detectionResult, len(detectors))
	var wg sync.WaitGroup

	for _, detector := range detectors {
		wg.Add(1)
		go func(d Detector) {
			defer wg.Done()
			confidence, version := d.Detect(bodyStr, resp.Header)
			results <- detectionResult{
				name:       d.Name(),
				confidence: confidence,
				version:    version,
			}
		}(detector)
	}

	// Close results channel when all goroutines complete
	go func() {
		wg.Wait()
		close(results)
	}()

	out := make([]detectionResult, 0, len(detectors))
	for r := range results {
		out = append(out, r)
	}
	return out, bodyStr, nil
}

// DetectFramework runs all registered detectors against the target URL and
// returns the single highest-confidence match. see DetectFrameworks for the
// full set of detected technologies.
func DetectFramework(url string, timeout time.Duration, logdir string) (*FrameworkResult, error) {
	loadCustomOnce.Do(loadCustomDetectors)

	log := output.Module("FRAMEWORK")
	log.Start()

	results, bodyStr, err := gatherDetections(url, timeout)
	if err != nil {
		return nil, err
	}
	if len(results) == 0 {
		log.Warn("No framework detectors registered")
		return nil, nil //nolint:nilnil // no detectors registered is not an error
	}

	// Find the best match. tie-break on name so the winner is deterministic when
	// two detectors land on the same confidence.
	var best detectionResult
	for _, r := range results {
		if r.confidence > best.confidence || (r.confidence == best.confidence && r.name < best.name) {
			best = r
		}
	}

	if best.confidence <= detectionThreshold {
		log.Info("No framework detected with sufficient confidence")
		log.Complete(0, "detected")
		return nil, nil //nolint:nilnil // no framework detected is not an error
	}

	result := assembleResult(best, bodyStr, url, logdir, log)
	log.Complete(1, "detected")

	return result, nil
}

// DetectFrameworks runs all registered detectors and returns every framework
// that clears the detection threshold, ranked most-confident first. unlike
// DetectFramework it does not collapse to a single winner, so a page built from
// several technologies (say react behind a next.js server) reports each.
func DetectFrameworks(url string, timeout time.Duration, logdir string) ([]*FrameworkResult, error) {
	loadCustomOnce.Do(loadCustomDetectors)

	log := output.Module("FRAMEWORK")
	log.Start()

	results, bodyStr, err := gatherDetections(url, timeout)
	if err != nil {
		return nil, err
	}
	if len(results) == 0 {
		log.Warn("No framework detectors registered")
		return nil, nil //nolint:nilnil // no detectors registered is not an error
	}

	// keep everything above the threshold, most-confident first with a name
	// tie-break so the order is stable across runs.
	detected := make([]detectionResult, 0, len(results))
	for _, r := range results {
		if r.confidence > detectionThreshold {
			detected = append(detected, r)
		}
	}
	sort.Slice(detected, func(i, j int) bool {
		if detected[i].confidence != detected[j].confidence {
			return detected[i].confidence > detected[j].confidence
		}
		return detected[i].name < detected[j].name
	})

	if len(detected) == 0 {
		log.Info("No framework detected with sufficient confidence")
		log.Complete(0, "detected")
		return nil, nil //nolint:nilnil // no framework detected is not an error
	}

	out := make([]*FrameworkResult, 0, len(detected))
	for i := range detected {
		out = append(out, assembleResult(detected[i], bodyStr, url, logdir, log))
	}
	log.Complete(len(out), "detected")

	return out, nil
}

// assembleResult turns one detector hit into a full FrameworkResult: it digs out
// the concrete version, looks up CVEs for it, and logs the detection. the
// detector's own version is often "unknown" (it fingerprints the framework, not
// always the version), while ExtractVersionOptimized reads the real version from
// the body; prefer that for both the reported version and the cve lookup, else
// CVEs that only match a concrete version are silently missed.
func assembleResult(d detectionResult, bodyStr, url, logdir string, log *output.ModuleLogger) *FrameworkResult {
	versionMatch := ExtractVersionOptimized(bodyStr, d.name)
	version := resolveVersion(d.version, versionMatch.Version)
	cves, suggestions := getVulnerabilities(d.name, version)

	result := NewFrameworkResult(d.name, version, d.confidence, versionMatch.Confidence)
	result.WithVulnerabilities(cves, suggestions)

	if logdir != "" {
		logEntry := fmt.Sprintf("Detected framework: %s (version: %s, confidence: %.2f, version_confidence: %.2f)\n",
			d.name, version, d.confidence, versionMatch.Confidence)
		if len(cves) > 0 {
			logEntry += fmt.Sprintf("  Risk Level: %s\n", result.RiskLevel)
			logEntry += fmt.Sprintf("  CVEs: %v\n", cves)
			logEntry += fmt.Sprintf("  Recommendations: %v\n", suggestions)
		}
		_ = logger.Write(url, logdir, logEntry)
	}

	log.Success("Detected %s framework (version: %s, confidence: %.2f)",
		output.Highlight.Render(d.name), version, d.confidence)

	if versionMatch.Confidence > 0 {
		charmlog.Debugf("Version detected from: %s (confidence: %.2f)",
			versionMatch.Source, versionMatch.Confidence)
	}

	if len(cves) > 0 {
		log.Warn("Risk level: %s", output.SeverityHigh.Render(result.RiskLevel))
		for _, cve := range cves {
			log.Warn("Found potential vulnerability: %s", output.Highlight.Render(cve))
		}
		for _, suggestion := range suggestions {
			log.Info("Recommendation: %s", suggestion)
		}
	}

	return result
}

// unknownVersion is the sentinel both detectors and the version extractor emit
// when no concrete version could be read from the response.
const unknownVersion = "unknown"

// resolveVersion picks the version to report and look CVEs up against. the
// detector's own value wins when it's concrete; otherwise we fall back to the
// version dug out of the body by ExtractVersionOptimized. either being
// "unknown"/empty means "no info", not a real version.
func resolveVersion(detectorVersion, extractedVersion string) string {
	if detectorVersion != "" && detectorVersion != unknownVersion {
		return detectorVersion
	}
	if extractedVersion != "" && extractedVersion != unknownVersion {
		return extractedVersion
	}
	return unknownVersion
}

// getVulnerabilities returns CVEs and recommendations for a framework version.
func getVulnerabilities(framework, version string) ([]string, []string) {
	entries, exists := knownCVEs[framework]
	if !exists {
		return nil, nil
	}

	var cves []string
	var recommendations []string
	seenRecs := make(map[string]bool)

	for _, entry := range entries {
		for _, affectedVer := range entry.AffectedVersions {
			if versionAffected(version, affectedVer) {
				cves = append(cves, fmt.Sprintf("%s (%s)", entry.CVE, entry.Severity))
				for _, rec := range entry.Recommendations {
					if !seenRecs[rec] {
						recommendations = append(recommendations, rec)
						seenRecs[rec] = true
					}
				}
				break
			}
		}
	}

	return cves, recommendations
}

// versionAffected reports whether version falls under an affected-version
// entry. matching is on dotted component boundaries in either direction, so
// "4.2" covers 4.2 and 4.2.1 but not 4.20, and a coarser version covers the
// listed sub-versions. the latter matters because some detectors only recover
// a bare major (Drupal's generator emits "10"); without it a bare major never
// matches a dotted affected entry and the CVE is silently missed.
func versionAffected(version, affected string) bool {
	return version == affected ||
		strings.HasPrefix(version, affected+".") ||
		strings.HasPrefix(affected, version+".")
}
