package commands

import (
	"errors"
	"flag"
	"fmt"
	"os"
	"path"
	"regexp"
	"strings"

	"fyne.io/fyne/v2"
	"github.com/urfave/cli/v2"
)

const fileHeader = "// auto-generated\n" + // to exclude this file in goreportcard (it has to be first)
	"// Code generated by '$ fyne bundle'. DO NOT EDIT." // idiomatic mark, see https://golang.org/s/generatedcode

var filenameRegex = regexp.MustCompile("[^a-zA-Z0-9]+")

// Bundle returns the vendor cli command
func Bundle() *cli.Command {
	b := &Bundler{}

	return &cli.Command{
		Name:        "bundle",
		Usage:       "Embeds static content into your go application.",
		Description: "Each resource will have a generated filename unless specified.",
		Flags: []cli.Flag{
			&cli.StringFlag{
				Name:        "name",
				Usage:       "The variable name to assign the resource (file mode only).",
				Destination: &b.name,
			},
			&cli.StringFlag{
				Name:        "output",
				Aliases:     []string{"o"},
				Usage:       "Specify an output file instead of printing to standard output.",
				Destination: &b.out,
			},
			&cli.StringFlag{
				Name:        "package",
				Aliases:     []string{"pkg"},
				Usage:       "The package to output in headers (if not appending).",
				Value:       "main",
				Destination: &b.pkg,
			},
			&cli.StringFlag{
				Name:        "prefix",
				Usage:       "A prefix for variables (ignored if name is set).",
				Value:       "resource",
				Destination: &b.prefix,
			},
			&cli.BoolFlag{
				Name:        "append",
				Aliases:     []string{"a"},
				Usage:       "Append an existing go file (don't output headers).",
				Destination: &b.noheader,
			},
		},
		Action: b.bundleAction,
	}
}

// Bundler bundles resources into Go code.
type Bundler struct {
	name, pkg, out string
	prefix         string
	noheader       bool
}

// NewBundler returns a command that can handle the bundling assets into a GUI app binary.
func NewBundler() *Bundler {
	return &Bundler{}
}

// AddFlags adds all the command line flags for passing to the Bundler.
//
// Deprecated: Access to the individual cli commands are being removed.
func (b *Bundler) AddFlags() {
	flag.StringVar(&b.name, "name", "", "The variable name to assign the resource (file mode only)")
	flag.StringVar(&b.out, "o", "", "Specify an output file instead of printing to standard output")
	flag.StringVar(&b.pkg, "package", "main", "The package to output in headers (if not appending)")
	flag.StringVar(&b.prefix, "prefix", "resource", "A prefix for variables (ignored if name is set)")
	flag.BoolVar(&b.noheader, "append", false, "Append an existing go file (don't output headers)")
}

// PrintHelp prints the help message for the bundle command.
//
// Deprecated: Access to the individual cli commands are being removed.
func (b *Bundler) PrintHelp(indent string) {
	fmt.Println(indent, "The bundle command embeds static content into your go application.")
	fmt.Println(indent, "Each resource will have a generated filename unless specified")
	fmt.Println(indent, "Command usage: fyne bundle [parameters] file|directory")
}

// Run runs the bundle command.
//
// Deprecated: A better version will be exposed in the future.
func (b *Bundler) Run(args []string) {
	if len(args) != 1 {
		fyne.LogError("Missing required file or directory parameter after flags", nil)
		return
	}

	outFile := os.Stdout
	if b.out != "" {
		fileModes := os.O_RDWR | os.O_CREATE | os.O_TRUNC
		if b.noheader {
			fileModes = os.O_RDWR | os.O_APPEND
		}

		f, err := os.OpenFile(b.out, fileModes, 0666)
		if err == nil {
			outFile = f
		} else {
			if os.IsNotExist(err) {
				f, err = os.Create(b.out)
				if err == nil {
					outFile = f
				} else {
					fyne.LogError("Unable to read, or create, output file : "+b.out, err)
					return
				}
			} else {
				fyne.LogError("Unable to open output file", err)
				return
			}
		}
	}

	switch stat, err := os.Stat(args[0]); {
	case os.IsNotExist(err):
		fyne.LogError("Specified file could not be found", err)
	case stat.IsDir():
		b.dirBundle(args[0], outFile)
	case b.name != "":
		b.prefix = ""
		fallthrough
	default:
		b.doBundle(args[0], outFile)
	}
}

func (b *Bundler) bundleAction(ctx *cli.Context) (err error) {
	if ctx.Args().Len() != 1 {
		return errors.New("missing required file or directory parameter after flags")
	}

	outFile := os.Stdout
	if b.out != "" {
		file, closeFile, err := openOutputFile(b.out, b.noheader)
		if err != nil {
			return err
		}
		defer func() {
			if r := closeFile(); r != nil {
				err = r
			}
		}()
		outFile = file
	}

	arg := ctx.Args().First()
	switch stat, err := os.Stat(arg); {
	case os.IsNotExist(err):
		fyne.LogError("Specified file could not be found", err)
		return err
	case stat.IsDir():
		return b.dirBundle(arg, outFile)
	case b.name != "":
		b.prefix = ""
		fallthrough
	default:
		b.doBundle(arg, outFile)
	}

	return nil
}

func (b *Bundler) dirBundle(dirpath string, out *os.File) error {
	files, err := os.ReadDir(dirpath)
	if err != nil {
		fyne.LogError("Error reading specified directory", err)
		return err
	}

	for i, file := range files {
		filename := file.Name()
		if path.Ext(filename) == ".go" {
			continue
		}

		b.name = ""

		b.doBundle(path.Join(dirpath, filename), out)
		if i == 0 { // only show header on first iteration
			b.noheader = true
		}
	}

	return nil
}

// Bundle takes a file (at filepath) and serialises it into Go to be output into
// a generated bundle file. The go file will be part of the specified package
// (pkg) and the data will be assigned to variable named "name". If you are
// appending an existing resource file then pass true to noheader as the headers
// should only be output once per file.
func (b *Bundler) doBundle(filepath string, out *os.File) {
	if !b.noheader {
		writeHeader(b.pkg, out)
	}

	if b.name == "" {
		b.name = sanitiseName(path.Base(filepath), b.prefix)
	}
	writeResource(filepath, b.name, out)
}

func openOutputFile(filePath string, noheader bool) (file *os.File, close func() error, err error) {
	fileModes := os.O_RDWR | os.O_CREATE | os.O_TRUNC
	if noheader {
		fileModes = os.O_RDWR | os.O_APPEND
	}

	f, err := os.OpenFile(filePath, fileModes, 0666)
	if err != nil {
		if !os.IsNotExist(err) {
			fyne.LogError("Unable to open output file", err)
			return nil, nil, err
		}

		// try creating the file
		f, err = os.Create(filePath)
		if err != nil {
			fyne.LogError("Unable to read, or create, output file : "+filePath, err)
			return nil, nil, err
		}
	}

	return f, f.Close, nil
}

func sanitiseName(file, prefix string) string {
	titled := strings.Title(file) //lint:ignore SA1019 This is fine for our use case.
	name := filenameRegex.ReplaceAllString(titled, "")

	return prefix + name
}

func writeHeader(pkg string, out *os.File) {
	out.WriteString(fileHeader)
	out.WriteString("\n\npackage ")
	out.WriteString(pkg)
	out.WriteString("\n\nimport \"fyne.io/fyne/v2\"\n\n")
}

func writeResource(file, name string, f *os.File) {
	res, err := fyne.LoadResourceFromPath(file)
	if err != nil {
		fyne.LogError("Unable to load file "+file, err)
		return
	}

	const format = "var %s = &fyne.StaticResource{\n\tStaticName: %q,\n\tStaticContent: []byte(\n\t\t%q),\n}\n"
	_, err = fmt.Fprintf(f, format, name, res.Name(), res.Content())
	if err != nil {
		fyne.LogError("Unable to write to bundled file", err)
	}
}
