// This file is Free Software under the Apache-2.0 License
// without warranty, see README.md and LICENSES/Apache-2.0.txt for details.
//
// SPDX-License-Identifier: Apache-2.0
//
// SPDX-FileCopyrightText: 2023 German Federal Office for Information Security (BSI) <https://www.bsi.bund.de>
// Software-Engineering: 2023 Intevation GmbH <https://intevation.de>

package main

import (
	"context"
	"errors"
	"net/http"
	"net/url"
	"sort"
	"strings"

	"github.com/gocsaf/csaf/v3/csaf"
	"github.com/gocsaf/csaf/v3/util"
)

// identifier consist of document/tracking/id and document/publisher/namespace,
// which in sum are unique for each csaf document and the name of a csaf document
type identifier struct {
	id        string
	namespace string
}

// String implements fmt.Stringer
func (id identifier) String() string {
	return "(" + id.namespace + ", " + id.id + ")"
}

// labelChecker helps to check if advisories are of the right TLP color.
type labelChecker struct {
	feedURL   string
	feedLabel csaf.TLPLabel

	advisories      map[csaf.TLPLabel]util.Set[string]
	whiteAdvisories map[identifier]bool
}

// reset brings the checker back to an initial state.
func (lc *labelChecker) reset() {
	lc.feedLabel = ""
	lc.feedURL = ""
	lc.advisories = map[csaf.TLPLabel]util.Set[string]{}
	lc.whiteAdvisories = map[identifier]bool{}
}

// tlpLevel returns an inclusion order of TLP colors.
func tlpLevel(label csaf.TLPLabel) int {
	switch label {
	case csaf.TLPLabelWhite:
		return 1
	case csaf.TLPLabelGreen:
		return 2
	case csaf.TLPLabelAmber:
		return 3
	case csaf.TLPLabelRed:
		return 4
	default:
		return 0
	}
}

// extractTLP extracts the tlp label of the given document
// and defaults to UNLABELED if not found.
func (p *processor) extractTLP(doc any) csaf.TLPLabel {
	labelString, err := p.expr.Eval(`$.document.distribution.tlp.label`, doc)
	if err != nil {
		return csaf.TLPLabelUnlabeled
	}
	label, ok := labelString.(string)
	if !ok {
		return csaf.TLPLabelUnlabeled
	}
	return csaf.TLPLabel(label)
}

// check tests if the TLP label of an advisory is used correctly.
func (lc *labelChecker) check(
	ctx context.Context,
	p *processor,
	doc any,
	url string,
) {
	label := p.extractTLP(doc)

	// Check the permissions.
	lc.checkPermissions(ctx, p, label, doc, url)

	// Associate advisory label to urls.
	lc.add(label, url)

	// If entry shows up in feed of higher tlp level, give out info or warning.
	lc.checkRank(p, label, url)
}

// checkPermissions checks for mistakes in access-protection.
func (lc *labelChecker) checkPermissions(
	ctx context.Context,
	p *processor,
	label csaf.TLPLabel,
	doc any,
	url string,
) {
	switch label {
	case csaf.TLPLabelAmber, csaf.TLPLabelRed:
		// If the client has no authorization it shouldn't be able
		// to access TLP:AMBER or TLP:RED advisories
		p.badAmberRedPermissions.use()
		if !p.usedAuthorizedClient() {
			p.badAmberRedPermissions.error(
				"Advisory %s of TLP level %v is not access protected.",
				url, label)
		} else {
			res, err := p.unauthorizedClient().GetWithContext(ctx, url)
			if err != nil {
				p.badAmberRedPermissions.error(
					"Unexpected Error %v when trying to fetch: %s", err, url)
			} else {
				if res.StatusCode == http.StatusOK {
					p.badAmberRedPermissions.error(
						"Advisory %s of TLP level %v is not properly access protected.",
						url, label)
				}
				res.Body.Close()
			}
		}

	case csaf.TLPLabelWhite:
		// If we found a white labeled document we need to track it
		// to find out later if there was an unprotected way to access it.

		p.badWhitePermissions.use()
		// Being not able to extract the identifier from the document
		// indicates that the document is not valid. Should not happen
		// as the schema validation passed before.
		p.invalidAdvisories.use()
		if id, err := p.extractAdvisoryIdentifier(doc); err != nil {
			p.invalidAdvisories.error("Bad document %s: %v", url, err)
		} else if !lc.whiteAdvisories[id] {
			// Only do check if we haven't seen it as accessible before.

			if !p.usedAuthorizedClient() {
				// We already downloaded it without protection
				lc.whiteAdvisories[id] = true
			} else {
				// Need to try to re-download it unauthorized.
				if resp, err := p.unauthorizedClient().GetWithContext(ctx, url); err == nil {
					accessible := resp.StatusCode == http.StatusOK
					lc.whiteAdvisories[id] = accessible
					// If we are in a white rolie feed or in a dirlisting
					// directly warn if we cannot access it.
					// The cases of being in an amber or red feed are resolved.
					if !accessible &&
						(lc.feedLabel == "" || lc.feedLabel == csaf.TLPLabelWhite) {
						p.badWhitePermissions.warn(
							"Advisory %s of TLP level WHITE is access-protected.", url)
					}
					resp.Body.Close()
				}
			}
		}
	}
}

// add registers a given url to a label.
func (lc *labelChecker) add(label csaf.TLPLabel, url string) {
	advs := lc.advisories[label]
	if advs == nil {
		advs = util.Set[string]{}
		lc.advisories[label] = advs
	}
	advs.Add(url)
}

// checkRank tests if a given advisory is contained by the
// the right feed color.
func (lc *labelChecker) checkRank(
	p *processor,
	label csaf.TLPLabel,
	url string,
) {
	// Only do this check when we are inside a ROLIE feed.
	if lc.feedLabel == "" {
		return
	}

	switch advisoryRank, feedRank := tlpLevel(label), tlpLevel(lc.feedLabel); {

	case advisoryRank < feedRank:
		if advisoryRank == 0 { // All kinds of 'UNLABELED'
			p.badROLIEFeed.info(
				"Found unlabeled advisory %q in feed %q.",
				url, lc.feedURL)
		} else {
			p.badROLIEFeed.warn(
				"Found advisory %q labled TLP:%s in feed %q (TLP:%s).",
				url, label,
				lc.feedURL, lc.feedLabel)
		}

	case advisoryRank > feedRank:
		// Must not happen, give error
		p.badROLIEFeed.error(
			"%s of TLP level %s must not be listed in feed %s of TLP level %s",
			url, label, lc.feedURL, lc.feedLabel)
	}
}

// defaults returns the value of the referencend pointer p
// if it is not nil, def otherwise.
func defaults[T any](p *T, def T) T {
	if p != nil {
		return *p
	}
	return def
}

// processROLIEFeeds goes through all ROLIE feeds and checks their
// integrity and completeness.
func (p *processor) processROLIEFeeds(ctx context.Context, feeds [][]csaf.Feed) error {
	p.badROLIEFeed.use()

	advisories := map[*csaf.Feed][]csaf.AdvisoryFile{}

	// Phase 1: load all advisories urls.
	for _, fs := range feeds {
		for i := range fs {
			feed := &fs[i]
			if feed.URL == nil {
				continue
			}
			feedBase, err := url.Parse(string(*feed.URL))
			if err != nil {
				p.badProviderMetadata.error("Invalid URL %s in feed: %v.", *feed.URL, err)
				continue
			}
			feedURL := feedBase.String()
			p.checkTLS(feedURL)

			advs, err := p.rolieFeedEntries(ctx, feedURL)
			if err != nil {
				if err != errContinue {
					return err
				}
				continue
			}
			advisories[feed] = advs
		}
	}

	// Phase 2: check for integrity.
	for _, fs := range feeds {
		for i := range fs {
			feed := &fs[i]
			if feed.URL == nil {
				continue
			}
			files := advisories[feed]
			if files == nil {
				continue
			}

			feedURL, err := url.Parse(string(*feed.URL))
			if err != nil {
				p.badProviderMetadata.error("Invalid URL %s in feed: %v.", *feed.URL, err)
				continue
			}

			feedBase, err := util.BaseURL(feedURL)
			if err != nil {
				p.badProviderMetadata.error("Bad base path: %v", err)
				continue
			}

			label := defaults(feed.TLPLabel, csaf.TLPLabelUnlabeled)
			if err := p.categoryCheck(ctx, feedBase, label); err != nil {
				if err != errContinue {
					return err
				}
			}

			p.labelChecker.feedURL = feedURL.String()
			p.labelChecker.feedLabel = label

			// TODO: Issue a warning if we want check AMBER+ without an
			// authorizing client.

			if err := p.integrity(ctx, files, rolieMask, p.badProviderMetadata.add); err != nil {
				if err != errContinue {
					return err
				}
			}
		}
	}

	// Phase 3: Check for completeness.

	hasSummary := util.Set[csaf.TLPLabel]{}

	var (
		hasUnlabeled = false
		hasWhite     = false
		hasGreen     = false
	)

	for _, fs := range feeds {
		for i := range fs {
			feed := &fs[i]
			if feed.URL == nil {
				continue
			}
			files := advisories[feed]
			if files == nil {
				continue
			}

			feedBase, err := url.Parse(string(*feed.URL))
			if err != nil {
				p.badProviderMetadata.error("Invalid URL %s in feed: %v.", *feed.URL, err)
				continue
			}

			makeAbs := makeAbsolute(feedBase)
			label := defaults(feed.TLPLabel, csaf.TLPLabelUnlabeled)

			switch label {
			case csaf.TLPLabelUnlabeled:
				hasUnlabeled = true
			case csaf.TLPLabelWhite:
				hasWhite = true
			case csaf.TLPLabelGreen:
				hasGreen = true
			}

			reference := p.labelChecker.advisories[label]
			advisories := make(util.Set[string], len(reference))

			for _, adv := range files {
				u, err := url.Parse(adv.URL())
				if err != nil {
					p.badProviderMetadata.error(
						"Invalid URL %s in feed: %v.", *feed.URL, err)
					continue
				}
				advisories.Add(makeAbs(u).String())
			}
			if advisories.ContainsAll(reference) {
				hasSummary.Add(label)
			}
		}
	}

	if !hasWhite && !hasGreen && !hasUnlabeled {
		p.badROLIEFeed.error(
			"One ROLIE feed with a TLP:WHITE, TLP:GREEN or unlabeled tlp must exist, " +
				"but none were found.")
	}

	// Every TLP level with data should have at least on summary feed.
	for _, label := range []csaf.TLPLabel{
		csaf.TLPLabelUnlabeled,
		csaf.TLPLabelWhite,
		csaf.TLPLabelGreen,
		csaf.TLPLabelAmber,
		csaf.TLPLabelRed,
	} {
		if !hasSummary.Contains(label) && len(p.labelChecker.advisories[label]) > 0 {
			p.badROLIEFeed.warn(
				"ROLIE feed for TLP:%s has no accessible listed feed covering all advisories.",
				label)
		}
	}

	return nil
}

// categoryCheck checks for the existence of a feeds ROLIE category document and if it does,
// whether the category document contains distinguishing categories
func (p *processor) categoryCheck(ctx context.Context, folderURL string, label csaf.TLPLabel) error {
	labelname := strings.ToLower(string(label))
	urlrc := folderURL + "category-" + labelname + ".json"

	p.badROLIECategory.use()
	client := p.httpClient()
	res, err := client.GetWithContext(ctx, urlrc)
	if err != nil {
		p.badROLIECategory.error(
			"Cannot fetch rolie category document %s: %v", urlrc, err)
		return errContinue
	}
	if res.StatusCode != http.StatusOK {
		p.badROLIECategory.warn("Fetching %s failed. Status code %d (%s)",
			urlrc, res.StatusCode, res.Status)
		res.Body.Close()
		return errContinue
	}
	rolieCategory, err := func() (*csaf.ROLIECategoryDocument, error) {
		defer res.Body.Close()
		return csaf.LoadROLIECategoryDocument(res.Body)
	}()

	if err != nil {
		p.badROLIECategory.error(
			"Loading ROLIE category document %s failed: %v.", urlrc, err)
		return errContinue
	}
	if len(rolieCategory.Categories.Category) == 0 {
		p.badROLIECategory.warn(
			"No distinguishing categories in ROLIE category document: %s", urlrc)
	}
	return nil
}

// serviceCheck checks if a ROLIE service document exists and if it does,
// whether it contains all ROLIE feeds.
func (p *processor) serviceCheck(ctx context.Context, feeds [][]csaf.Feed) error {
	// service category document should be next to the pmd
	pmdURL, err := url.Parse(p.pmdURL)
	if err != nil {
		return err
	}
	baseURL, err := util.BaseURL(pmdURL)
	if err != nil {
		return err
	}
	urls := baseURL + "service.json"

	// load service document
	p.badROLIEService.use()

	client := p.httpClient()
	res, err := client.GetWithContext(ctx, urls)
	if err != nil {
		p.badROLIEService.error(
			"Cannot fetch rolie service document %s: %v", urls, err)
		return errContinue
	}
	if res.StatusCode != http.StatusOK {
		p.badROLIEService.warn("Fetching %s failed. Status code %d (%s)",
			urls, res.StatusCode, res.Status)
		res.Body.Close()
		return errContinue
	}

	rolieService, err := func() (*csaf.ROLIEServiceDocument, error) {
		defer res.Body.Close()
		return csaf.LoadROLIEServiceDocument(res.Body)
	}()

	if err != nil {
		p.badROLIEService.error(
			"Loading ROLIE service document %s failed: %v.", urls, err)
		return errContinue
	}

	// Build lists of all feeds in feeds and in the Service Document
	var (
		sfeeds = util.Set[string]{}
		ffeeds = util.Set[string]{}
	)
	for _, col := range rolieService.Service.Workspace {
		for _, fd := range col.Collection {
			sfeeds.Add(fd.HRef)
		}
	}
	for _, r := range feeds {
		for _, s := range r {
			ffeeds.Add(string(*s.URL))
		}
	}

	// Check if ROLIE Service Document contains exactly all ROLIE feeds
	if m1 := sfeeds.Difference(ffeeds).Keys(); len(m1) != 0 {
		sort.Strings(m1)
		p.badROLIEService.error(
			"The ROLIE service document %s contains nonexistent feed entries: %v", urls, m1)
	}
	if m2 := ffeeds.Difference(sfeeds).Keys(); len(m2) != 0 {
		sort.Strings(m2)
		p.badROLIEService.error(
			"The ROLIE service document %s is missing feed entries: %v", urls, m2)
	}

	// TODO: Check conformity with RFC8322
	return nil
}

// extractAdvisoryIdentifier extracts document/publisher/namespace and
// document/tracking/id from advisory and stores it in an identifier.
func (p *processor) extractAdvisoryIdentifier(doc any) (identifier, error) {
	namespace, err := p.expr.Eval(`$.document.publisher.namespace`, doc)
	if err != nil {
		return identifier{}, err
	}

	idString, err := p.expr.Eval(`$.document.tracking.id`, doc)
	if err != nil {
		return identifier{}, err
	}

	ns, ok := namespace.(string)
	if !ok {
		return identifier{}, errors.New("cannot extract 'namespace'")
	}
	id, ok := idString.(string)
	if !ok {
		return identifier{}, errors.New("cannot extract 'id'")
	}

	return identifier{
		namespace: ns,
		id:        id,
	}, nil
}
