package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	goutil "git.gutmet.org/goutil.git/misc"
	"io/ioutil"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"text/template"
	"time"
)

const (
	CHARACTER_LIMIT         = 280
	ALLOWLIST_MENTIONS      = "AllowlistMentions"
	ALLOWLIST_HOME          = "AllowlistHome"
	WIPE_KEEP_DAYS          = 10
	STATUS_ENDPOINT         = "https://api.twitter.com/1.1/statuses/update.json"
	MAX_TIMELINE_REQUESTS   = 15
	DEFAULT_COUNT           = 200
	MENTIONS_ENDPOINT       = "https://api.twitter.com/1.1/statuses/mentions_timeline.json?tweet_mode=extended&count=200"
	HOME_ENDPOINT           = "https://api.twitter.com/1.1/statuses/home_timeline.json?tweet_mode=extended&count=200"
	TIMELINE_ENDPOINT       = "https://api.twitter.com/1.1/statuses/user_timeline.json?tweet_mode=extended&count=200"
	LIKES_TIMELINE_ENDPOINT = "https://api.twitter.com/1.1/favorites/list.json?tweet_mode=extended&count=200"
	LOOKUP_ENDPOINT         = "https://api.twitter.com/1.1/statuses/lookup.json?tweet_mode=extended"
	RETWEET_ENDPOINT        = "https://api.twitter.com/1.1/statuses/retweet/"
	LIKE_ENDPOINT           = "https://api.twitter.com/1.1/favorites/create.json"
	FOLLOWING_ENDPOINT      = "https://api.twitter.com/1.1/friends/list.json?count=200"
	DESTROY_STATUS_ENDPOINT = "https://api.twitter.com/1.1/statuses/destroy/"
	DESTROY_LIKE_ENDPOINT   = "https://api.twitter.com/1.1/favorites/destroy.json"
)

func optLogFatal(decorum string, err error) {
	if err != nil && err.Error() != "" {
		panic("drivel: " + decorum + ": " + err.Error())
	}
}

func checkUsage(args []string, argcMin int, argcMax int, help string) {
	argc := len(args)
	if (argcMin > -1 && argc < argcMin) || (argcMax > -1 && argc > argcMax) {
		fmt.Fprintf(os.Stderr, "USAGE: %s %s\n", os.Args[0], help)
		os.Exit(-1)
	}
}

func get(url string) []byte {
	return send(url, nil, false)
}

func post(url string, vals url.Values) []byte {
	return send(url, vals, true)
}

func send(url string, vals url.Values, usePost bool) []byte {
	log := func(err error) {
		v, _ := json.Marshal(vals)
		optLogFatal("get/post "+url+" "+string(v), err)
	}
	var resp *http.Response
	var err error
	if usePost {
		resp, err = client.PostForm(url, vals)
	} else {
		resp, err = client.Get(url)
	}
	log(err)
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		log(errors.New(fmt.Sprintf("HTTP status %d\n\nresponse: %v\n\nbody: %s", resp.StatusCode, resp, string(body))))
	}
	log(err)
	return body
}

func lastSpace(slice []rune) int {
	for i := len(slice) - 1; i >= 0; i-- {
		if slice[i] == ' ' {
			return i
		}
	}
	return -1
}

func splitStatus(status string) []string {
	characterLimit := CHARACTER_LIMIT
	// Twitter has an insane definition of what counts as a character
	// ( see https://developer.twitter.com/en/docs/counting-characters )
	// - as a crude approximation, anything outside LATIN-1 halfs the limit
	for _, ch := range status {
		if ch > 0x10FF {
			characterLimit = CHARACTER_LIMIT / 2
			break
		}
	}
	asRunes := []rune(status)
	split := []string{}
	for len(asRunes) != 0 {
		var limit int
		if len(asRunes) <= characterLimit {
			limit = len(asRunes)
		} else {
			limit = lastSpace(asRunes[0:characterLimit])
			if limit == -1 {
				limit = characterLimit
			} else {
				limit = limit + 1
			}
		}
		split = append(split, string(asRunes[0:limit]))
		asRunes = asRunes[limit:]
	}
	return split
}

func splitArguments(args []string) data {
	if len(args) < 1 {
		fmt.Fprintln(os.Stderr, "Usage: drivel status STATUS [FILE1 FILE2 ...]")
		os.Exit(-1)
	}
	d := data{}
	d.status = splitStatus(args[0])
	for _, arg := range args[1:] {
		exitIfInvalid(arg)
		switch kind(arg) {
		case PIC:
			d.pics = append(d.pics, arg)
		case GIF:
			d.gifs = append(d.gifs, arg)
		case VIDEO:
			d.videos = append(d.videos, arg)
		default:
			optLogFatal("splitArguments", errors.New("Unsupported file: "+arg))
		}
	}
	return d
}

func tweet(status string, mediaIDs []ObjectID, previousID ObjectID) Status {
	log := func(err error) { optLogFatal("tweet "+status, err) }
	request := UpdateStatusRequest(status, mediaIDs, previousID)
	body := post(STATUS_ENDPOINT, request)
	var tweet Status
	err := json.Unmarshal(body, &tweet)
	log(err)
	log(tweet)
	return tweet
}

type data struct {
	status []string
	pics   []string
	gifs   []string
	videos []string
}

func (d *data) getStatus(i int) string {
	return goutil.StrSliceAt(d.status, i)
}

func (d *data) getGif(i int) string {
	return goutil.StrSliceAt(d.gifs, i)
}

func (d *data) getVideo(i int) string {
	return goutil.StrSliceAt(d.videos, i)
}

func (d *data) uploadPics(from, to int) []ObjectID {
	pics := goutil.StrSlice(d.pics, from, to)
	return uploadAll(pics)
}

func (d *data) uploadGif(i int) []ObjectID {
	gif := d.getGif(i)
	return uploadAll([]string{gif})
}

func (d *data) uploadVideo(i int) []ObjectID {
	vid := d.getVideo(i)
	return uploadAll([]string{vid})
}

func (d *data) push(previous ObjectID) []Status {
	empty := false
	i, g, v := 0, 0, 0
	tweets := []Status{}
	if d == nil {
		return tweets
	}
	for !empty {
		empty = true
		status := d.getStatus(i)
		mediaIDs := []ObjectID{}
		if status != "" {
			empty = false
		}
		from := i * 4
		to := (i + 1) * 4
		mediaIDs = d.uploadPics(from, to)
		if len(mediaIDs) == 0 {
			mediaIDs = d.uploadGif(g)
			g++
		}
		if len(mediaIDs) == 0 {
			mediaIDs = d.uploadVideo(v)
			v++
		}
		if len(mediaIDs) > 0 {
			empty = false
		}
		if !empty {
			t := tweet(status, mediaIDs, previous)
			tweets = append(tweets, t)
			previous = ObjectID(t.Id_str)
			i++
		}
	}
	return tweets
}

func updateStatus(args []string, previous ObjectID, embedTweet ObjectID) []Status {
	d := splitArguments(args)
	if embedTweet != "" {
		tweets := _lookup([]string{string(embedTweet)})
		if len(tweets) == 1 {
			d.status[0] += " " + tweets[0].URL()
		}
	}
	return d.push(previous)
}

func PrintTweets(tweets []Status) {
	if formatTemplate != nil {
		optLogFatal("printTweets", formatTemplate.Execute(os.Stdout, tweets))
	} else {
		for _, tweet := range tweets {
			fmt.Println(tweet.String())
			fmt.Println("---------")
		}
	}
}

func status(args []string) error {
	checkUsage(args, 1, -1, "status STATUS [FILE1 FILE2 ...]")
	tweets := updateStatus(args, "", "")
	PrintTweets(tweets)
	return nil
}

func reply(args []string) error {
	checkUsage(args, 2, -1, "reply TWEET_ID MESSAGE [FILE1 FILE2 ...]")
	tweets := updateStatus(args[1:], ObjectID(args[0]), "")
	PrintTweets(tweets)
	return nil
}

func quote(args []string) error {
	checkUsage(args, 2, -1, "quote TWEET_ID MESSAGE [FILE1 FILE2 ...]")
	tweets := updateStatus(args[1:], "", ObjectID(args[0]))
	PrintTweets(tweets)
	return nil
}

func _lookup(ids []string) []Status {
	log := func(err error) { optLogFatal("lookup "+strings.Join(ids, ","), err) }
	body := get(LOOKUP_ENDPOINT + LookupParameters(ids))
	var tweets []Status
	err := json.Unmarshal(body, &tweets)
	log(err)
	return tweets
}

func lookup(args []string) error {
	checkUsage(args, 1, -1, "lookup TWEET_ID1 [TWEET_ID2 TWEET_ID3 ...]")
	tweets := _lookup(args)
	PrintTweets(tweets)
	return nil
}

func timeline(endpoint string, maxID string) []Status {
	log := func(err error) { optLogFatal("timeline", err) }
	if maxID != "" {
		endpoint += "&max_id=" + maxID
	}
	body := get(endpoint)
	var tweets []Status
	err := json.Unmarshal(body, &tweets)
	log(err)
	return tweets
}

func timelineLoop(endpoint string, flags timelineFlags, allowlist hashset) (tweets []Status) {
	defer func() {
		if r := recover(); r != nil {
			fmt.Fprintln(os.Stderr, "INFO:", r)
		}
	}()
	requestCount := 0
	maxID := flags.maxID
	for len(tweets) < flags.count && requestCount < flags.maxRequests {
		tmp := timeline(endpoint, maxID)
		if len(tmp) == 0 {
			break
		}
		var lowestSoFar int64
		lowestSoFar, _ = strconv.ParseInt(tmp[len(tmp)-1].Id_str, 10, 64)
		maxID = strconv.FormatInt(lowestSoFar-1, 10)
		for _, tweet := range tmp {
			if allowlist == nil || allowlist.contains(tweet.User.Screen_name) {
				tweets = append(tweets, tweet)
			}
		}
		if len(tweets) > flags.count {
			tweets = tweets[:flags.count]
		}
		requestCount++
	}
	return
}

type timelineFlags struct {
	count       int
	maxRequests int
	maxID       string
}

func timelineFlagsVars(s *flag.FlagSet, f *timelineFlags) {
	s.IntVar(&f.count, "count", DEFAULT_COUNT, "try to get up to N tweets")
	s.IntVar(&f.maxRequests, "max-requests", MAX_TIMELINE_REQUESTS, "try to achieve count with a maximum of N requests")
	s.StringVar(&f.maxID, "max-id", "", "only get tweets with an ID lower or equal to max-id")
}

func mentions(flags timelineFlags, args []string) error {
	checkUsage(args, 0, 0, "mentions")
	allowlist := getHashset(ALLOWLIST_MENTIONS)
	tweets := timelineLoop(MENTIONS_ENDPOINT, flags, allowlist)
	PrintTweets(tweets)
	return nil
}

func mentionsCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) {
	f := timelineFlags{}
	flagsInit := func(s *flag.FlagSet) {
		timelineFlagsVars(s, &f)
	}
	return flagsInit, func(args []string) error { return mentions(f, args) }
}

func home(flags timelineFlags, args []string) error {
	checkUsage(args, 0, 0, "home")
	allowlist := getHashset(ALLOWLIST_HOME)
	tweets := timelineLoop(HOME_ENDPOINT, flags, allowlist)
	PrintTweets(tweets)
	return nil
}

func homeCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) {
	f := timelineFlags{}
	flagsInit := func(s *flag.FlagSet) {
		timelineFlagsVars(s, &f)
	}
	return flagsInit, func(args []string) error { return home(f, args) }
}

func userTimeline(flags userTimelineFlags, args []string) error {
	checkUsage(args, 1, 1, "timeline USER")
	tweets := timelineLoop(TIMELINE_ENDPOINT+UserTimelineParameters(flags, args[0]), flags.timelineFlags, nil)
	PrintTweets(tweets)
	return nil
}

type userTimelineFlags struct {
	timelineFlags
	withReplies bool
}

func userTimelineCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) {
	f := userTimelineFlags{}
	flagsInit := func(s *flag.FlagSet) {
		timelineFlagsVars(s, &f.timelineFlags)
		s.BoolVar(&f.withReplies, "with-replies", false, "include replies in timeline")
	}
	return flagsInit, func(args []string) error { return userTimeline(f, args) }
}

func retweet(args []string) error {
	checkUsage(args, 1, 1, "retweet TWEET_ID")
	log := func(err error) { optLogFatal("retweet", err) }
	id := args[0]
	tweets := _lookup([]string{id})
	if len(tweets) != 1 {
		log(errors.New("Could not find tweet " + id))
	}
	body := post(RETWEET_ENDPOINT+RetweetParameters(id), nil)
	var retweet Status
	err := json.Unmarshal(body, &retweet)
	log(err)
	PrintTweets([]Status{retweet})
	return nil
}

func like(args []string) error {
	checkUsage(args, 1, 1, "like TWEET_ID")
	log := func(err error) { optLogFatal("like", err) }
	body := post(LIKE_ENDPOINT, LikeRequest(args[0]))
	var tweet Status
	err := json.Unmarshal(body, &tweet)
	log(err)
	PrintTweets([]Status{tweet})
	return nil
}

func _following() (following []string) {
	log := func(err error) { optLogFatal("following", err) }
	defer func() {
		if r := recover(); r != nil {
			fmt.Fprintln(os.Stderr, r)
		}
	}()
	var cursor int64 = -1
	var err error
	for cursor != 0 && err == nil {
		body := get(FOLLOWING_ENDPOINT + FollowingParameters(cursor))
		var response FollowingResponse
		err = json.Unmarshal(body, &response)
		log(err)
		for _, user := range response.Users {
			following = append(following, user.Screen_name)
		}
		cursor = response.Next_cursor
	}
	return
}

func following(args []string) error {
	checkUsage(args, 0, 0, "following")
	users := _following()
	for _, user := range users {
		fmt.Println(user)
	}
	return nil
}

func unlike(id string) {
	log := func(err error) { optLogFatal("unlike", err) }
	body := post(DESTROY_LIKE_ENDPOINT, UnlikeRequest(id))
	var tweet Status
	err := json.Unmarshal(body, &tweet)
	log(err)
	fmt.Println("Unliked", tweet.Id_str)
}

func destroyStatus(id string) {
	log := func(err error) { optLogFatal("destroy", err) }
	body := post(DESTROY_STATUS_ENDPOINT+DestroyParameters(id), nil)
	var tweet Status
	err := json.Unmarshal(body, &tweet)
	log(err)
	fmt.Println("Destroyed", tweet.Id_str)
}

func wipeTimeline(likes bool, keepDays int) {
	var endpoint string
	if likes {
		endpoint = LIKES_TIMELINE_ENDPOINT
	} else {
		endpoint = TIMELINE_ENDPOINT
	}
	n := 0
	now := time.Now()
	maxID := ""
	tweets := timeline(endpoint, maxID)
	for {
		for _, tweet := range tweets {
			daysSince := now.Sub(tweet.Created_at.Time).Hours() / 24
			if tweet.Created_at != (TwitterTime{}) && daysSince >= float64(keepDays) {
				if likes {
					unlike(tweet.Id_str)
				} else {
					destroyStatus(tweet.Id_str)
				}
				n++
				if n >= 200 {
					fmt.Println("reached limit of 200")
					return
				}
			}
			maxID = tweet.Id_str
		}
		newTweets := timeline(endpoint, maxID)
		if !equals(newTweets, tweets) {
			tweets = newTweets
		} else {
			return
		}
	}
}

func wipe(flags wipeFlags, args []string) error {
	checkUsage(args, 0, 0, "wipe")
	wipeTimeline(true, flags.keepDays)
	wipeTimeline(false, flags.keepDays)
	return nil
}

type wipeFlags struct {
	keepDays int
}

func wipeCommand() (goutil.CommandFlagsInit, goutil.CommandFunc) {
	f := wipeFlags{}
	flagsInit := func(s *flag.FlagSet) {
		s.IntVar(&f.keepDays, "keep-days", WIPE_KEEP_DAYS, "don't wipe the last N days")
	}
	return flagsInit, func(args []string) error { return wipe(f, args) }
}

type hashset map[string]interface{}

func makeHashset() hashset {
	return make(map[string]interface{})
}

func (s hashset) add(member string) {
	if s != nil {
		s[member] = nil
	}
}

func (s hashset) contains(member string) bool {
	if s == nil {
		return false
	}
	if _, ok := s[member]; ok {
		return true
	} else {
		return false
	}
}

func getHashset(name string) (set hashset) {
	fullpath := filepath.Join(appDir(), name)
	s, err := goutil.ReadFile(fullpath)
	if err == nil {
		set = makeHashset()
		for _, id := range strings.Split(s, "\n") {
			if trimmed := strings.TrimSpace(id); trimmed != "" {
				set.add(trimmed)
			}
		}
	}
	return
}

type generalFlags struct {
	templateFile string
}

func wrapCommand(cmd goutil.CommandFunc) func() (goutil.CommandFlagsInit, goutil.CommandFunc) {
	return wrapCommandFl(func() (goutil.CommandFlagsInit, goutil.CommandFunc) { return nil, cmd })
}

func wrapCommandFl(cmd func() (goutil.CommandFlagsInit, goutil.CommandFunc)) func() (goutil.CommandFlagsInit, goutil.CommandFunc) {
	f := generalFlags{}
	flagsInit := func(s *flag.FlagSet) {
		s.StringVar(&f.templateFile, "template", "", "use a template file to format tweets")
	}
	return func() (goutil.CommandFlagsInit, goutil.CommandFunc) {
		formerInit, commandFunc := cmd()
		return func(s *flag.FlagSet) {
				if formerInit != nil {
					formerInit(s)
				}
				flagsInit(s)
			}, func(args []string) error {
				if f.templateFile != "" {
					formatTemplate = template.Must(template.New(filepath.Base(f.templateFile)).Funcs(template.FuncMap{"replaceAll": strings.ReplaceAll}).ParseFiles(f.templateFile))
				}
				return commandFunc(args)
			}
	}
}

var client *http.Client
var formatTemplate *template.Template

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Fprintln(os.Stderr, r)
			os.Exit(-1)
		}
	}()
	client = getClient()
	commands := []goutil.Command{
		goutil.NewCommandWithFlags("status", wrapCommand(status), "post a status with message and/or media"),
		goutil.NewCommandWithFlags("home", wrapCommandFl(homeCommand), "get your home timeline"),
		goutil.NewCommandWithFlags("mentions", wrapCommandFl(mentionsCommand), "get your mention timeline"),
		goutil.NewCommandWithFlags("timeline", wrapCommandFl(userTimelineCommand), "get timeline of a specific user"),
		goutil.NewCommandWithFlags("lookup", wrapCommand(lookup), "lookup tweets with specific IDs"),
		goutil.NewCommandWithFlags("reply", wrapCommand(reply), "reply to a tweet with a specific ID"),
		goutil.NewCommandWithFlags("quote", wrapCommand(quote), "quote retweet a tweet with a specific ID"),
		goutil.NewCommandWithFlags("retweet", wrapCommand(retweet), "retweet a tweet with a specific ID"),
		goutil.NewCommandWithFlags("like", wrapCommand(like), "like a tweet with a specific ID"),
		goutil.NewCommand("following", following, "try to get the list of users you are following"),
		goutil.NewCommandWithFlags("wipe", wipeCommand, "wipe your timeline and likes"),
	}
	_ = goutil.Execute(commands)
}