// Package main provides the command-line entrypoint.
package main

import (
	"errors"
	"flag"
	"fmt"
	"io/fs"
	"io/ioutil"
	"log/slog"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"strings"
	"time"

	"github.com/gabriel-vasile/mimetype"
	thumb "github.com/prplecake/go-thumbnail"
)

// logger is the global logger.
var logger *slog.Logger

// thumbnailSuffix is the file name suffix used for generated thumbnails.
var thumbnailSuffix = "_distribusi_thumbnail"

// logStripMsg strips content from logs messages for brevity.
var logStripMsg = "...stripped from logs for brevity..."

// port is for serving locally
var port = ":1312"

// htmlBody is the template for the index.html files that are generated by distribusi-go.
var htmlBody = `
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- Generated with distribusi-go -->
    %s

    <meta http-equiv="content-type" content="text/html; charset=utf-8">

    <style>
      .image {
        max-width: 100%%;
      }

      .pdf {
        width: 640px;
        height: 640px;
      }

      .x-directory::before {
        content: "📁 ";
        font-size: 18px;
      }

      .filename {
        display: block;
        font-family: mono;
      }

      .gif {
        width: 450px;
        max-height: 450px;
      }

      div {
        display: inline-block;
        vertical-align: top;
        margin: 1em;
        padding: 1em;
      }

      video {
        width: 450px;
        max-height: 450px;
      }

      %s
    </style>
  </head>
  <body>
    %s
  </body>
</html
`

// fileType is a file type identifier such as "image" or "audio".
type fileType = string

// subType is a more specific file type identifier compared to fileType, such as "gif" or "mp4".
type subType = string

// htmlTag is an appropriate HTML tag element for a specific fileType & subType.
type htmlTag = string

// htmlTags is a fileType/subType to htmlTag mapping.
var htmlTags = map[fileType]map[subType]htmlTag{
	"text": {
		"html":    `<section id="%s">%s</section>`,
		"generic": `<pre>%s</pre>`,
	},
	"image": {
		"thumbnail": trimAllNewlines(`<figure>
			<a href="%s">
				<img class="thumbnail" loading="lazy" src="%s">
			</a>
		</figure>`),
		"generic": trimAllNewlines(`<figure>
			<a href="%s">
				<img class=%s loading="lazy" src="%s">
			</a>
		</figure>`),
	},
	"application": {
		"pdf": trimAllNewlines(`<object data="%s" class="pdf" type="application/pdf">
			<embed src="%s" type="application/pdf"/>
		</object>`),
	},
	"audio": {
		"generic": trimAllNewlines(`<audio controls>
			<source src="%s" type="audio/%s">
			Sorry, your browser does not support the audio element.
		</audio>`),
	},
	"video": {
		"generic": trimAllNewlines(`<video controls>
			<source src="%s" type="video/%s">
		</video>`),
	},
	"unknown": {
		"generic": `<a class="%s" href="%s">%s</a>`,
	},
}

// generatedInDistribusi is an internal marker to help recognise when
// distribusi-go has generated files.
var generatedInDistribusi = "<meta name='generator' content='distribusi-go' />"

var helpOutput = `USAGE:
  distribusi [options] [arguments]

DESCRIPTION:
  A low-tech content management system for the web that produces static index
  pages based on folders in the files system. It is inspired by the automatic
  index functions featured in several popular web servers.

ARGUMENTS:
  <path>  path to distribusify (default: ".")

OPTIONS:
  -d  show debug output
  -c  css file for custom styles
  -h  output help
  -i  ignore paths (e.g. "*.gif")
  -s  serve locally
  -v  output version
  -w  wipe generated files
`

var (
	cssFlag    string
	ignoreFlag string

	debugFlag bool
	helpFlag  bool
	serveFlag bool
	wipeFlag  bool

	version     = "0.2.0"
	versionFlag bool
)

func main() {
	flag.StringVar(&cssFlag, "c", "", "")
	flag.StringVar(&ignoreFlag, "i", "", "")

	flag.BoolVar(&debugFlag, "d", false, "")
	flag.BoolVar(&helpFlag, "h", false, "")
	flag.BoolVar(&serveFlag, "s", false, "")
	flag.BoolVar(&versionFlag, "v", false, "")
	flag.BoolVar(&wipeFlag, "w", false, "")

	flag.Usage = func() {
		return
	}

	flag.Parse()

	opts := &slog.HandlerOptions{
		Level: slog.LevelInfo,
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == "time" {
				t := a.Value.Time()
				return slog.String("time", t.Format(time.Kitchen))
			}
			return a
		},
	}
	if debugFlag {
		opts.Level = slog.LevelDebug
		opts.AddSource = true
	}
	logger = slog.New(slog.NewTextHandler(os.Stdout, opts))
	slog.SetDefault(logger)

	if helpFlag {
		fmt.Print(helpOutput)
		os.Exit(0)
	}

	if versionFlag {
		fmt.Printf("%s\n", version)
		os.Exit(0)
	}

	path, err := os.Getwd()
	if err != nil {
		logger.Error(fmt.Sprintf("unable to determine current working directory: %s", err))
	}
	if len(flag.Args()) == 1 {
		path = flag.Args()[0]
	}

	root, err := filepath.Abs(path)
	if err != nil {
		logger.Error(fmt.Sprintf("unable to determine absolute path of %s: %s", path, err))
	}

	if _, err := os.Stat(root); os.IsNotExist(err) {
		logger.Error(fmt.Sprintf("%s does not exist?", root))
	}

	logger.Debug(fmt.Sprintf("selecting %s as distribusi root", root))

	var ignore []string
	if ignoreFlag != "" {
		ignore = strings.Split(ignoreFlag, ",")
		for i := range ignore {
			ignore[i] = strings.TrimSpace(ignore[i])
		}
		logger.Debug(fmt.Sprintf("parsed %s as ignore patterns", strings.Join(ignore, " ")))
	}

	if wipeFlag {
		if err := wipeGeneratedFiles(root); err != nil {
			logger.Error(err.Error())
		}
		logger.Info(fmt.Sprintf("wiped generated files in %s", root))
		return
	}

	ch := make(chan error, 2)
	go func() {
		if err := distribusify(root, ignore); err != nil {
			ch <- err
			return
		}

		msg := " done!"
		if serveFlag {
			fmt.Printf(msg)
		} else {
			fmt.Println(msg)
		}

		ch <- nil
		return
	}()

	if serveFlag {
		go func() {
			logger.Debug("attempting to start up the web server")
			if err := serveHTTP(root); err != nil {
				ch <- err
				return
			}
			ch <- nil
			return
		}()
	} else {
		// NOTE(d1): close the channel, we're not serving anything
		ch <- nil
	}

	for i := 1; i <= 2; i++ {
		err := <-ch
		if err != nil {
			logger.Error(err.Error())
		}
	}
}

// removeIndex safely removes an index.html, making sure to check that
// distribusi generated it.
func removeIndex(fpath string) error {
	file, err := os.ReadFile(fpath)
	if err != nil {
		return err
	}
	if strings.Contains(string(file), generatedInDistribusi) {
		if err := os.Remove(fpath); err != nil {
			return err
		}
		return nil
	}
	return nil
}

// wipeGeneratedFiles removes all distribusi generated files under a file
// system path. We do take care to avoid deleting files that distribusi has not
// generated by checking their contents.
func wipeGeneratedFiles(dir string) error {
	if err := filepath.WalkDir(dir, func(fpath string, dirEntry fs.DirEntry, err error) error {
		fname := filepath.Base(fpath)
		if fname == "index.html" {
			if err := removeIndex(fpath); err != nil {
				return fmt.Errorf("unable to remove %s: %s", fpath, err)
			}
			logger.Debug(fmt.Sprintf("wiping %s as requested", fpath))
		} else if strings.Contains(fname, thumbnailSuffix) {
			if err := os.Remove(fpath); err != nil {
				return fmt.Errorf("unable to remove %s: %s", fpath, err)
			}
			logger.Debug(fmt.Sprintf("wiping %s as requested", fpath))
		}

		return nil
	}); err != nil {
		return err
	}
	return nil
}

// createDiv cosntructs a HTML div for inclusion in the generated index.html. These
// dives are used to wrap the elements that appear on generated pages with
// relevant identifiers for convenient styling.
func createDiv(mtype string, href, fname string, unknown bool) (string, error) {
	var div string
	var divTemplate string
	var strippedDebugOutput string

	filename := fmt.Sprintf("<span class='filename'>%s</span>", fname)

	ftype, stype, err := parseMtype(mtype)
	if err != nil {
		return div, err
	}

	if ftype == "text" {
		divTemplate = "<div id=\"%s\" class='%s'>%s%s</div>"
		div = fmt.Sprintf(divTemplate, fname, ftype, href, filename)
		strippedDebugOutput = fmt.Sprintf(divTemplate, fname, ftype, logStripMsg, filename)
	} else if ftype == "os" {
		if stype == "directory" {
			divTemplate = "<div id=\"%s\" class='x-%s'>%s</div>"
			div = fmt.Sprintf(divTemplate, fname, stype, href)
		} else {
			// don't include filename since link already has it
			divTemplate = "<div id=\"%s\" class='%s'>%s</div>"
			div = fmt.Sprintf(divTemplate, fname, ftype, href)
		}
	} else {
		if unknown {
			// don't include filename since link already has it
			divTemplate = "<div id=\"%s\" class='%s'>%s</div>"
			div = fmt.Sprintf(divTemplate, fname, ftype, href)
		} else {
			divTemplate = "<div id=\"%s\" class='%s'>%s%s</div>"
			div = fmt.Sprintf(divTemplate, fname, ftype, href, filename)
			strippedDebugOutput = fmt.Sprintf(divTemplate, fname, ftype, logStripMsg, filename)
		}
	}

	if strippedDebugOutput != "" {
		logger.Debug(fmt.Sprintf("%s was wrapped in: %s", fname, strippedDebugOutput))
	} else {
		logger.Debug(fmt.Sprintf("%s was wrapped in: %s", fname, div))
	}

	return div, nil
}

// parseMtype parses a mimetype string to simplify programmatic type lookups.
func parseMtype(mtype string) (string, string, error) {
	if !strings.Contains(mtype, "/") {
		return "", "", fmt.Errorf("unable to parse %s", mtype)
	}

	stripCharset := strings.Split(mtype, ";")
	splitTypes := strings.Split(stripCharset[0], "/")
	ftype, stype := splitTypes[0], splitTypes[1]

	return ftype, stype, nil
}

// trimAllNewlines removes all new lines.
func trimAllNewlines(contents string) string {
	return strings.ReplaceAll(string(contents), "\n", "")
}

// createHref figures out which href tag corresponds to which file by navigating
// the mimetype. If a type of file is unknown, this is signalled via the bool
// return value.
func createHref(fpath string, mtype string) (bool, string, error) {
	var (
		href                string
		hrefTemplate        string
		strippedDebugOutput string
		unknown             bool
	)

	fname := filepath.Base(fpath)
	ftype, stype, err := parseMtype(mtype)
	if err != nil {
		return unknown, href, err
	}

	if ftype == "text" {
		fcontents, err := os.ReadFile(fpath)
		if err != nil {
			return unknown, href, err
		}

		trimmed := strings.TrimSuffix(string(fcontents), "\n")

		if stype == "html" {
			hrefTemplate = htmlTags[ftype][stype]
			href = fmt.Sprintf(hrefTemplate, fname, trimmed)
			strippedDebugOutput = fmt.Sprintf(hrefTemplate, fname, logStripMsg)
		} else {
			hrefTemplate = htmlTags[ftype]["generic"]
			href = fmt.Sprintf(hrefTemplate, trimmed)
			strippedDebugOutput = fmt.Sprintf(hrefTemplate, logStripMsg)
		}
	} else if ftype == "image" {
		if stype == "gif" {
			hrefTemplate = htmlTags[ftype]["generic"]
			href = fmt.Sprintf(hrefTemplate, fname, stype, fname)
		} else {
			thumbPath, err := genThumb(fpath)
			if err != nil {
				hrefTemplate = htmlTags[ftype]["generic"]
				href = fmt.Sprintf(hrefTemplate, fname, stype, fname)
				logger.Debug(fmt.Sprintf("failed to generate thumbnail for %s, showing original image", fpath))
			} else {
				hrefTemplate = htmlTags[ftype]["thumbnail"]
				href = fmt.Sprintf(hrefTemplate, fname, thumbPath)
				strippedDebugOutput = fmt.Sprintf(hrefTemplate, fname, logStripMsg)
			}
		}
	} else if ftype == "application" {
		if stype == "pdf" {
			hrefTemplate = htmlTags[ftype][stype]
			href = fmt.Sprintf(hrefTemplate, fname, fname)
		} else {
			unknown = true
			hrefTemplate = htmlTags["unknown"]["generic"]
			href = fmt.Sprintf(hrefTemplate, stype, fname, fname)
		}
	} else if ftype == "audio" {
		hrefTemplate = htmlTags[ftype]["generic"]
		href = fmt.Sprintf(hrefTemplate, fname, stype)
	} else if ftype == "video" {
		hrefTemplate = htmlTags[ftype]["generic"]
		href = fmt.Sprintf(hrefTemplate, fname, stype)
	} else {
		unknown = true
		hrefTemplate = htmlTags["unknown"]["generic"]
		href = fmt.Sprintf(hrefTemplate, stype, fname, fname)
	}

	if strippedDebugOutput != "" {
		logger.Debug(fmt.Sprintf("%s was wrapped in: %s", fname, strippedDebugOutput))
	} else {
		logger.Debug(fmt.Sprintf("%s was wrapped in: %s", fname, href))
	}

	return unknown, href, nil
}

// genThumb generates a thumbnail alongside the image.
func genThumb(fpath string) (string, error) {
	ext := filepath.Ext(fpath)
	destPath := strings.Replace(fpath, ext, fmt.Sprintf("%s%s", thumbnailSuffix, ext), 1)

	if _, err := os.Stat(destPath); err == nil || strings.Contains(fpath, thumbnailSuffix) {
		return filepath.Base(destPath), nil
	}

	gen := thumb.NewGenerator(thumb.Generator{Scaler: "CatmullRom"})
	img, err := gen.NewImageFromFile(fpath)
	if err != nil {
		return "", fmt.Errorf("unable to read image file %s: %s", fpath, err)
	}

	thumbBytes, err := gen.CreateThumbnail(img)
	if err != nil {
		return "", fmt.Errorf("unable to generate thumbnail for %s: %s", fpath, err)
	}

	if _, err := os.Stat(destPath); errors.Is(err, os.ErrNotExist) {
		if err = ioutil.WriteFile(destPath, thumbBytes, 0644); err != nil {
			return "", fmt.Errorf("unable to write thumbnail to %s: %s", destPath, err)
		}
		logger.Debug(fmt.Sprintf("generated thumbnail for %s", fpath))
	}

	return filepath.Base(destPath), nil
}

// shouldSkip checks if a specific file system path should be skipped over when
// running distribusi file generation. This might happen due to being a hidden
// directory or matching a pattern provided by the end-user.
func shouldSkip(fpath string, ignore []string) (bool, error) {
	base := filepath.Base(fpath)

	for _, pattern := range ignore {
		match, err := filepath.Match(pattern, base)
		if err != nil {
			return false, err
		}
		if match {
			logger.Debug(fmt.Sprintf("skipping %s, matched %s", base, pattern))
			return true, nil
		}
	}

	if strings.Contains(base, thumbnailSuffix) {
		logger.Debug(fmt.Sprintf("skipping %s (generated thumbnail)", base))
		return true, nil
	}

	return false, nil
}

// createIndex writes a new generated index.html file to the file system.
func createIndex(fpath string, html []string, styles string) error {
	body := fmt.Sprintf(htmlBody, generatedInDistribusi, "", strings.Join(html, "\n"))

	if styles != "" {
		absPath, err := filepath.Abs(styles)
		if err != nil {
			return err
		}

		if _, err := os.Stat(absPath); !os.IsNotExist(err) {
			contents, err := os.ReadFile(absPath)
			if err != nil {
				return nil
			}

			logger.Debug(fmt.Sprintf("loading custom styles from %s", absPath))

			body = fmt.Sprintf(htmlBody, generatedInDistribusi, contents, strings.Join(html, "\n"))
		}
	}

	HTMLPath := path.Join(fpath, "index.html")
	contents := []byte(body)

	if _, err := os.Stat(HTMLPath); err != nil {
		if os.IsNotExist(err) {
			if err := ioutil.WriteFile(HTMLPath, contents, 0644); err != nil {
				logger.Debug(fmt.Sprintf("unable to write %s, skipping", HTMLPath))
				return nil
			}
		} else {
			logger.Debug(fmt.Sprintf("unable to read %s, skipping", HTMLPath))
			return nil
		}
	} else {
		file, err := os.ReadFile(HTMLPath)
		if err != nil {
			logger.Debug(fmt.Sprintf("unable to read %s, skipping", HTMLPath))
			return nil
		}

		if strings.Contains(string(file), generatedInDistribusi) {
			if err := ioutil.WriteFile(HTMLPath, contents, 0644); err != nil {
				logger.Debug(fmt.Sprintf("unable to write %s, skipping", HTMLPath))
				return nil
			}
		}
	}

	return nil
}

// getMtype reads file mimetypes directlry from files.
func getMtype(fpath string) (string, error) {
	mtype, err := mimetype.DetectFile(fpath)
	if err != nil {
		return "", err
	}
	return mtype.String(), nil
}

// serveHTTP serves a web server for browsing distribusi generated files. This
// is mostly convenient when doing development work or showing something
// quickly on your work station. It should be fine to serve "for production"
// though too as it uses the stdlib Go HTTP server. Distribusi generated files
// still works just fine with the usual Nginx, Apache, etc.
func serveHTTP(fpath string) error {
	fs := http.FileServer(http.Dir(fpath))
	http.Handle("/", fs)
	if err := http.ListenAndServe(port, nil); err != nil {
		return err
	}
	return nil
}

// distribusify runs the main distribusi generation logic.
func distribusify(root string, ignore []string) error {
	loading := "distribusifying..."
	if serveFlag {
		loading = fmt.Sprintf("distribusifying... live @ http://localhost%s", port)
	}
	fmt.Printf(loading)

	if err := filepath.Walk(root, func(fpath string, finfo os.FileInfo, err error) error {
		skip, err := shouldSkip(fpath, ignore)
		if err != nil {
			return err
		}
		if skip {
			return nil
		}

		var html []string

		if finfo.IsDir() {
			var dirs []string
			var files []string

			absPath, err := filepath.Abs(fpath)
			if err != nil {
				logger.Debug(fmt.Sprintf("unable to read %s", absPath))
				return nil
			}

			contents, err := ioutil.ReadDir(absPath)
			if err != nil {
				logger.Debug(fmt.Sprintf("unable to read %s", absPath))
				return nil
			}

			for _, content := range contents {
				if content.IsDir() {
					dirs = append(dirs, path.Join(absPath, content.Name()))
				} else {
					if content.Name() == "index.html" {
						indexPath := path.Join(absPath, content.Name())
						file, err := os.ReadFile(indexPath)
						if err != nil {
							logger.Debug(fmt.Sprintf("unable to read %s, skipping", content.Name()))
							continue
						}

						if strings.Contains(string(file), generatedInDistribusi) {
							logger.Debug(fmt.Sprintf("%s was not generated by distribusi, skipping", indexPath))
							continue
						}
					}

					files = append(files, path.Join(absPath, content.Name()))
				}
			}

			if len(dirs) == 0 && len(files) == 0 {
				return nil
			}

			if root != fpath {
				href := "<a href='../'>../</a>"
				div, err := createDiv("os/directory", href, "menu", false)
				if err != nil {
					return err
				}
				html = append(html, div)
			}

			for _, dir := range dirs {
				fname := filepath.Base(dir)

				skip, err := shouldSkip(fname, ignore)
				if err != nil {
					return err
				}
				if skip {
					continue
				}

				mtype := "os/directory"

				href := fmt.Sprintf("<a href='%s/'>%s/</a>", fname, fname)
				div, err := createDiv(mtype, href, fname, false)
				if err != nil {
					return err
				}

				html = append(html, div)
			}

			for _, file := range files {
				fname := filepath.Base(file)

				skip, err := shouldSkip(fname, ignore)
				if err != nil {
					return err
				}
				if skip {
					continue
				}

				mtype, err := getMtype(file)
				if err != nil {
					logger.Debug(fmt.Sprintf("failed to read mimetype of %s", file))
					continue
				}

				unknown, href, err := createHref(file, mtype)
				if err != nil {
					logger.Debug(fmt.Sprintf("failed to generate href for %s", file))
					continue
				}

				div, err := createDiv(mtype, href, fname, unknown)
				if err != nil {
					logger.Debug(fmt.Sprintf("failed to generate div for %s", file))
					continue
				}

				html = append(html, div)
			}

			if err := createIndex(absPath, html, cssFlag); err != nil {
				logger.Debug(fmt.Sprintf("unable to generated %s, skipping", path.Join(absPath, "index.html")))
				return nil
			}
		}

		return nil
	}); err != nil {
		return err
	}

	return nil
}
