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

package scan

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"time"

	"github.com/vmfunc/sif/internal/fingerprint"
	"github.com/vmfunc/sif/internal/httpx"
	"github.com/vmfunc/sif/internal/logger"
	"github.com/vmfunc/sif/internal/output"
)

// FaviconResult is the computed shodan-style favicon hash plus the pivot query
// and any matched tech.
type FaviconResult struct {
	FaviconURL string `json:"favicon_url"` // where the icon was fetched
	Hash       int32  `json:"hash"`        // shodan mmh3 hash (signed int32)
	Tech       string `json:"tech"`        // matched technology, empty when unknown
	ShodanQ    string `json:"shodan_query"`
}

// faviconBodyReadCap bounds the icon read. real favicons are tens of kilobytes;
// a megabyte ceiling covers oversized ones without letting a hostile endpoint
// stream forever.
const faviconBodyReadCap = 1 << 20

// faviconLinkRegex pulls the href off a <link rel="...icon..."> tag so we can
// fall back to a declared icon when /favicon.ico is absent.
var faviconLinkRegex = regexp.MustCompile(`(?i)<link[^>]+rel=["'][^"']*icon[^"']*["'][^>]*>`)

// faviconHrefRegex extracts the href attribute value from a matched link tag.
var faviconHrefRegex = regexp.MustCompile(`(?i)href=["']([^"']+)["']`)

// Favicon fetches the target's favicon, computes the shodan mmh3 hash and matches
// it against the bundled fingerprint map.
func Favicon(targetURL string, timeout time.Duration, logdir string) (*FaviconResult, error) {
	log := output.Module("FAVICON")
	log.Start()

	sanitizedURL := stripScheme(targetURL)

	if logdir != "" {
		if err := logger.WriteHeader(sanitizedURL, logdir, "Favicon hash fingerprint"); err != nil {
			log.Error("error creating log file: %v", err)
			return nil, fmt.Errorf("create favicon log: %w", err)
		}
	}

	client := httpx.Client(timeout)
	base := strings.TrimRight(targetURL, "/")

	iconURL, data, err := fetchFavicon(client, base)
	if err != nil {
		log.Info("no favicon found: %v", err)
		log.Complete(0, "found")
		return nil, nil //nolint:nilnil // a missing favicon is not an error
	}

	hash := fingerprint.FaviconHash(data)
	tech, _ := fingerprint.LookupFaviconTech(hash)
	result := &FaviconResult{
		FaviconURL: iconURL,
		Hash:       hash,
		Tech:       tech,
		ShodanQ:    fmt.Sprintf("http.favicon.hash:%d", hash),
	}

	if result.Tech != "" {
		log.Warn("favicon hash %d matches %s", hash, output.Highlight.Render(result.Tech))
	} else {
		log.Info("favicon hash %d (no fingerprint match)", hash)
	}
	log.Info("shodan pivot: %s", output.Highlight.Render(result.ShodanQ))

	if logdir != "" {
		_ = logger.Write(sanitizedURL, logdir,
			fmt.Sprintf("Favicon %s hash=%d tech=%q query=%s\n", iconURL, hash, result.Tech, result.ShodanQ))
	}

	log.Complete(1, "hashed")
	return result, nil
}

// fetchFavicon tries /favicon.ico first, then the <link rel=icon> declared in the
// homepage html. it returns the url it pulled the bytes from so the report shows
// exactly which icon was hashed.
func fetchFavicon(client *http.Client, base string) (string, []byte, error) {
	// the well-known icon always lives at the origin root, so resolve it there
	// rather than appending to a target that may carry a path.
	iconURL := resolveFaviconURL(base, "/favicon.ico")
	if data, err := getFaviconBytes(client, iconURL); err == nil {
		return iconURL, data, nil
	}

	// no /favicon.ico; parse the homepage for a declared icon link.
	href, err := declaredFaviconHref(client, base)
	if err != nil {
		return "", nil, err
	}
	iconURL = resolveFaviconURL(base, href)
	data, err := getFaviconBytes(client, iconURL)
	if err != nil {
		return "", nil, err
	}
	return iconURL, data, nil
}

// getFaviconBytes GETs an icon url and returns the body, erroring on a non-200 or
// an empty body so a soft-404 html page isn't hashed as if it were an icon.
func getFaviconBytes(client *http.Client, iconURL string) ([]byte, error) {
	req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, iconURL, http.NoBody)
	if err != nil {
		return nil, fmt.Errorf("build favicon request: %w", err)
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("fetch favicon: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("favicon status %d", resp.StatusCode)
	}
	data, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap))
	if err != nil {
		return nil, fmt.Errorf("read favicon: %w", err)
	}
	if len(data) == 0 {
		return nil, fmt.Errorf("empty favicon body")
	}
	return data, nil
}

// declaredFaviconHref fetches the homepage and extracts the href of the first
// <link rel="...icon..."> tag.
func declaredFaviconHref(client *http.Client, base string) (string, error) {
	req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, base, http.NoBody)
	if err != nil {
		return "", fmt.Errorf("build homepage request: %w", err)
	}
	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("fetch homepage: %w", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap))
	if err != nil {
		return "", fmt.Errorf("read homepage: %w", err)
	}

	link := faviconLinkRegex.Find(body)
	if link == nil {
		return "", fmt.Errorf("no favicon link in homepage")
	}
	href := faviconHrefRegex.FindSubmatch(link)
	if href == nil {
		return "", fmt.Errorf("favicon link has no href")
	}
	return string(href[1]), nil
}

// resolveFaviconURL turns a possibly-relative href into an absolute url against
// the target base, browser-style (root-relative anchors at the origin, not the
// target's path). unparsable input falls back to the raw href.
func resolveFaviconURL(base, href string) string {
	baseURL, err := url.Parse(base)
	if err != nil {
		return href
	}
	ref, err := url.Parse(href)
	if err != nil {
		return href
	}
	return baseURL.ResolveReference(ref).String()
}

// ResultType identifies favicon findings for the result registry.
func (r *FaviconResult) ResultType() string { return "favicon" }

var _ ScanResult = (*FaviconResult)(nil)
