package main

import (
	"bytes"
	"errors"
	"fmt"
	"html/template"
	"log"
	"mime"
	"net/http"
	"path"
	"strings"

	"vitrine/display"
)

// This file builds the browse pages: HTML content fragments (html/template throughout,
// so everything that came out of a repo is escaped) placed into display.PageData.Content,
// with chrome, crumbs, and theme coming from the display package.

const siteName = "socle"

// logLimit caps the commit log page; history older than this is reachable by
// following parent shas (and a real pager can come later if it's ever missed).
const logLimit = 100

// socleCSS is appended after display.StyleCSS in /-/style.css: just enough for
// socle's own tables and file views, staying in vitrine's visual family.
const socleCSS = `
/* socle additions: listing and log tables, file views. */
article table.tree td, article table.tree th,
article table.log td, article table.log th { border: none; padding: .15rem .6rem; }
article table.tree tbody tr:hover, article table.log tbody tr:hover { background: var(--well); }
article th { text-align: left; }
article td.size { text-align: right; color: var(--ink-soft); }
article td.date { color: var(--ink-soft); white-space: nowrap; }
article td.hash a { font-family: monospace; }
article p.clone code { user-select: all; }
article img.file { max-width: 100%; }
`

// renderPage fills in the site-wide theme and writes the full page.
func (s *server) renderPage(w http.ResponseWriter, pd display.PageData) {
	pd.ThemeName = s.theme.Name
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := display.Page(w, pd); err != nil {
		// Headers are gone already; leave a trace in the body and the log.
		log.Print("socle: ", err)
		fmt.Fprintf(w, "\n<!-- template error: %v -->", err)
	}
}

// render executes one content-fragment template; a failure is an internal error (500).
func (s *server) render(w http.ResponseWriter, tmpl *template.Template, data any, pd display.PageData) {
	var buf bytes.Buffer
	if err := tmpl.Execute(&buf, data); err != nil {
		httpError(w, err)
		return
	}
	pd.Content = template.HTML(buf.String())
	s.renderPage(w, pd)
}

// treeHref builds the browse URL for a path at a ref; dirs get a trailing slash by the caller.
// Escaping is left to html/template's URL normalization in the templates.
func treeHref(name, ref, pth string) string {
	h := "/" + name + "/tree/" + ref
	if pth != "" {
		h += "/" + pth
	}
	return h
}

func rawHref(name, ref, pth string) string {
	return "/" + name + "/raw/" + ref + "/" + pth
}

// isImagePath reports whether a filename looks like an image a browser renders natively:
// those inline via the raw URL instead of getting a text or download treatment.
func isImagePath(pth string) bool {
	switch strings.ToLower(path.Ext(pth)) {
	case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico":
		return true
	}
	return false
}

// rawContentType picks the Content-Type for a raw blob response, by extension only
// (blob content is not consulted).  Markdown is forced to text/plain so browsers display
// rather than download it.  Empty means "let net/http sniff the first bytes".
func rawContentType(pth string) string {
	ext := strings.ToLower(path.Ext(pth))
	if ext == ".md" {
		return "text/plain; charset=utf-8"
	}
	return mime.TypeByExtension(ext)
}

// crumbLink is one segment of the breadcrumb nav; an empty Href renders as plain text.
type crumbLink = display.Crumb

// repoCrumbs starts a breadcrumb trail: site, then the repo (its full name as one crumb,
// slashes and all: intermediate name segments are not pages).
func repoCrumbs(name string, more ...crumbLink) []display.Crumb {
	cs := []display.Crumb{{Name: siteName, Href: "/"}, {Name: name, Href: "/" + name + "/"}}
	cs = append(cs, more...)
	cs[len(cs)-1].Href = "" // the last crumb is the page being viewed
	return cs
}

// ---- index ----

var indexTmpl = template.Must(template.New("index").Parse(`<h1>repositories</h1>
<table class="log">
<tbody>
{{range .}}<tr><td><a href="/{{.Name}}/">{{.Name}}</a></td><td>{{.Description}}</td><td class="date">{{.Head}}</td></tr>
{{end}}</tbody>
</table>
`))

type indexRow struct {
	Name        string
	Description string
	Head        string // subject of the newest commit on the default branch; empty if none
}

func (s *server) serveIndex(w http.ResponseWriter, r *http.Request) {
	var rows []indexRow
	for _, name := range sortedNames(s.repos) {
		rp := s.repos[name]
		row := indexRow{Name: name, Description: rp.description}
		if cs, err := rp.commits(rp.defaultBranch(), 1); err == nil && len(cs) > 0 {
			row.Head = cs[0].subject
		}
		rows = append(rows, row)
	}
	s.render(w, indexTmpl, rows, display.PageData{
		Title:  siteName,
		Crumbs: []display.Crumb{{Name: siteName}},
	})
}

// ---- repo home ----

var homeTmpl = template.Must(template.New("home").Parse(`{{if .Description}}<p>{{.Description}}</p>
{{end}}<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p>
{{if .Empty}}<p>This repository has no commits yet.</p>
{{else}}<p><a href="{{.FilesHref}}">files</a> · <a href="{{.LogHref}}">log</a>
· branches: {{range $i, $b := .Branches}}{{if $i}}, {{end}}<a href="{{$b.Href}}">{{$b.Name}}</a>{{end}}{{if .Tags}}
· tags: {{range $i, $t := .Tags}}{{if $i}}, {{end}}<a href="{{$t.Href}}">{{$t.Name}}</a>{{end}}{{end}}</p>
{{end}}{{.Readme}}`))

type refLink struct {
	Name string
	Href string
}

type homeData struct {
	Description string
	CloneURL    string
	Empty       bool // no commits yet: nothing to browse
	FilesHref   string
	LogHref     string
	Branches    []refLink
	Tags        []refLink
	Readme      template.HTML
}

func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo) {
	scheme := "http"
	if r.TLS != nil {
		scheme = "https"
	}
	db := rp.defaultBranch()
	d := homeData{
		Description: rp.description,
		CloneURL:    scheme + "://" + r.Host + "/" + rp.name,
		FilesHref:   treeHref(rp.name, db, "") + "/",
		LogHref:     "/" + rp.name + "/log/" + db,
	}
	branches, tags, err := rp.refs()
	if err != nil {
		httpError(w, err)
		return
	}
	for _, b := range branches {
		d.Branches = append(d.Branches, refLink{b.name, treeHref(rp.name, b.name, "") + "/"})
	}
	for _, t := range tags {
		d.Tags = append(d.Tags, refLink{t.name, treeHref(rp.name, t.name, "") + "/"})
	}
	sha, err := rp.resolveCommit("HEAD")
	switch {
	case err == nil:
		d.Readme, err = s.renderReadme(rp, db, sha)
		if err != nil {
			httpError(w, err)
			return
		}
	case errors.Is(err, errNotFound):
		d.Empty = true // an unborn HEAD: a freshly-init'd repo is fine to show, just bare
	default:
		httpError(w, err)
		return
	}
	s.render(w, homeTmpl, d, display.PageData{
		Title:  rp.name,
		Crumbs: repoCrumbs(rp.name),
	})
}

// renderReadme finds and renders the README blob in the root tree at a commit, if any:
// the first root entry named readme.md (any case) renders as markdown;
// failing that, readme or readme.txt (any case) shows preformatted.  No README is fine.
// ref is used for link targets so browsing stays on the branch the viewer arrived at.
func (s *server) renderReadme(rp *repo, ref, sha string) (template.HTML, error) {
	entries, err := rp.lsTree(sha, "")
	if err != nil {
		return "", err
	}
	name, markdown := "", false
	for _, e := range entries {
		if e.typ != "blob" || e.size > display.MaxRenderSize {
			continue
		}
		switch strings.ToLower(e.name) {
		case "readme.md":
			name, markdown = e.name, true
		case "readme", "readme.txt":
			if name == "" {
				name = e.name
			}
		}
	}
	if name == "" {
		return "", nil
	}
	var buf bytes.Buffer
	if err := rp.catBlob(sha, name, &buf); err != nil {
		return "", err
	}
	if markdown {
		return display.Markdown(buf.Bytes())
	}
	var out bytes.Buffer
	if err := preTmpl.Execute(&out, buf.String()); err != nil {
		return "", err
	}
	return template.HTML(out.String()), nil
}

// ---- tree listing ----

var treeTmpl = template.Must(template.New("tree").Parse(`<h1>{{.Title}}</h1>
<table class="tree">
<tbody>
{{range .Rows}}<tr><td>{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td class="size">{{.Size}}</td></tr>
{{end}}</tbody>
</table>
`))

type treeRow struct {
	Name string
	Href string // empty renders unlinked (submodules)
	Size string // blob size in bytes; empty for non-blobs
}

type treeData struct {
	Title string
	Rows  []treeRow
}

func (s *server) serveTreeListing(w http.ResponseWriter, rp *repo, ref, sha, pth string) {
	entries, err := rp.lsTree(sha, pth)
	if err != nil {
		httpError(w, err)
		return
	}
	d := treeData{Title: pth}
	if pth == "" {
		d.Title = rp.name
	}
	if pth != "" {
		d.Rows = append(d.Rows, treeRow{Name: "..", Href: treeHref(rp.name, ref, parentDir(pth)) + "/"})
	}
	for _, e := range entries {
		full := e.name
		if pth != "" {
			full = pth + "/" + e.name
		}
		row := treeRow{Name: e.name}
		switch e.typ {
		case "tree":
			row.Name += "/"
			row.Href = treeHref(rp.name, ref, full) + "/"
		case "blob":
			row.Href = treeHref(rp.name, ref, full)
			row.Size = fmt.Sprint(e.size)
		default: // a submodule pointer: nothing here to link to
			row.Name += " @ " + shortSha(e.hash)
		}
		d.Rows = append(d.Rows, row)
	}
	s.render(w, treeTmpl, d, display.PageData{
		Title:  pageTitle(rp.name, ref, pth),
		Crumbs: treeCrumbs(rp.name, ref, pth),
	})
}

// treeCrumbs: site / repo / ref / path segments, each linking to its listing.
func treeCrumbs(name, ref, pth string, more ...crumbLink) []display.Crumb {
	cs := []crumbLink{{Name: ref, Href: treeHref(name, ref, "") + "/"}}
	if pth != "" {
		segs := strings.Split(pth, "/")
		for i, seg := range segs {
			cs = append(cs, crumbLink{Name: seg, Href: treeHref(name, ref, strings.Join(segs[:i+1], "/")) + "/"})
		}
	}
	return repoCrumbs(name, append(cs, more...)...)
}

// parentDir is path.Dir for tree paths: "" (the root), not ".", for a top-level entry.
func parentDir(pth string) string {
	if d := path.Dir(pth); d != "." {
		return d
	}
	return ""
}

func pageTitle(name, ref, pth string) string {
	t := name
	if pth != "" {
		t += "/" + pth
	}
	return t + " @ " + shortSha(ref)
}

// shortSha abbreviates full hex shas for display; anything else (branch names) passes through.
func shortSha(ref string) string {
	if len(ref) == 40 && !strings.ContainsFunc(ref, func(c rune) bool { return !isHex(c) }) {
		return ref[:10]
	}
	return ref
}

func isHex(c rune) bool {
	return c >= '0' && c <= '9' || c >= 'a' && c <= 'f'
}

// ---- file views ----

var (
	preTmpl   = template.Must(template.New("pre").Parse(`<pre class="file">{{.}}</pre>`))
	imageTmpl = template.Must(template.New("image").Parse(`<h1>{{.Name}}</h1>
<p><img class="file" src="{{.RawHref}}" alt="{{.Name}}"></p>
`))
	fileTmpl = template.Must(template.New("file").Parse(`<h1>{{.Name}}</h1>
{{.Body}}`))
	rawLinkTmpl = template.Must(template.New("rawlink").Parse(`<h1>{{.Name}}</h1>
<p>{{.Size}} bytes of {{.Kind}}; <a href="{{.RawHref}}">view raw</a>.</p>
`))
)

// serveFileView shows a blob as a page: markdown rendered, text preformatted, images inlined,
// and anything binary or oversized as a link to the raw view.
func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth string) {
	name := path.Base(pth)
	raw := rawHref(rp.name, ref, pth)
	pd := display.PageData{
		Title:   pageTitle(rp.name, ref, pth),
		Crumbs:  treeCrumbs(rp.name, ref, parentDir(pth), crumbLink{Name: name}),
		RawHref: raw,
	}
	if isImagePath(pth) {
		s.render(w, imageTmpl, struct{ Name, RawHref string }{name, raw}, pd)
		return
	}
	size, err := rp.blobSize(sha, pth)
	if err != nil {
		httpError(w, err)
		return
	}
	rawLink := func(kind string) {
		s.render(w, rawLinkTmpl, struct {
			Name, Kind, RawHref string
			Size                int64
		}{name, kind, raw, size}, pd)
	}
	if size > display.MaxRenderSize {
		rawLink("file, too large to display inline")
		return
	}
	var buf bytes.Buffer
	if err := rp.catBlob(sha, pth, &buf); err != nil {
		httpError(w, err)
		return
	}
	if looksBinary(buf.Bytes()) {
		rawLink("binary data")
		return
	}
	if strings.ToLower(path.Ext(pth)) == ".md" {
		body, err := display.Markdown(buf.Bytes())
		if err != nil {
			httpError(w, err)
			return
		}
		s.render(w, fileTmpl, struct {
			Name string
			Body template.HTML
		}{name, body}, pd)
		return
	}
	var pre bytes.Buffer
	if err := preTmpl.Execute(&pre, buf.String()); err != nil {
		httpError(w, err)
		return
	}
	s.render(w, fileTmpl, struct {
		Name string
		Body template.HTML
	}{name, template.HTML(pre.String())}, pd)
}

// looksBinary applies the classic cheap heuristic: a NUL byte in the leading bytes means
// "not text".  Content that dodges this (a NUL late in a big file) still renders;
// it is just ugly, not harmful, since everything goes through template escaping anyway.
func looksBinary(b []byte) bool {
	if len(b) > 8000 {
		b = b[:8000]
	}
	return bytes.IndexByte(b, 0) >= 0
}

// ---- log ----

var logTmpl = template.Must(template.New("log").Parse(`<h1>log: {{.Title}}</h1>
<table class="log">
<thead><tr><th>commit</th><th>date</th><th>author</th><th>subject</th></tr></thead>
<tbody>
{{range .Rows}}<tr><td class="hash"><a href="{{.Href}}">{{.Sha}}</a></td><td class="date">{{.Date}}</td><td>{{.Author}}</td><td>{{.Subject}}</td></tr>
{{end}}</tbody>
</table>
{{if .Capped}}<p>Showing the {{.Limit}} most recent commits.</p>
{{end}}`))

type logRow struct {
	Sha     string
	Href    string
	Date    string
	Author  string
	Subject string
}

type logData struct {
	Title  string
	Rows   []logRow
	Capped bool
	Limit  int
}

// renderLog writes the log page.  Commit shas link to the tree at that commit:
// commit-diff pages are a later stretch goal, and browsing the snapshot is useful today.
func (s *server) renderLog(w http.ResponseWriter, rp *repo, ref string, cs []commitInfo) {
	d := logData{Title: shortSha(ref), Capped: len(cs) == logLimit, Limit: logLimit}
	for _, c := range cs {
		d.Rows = append(d.Rows, logRow{
			Sha:     shortSha(c.hash),
			Href:    treeHref(rp.name, c.hash, "") + "/",
			Date:    c.time.Format("2006-01-02"),
			Author:  c.author,
			Subject: c.subject,
		})
	}
	s.render(w, logTmpl, d, display.PageData{
		Title:  rp.name + " log @ " + shortSha(ref),
		Crumbs: repoCrumbs(rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}),
	})
}
