// lintian-ssg, a static site generator for lintian tags explanations.
//
// Copyright (C) Nicolas Peugnet <nicolas@club1.fr>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, see <https://www.gnu.org/licenses/>.

//go:generate wget -nv https://www.debian.org/debian.css -O assets/debian.css
//go:generate patch assets/debian.css assets/debian.css.diff

package main

import (
	"bytes"
	"compress/gzip"
	"embed"
	"encoding/json"
	"flag"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"log"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"syscall"
	text_template "text/template"
	"time"

	"salsa.debian.org/lintian/lintian-ssg/ioutil"
	"salsa.debian.org/lintian/lintian-ssg/lintian"
	"salsa.debian.org/lintian/lintian-ssg/markdown"
	"salsa.debian.org/lintian/lintian-ssg/version"
)

const (
	renderTagWorkers = 16
)

type tmplParams struct {
	DateYear       int
	DateHuman      string
	DateMachine    string
	BaseURL        string
	Root           string
	Version        string
	VersionLintian string
	FooterHTML     template.HTML
}

type indexTmplParams struct {
	tmplParams
	TagList []string
}

type manualTmplParams struct {
	tmplParams
	Manual template.HTML
}

type tagTmplParams struct {
	tmplParams
	*lintian.Tag
	PrevName string
}

type templateExecuter interface {
	Execute(wr io.Writer, data any) error
}

var (
	//go:embed templates/*.tmpl
	tmplFS      embed.FS
	tmplDir, _  = fs.Sub(tmplFS, "templates")
	tmplHelpers = text_template.FuncMap{
		"escapeurl": escapeURL,
	}
	indexTmpl   = template.Must(template.ParseFS(tmplDir, "index.html.tmpl"))
	tagTmpl     = template.Must(template.Must(indexTmpl.Clone()).ParseFS(tmplDir, "tag.html.tmpl"))
	renamedTmpl = template.Must(template.Must(indexTmpl.Clone()).ParseFS(tmplDir, "renamed.html.tmpl"))
	manualTmpl  = template.Must(template.Must(indexTmpl.Clone()).ParseFS(tmplDir, "manual.html.tmpl"))
	aboutTmpl   = template.Must(template.Must(indexTmpl.Clone()).ParseFS(tmplDir, "about.html.tmpl"))
	e404Tmpl    = template.Must(template.Must(indexTmpl.Clone()).ParseFS(tmplDir, "404.html.tmpl"))
	taglistTmpl = text_template.Must(text_template.ParseFS(tmplDir, "taglist.js.tmpl"))
	robotsTmpl  = text_template.Must(text_template.ParseFS(tmplDir, "robots.txt.tmpl"))
	sitemapTmpl = text_template.Must(
		text_template.New("sitemap.xml.tmpl").Funcs(tmplHelpers).ParseFS(tmplDir, "sitemap.xml.tmpl"),
	)
	//go:embed assets/debian.css
	debianCSS []byte
	//go:embed assets/main.css
	mainCSS []byte
	//go:embed assets/openlogo-50.svg
	logoSVG []byte
	//go:embed assets/favicon.ico
	faviconICO []byte

	start = time.Now()
)

var (
	flagBaseURL    string
	flagFooter     string
	flagLintianVer string
	flagManualFile string
	flagNoSitemap  bool
	flagOutDir     string
	flagStats      bool
	flagTagsFile   string
	flagDebug      bool
	flagHelp       bool
	flagVersion    bool
)

const (
	flagManualFileDef = "/usr/share/doc/lintian/lintian.html"
	flagOutDirDef     = "out"
)

func usage() {
	var output io.Writer
	if flagHelp {
		output = os.Stdout
	} else {
		output = flag.CommandLine.Output()
	}
	fmt.Fprintf(output, `Usage: lintian-ssg [OPTION]...

Generate a static web site for Lintian tags' explanations. By default, it will
call lintian-explain-tags in a subprocess, except if the --tags-file option is
provided.

Options:
  --base-url=URL        URL, including the scheme, where the root of the website
                        will be located. This will be used in the sitemap, the
                        canonical link of each page and the robots.txt.

  --footer=TEXT         TEXT to add to the footer, inline Markdown elements will
                        be parsed.

  --lintian-version=V   Override Lintian's version in output with V.
  --manual-file=FILE    Read Lintian's manual from FILE.
                        (default %q)

  --no-sitemap          Disable sitemap.xml.gz (and thus robots.txt) generation.
  -o, --output-dir=DIR  Path of the directory DIR where to output the generated
                        website. (default %q)

  --stats               Display some statistics.
  --tags-file=FILE      Read Lintian tags from FILE (in JSON format).

  --debug               Print stack traces on errors.
  -h, --help            Show this help and exit.
  --version             Show version and exit.
`,
		flagManualFileDef,
		flagOutDirDef,
	)
}

// escapeURL converts characters forbidden in URL into their percent encoded
// representation. The "/" character is preserved as is.
func escapeURL(location string) string {
	escaped := url.PathEscape(location)
	// slash is escaped by url.PathEscape but we don't want that
	escaped = strings.Replace(escaped, "%2F", "/", -1)
	return escaped
}

func rootRelPath(dir string) string {
	count := strings.Count(dir, "/")
	if count == 0 {
		return "./"
	}
	return strings.Repeat("../", count)
}

func createTagFile(name string) (page string, file *os.File, err error) {
	page = path.Join("tags", name+".html")
	outPath := filepath.Join(flagOutDir, page)
	if err = os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
		return
	}
	file, err = os.Create(outPath)
	return
}

func renderTag(tag *lintian.Tag, params *tmplParams, pages chan<- string) {
	page, file, err := createTagFile(tag.Name)
	if err != nil {
		panic(err)
	}
	defer file.Close()
	pages <- page
	tagParams := tagTmplParams{
		tmplParams: *params,
		Tag:        tag,
	}
	tagParams.Root = rootRelPath(page)
	if err := tagTmpl.Execute(file, &tagParams); err != nil {
		panic(err)
	}
	for _, name := range tag.RenamedFrom {
		page, file, err := createTagFile(name)
		if err != nil {
			panic(err)
		}
		defer file.Close()
		pages <- page
		tagParams.Root = rootRelPath(page)
		tagParams.PrevName = name
		if err := renamedTmpl.Execute(file, &tagParams); err != nil {
			panic(err)
		}
	}
}

func writeAssets() error {
	files := []struct {
		name    string
		content io.Reader
	}{
		{"debian.css", bytes.NewReader(debianCSS)},
		{"main.css", bytes.NewReader(mainCSS)},
		{"openlogo-50.svg", bytes.NewReader(logoSVG)},
		{"favicon.ico", bytes.NewReader(faviconICO)},
	}
	for _, f := range files {
		if err := ioutil.WriteFile(flagOutDir, f.name, f.content); err != nil {
			return err
		}
	}
	return nil
}

func writeSitemap(baseURL string, pages []string) error {
	file, err := os.Create(filepath.Join(flagOutDir, "sitemap.xml.gz"))
	if err != nil {
		return err
	}
	defer file.Close()
	gzfile, _ := gzip.NewWriterLevel(file, gzip.BestCompression)
	sort.Strings(pages)
	params := struct {
		BaseURL string
		Pages   []string
	}{baseURL, pages}
	if err := sitemapTmpl.Execute(gzfile, params); err != nil {
		return err
	}
	return gzfile.Close()
}

func writeManual(params *tmplParams, path string, pages chan<- string) error {
	file, err := os.Open(flagManualFile)
	if err != nil {
		return err
	}
	defer file.Close()
	reader := ioutil.NewBodyFilterReader(file)
	body := bytes.Buffer{}
	if _, err := io.Copy(&body, reader); err != nil {
		return err
	}
	pages <- path
	manualParams := manualTmplParams{*params, template.HTML(body.String())}
	manualParams.Root = rootRelPath(path)
	out := bytes.Buffer{}
	if err := manualTmpl.Execute(&out, &manualParams); err != nil {
		return err
	}
	return ioutil.WriteFile(flagOutDir, path, &out)
}

func writeSimplePage(tmpl templateExecuter, params any, path string, pages chan<- string) error {
	file, err := os.Create(filepath.Join(flagOutDir, path))
	if err != nil {
		return err
	}
	defer file.Close()
	if pages != nil {
		pages <- path
	}
	return tmpl.Execute(file, params)
}

func handlePages(pages <-chan string, count *int, wg *sync.WaitGroup) {
	defer wg.Done()
	s := make([]string, 0, 2048)
	for page := range pages {
		s = append(s, page)
	}
	if flagBaseURL != "" && !flagNoSitemap {
		if err := writeSitemap(flagBaseURL, s); err != nil {
			panic(err)
		}
	}
	*count = len(s)
}

func withRoot(params tmplParams, root string) tmplParams {
	params.Root = root
	return params
}

func buildDate() time.Time {
	source_date_epoch := os.Getenv("SOURCE_DATE_EPOCH")
	if source_date_epoch == "" {
		return start.UTC()
	} else {
		sde, err := strconv.ParseInt(source_date_epoch, 10, 64)
		if err != nil {
			panic(fmt.Sprintf("invalid SOURCE_DATE_EPOCH: %s", err))
		}
		return time.Unix(sde, 0).UTC()
	}
}

func checkErr(err error, msg ...string) {
	if err != nil {
		panic(strings.Join(append(msg, err.Error()), ": "))
	}
}

func checkPanic() {
	if !flagDebug {
		if p := recover(); p != nil {
			log.Fatalln("ERROR:", p)
		}
	}
}

func Run() {
	log.SetFlags(0)
	flag.StringVar(&flagBaseURL, "base-url", "", "")
	flag.StringVar(&flagFooter, "footer", "", "")
	flag.StringVar(&flagLintianVer, "lintian-version", "", "")
	flag.StringVar(&flagManualFile, "manual-file", flagManualFileDef, "")
	flag.BoolVar(&flagNoSitemap, "no-sitemap", false, "")
	flag.StringVar(&flagOutDir, "o", flagOutDirDef, "")
	flag.StringVar(&flagOutDir, "output-dir", flagOutDirDef, "")
	flag.BoolVar(&flagStats, "stats", false, "")
	flag.StringVar(&flagTagsFile, "tags-file", "", "")
	flag.BoolVar(&flagDebug, "debug", false, "")
	flag.BoolVar(&flagHelp, "h", false, "")
	flag.BoolVar(&flagHelp, "help", false, "")
	flag.BoolVar(&flagVersion, "version", false, "")
	flag.Usage = usage
	flag.Parse()

	if flagHelp {
		flag.Usage()
		return
	}
	if flagVersion {
		fmt.Println(version.Number)
		return
	}
	if flagBaseURL != "" && !strings.HasSuffix(flagBaseURL, "/") {
		flagBaseURL += "/"
	}

	checkErr(os.MkdirAll(flagOutDir, 0755), "create out dir")

	pagesChan := make(chan string, 32)
	pagesWG := sync.WaitGroup{}
	pagesWG.Add(1)
	var pagesCount int
	go handlePages(pagesChan, &pagesCount, &pagesWG)

	var tagsProvider lintian.TagsProvider
	var err error
	if flagTagsFile != "" {
		tagsProvider, err = lintian.NewFileTagsProvider(flagTagsFile)
	} else {
		tagsProvider, err = lintian.NewSystemTagsProvider()
	}
	checkErr(err, "get tags")

	date := buildDate()
	params := tmplParams{
		BaseURL:        flagBaseURL,
		DateYear:       date.Year(),
		DateHuman:      date.Format(time.RFC1123),
		DateMachine:    date.Format(time.RFC3339),
		Version:        version.Number,
		VersionLintian: flagLintianVer,
		FooterHTML:     markdown.ToHTML(flagFooter, markdown.StyleInline),
	}

	tagList := make([]string, 0, 2048)
	tagChan := make(chan *lintian.Tag, 32)
	tagsWG := sync.WaitGroup{}
	// Launch a limited amount of workers
	for i := 0; i < renderTagWorkers; i++ {
		go func() {
			defer checkPanic()
			for tag := range tagChan {
				renderTag(tag, &params, pagesChan)
				tagsWG.Done()
			}
		}()
	}
	err = tagsProvider.ForEach(func(tag *lintian.Tag) {
		if params.VersionLintian == "" {
			params.VersionLintian = tag.LintianVersion
		}
		// Override tag.LintianVersion in case --lintian-version option was passed
		tag.LintianVersion = params.VersionLintian
		tagsWG.Add(1)
		tagChan <- tag
		tagList = append(tagList, tag.Name)
	})
	checkErr(err, "iterate over tags")
	close(tagChan)

	tagListJSON, err := json.Marshal(tagList)
	checkErr(err, "marshal tagList")
	taglistParams := struct{ TagListJSON []byte }{tagListJSON}
	checkErr(writeSimplePage(taglistTmpl, taglistParams, "taglist.js", nil), "write taglist.js")
	checkErr(writeAssets(), "write assets")
	checkErr(writeManual(&params, "manual/index.html", pagesChan), "write manual")
	indexParams := indexTmplParams{withRoot(params, "./"), tagList}
	checkErr(writeSimplePage(indexTmpl, indexParams, "index.html", pagesChan), "write index.html")
	checkErr(writeSimplePage(aboutTmpl, withRoot(params, "./"), "about.html", pagesChan), "write about.html")
	checkErr(writeSimplePage(e404Tmpl, withRoot(params, "/"), "404.html", nil), "write 404.html")
	if flagBaseURL != "" && !flagNoSitemap {
		robotsParams := struct{ BaseURL string }{flagBaseURL}
		checkErr(writeSimplePage(robotsTmpl, robotsParams, "robots.txt", nil))
	}

	tagsWG.Wait()
	close(pagesChan)
	tagsProviderStats, err := tagsProvider.Wait()
	if err != nil {
		log.Println("WARNING: get tags:", err)
	}

	pagesWG.Wait()
	log.Printf("Generated website in %q", flagOutDir)
	if flagStats {
		usage := syscall.Rusage{}
		checkErr(syscall.Getrusage(syscall.RUSAGE_SELF, &usage), "get resources usage")
		fmt.Printf(`number of tags: %d
number of pages: %d
tags json generation CPU time: %v (user: %v sys: %v)
website generation CPU time: %v (user: %v sys: %v)
total duration: %v
`,
			len(tagList),
			pagesCount,
			(tagsProviderStats.UserTime + tagsProviderStats.SystemTime).Round(time.Millisecond),
			tagsProviderStats.UserTime.Round(time.Millisecond),
			tagsProviderStats.SystemTime.Round(time.Millisecond),
			time.Duration(usage.Utime.Nano()+usage.Stime.Nano()).Round(time.Millisecond),
			time.Duration(usage.Utime.Nano()).Round(time.Millisecond),
			time.Duration(usage.Stime.Nano()).Round(time.Millisecond),
			time.Since(start).Round(time.Millisecond),
		)
	}
}

func main() {
	defer checkPanic()
	Run()
}
