package test

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"io"
	"maps"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/open-policy-agent/opa/internal/file/archive"
	"github.com/open-policy-agent/opa/v1/ast"
	"github.com/open-policy-agent/opa/v1/bundle"
	"github.com/open-policy-agent/opa/v1/compile"

	"github.com/opencontainers/go-digest"
	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

// MockBundle sets a bundle named file on the test server containing the given
// policies.
func MockBundle(file string, policies map[string]string) func(*Server) error {
	return func(s *Server) error {
		if !strings.HasPrefix(file, "/bundles/") {
			return fmt.Errorf("mock bundle filename must be prefixed with '/bundles/ but got %q", file)
		}
		s.bundles[file] = policies
		return nil
	}
}

// MockOCIBundle prepares the server to allow serving "/v2" OCI responses from the supplied policies
// Ref parameter must be in the form of <registry>/<org>/<repo>:<tag> that will be used in detecting future calls
func MockOCIBundle(ref string, policies map[string]string) func(*Server) error {
	return func(s *Server) error {
		if !strings.Contains(ref, "/") {
			return fmt.Errorf("mock oci bundle ref must contain 'org/repo' but got %q", ref)
		}
		return s.buildBundles(ref, policies)
	}
}

// Ready provides a channel that the server will use to gate readiness. The
// caller can provide this channel to prevent the server from becoming ready.
// The server will response with HTTP 500 responses until ready. The caller
// should close the channel to indicate readiness.
func Ready(ch chan struct{}) func(*Server) error {
	return func(s *Server) error {
		s.ready = ch
		return nil
	}
}

// ParserOptions sets the ast.ParserOptions to use when parsing modules when preparing bundles.
func ParserOptions(popts ast.ParserOptions) func(*Server) error {
	return func(s *Server) error {
		s.parserOptions = popts
		return nil
	}
}

// Server provides a mock HTTP server for testing the SDK and integrations.
type Server struct {
	server        *httptest.Server
	ready         chan struct{}
	bundles       map[string]map[string]string
	rawBundles    bool
	parserOptions ast.ParserOptions
}

// MustNewServer returns a new Server for test purposes or panics if an error occurs.
func MustNewServer(opts ...func(*Server) error) *Server {
	s, err := NewServer(opts...)
	if err != nil {
		panic(err)
	}
	return s
}

// NewServer returns a new Server for test purposes.
func NewServer(opts ...func(*Server) error) (*Server, error) {
	s := &Server{
		bundles: map[string]map[string]string{},
	}
	for _, opt := range opts {
		if err := opt(s); err != nil {
			return nil, err
		}
	}
	if s.ready == nil {
		s.ready = make(chan struct{})
		close(s.ready)
	}
	s.server = httptest.NewServer(http.HandlerFunc(s.handle))
	return s, nil
}

func RawBundles(raw bool) func(*Server) error {
	return func(s *Server) error {
		s.rawBundles = raw
		return nil
	}
}

func (s *Server) ParserOptions() ast.ParserOptions {
	return s.parserOptions
}

// WithTestBundle adds a bundle to the server at the specified endpoint.
func (s *Server) WithTestBundle(endpoint string, policies map[string]string) *Server {
	s.bundles[endpoint] = policies
	return s
}

// Stop stops the test server.
func (s *Server) Stop() {
	s.server.Close()
}

// URL returns the base URL of the server.
func (s *Server) URL() string {
	return s.server.URL
}

// Builds the tarball from the supplied policies and prepares the layers in a temporary directory
func (s *Server) buildBundles(ref string, policies map[string]string) error {
	// Prepare the modules to include in the bundle. Sort them so bundles are deterministic.
	modules := make([]bundle.ModuleFile, 0, len(policies))
	for url, str := range policies {
		module, err := ast.ParseModuleWithOpts(url, str, s.parserOptions)
		if err != nil {
			return fmt.Errorf("failed to parse module: %v", err)
		}
		modules = append(modules, bundle.ModuleFile{
			URL:    url,
			Parsed: module,
		})
	}
	slices.SortFunc(modules, func(a, b bundle.ModuleFile) int {
		return strings.Compare(a.URL, b.URL)
	})

	// Compile the bundle out into a buffer
	buf := bytes.NewBuffer(nil)

	// We need to explicitly set the global bundle rego-version, as an unassigned version will be
	// interpreted as v0 on the receiving end, which will cause problems if modules are parsed/compiled
	// as v1 on this end, which will drop 'rego.v1' and 'future.keywords' imports.
	bundleManifest := bundle.Manifest{}
	bundleManifest.SetRegoVersion(ast.DefaultRegoVersion)
	bundleManifest.Init()

	err := compile.New().WithOutput(buf).WithBundle(&bundle.Bundle{
		Data:     map[string]any{},
		Modules:  modules,
		Manifest: bundleManifest,
	}).Build(context.Background())
	if err != nil {
		return err
	}
	directoryName, err := os.MkdirTemp("", "oci-test-temp")
	fmt.Println("Testing OCI temporary directory:", directoryName)
	if err != nil {
		return err
	}
	// Write buf tarball to layer
	tarLayer := filepath.Join(directoryName, "tar.layer")
	err = os.WriteFile(tarLayer, buf.Bytes(), 0655)
	if err != nil {
		return err
	}
	// Write empty config layer
	configLayer := filepath.Join(directoryName, "config.layer")
	err = os.WriteFile(configLayer, []byte("{}"), 0655)
	if err != nil {
		return err
	}
	// Calculate SHA and size and prepare manifest layer
	tarSHA, err := getFileSHA(tarLayer)
	if err != nil {
		return err
	}

	configSHA, err := getFileSHA(configLayer)
	if err != nil {
		return err
	}

	var manifest ocispec.Manifest
	manifest.SchemaVersion = 2
	manifest.Config = ocispec.Descriptor{
		MediaType: ocispec.MediaTypeImageConfig,
		Digest:    digest.Digest(fmt.Sprintf("sha256:%x", configSHA)),
		Size:      int64(2), // config size is set to 2 as an empty config is used
	}
	manifest.Layers = []ocispec.Descriptor{
		{
			MediaType: ocispec.MediaTypeImageLayerGzip,
			Digest:    digest.Digest(fmt.Sprintf("sha256:%x", tarSHA)),
			Size:      int64(buf.Len()),
			Annotations: map[string]string{
				ocispec.AnnotationTitle:   ref,
				ocispec.AnnotationCreated: time.Now().Format(time.RFC3339),
			},
		},
	}

	manifestData, err := json.Marshal(manifest)
	if err != nil {
		return err
	}
	manifestLayer := filepath.Join(directoryName, "manifest.layer")
	err = os.WriteFile(manifestLayer, manifestData, 0655)
	if err != nil {
		return err
	}

	// Set ref layer paths to server bundles
	s.bundles[ref] = map[string]string{
		"manifest": manifestLayer,
		"config":   configLayer,
		"tar":      tarLayer,
	}
	return nil
}

func getFileSHA(filePath string) ([]byte, error) {
	f, err := os.Open(filePath)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	hash := sha256.New()
	if _, err := io.Copy(hash, f); err != nil {
		return nil, err
	}
	return hash.Sum(nil), nil
}

func (s *Server) handle(w http.ResponseWriter, r *http.Request) {

	select {
	case <-s.ready:
	default:
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	if strings.HasPrefix(r.URL.Path, "/v2") {
		s.handleOCIBundles(w, r)
		return
	}

	if strings.HasPrefix(r.URL.Path, "/bundles") {
		if s.rawBundles {
			s.handleRawBundles(w, r)
		} else {
			s.handleBundles(w, r)
		}
		return
	}

	w.WriteHeader(http.StatusInternalServerError)
}

func (s *Server) handleOCIBundles(w http.ResponseWriter, r *http.Request) {
	ref := ""  // key used to detect layers from s.bundles
	tag := ""  // image tag used in request path verification
	repo := "" // image repo used in request path verification
	var buf bytes.Buffer
	// get first key that matches request url pattern
	for key := range s.bundles {
		// extract tag
		parsedRef := strings.Split(key, ":")
		checkRef := strings.Split(parsedRef[0], "/")
		// check if request path contains org and repository
		if strings.Contains(r.URL.Path, checkRef[1]) && strings.Contains(r.URL.Path, checkRef[2]) {
			ref = key
			tag = parsedRef[1]
			repo = checkRef[1] + "/" + checkRef[2]
			break
		}
	}
	if ref == "" || tag == "" || repo == "" {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	layers := s.bundles[ref]
	fi, err := os.Stat(layers["manifest"])
	if err != nil {
		w.WriteHeader(http.StatusFailedDependency)
		return
	}
	manifestSize := fi.Size()
	manifestSHA, err := getFileSHA(layers["manifest"])
	if err != nil {
		w.WriteHeader(http.StatusFailedDependency)
		return
	}
	fi, err = os.Stat(layers["config"])
	if err != nil {
		w.WriteHeader(http.StatusFailedDependency)
		return
	}
	configSize := fi.Size()
	configSHA, err := getFileSHA(layers["config"])
	if err != nil {
		w.WriteHeader(http.StatusFailedDependency)
		return
	}
	fi, err = os.Stat(layers["tar"])
	if err != nil {
		w.WriteHeader(http.StatusFailedDependency)
		return
	}
	// get the size
	tarSize := fi.Size()
	tarSHA, err := getFileSHA(layers["tar"])
	if err != nil {
		w.WriteHeader(http.StatusFailedDependency)
		return
	}

	if r.URL.Path == fmt.Sprintf("/v2/%s/manifests/%s", repo, tag) {
		w.Header().Add("Content-Length", strconv.FormatInt(manifestSize, 10))
		w.Header().Add("Content-Type", "application/vnd.oci.image.manifest.v1+json")
		w.Header().Add("Docker-Content-Digest", fmt.Sprintf("sha256:%x", manifestSHA))
		w.WriteHeader(http.StatusOK)
		return
	}
	if r.URL.Path == fmt.Sprintf("/v2/%s/manifests/sha256:%x", repo, manifestSHA) {
		w.Header().Add("Content-Length", strconv.FormatInt(manifestSize, 10))
		w.Header().Add("Content-Type", "application/vnd.oci.image.manifest.v1+json")
		w.Header().Add("Docker-Content-Digest", fmt.Sprintf("sha256:%x", manifestSHA))
		w.WriteHeader(200)
		bs, err := os.ReadFile(layers["manifest"])
		if err != nil {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		buf.Write(bs)
		_, err = w.Write(buf.Bytes())
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
		}
		return
	}
	if r.URL.Path == fmt.Sprintf("/v2/%s/blobs/sha256:%x", repo, configSHA) {
		w.Header().Add("Content-Length", strconv.FormatInt(configSize, 10))
		w.Header().Add("Content-Type", "application/vnd.oci.image.manifest.v1+json")
		w.Header().Add("Docker-Content-Digest", fmt.Sprintf("sha256:%x", configSHA))
		w.WriteHeader(200)
		bs, err := os.ReadFile(layers["config"])
		if err != nil {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		buf.Write(bs)
		_, err = w.Write(buf.Bytes())
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
		}
		return
	}
	if r.URL.Path == fmt.Sprintf("/v2/%s/blobs/sha256:%x", repo, tarSHA) {
		w.Header().Add("Content-Length", strconv.FormatInt(tarSize, 10))
		w.Header().Add("Content-Type", "application/vnd.oci.image.manifest.v1+json")
		w.Header().Add("Docker-Content-Digest", fmt.Sprintf("sha256:%x", tarSHA))
		w.WriteHeader(200)
		bs, err := os.ReadFile(layers["tar"])
		if err != nil {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		buf.Write(bs)
		_, err = w.Write(buf.Bytes())
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
		}
		return
	}
}

func (s *Server) handleBundles(w http.ResponseWriter, r *http.Request) {

	// Return 404 if bundle path does not exist.
	b, ok := s.bundles[r.URL.Path]
	if !ok {
		w.WriteHeader(http.StatusNotFound)
		return
	}

	// Prepare a mapping to store bundle data
	data := map[string]any{}

	// Prepare a manifest for use if a .manifest file exists.
	var manifest bundle.Manifest

	// Prepare the modules to include in the bundle. Sort them so bundles are deterministic.
	modules := make([]bundle.ModuleFile, 0, len(b))
	for url, str := range b {
		switch {
		case url == ".manifest":
			err := json.Unmarshal([]byte(str), &manifest)
			if err != nil {
				w.WriteHeader(http.StatusInternalServerError)
				fmt.Fprintf(w, "error unmarshaling .manifest file: %v", err)
				return
			}
		case strings.HasSuffix(url, ".rego"):
			module, err := ast.ParseModuleWithOpts(url, str, s.parserOptions)
			if err != nil {
				w.WriteHeader(http.StatusInternalServerError)
				_, _ = w.Write([]byte(err.Error()))
				return
			}
			modules = append(modules, bundle.ModuleFile{
				URL:    url,
				Parsed: module,
			})
		case strings.HasSuffix(url, ".json"):
			if strings.Contains(url, "/") {
				w.WriteHeader(http.StatusInternalServerError)
				fmt.Fprintf(w, "nested data documents are not implemented in the dummy server: %s", url)
				return
			}

			var d map[string]any

			err := json.Unmarshal([]byte(str), &d)
			if err != nil {
				w.WriteHeader(http.StatusInternalServerError)
				fmt.Fprintf(w, "error unmarshaling json file: %v", err)
				return
			}

			maps.Copy(data, d)
		default:
			w.WriteHeader(http.StatusInternalServerError)
			fmt.Fprintf(w, "unexpected file in dummy bundle: %s", url)
			return
		}
	}
	slices.SortFunc(modules, func(a, b bundle.ModuleFile) int {
		return strings.Compare(a.URL, b.URL)
	})

	// Compile the bundle out into a buffer
	buf := bytes.NewBuffer(nil)
	err := compile.New().WithOutput(buf).WithBundle(&bundle.Bundle{
		Data:     data,
		Modules:  modules,
		Manifest: manifest,
	}).Build(r.Context())
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		_, _ = w.Write([]byte(err.Error()))
		return
	}

	// Write out the bundle
	w.WriteHeader(http.StatusOK)
	_, _ = io.Copy(w, buf)
}

func (s *Server) handleRawBundles(w http.ResponseWriter, r *http.Request) {
	// Return 404 if bundle path does not exist.
	b, ok := s.bundles[r.URL.Path]
	if !ok {
		w.WriteHeader(http.StatusNotFound)
		return
	}

	files := make([][2]string, 0, len(b))
	for url, str := range b {
		files = append(files, [2]string{url, str})
	}
	buf := archive.MustWriteTarGz(files)

	// Write out the bundle
	w.WriteHeader(http.StatusOK)
	_, _ = io.Copy(w, buf)
}
