package models

import (
	"errors"
	"fmt"
	"html/template"
	"math"
	"regexp"
	"strconv"
	"strings"
	"time"

	"git.kirsle.net/apps/gophertype/pkg/console"
	"git.kirsle.net/apps/gophertype/pkg/markdown"
	"git.kirsle.net/apps/gophertype/pkg/mogrify"
	"git.kirsle.net/apps/gophertype/pkg/rng"
	"git.kirsle.net/apps/gophertype/pkg/search"
	"github.com/albrow/forms"
)

type postMan struct{}

// Posts is a singleton manager class for Post model access.
var Posts = postMan{}

// Post represents a single blog entry.
type Post struct {
	BaseModel

	Title       string
	Fragment    string `gorm:"unique_index"`
	ContentType string `gorm:"default:'html'"`
	AuthorID    int    // foreign key to User.ID
	Thumbnail   string // image thumbnail for the post
	Body        string
	Privacy     string

	Sticky         bool
	EnableComments bool
	Tags           []TaggedPost
	Author         User `gorm:"foreign_key:UserID"`

	// Private fields not in DB.
	CommentCount int `gorm:"-"`
}

// PagedPosts holds a paginated response of multiple posts.
type PagedPosts struct {
	Posts        []Post
	Page         int
	PerPage      int
	Pages        int
	Total        int
	NextPage     int
	PreviousPage int
}

// PostArchive holds the posts for a single year/month for the archive page.
type PostArchive struct {
	Label string
	Date  time.Time
	Posts []Post
}

// Regexp for matching a thumbnail image for a blog post.
var ThumbnailImageRegexp = regexp.MustCompile(`['"(]([a-zA-Z0-9-_:/?.=&]+\.(?:jpe?g|png|gif))['")]`)

// New creates a new Post model.
func (m postMan) New() Post {
	return Post{
		ContentType:    Markdown,
		Privacy:        Public,
		EnableComments: true,
	}
}

// Load a post by ID.
func (m postMan) Load(id int) (Post, error) {
	var post Post
	r := DB.Preload("Author").Preload("Tags").First(&post, id)
	return post, r.Error
}

// LoadFragment loads a blog post by its URL fragment.
func (m postMan) LoadFragment(fragment string) (Post, error) {
	var post Post
	r := DB.Preload("Author").Preload("Tags").Where("fragment = ?", strings.Trim(fragment, "/")).First(&post)
	return post, r.Error
}

// LoadRandom gets a random post for a given privacy setting.
func (m postMan) LoadRandom(privacy string) (Post, error) {
	// Find all the post IDs.
	var pp []Post
	r := DB.Select("id").Where("privacy = ?", privacy).Find(&pp)
	if r.Error != nil || len(pp) == 0 {
		return Post{}, r.Error
	}

	// Pick one at random.
	randPost := pp[rng.Intn(len(pp))]
	post, err := Posts.Load(randPost.ID)
	return post, err
}

// GetIndex returns the index page of blog posts.
func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, error) {
	var pp = PagedPosts{
		Page:    page,
		PerPage: perPage,
	}

	if pp.Page < 1 {
		pp.Page = 1
	}
	if pp.PerPage <= 0 {
		pp.PerPage = 20
	}

	query := DB.Preload("Author").Preload("Tags").
		Where("privacy = ?", privacy).
		Order("sticky desc, created_at desc")

	// Count the total number of rows for paging purposes.
	query.Model(&Post{}).Count(&pp.Total)

	// Query the paginated slice of results.
	r := query.
		Offset((pp.Page - 1) * pp.PerPage).
		Limit(pp.PerPage).
		Find(&pp.Posts)

	pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage)))
	if pp.Page < pp.Pages {
		pp.NextPage = pp.Page + 1
	}
	if pp.Page > 1 {
		pp.PreviousPage = pp.Page - 1
	}

	if err := pp.CountComments(); err != nil {
		console.Error("PagedPosts.CountComments: %s", err)
	}

	return pp, r.Error
}

// SearchPosts does a full text search over posts (public only).
func (m postMan) SearchPosts(search *search.Search, page, perPage int) (PagedPosts, error) {
	var pp = PagedPosts{
		Page:    page,
		PerPage: perPage,
	}

	if pp.Page < 1 {
		pp.Page = 1
	}
	if pp.PerPage <= 0 {
		pp.PerPage = 20
	}

	var (
		wheres       = []string{}
		placeholders = []interface{}{}
	)

	// Global filters.
	wheres = append(wheres, "privacy = ?")
	placeholders = append(placeholders, Public)

	// Search terms.
	for _, term := range search.Includes {
		var ilike = "%" + strings.ToLower(term) + "%"
		wheres = append(wheres, "(title ILIKE ? OR body ILIKE ?)")
		placeholders = append(placeholders, ilike, ilike)
	}
	for _, term := range search.Excludes {
		var ilike = "%" + strings.ToLower(term) + "%"
		wheres = append(wheres, "(title NOT ILIKE ? AND body NOT ILIKE ?)")
		placeholders = append(placeholders, ilike, ilike)
	}

	query := DB.Preload("Author").Preload("Tags").
		Where(strings.Join(wheres, " AND "), placeholders...).
		Order("sticky desc, created_at desc")

	// Count the total number of rows for paging purposes.
	query.Model(&Post{}).Count(&pp.Total)

	// Query the paginated slice of results.
	r := query.
		Offset((pp.Page - 1) * pp.PerPage).
		Limit(pp.PerPage).
		Find(&pp.Posts)

	pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage)))
	if pp.Page < pp.Pages {
		pp.NextPage = pp.Page + 1
	}
	if pp.Page > 1 {
		pp.PreviousPage = pp.Page - 1
	}

	if err := pp.CountComments(); err != nil {
		console.Error("PagedPosts.CountComments: %s", err)
	}

	return pp, r.Error
}

// GetPostsByTag gets posts by a certain tag.
func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPosts, error) {
	var pp = PagedPosts{
		Page:    page,
		PerPage: perPage,
	}

	if pp.Page < 1 {
		pp.Page = 1
	}
	if pp.PerPage <= 0 {
		pp.PerPage = 20
	}

	// Multi-tag query.
	whitelist, blacklist, err := ParseMultitag(tag)
	if err != nil {
		return pp, err
	}

	// Query the whitelist of post IDs which match the whitelist tags.
	postIDs := getPostIDsByTag("tag IN (?)", whitelist)
	notPostIDs := getPostIDsByTag("tag IN (?)", blacklist)
	postIDs = narrowWhitelistByBlacklist(postIDs, notPostIDs)
	if len(postIDs) == 0 {
		return pp, errors.New("no posts found")
	}

	// Query this set of posts.
	query := DB.Preload("Author").Preload("Tags").
		Where("id IN (?) AND privacy = ?", postIDs, privacy).
		Order("sticky desc, created_at desc")

	// Count the total number of rows for paging purposes.
	query.Model(&Post{}).Count(&pp.Total)

	// Query the paginated slice of results.
	r := query.
		Offset((page - 1) * perPage).
		Limit(perPage).
		Find(&pp.Posts)

	pp.Pages = int(math.Ceil(float64(pp.Total) / float64(pp.PerPage)))
	if pp.Page < pp.Pages {
		pp.NextPage = pp.Page + 1
	}
	if pp.Page > 1 {
		pp.PreviousPage = pp.Page - 1
	}

	if err := pp.CountComments(); err != nil {
		console.Error("PagedPosts.CountComments: %s", err)
	}

	return pp, r.Error
}

// getPostIDsByTag helper function returns the post IDs that either whitelist,
// or blacklist, a set of tags.
func getPostIDsByTag(query string, value []string) []int {
	var tags []TaggedPost
	var result []int

	if len(value) == 0 {
		return result
	}

	DB.Where(query, value).Find(&tags)
	for _, tag := range tags {
		result = append(result, tag.PostID)
	}

	return result
}

// narrowWhitelistByBlacklist removes IDs in whitelist that appear in blacklist.
func narrowWhitelistByBlacklist(wl []int, bl []int) []int {
	// Map the blacklist into a hash map.
	var blacklist = map[int]interface{}{}
	for _, id := range bl {
		blacklist[id] = nil
	}

	// Limit the whitelist by the blacklist.
	var result []int
	for _, id := range wl {
		if _, ok := blacklist[id]; !ok {
			result = append(result, id)
		}
	}

	return result
}

// GetArchive queries the archive view of the blog.
// Set private=true to return private posts, false returns public only.
func (m postMan) GetArchive(private bool) ([]*PostArchive, error) {
	var result = []*PostArchive{}

	query := DB.Table("posts").
		Select("title, fragment, thumbnail, created_at, privacy")
	if !private {
		query = query.Where("privacy=?", Public)
	}
	rows, err := query.
		Order("created_at desc").
		Rows()
	if err != nil {
		return result, err
	}

	// Group the posts by their month/year.
	var months []string
	var byMonth = map[string]*PostArchive{}

	for rows.Next() {
		var row Post
		if err := rows.Scan(&row.Title, &row.Fragment, &row.Thumbnail, &row.CreatedAt, &row.Privacy); err != nil {
			return result, err
		}

		label := row.CreatedAt.Format("2006-01")
		if _, ok := byMonth[label]; !ok {
			months = append(months, label)
			byMonth[label] = &PostArchive{
				Label: label,
				Date: time.Date(
					row.CreatedAt.Year(), row.CreatedAt.Month(), 1,
					0, 0, 0, 0, time.UTC,
				),
				Posts: []Post{},
			}
		}

		byMonth[label].Posts = append(byMonth[label].Posts, row)
	}

	for _, month := range months {
		result = append(result, byMonth[month])
	}

	return result, nil
}

// CountComments gets comment counts for one or more posts.
// Returns a map[uint]int mapping post ID to comment count.
func (m postMan) CountComments(posts ...Post) (map[int]int, error) {
	var result = map[int]int{}

	// Create the comment thread IDs.
	var threadIDs = make([]string, len(posts))
	for i, post := range posts {
		threadIDs[i] = fmt.Sprintf("post-%d", post.ID)
	}

	// Query comment counts for each thread.
	if len(threadIDs) > 0 {
		rows, err := DB.Table("comments").
			Select("thread, count(*) as count").
			Group("thread").
			Rows()
		if err != nil {
			return result, err
		}

		for rows.Next() {
			var thread string
			var count int
			if err := rows.Scan(&thread, &count); err != nil {
				console.Error("CountComments: rows.Scan: %s", err)
			}

			postID, err := strconv.Atoi(strings.TrimPrefix(thread, "post-"))
			if err != nil {
				console.Warn("CountComments: strconv.Atoi(%s): %s", thread, err)
				continue
			}
			result[postID] = count
		}
	}

	return result, nil
}

// ParseMultitag parses a tag string to return arrays for IN and NOT IN queries from DB.
//
// Example input: "blog,updates,-photo,-ask"
// Returns: ["blog", "updates"], ["photo", "ask"]
func ParseMultitag(tagline string) (whitelist, blacklist []string, err error) {
	words := strings.Split(tagline, ",")
	for _, word := range words {
		word = strings.TrimSpace(word)
		if len(word) == 0 {
			continue
		}

		// Negation
		if strings.HasPrefix(word, "-") {
			blacklist = append(blacklist, strings.TrimPrefix(word, "-"))
		} else {
			whitelist = append(whitelist, word)
		}
	}

	if len(whitelist) == 0 && len(blacklist) == 0 {
		err = errors.New("parsing error")
	}
	return
}

// CountComments on the posts in a PagedPosts list.
func (pp *PagedPosts) CountComments() error {
	counts, err := Posts.CountComments(pp.Posts...)
	if err != nil {
		return err
	}

	for i, post := range pp.Posts {
		if count, ok := counts[post.ID]; ok {
			pp.Posts[i].CommentCount = count
		}
	}

	return nil
}

// PreviewHTML returns the post's body as rendered HTML code, but only above
// the <snip> tag for index views.
func (p Post) PreviewHTML() template.HTML {
	var (
		parts   = strings.Split(p.Body, "<snip>")
		hasMore = len(parts) > 1
		body    = strings.TrimSpace(parts[0])
	)

	if p.ContentType == Markdown {
		if hasMore {
			body += fmt.Sprintf("\n\n[Read more...](/%s)", p.Fragment)
		}
		body = markdown.RenderTrustedMarkdown(body)
	} else if hasMore {
		body += fmt.Sprintf(`<p><a href="/%s">Read more...</a></p>`, p.Fragment)
	}

	// Make all images lazy loaded. TODO: make this configurable behavior?
	body = mogrify.LazyLoadImages(body)
	return template.HTML(body)
}

// HTML returns the post's body as rendered HTML code.
func (p Post) HTML() template.HTML {
	body := strings.ReplaceAll(p.Body, "<snip>", "")
	if p.ContentType == Markdown {
		body = markdown.RenderTrustedMarkdown(body)
	}

	// Make all images lazy loaded. TODO: make this configurable behavior?
	body = mogrify.LazyLoadImages(body)
	return template.HTML(body)
}

// Save a post.
// This method also makes sure a unique Fragment is set and links the Tags correctly.
func (p *Post) Save() error {
	// Generate the default fragment from the post title.
	if p.Fragment == "" {
		fragment := strings.ToLower(p.Title)
		fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-")
		fragment = strings.ReplaceAll(fragment, "--", "-")
		p.Fragment = strings.Trim(fragment, "-")
		console.Error("frag: %s", p.Fragment)

		// If still no fragment, make one up from the current time.
		if p.Fragment == "" {
			p.Fragment = time.Now().Format("2006-01-02-150405")
		}
	}

	// Ensure the fragment is unique!
	{
		console.Debug("Ensuring fragment '%s' is unique", p.Fragment)
		if exist, err := Posts.LoadFragment(p.Fragment); err == nil && exist.ID != p.ID {
			console.Debug("Post.Save: fragment %s is not unique, trying to resolve", p.Fragment)
			var resolved bool
			for i := 2; i <= 100; i++ {
				fragment := fmt.Sprintf("%s-%d", p.Fragment, i)
				console.Debug("Post.Save: try fragment '%s'", fragment)
				exist, err = Posts.LoadFragment(fragment)
				if err == nil && exist.ID != p.ID {
					continue
				}

				p.Fragment = fragment
				resolved = true
				break
			}

			if !resolved {
				return fmt.Errorf("failed to generate a unique URL fragment for '%s' after 100 attempts", p.Fragment)
			}
		}
	}

	// Cache the post thumbnail from the body.
	if thumbnail, ok := p.ExtractThumbnail(); ok {
		p.Thumbnail = thumbnail
	}

	// Empty tags list.
	if len(p.Tags) == 1 && p.Tags[0].Tag == "" {
		p.Tags = []TaggedPost{}
	}

	// TODO: tag relationships. For now just delete and re-add them all.
	if p.ID != 0 {
		DB.Where("post_id = ?", p.ID).Delete(TaggedPost{})
	}

	// Dedupe tags.
	p.fixTags()

	// Save the post.
	if DB.NewRecord(p) {
		return DB.Create(&p).Error
	}

	return DB.Save(&p).Error
}

// SetUpdated force sets the updated time of a post, i.e. to reset it to original.
func (p Post) SetUpdated(dt time.Time) error {
	r := DB.Table("posts").Where("id = ?", p.ID).Updates(map[string]interface{}{
		"updated_at": dt,
	})
	return r.Error
}

// ParseForm populates a Post from an HTTP form.
func (p *Post) ParseForm(form *forms.Data) {
	p.Title = form.Get("title")
	p.Fragment = form.Get("fragment")
	p.ContentType = form.Get("content-type")
	p.Body = form.Get("body")
	p.Privacy = form.Get("privacy")
	p.Sticky = form.GetBool("sticky")
	p.EnableComments = form.GetBool("enable-comments")

	// Parse the tags array. This replaces the post.Tags with an empty TaggedPost
	// list containing only the string Tag values. The IDs and DB side will be
	// patched up when the post gets saved.
	p.Tags = []TaggedPost{}
	tags := strings.Split(form.Get("tags"), ",")
	for _, tag := range tags {
		tag = strings.TrimSpace(tag)
		if len(tag) == 0 {
			continue
		}

		p.Tags = append(p.Tags, TaggedPost{
			Tag: tag,
		})
	}
}

// ExtractThumbnail searches and returns a thumbnail image to represent the post.
// It will be the first image embedded in the post body, or nothing.
func (p Post) ExtractThumbnail() (string, bool) {
	result := ThumbnailImageRegexp.FindStringSubmatch(p.Body)
	if len(result) < 2 {
		return "", false
	}
	return result[1], true
}

// TagsString turns the post tags into a comma separated string.
func (p Post) TagsString() string {
	console.Error("TagsString: %+v", p.Tags)
	var tags = make([]string, len(p.Tags))
	for i, tag := range p.Tags {
		tags[i] = tag.Tag
	}
	return strings.Join(tags, ", ")
}

// fixTags is a pre-Save function to fix up the Tags relationships.
// It checks that each tag has an ID, and if it doesn't have an ID yet, removes
// it if a duplicate tag does exist that has an ID.
func (p *Post) fixTags() {
	// De-duplicate tag values.
	var dedupe = map[string]interface{}{}
	var finalTags []TaggedPost
	for _, tag := range p.Tags {
		if _, ok := dedupe[tag.Tag]; !ok {
			finalTags = append(finalTags, tag)
			dedupe[tag.Tag] = nil
		}
	}

	p.Tags = finalTags
}
