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

package js

import (
	"bufio"
	"context"
	"io"
	"net/http"
	"slices"
	"strings"
	"time"

	"github.com/antchfx/htmlquery"
	charmlog "github.com/charmbracelet/log"
	"github.com/dropalldatabases/sif/internal/httpx"
	"github.com/dropalldatabases/sif/internal/output"
	"github.com/dropalldatabases/sif/internal/scan/js/frameworks"
	urlutil "github.com/projectdiscovery/utils/url"
)

type JavascriptScanResult struct {
	SupabaseResults      []supabaseScanResult `json:"supabase_results"`
	FoundEnvironmentVars map[string]string    `json:"environment_variables"`
	SecretMatches        []SecretMatch        `json:"secret_matches"`
	Endpoints            []string             `json:"endpoints"`
}

// ResultType implements the ScanResult interface.
func (r *JavascriptScanResult) ResultType() string { return "js" }

// SupabaseFinding is the exported view of one discovered supabase project. the
// raw supabaseScanResult stays package-private (it carries scan internals), so
// downstream normalizers consume this projection instead.
type SupabaseFinding struct {
	ProjectId   string
	Role        string
	Collections int
}

// SupabaseFindings projects the package-private supabase results into a stable
// exported shape for the finding normalizer; role is what makes one interesting
// (a non-anon key is the real bug).
func (r *JavascriptScanResult) SupabaseFindings() []SupabaseFinding {
	out := make([]SupabaseFinding, 0, len(r.SupabaseResults))
	for i := 0; i < len(r.SupabaseResults); i++ {
		s := r.SupabaseResults[i]
		out = append(out, SupabaseFinding{
			ProjectId:   s.ProjectId,
			Role:        s.Role,
			Collections: len(s.Collections),
		})
	}
	return out
}

func JavascriptScan(url string, timeout time.Duration, threads int, logdir string) (*JavascriptScanResult, error) {
	log := output.Module("JS")
	log.Start()

	spin := output.NewSpinner("Scanning JavaScript files")
	spin.Start()

	client := httpx.Client(timeout)

	baseUrl, err := urlutil.Parse(url)
	if err != nil {
		spin.Stop()
		return nil, err
	}
	req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, http.NoBody)
	if err != nil {
		spin.Stop()
		return nil, err
	}
	resp, err := client.Do(req)
	if err != nil {
		spin.Stop()
		return nil, err
	}
	defer resp.Body.Close()

	var sb strings.Builder
	scanner := bufio.NewScanner(resp.Body)
	scanner.Split(bufio.ScanLines)
	for scanner.Scan() {
		sb.WriteString(scanner.Text())
	}
	html := sb.String()

	doc, err := htmlquery.Parse(strings.NewReader(html))
	if err != nil {
		return nil, err
	}

	var scripts []string
	nodes, err := htmlquery.QueryAll(doc, "//script/@src")
	if err != nil {
		return nil, err
	}
	for _, node := range nodes {
		var src = htmlquery.InnerText(node)
		url, err := urlutil.Parse(src)
		if err != nil {
			continue
		}

		if url.IsRelative {
			url.Host = baseUrl.Host
			url.Scheme = baseUrl.Scheme
		}
		scripts = append(scripts, url.String())
	}

	for _, script := range scripts {
		if strings.Contains(script, "/_buildManifest.js") {
			log.Info("Detected Next.JS pages router! Getting all scripts from %s", script)
			nextScripts, err := frameworks.GetPagesRouterScripts(script)
			if err != nil {
				spin.Stop()
				return nil, err
			}

			for _, nextScript := range nextScripts {
				if slices.Contains(scripts, nextScript) {
					continue
				}
				scripts = append(scripts, nextScript)
			}
		}
	}

	log.Info("Got %d scripts, now running scans on them", len(scripts))

	supabaseResults := make([]supabaseScanResult, 0, len(scripts))
	secretMatches := make([]SecretMatch, 0)
	endpoints := make([]string, 0)
	// dedupe secrets and endpoints across every script, not just within one.
	seenSecrets := make(map[string]struct{})
	seenEndpoints := make(map[string]struct{})
	for _, script := range scripts {
		charmlog.Debugf("Scanning %s", script)
		req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, script, http.NoBody)
		if err != nil {
			charmlog.Warnf("Failed to create request: %s", err)
			continue
		}
		resp, err := client.Do(req)
		if err != nil {
			charmlog.Warnf("Failed to fetch script: %s", err)
			continue
		}

		bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
		resp.Body.Close()
		if err != nil {
			charmlog.Errorf("Failed to read script body: %s", err)
			continue
		}
		content := string(bodyBytes)

		charmlog.Debugf("Running supabase scanner on %s", script)
		scriptSupabaseResults, err := ScanSupabase(content, script, timeout)

		if err != nil {
			charmlog.Errorf("Error while scanning supabase: %s", err)
		}

		if scriptSupabaseResults != nil {
			supabaseResults = append(supabaseResults, scriptSupabaseResults...)
		}

		// reuse the same script buffer for credential and endpoint extraction.
		for _, match := range ScanSecrets(content, script) {
			key := match.Rule + "\x00" + match.Match
			if _, ok := seenSecrets[key]; ok {
				continue
			}
			seenSecrets[key] = struct{}{}
			secretMatches = append(secretMatches, match)
			log.Warn("found %s in %s", match.Rule, script)
		}

		for _, endpoint := range ExtractEndpoints(content, script) {
			if _, ok := seenEndpoints[endpoint]; ok {
				continue
			}
			seenEndpoints[endpoint] = struct{}{}
			endpoints = append(endpoints, endpoint)
		}
	}

	spin.Stop()

	if len(endpoints) > 0 {
		log.Info("extracted %d endpoints", len(endpoints))
	}

	result := JavascriptScanResult{
		SupabaseResults:      supabaseResults,
		FoundEnvironmentVars: map[string]string{},
		SecretMatches:        secretMatches,
		Endpoints:            endpoints,
	}

	log.Complete(len(supabaseResults)+len(secretMatches)+len(endpoints), "found")

	return &result, nil
}
