// 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 3 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/>.

package main

import (
	"bytes"
	"embed"
	"encoding/json"
	"flag"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"log"
	"os"
	"path"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"syscall"
	"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"
)

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
}

var (
	//go:embed templates/*.tmpl
	tmplFS      embed.FS
	tmplDir, _  = fs.Sub(tmplFS, "templates")
	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"))
	//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
	flagHelp       bool
	flagManualFile string
	flagNoSitemap  bool
	flagOutDir     string
	flagStats      bool
	flagTagsFile   string
	flagVersion    bool
	flagDebug      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 [OPTIONS]...

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 and in
                        the canonical link of each page.

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

  --manual-file=FILE    Read Lintian's manual from FILE.
                        (default %q)

  --no-sitemap          Disable sitemap.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).
  -h, --help            Show this help and exit.
  --version             Show version and exit.
  --debug               Print stack traces on errors.
`,
		flagManualFileDef,
		flagOutDirDef,
	)
}

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, wg *sync.WaitGroup) {
	defer wg.Done()
	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
	}{
		{"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.txt"))
	if err != nil {
		return err
	}
	defer file.Close()
	sort.Strings(pages)
	builder := strings.Builder{}
	builder.Grow(len(pages) * 32)
	for _, page := range pages {
		builder.WriteString(baseURL + page + "\n")
	}
	if _, err := file.WriteString(builder.String()); err != nil {
		return err
	}
	return nil
}

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 *template.Template, 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 checkErr(err error, msg ...string) {
	if err != nil {
		panic(strings.Join(append(msg, err.Error()), ": "))
	}
}

func Run() {
	log.SetFlags(0)
	flag.StringVar(&flagBaseURL, "base-url", "", "")
	flag.StringVar(&flagFooter, "footer", "", "")
	flag.BoolVar(&flagHelp, "h", false, "")
	flag.BoolVar(&flagHelp, "help", false, "")
	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(&flagVersion, "version", false, "")
	flag.BoolVar(&flagDebug, "debug", 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 := start.UTC()
	params := tmplParams{
		BaseURL:     flagBaseURL,
		DateYear:    date.Year(),
		DateHuman:   date.Format(time.RFC1123),
		DateMachine: date.Format(time.RFC3339),
		Version:     version.Number,
		FooterHTML:  markdown.ToHTML(flagFooter, markdown.StyleInline),
	}

	tagList := make([]string, 0, 2048)
	tagsWG := sync.WaitGroup{}
	err = tagsProvider.ForEach(func(tag *lintian.Tag) {
		if params.VersionLintian == "" {
			params.VersionLintian = tag.LintianVersion
		}
		tagsWG.Add(1)
		go renderTag(tag, &params, pagesChan, &tagsWG)
		tagList = append(tagList, tag.Name)
	})
	checkErr(err, "iterate over tags")

	tagListJSON, err := json.Marshal(tagList)
	checkErr(err, "marshal tagList")
	checkErr(ioutil.WriteFile(flagOutDir, "taglist.json", bytes.NewReader(tagListJSON)), "write taglist")
	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")

	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 func() {
		if !flagDebug {
			if p := recover(); p != nil {
				log.Fatalln("ERROR:", p)
			}
		}
	}()
	Run()
}
