package sandbox

import (
	"regexp"
	"strconv"
	"strings"

	"github.com/safedep/dry/utils"
	"github.com/safedep/pmg/sandbox/util"
)

// LintLevel categorises a lint issue. Errors indicate a profile that cannot
// safely be used; warnings flag risky or contradictory configuration; info
// surfaces minor cleanups (e.g. dead rules) that callers can hide by default.
type LintLevel string

const (
	LintLevelError LintLevel = "error"
	LintLevelWarn  LintLevel = "warn"
	LintLevelInfo  LintLevel = "info"
)

type LintIssue struct {
	Level   LintLevel `json:"level"`
	Code    string    `json:"code"`
	Message string    `json:"message"`
	Field   string    `json:"field,omitempty"`
	Rule    string    `json:"rule,omitempty"`
}

var variableTokenRe = regexp.MustCompile(`\$\{[^}]+\}`)

// LintProfile returns issues in stable order: schema errors, then warnings in
// field-declaration order, then info-level findings.
func LintProfile(policy *SandboxPolicy) []LintIssue {
	if policy == nil {
		return []LintIssue{{
			Level:   LintLevelError,
			Code:    "schema.invalid",
			Message: "policy is nil",
		}}
	}

	var errors []LintIssue
	var warns []LintIssue
	var infos []LintIssue

	schemaErr := policy.Validate()
	if schemaErr != nil {
		errors = append(errors, LintIssue{
			Level:   LintLevelError,
			Code:    "schema.invalid",
			Message: schemaErr.Error(),
		})
	}
	// ValidateResolved calls Validate first; only surface its error when the
	// basic schema was valid, so we don't duplicate the message above.
	if schemaErr == nil {
		if err := policy.ValidateResolved(); err != nil {
			errors = append(errors, LintIssue{
				Level:   LintLevelError,
				Code:    "schema.invalid",
				Message: err.Error(),
			})
		}
	}

	// allowOnly marks lists where broadness and dead-rule checks make sense:
	// deny-lists and network allow_bind are out of scope for those checks.
	allowLists := []struct {
		name      string
		rules     []string
		allowOnly bool
	}{
		{"filesystem.allow_read", policy.Filesystem.AllowRead, true},
		{"filesystem.allow_write", policy.Filesystem.AllowWrite, true},
		{"filesystem.deny_read", policy.Filesystem.DenyRead, false},
		{"filesystem.deny_write", policy.Filesystem.DenyWrite, false},
		{"network.allow_outbound", policy.Network.AllowOutbound, false},
		{"network.deny_outbound", policy.Network.DenyOutbound, false},
		{"network.allow_bind", policy.Network.AllowBind, false},
		{"process.allow_exec", policy.Process.AllowExec, true},
		{"process.deny_exec", policy.Process.DenyExec, false},
	}

	knownVars := strings.Join(util.SupportedVariables, ", ")
	for _, list := range allowLists {
		for i, rule := range list.rules {
			for _, tok := range variableTokenRe.FindAllString(rule, -1) {
				if util.IsSupportedVariable(tok) {
					continue
				}
				warns = append(warns, LintIssue{
					Level:   LintLevelWarn,
					Code:    "vars.unresolved",
					Message: "unsupported variable " + tok + " (known: " + knownVars + ")",
					Field:   fieldRef(list.name, i),
					Rule:    rule,
				})
			}
		}
	}

	for _, list := range allowLists {
		if !list.allowOnly {
			continue
		}
		for i, rule := range list.rules {
			code, msg := broadCheck(rule)
			if code == "" {
				continue
			}
			warns = append(warns, LintIssue{
				Level:   LintLevelWarn,
				Code:    code,
				Message: msg,
				Field:   fieldRef(list.name, i),
				Rule:    rule,
			})
		}
	}

	if utils.SafelyGetValue(policy.AllowDirectDNS) && !utils.SafelyGetValue(policy.NetworkViaProxyOnly) {
		warns = append(warns, LintIssue{
			Level:   LintLevelWarn,
			Code:    "allow-direct-dns-without-lockdown",
			Message: "allow_direct_dns has no effect unless network_via_proxy_only is true",
			Field:   "allow_direct_dns",
		})
	}

	conflictPairs := []struct {
		allowName string
		allow     []string
		denyName  string
		deny      []string
	}{
		{"filesystem.allow_read", policy.Filesystem.AllowRead, "filesystem.deny_read", policy.Filesystem.DenyRead},
		{"filesystem.allow_write", policy.Filesystem.AllowWrite, "filesystem.deny_write", policy.Filesystem.DenyWrite},
		{"network.allow_outbound", policy.Network.AllowOutbound, "network.deny_outbound", policy.Network.DenyOutbound},
		{"process.allow_exec", policy.Process.AllowExec, "process.deny_exec", policy.Process.DenyExec},
	}
	for _, pair := range conflictPairs {
		denyIdx := map[string]int{}
		for i, r := range pair.deny {
			if _, exists := denyIdx[r]; !exists {
				denyIdx[r] = i
			}
		}
		for i, r := range pair.allow {
			if j, ok := denyIdx[r]; ok {
				warns = append(warns, LintIssue{
					Level:   LintLevelWarn,
					Code:    "conflict.allow_deny",
					Message: "rule appears in both " + fieldRef(pair.allowName, i) + " and " + fieldRef(pair.denyName, j) + "; deny takes precedence",
					Field:   fieldRef(pair.allowName, i),
					Rule:    r,
				})
			}
		}
	}

	// Dead rules: a later rule is "shadowed" only if it is a strict prefix
	// match of an earlier rule whose pattern ends with "/**".
	for _, list := range allowLists {
		if !list.allowOnly {
			continue
		}
		for i, rule := range list.rules {
			for j := 0; j < i; j++ {
				earlier := list.rules[j]
				if !strings.HasSuffix(earlier, "/**") {
					continue
				}
				prefix := strings.TrimSuffix(earlier, "/**")
				if prefix == "" {
					continue
				}
				if rule == earlier {
					continue
				}
				stripped := strings.TrimSuffix(rule, "/**")
				if stripped == prefix {
					continue
				}
				if strings.HasPrefix(stripped, prefix+"/") {
					infos = append(infos, LintIssue{
						Level:   LintLevelInfo,
						Code:    "dead.shadowed",
						Message: "rule shadowed by " + fieldRef(list.name, j) + " (" + earlier + ")",
						Field:   fieldRef(list.name, i),
						Rule:    rule,
					})
					break
				}
			}
		}
	}

	out := make([]LintIssue, 0, len(errors)+len(warns)+len(infos))
	out = append(out, errors...)
	out = append(out, warns...)
	out = append(out, infos...)
	return out
}

func broadCheck(rule string) (string, string) {
	switch rule {
	case "/**":
		return "broad.root_glob", "rule grants access to entire filesystem (/**)"
	case util.VarHome + "/**":
		return "broad.home_glob", "rule grants access to entire user home (" + util.VarHome + "/**)"
	case "**":
		return "broad.all_glob", "rule uses unrestricted glob (**)"
	}
	return "", ""
}

func fieldRef(name string, idx int) string {
	return name + "[" + strconv.Itoa(idx) + "]"
}
