package options

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"path"
	"sort"
	"strings"

	"github.com/hashicorp/go-multierror"
	"github.com/scylladb/go-set/strset"

	"github.com/anchore/go-homedir"
	"github.com/anchore/syft/internal/bus"
	"github.com/anchore/syft/internal/log"
	"github.com/anchore/syft/syft/format"
	"github.com/anchore/syft/syft/format/table"
	"github.com/anchore/syft/syft/sbom"
)

var _ sbom.Writer = (*sbomMultiWriter)(nil)

var _ interface {
	io.Closer
	sbom.Writer
} = (*sbomStreamWriter)(nil)

// makeSBOMWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer
// or an error but neither both and if there is no error, sbom.Writer.Close() should be called
func makeSBOMWriter(outputs []string, defaultFile string, encoders []sbom.FormatEncoder) (sbom.Writer, error) {
	outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, encoders)
	if err != nil {
		return nil, err
	}

	writer, err := newSBOMMultiWriter(outputOptions...)
	if err != nil {
		return nil, err
	}

	return writer, nil
}

// parseSBOMOutputFlags utility to parse command-line option strings and retain the existing behavior of default format and file
func parseSBOMOutputFlags(outputs []string, defaultFile string, encoders []sbom.FormatEncoder) (out []sbomWriterDescription, errs error) {
	encoderCollection := format.NewEncoderCollection(encoders...)

	// always should have one option -- we generally get the default of "table", but just make sure
	if len(outputs) == 0 {
		outputs = append(outputs, table.ID.String())
	}

	for _, name := range outputs {
		name = strings.TrimSpace(name)

		// split to at most two parts for <format>=<file>
		parts := strings.SplitN(name, "=", 2)

		// the format name is the first part
		name = parts[0]

		// default to the --file or empty string if not specified
		file := defaultFile

		// If a file is specified as part of the output formatName, use that
		if len(parts) > 1 {
			file = parts[1]
		}

		enc := encoderCollection.GetByString(name)
		if enc == nil {
			errs = multierror.Append(errs, fmt.Errorf(`unsupported output format "%s", supported formats are: %+v`, name, formatVersionOptions(encoderCollection.NameVersions())))
			continue
		}

		out = append(out, newSBOMWriterDescription(enc, file))
	}
	return out, errs
}

// formatVersionOptions takes a list like ["github-json", "syft-json@11.0.0", "cyclonedx-xml@1.0", "cyclondx-xml@1.1"...]
// and formats it into a human-readable string like:
//
// Available formats:
//   - cyclonedx-json @ 1.2, 1.3, 1.4, 1.5
//   - cyclonedx-xml @ 1.0, 1.1, 1.2, 1.3, 1.4, 1.5
//   - github-json
//   - spdx-json @ 2.2, 2.3
//   - spdx-tag-value @ 2.1, 2.2, 2.3
//   - syft-json
//   - syft-table
//   - syft-text
//   - template
func formatVersionOptions(nameVersionPairs []string) string {
	availableVersions := make(map[string][]string)
	availableFormats := strset.New()
	for _, nameVersion := range nameVersionPairs {
		fields := strings.SplitN(nameVersion, "@", 2)
		if len(fields) == 2 {
			availableVersions[fields[0]] = append(availableVersions[fields[0]], fields[1])
		}
		availableFormats.Add(fields[0])
	}

	// find any formats with exactly one version -- remove them from the version map
	for name, versions := range availableVersions {
		if len(versions) == 1 {
			delete(availableVersions, name)
		}
	}

	sortedAvailableFormats := availableFormats.List()
	sort.Strings(sortedAvailableFormats)

	var s strings.Builder

	s.WriteString("\n")
	s.WriteString("Available formats:")

	for _, name := range sortedAvailableFormats {
		s.WriteString("\n")

		fmt.Fprintf(&s, "   - %s", name)

		if len(availableVersions[name]) > 0 {
			s.WriteString(" @ ")
			s.WriteString(strings.Join(availableVersions[name], ", "))
		}
	}

	return s.String()
}

// sbomWriterDescription Format and path strings used to create sbom.Writer
type sbomWriterDescription struct {
	Format sbom.FormatEncoder
	Path   string
}

func newSBOMWriterDescription(f sbom.FormatEncoder, p string) sbomWriterDescription {
	expandedPath, err := homedir.Expand(p)
	if err != nil {
		log.Warnf("could not expand given writer output path=%q: %w", p, err)
		// ignore errors
		expandedPath = p
	}
	return sbomWriterDescription{
		Format: f,
		Path:   expandedPath,
	}
}

// sbomMultiWriter holds a list of child sbom.Writers to apply all Write and Close operations to
type sbomMultiWriter struct {
	writers []sbom.Writer
}

// newSBOMMultiWriter create all report writers from input options; if a file is not specified the given defaultWriter is used
func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, err error) {
	if len(options) == 0 {
		return nil, fmt.Errorf("no output options provided")
	}

	out := &sbomMultiWriter{}

	for _, option := range options {
		switch len(option.Path) {
		case 0:
			out.writers = append(out.writers, &sbomPublisher{
				format: option.Format,
			})
		default:
			// create any missing subdirectories
			dir := path.Dir(option.Path)
			if dir != "" {
				s, err := os.Stat(dir)
				if err != nil {
					err = os.MkdirAll(dir, 0755) // maybe should be os.ModePerm ?
					if err != nil {
						return nil, err
					}
				} else if !s.IsDir() {
					return nil, fmt.Errorf("output path does not contain a valid directory: %s", option.Path)
				}
			}
			fileOut, err := os.OpenFile(option.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
			if err != nil {
				return nil, fmt.Errorf("unable to create report file: %w", err)
			}
			out.writers = append(out.writers, &sbomStreamWriter{
				format: option.Format,
				out:    fileOut,
			})
		}
	}

	return out, nil
}

// Write writes the SBOM to all writers
func (m *sbomMultiWriter) Write(s sbom.SBOM) (errs error) {
	for _, w := range m.writers {
		err := w.Write(s)
		if err != nil {
			errs = multierror.Append(errs, fmt.Errorf("unable to write SBOM: %w", err))
		}
	}
	return errs
}

// sbomStreamWriter implements sbom.Writer for a given format and io.Writer, also providing a close function for cleanup
type sbomStreamWriter struct {
	format sbom.FormatEncoder
	out    io.Writer
}

// Write the provided SBOM to the data stream
func (w *sbomStreamWriter) Write(s sbom.SBOM) error {
	defer w.Close()
	return w.format.Encode(w.out, s)
}

// Close any resources, such as open files
func (w *sbomStreamWriter) Close() error {
	if closer, ok := w.out.(io.Closer); ok {
		return closer.Close()
	}
	return nil
}

// sbomPublisher implements sbom.Writer that publishes results to the event bus
type sbomPublisher struct {
	format sbom.FormatEncoder
}

// Write the provided SBOM to the data stream
func (w *sbomPublisher) Write(s sbom.SBOM) error {
	buf := &bytes.Buffer{}
	if err := w.format.Encode(buf, s); err != nil {
		return fmt.Errorf("unable to encode SBOM: %w", err)
	}

	bus.Report(buf.String())
	return nil
}
