package main

import (
	"bytes"
	"fmt"
	"html/template"
	"net/url"
	"os"
	"path"
	"sort"
	"strings"
	"time"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer/html"
)

var md goldmark.Markdown

// initMarkdown builds the package-level goldmark instance. Called once from
// main after the wiki root is known so the wiki-link extension can resolve
// targets against the filesystem.
func initMarkdown(root string) {
	md = goldmark.New(
		goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newExtLinksExt()),
		goldmark.WithParserOptions(parser.WithAutoHeadingID()),
		goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
	)
}

type entry struct {
	Icon            template.HTML
	Name, URL, Meta string
	// ThumbURL is set for thumbnailable files; the thumbnail view renders an
	// <img> when it is non-empty and falls back to Icon otherwise.
	ThumbURL string
	// IsVideo marks a thumbnail tile as a video so the grid can overlay a play
	// badge. Only meaningful when ThumbURL is set.
	IsVideo bool
	// modTime/size carry the raw sort keys; the template only reads the
	// formatted Meta string.
	modTime time.Time
	size    int64
}

type pageData struct {
	Title          string
	CanEdit        bool
	EditMode       bool
	IsRoot         bool
	SectionIndex   int // -1 = whole page; >=0 = section being edited
	InsertBefore   int // -1 = no insert; >=0 = splice new section at this index
	PostURL        string
	RawContent     string
	Content        template.HTML
	Entries        []entry
	View           string // listing view style: "list" or "thumbnail"
	Sort           string // listing sort key: "name" / "modified" / "size"
	Order          string // listing sort order: "asc" / "desc"
	SpecialContent template.HTML
	SidebarWidget  template.HTML
	SuppressTOC    bool
	RenderMS       int64
}

// Allowed values for the listing view settings. Unknown values in the file or
// a POST body fall back to the first (default) value of each set.
const (
	viewList      = "list"
	viewThumbnail = "thumbnail"

	sortName     = "name"
	sortModified = "modified"
	sortSize     = "size"

	orderAsc  = "asc"
	orderDesc = "desc"
)

// pageSettings holds the parsed contents of a .page-settings file. View, Sort,
// and Order are always valid once parsed (defaults applied on read).
type pageSettings struct {
	Type  string
	View  string
	Sort  string
	Order string
}

// viewSettings returns the listing view/sort/order, applying defaults when the
// receiver is nil (no .page-settings file).
func (s *pageSettings) viewSettings() (view, sortKey, order string) {
	if s == nil {
		return viewList, sortName, orderAsc
	}
	return s.View, s.Sort, s.Order
}

func validateView(v string) string {
	if v == viewThumbnail {
		return viewThumbnail
	}
	return viewList
}

func validateSort(v string) string {
	switch v {
	case sortModified, sortSize:
		return v
	default:
		return sortName
	}
}

func validateOrder(v string) string {
	if v == orderDesc {
		return orderDesc
	}
	return orderAsc
}

var (
	iconUp      = readIcon("up")
	iconFolder  = readIcon("folder")
	iconDoc     = readIcon("doc")
	iconImage   = readIcon("image")
	iconVideo   = readIcon("video")
	iconAudio   = readIcon("audio")
	iconArchive = readIcon("archive")
	iconGeneric = readIcon("generic")
)

// renderMarkdown converts raw markdown to trusted HTML.
func renderMarkdown(raw []byte) template.HTML {
	var buf bytes.Buffer
	if err := md.Convert(raw, &buf); err != nil {
		return ""
	}
	out := rewriteTaskCheckboxes(buf.Bytes())
	// Goldmark emits a bare `<table>`; tag it so it picks up the shared
	// .data-table styling with the grid modifier (per-cell borders + header).
	out = bytes.ReplaceAll(out, []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
	return template.HTML(out)
}

// extractFirstHeading returns the text of the first ATX heading in raw markdown,
// or an empty string if none is found.
func extractFirstHeading(raw []byte) string {
	for _, line := range strings.SplitN(string(raw), "\n", 50) {
		trimmed := strings.TrimSpace(line)
		if !strings.HasPrefix(trimmed, "#") {
			continue
		}
		text := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
		if text != "" {
			return text
		}
	}
	return ""
}

// stripFirstHeading removes the first ATX heading line from raw markdown.
func stripFirstHeading(raw []byte) []byte {
	lines := strings.Split(string(raw), "\n")
	for i, line := range lines {
		if strings.HasPrefix(strings.TrimSpace(line), "#") {
			result := append(lines[:i:i], lines[i+1:]...)
			return []byte(strings.TrimLeft(strings.Join(result, "\n"), "\n"))
		}
	}
	return raw
}

// parentURL returns the parent URL of a slash-terminated URL path.
func parentURL(urlPath string) string {
	parent := path.Dir(strings.TrimSuffix(urlPath, "/"))
	if parent == "." || parent == "/" {
		return "/"
	}
	return parent + "/"
}

func listEntries(fsPath, urlPath, sortKey, order string) []entry {
	entries, err := os.ReadDir(fsPath)
	if err != nil {
		return nil
	}

	var folders, files []entry
	for _, e := range entries {
		name := e.Name()
		if strings.HasPrefix(name, ".") {
			continue
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		entryURL := path.Join(urlPath, name)
		if e.IsDir() {
			folders = append(folders, entry{
				Icon:    iconFolder,
				Name:    name,
				URL:     entryURL + "/",
				Meta:    info.ModTime().Format("2006-01-02"),
				modTime: info.ModTime(),
			})
		} else {
			if name == "index.md" {
				continue // rendered above, don't list it
			}
			f := entry{
				Icon:    fileIcon(name),
				Name:    name,
				URL:     entryURL,
				Meta:    formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
				modTime: info.ModTime(),
				size:    info.Size(),
			}
			if hasThumbnail(name) {
				f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
				f.IsVideo = isVideoFile(name)
			}
			files = append(files, f)
		}
	}

	// Folders always sort by name regardless of the chosen key (they have no
	// meaningful byte size); files honor the chosen key. The chosen order
	// applies to both groups.
	sortEntries(folders, sortName, order)
	sortEntries(files, sortKey, order)

	// `..` row mirrors the header Up button so the listing itself is
	// navigable without reaching for the header on mobile. Prepended after
	// sort so it always sits at the top regardless of folder names.
	var out []entry
	if urlPath != "/" {
		out = append(out, entry{
			Icon: iconUp,
			Name: "..",
			URL:  parentURL(urlPath),
		})
	}
	out = append(out, folders...)
	out = append(out, files...)
	return out
}

// sortEntries sorts a single group (folders or files) in place by the given
// key, breaking ties on case-insensitive name, then reverses for descending
// order. The stable sort keeps the name tiebreak meaningful.
func sortEntries(group []entry, sortKey, order string) {
	sort.SliceStable(group, func(i, j int) bool {
		a, b := group[i], group[j]
		cmp := 0
		switch sortKey {
		case sortModified:
			if a.modTime.Before(b.modTime) {
				cmp = -1
			} else if a.modTime.After(b.modTime) {
				cmp = 1
			}
		case sortSize:
			if a.size < b.size {
				cmp = -1
			} else if a.size > b.size {
				cmp = 1
			}
		}
		if cmp == 0 {
			an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
			if an < bn {
				cmp = -1
			} else if an > bn {
				cmp = 1
			}
		}
		if order == orderDesc {
			return cmp > 0
		}
		return cmp < 0
	})
}

func readIcon(name string) template.HTML {
	b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
	return template.HTML(strings.TrimSpace(string(b)))
}

func fileIcon(name string) template.HTML {
	ext := strings.ToLower(path.Ext(name))
	switch ext {
	case ".md", ".pdf":
		return iconDoc
	case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
		return iconImage
	case ".mp4", ".mkv", ".avi", ".mov":
		return iconVideo
	case ".mp3", ".flac", ".ogg", ".wav":
		return iconAudio
	case ".zip", ".tar", ".gz", ".7z":
		return iconArchive
	default:
		return iconGeneric
	}
}

func formatSize(b int64) string {
	switch {
	case b < 1024:
		return fmt.Sprintf("%d B", b)
	case b < 1024*1024:
		return fmt.Sprintf("%.1f KB", float64(b)/1024)
	default:
		return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
	}
}

func pageTitle(urlPath string) string {
	if urlPath == "/" {
		return "Datascape"
	}
	parts := strings.Split(strings.Trim(urlPath, "/"), "/")
	return parts[len(parts)-1]
}
