package blog

import (
	"bytes"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"

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

var md = goldmark.New(
	goldmark.WithExtensions(extension.GFM),
	goldmark.WithRendererOptions(html.WithHardWraps(), html.WithUnsafe()),
)

type Post struct {
	Slug          string
	Title         string
	Date          time.Time
	HTML          string
	Description   string
	OGImage       string
	OGImageCustom bool
	CodeLangs     []string
	HasCode       bool
	HasErgon      bool
	HasArchASM    bool
}

type Store struct {
	posts []Post
	index map[string]int
}

func NewStore() *Store {
	return &Store{index: make(map[string]int)}
}

func (s *Store) Load(dir string) error {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return err
	}

	s.posts = s.posts[:0]
	s.index = make(map[string]int)

	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
			continue
		}

		path := filepath.Join(dir, e.Name())
		raw, err := os.ReadFile(path)
		if err != nil {
			continue
		}

		slug := strings.TrimSuffix(e.Name(), ".md")
		title, body, date, ogImage, ogImageCustom, description := parseFrontMatter(raw, slug, path)

		var buf bytes.Buffer
		if err := md.Convert(body, &buf); err != nil {
			continue
		}

		ogImagePath := ogImage
		if !ogImageCustom {
			ogImagePath = "/blog/og/" + slug + ".png"
		}

		codeLangs := extractCodeLangs(raw)
		var bundleLangs []string
		hasErgon := false
		hasArchASM := false
		for _, l := range codeLangs {
			switch l {
			case "ergon":
				hasErgon = true
			case "archasm":
				hasArchASM = true
			default:
				bundleLangs = append(bundleLangs, l)
			}
		}

		s.index[slug] = len(s.posts)
		s.posts = append(s.posts, Post{
			Slug:          slug,
			Title:         title,
			Date:          date,
			HTML:          buf.String(),
			Description:   description,
			OGImage:       ogImagePath,
			OGImageCustom: ogImageCustom,
			CodeLangs:     bundleLangs,
			HasCode:       len(bundleLangs) > 0 || hasErgon || hasArchASM,
			HasErgon:      hasErgon,
			HasArchASM:    hasArchASM,
		})
	}

	// sort newest first
	sort.Slice(s.posts, func(i, j int) bool {
		return s.posts[i].Date.After(s.posts[j].Date)
	})
	for i, p := range s.posts {
		s.index[p.Slug] = i
	}

	return nil
}

func (s *Store) GenerateOGImages(dir string) {
	for _, p := range s.posts {
		if p.OGImageCustom {
			continue
		}
		ensureGeneratedOGImage(dir, p.Slug, p.Title, p.Date.Format("January 2, 2006"), p.Description)
	}
}

func (s *Store) All() []Post {
	return s.posts
}

func (s *Store) Get(slug string) (Post, bool) {
	i, ok := s.index[slug]
	if !ok {
		return Post{}, false
	}
	return s.posts[i], true
}

func (s *Store) Count() int {
	return len(s.posts)
}

var h1Re = regexp.MustCompile(`(?m)^#\s+(.+)$`)

// Date formats we recognize on the line after the title
var dateFormats = []string{
	"2006-01-02",
	"January 2, 2006",
	"Jan 2, 2006",
	"02/01/2006",
}

// parseFrontMatter extracts the title from the first `# heading`,
// strips that line from the body, and tries to parse a date from
// the line immediately after. Falls back to file mod time.
// Also extracts optional og-image: and og-description: lines.
func parseFrontMatter(raw []byte, slug, path string) (title string, body []byte, date time.Time, ogImage string, ogImageCustom bool, description string) {
	body = raw

	// Extract title
	m := h1Re.FindSubmatch(raw)
	if len(m) >= 2 {
		title = strings.TrimSpace(string(m[1]))
	} else {
		title = slug
	}

	// Strip the first `# title` line from body so it's not rendered twice
	if loc := h1Re.FindIndex(raw); loc != nil {
		// Find end of that line
		lineEnd := loc[1]
		// advance past newline
		for lineEnd < len(raw) && (raw[lineEnd] == '\n' || raw[lineEnd] == '\r') {
			lineEnd++
		}
		body = raw[lineEnd:]
	}

	// Try to parse date from first remaining line
	date = fileModTime(path)
	rest := strings.TrimSpace(string(body))
	if rest != "" {
		firstLine := rest
		if idx := strings.IndexByte(rest, '\n'); idx >= 0 {
			firstLine = strings.TrimSpace(rest[:idx])
		}
		for _, fmt := range dateFormats {
			if t, err := time.Parse(fmt, firstLine); err == nil {
				date = t
				// strip the date line from body too
				if idx := strings.IndexByte(string(body), '\n'); idx >= 0 {
					body = body[idx+1:]
				} else {
					body = nil
				}
				break
			}
		}
	}

	// Strip og-image: and og-description: lines from body (can appear anywhere)
	ogImageRe := regexp.MustCompile(`(?m)^og-image:\s*(.+)$`)
	ogDescRe := regexp.MustCompile(`(?m)^og-description:\s*(.+)$`)

	if m := ogImageRe.FindSubmatch(body); len(m) >= 2 {
		ogImage = strings.TrimSpace(string(m[1]))
		ogImageCustom = true
		body = ogImageRe.ReplaceAll(body, nil)
	}

	if m := ogDescRe.FindSubmatch(body); len(m) >= 2 {
		description = strings.TrimSpace(string(m[1]))
		body = ogDescRe.ReplaceAll(body, nil)
	}

	// Auto-extract description from first paragraph if not set
	if description == "" {
		description = extractDescription(body)
	}

	return title, body, date, ogImage, ogImageCustom, description
}

var codeBlockRe = regexp.MustCompile("(?m)^```([a-zA-Z0-9_+-]+)?$")

func containsLang(langs []string, target string) bool {
	for _, l := range langs {
		if l == target {
			return true
		}
	}
	return false
}

func extractCodeLangs(raw []byte) []string {
	seen := make(map[string]bool)
	var langs []string
	for _, m := range codeBlockRe.FindAllSubmatch(raw, -1) {
		if len(m) >= 2 && len(m[1]) > 0 {
			lang := strings.ToLower(string(m[1]))
			if !seen[lang] {
				seen[lang] = true
				langs = append(langs, lang)
			}
		}
	}
	return langs
}

func fileModTime(path string) time.Time {
	info, err := os.Stat(path)
	if err != nil {
		return time.Now()
	}
	return info.ModTime()
}

func extractDescription(body []byte) string {
	// Find first non-empty line that isn't a markdown heading or front matter
	lines := strings.Split(string(body), "\n")
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "---") {
			continue
		}
		// Strip basic markdown formatting
		desc := strings.ReplaceAll(line, "**", "")
		desc = strings.ReplaceAll(desc, "*", "")
		desc = strings.ReplaceAll(desc, "_", "")
		// Truncate
		if len(desc) > 160 {
			desc = desc[:157] + "..."
		}
		return desc
	}
	return ""
}
