package discord

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httputil"
	"net/textproto"
	"net/url"
	"strconv"

	"git.handmade.network/hmn/hmn/src/config"
	"git.handmade.network/hmn/hmn/src/hmnurl"
	"git.handmade.network/hmn/hmn/src/logging"
	"git.handmade.network/hmn/hmn/src/oops"
)

const (
	BotName = "HandmadeNetwork"
	BaseUrl = "https://discord.com/api/v9"

	UserAgentURL     = "https://handmade.network/"
	UserAgentVersion = "1.0"
)

var UserAgent = fmt.Sprintf("%s (%s, %s)", BotName, UserAgentURL, UserAgentVersion)

var NotFound = errors.New("not found")

var httpClient = &http.Client{}

func buildUrl(path string) string {
	return fmt.Sprintf("%s%s", BaseUrl, path)
}

func makeRequest(ctx context.Context, method string, path string, body []byte) *http.Request {
	var bodyReader io.Reader
	if body != nil {
		bodyReader = bytes.NewBuffer(body)
	}

	req, err := http.NewRequestWithContext(ctx, method, buildUrl(path), bodyReader)
	if err != nil {
		panic(err)
	}
	req.Header.Add("Authorization", fmt.Sprintf("Bot %s", config.Config.Discord.BotToken))
	req.Header.Add("User-Agent", UserAgent)

	return req
}

type GetGatewayBotResponse struct {
	URL string `json:"url"`
	// We don't care about shards or session limit stuff; we will never hit those limits
}

func GetGatewayBot(ctx context.Context) (*GetGatewayBotResponse, error) {
	const name = "Get Gateway Bot"

	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		return makeRequest(ctx, http.MethodGet, "/gateway/bot", nil)
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode != 200 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var result GetGatewayBotResponse
	err = json.Unmarshal(body, &result)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord response")
	}

	return &result, nil
}

func GetGuildRoles(ctx context.Context, guildID string) ([]Role, error) {
	const name = "Get Guild Roles"

	path := fmt.Sprintf("/guilds/%s/roles", guildID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		return makeRequest(ctx, http.MethodGet, path, nil)
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var roles []Role
	err = json.Unmarshal(bodyBytes, &roles)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord message")
	}

	return roles, nil
}

func GetGuildChannels(ctx context.Context, guildID string) ([]Channel, error) {
	const name = "Get Guild Channels"

	path := fmt.Sprintf("/guilds/%s/channels", guildID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		return makeRequest(ctx, http.MethodGet, path, nil)
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var channels []Channel
	err = json.Unmarshal(bodyBytes, &channels)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord message")
	}

	return channels, nil
}

func GetGuildMember(ctx context.Context, guildID, userID string) (*GuildMember, error) {
	const name = "Get Guild Member"

	path := fmt.Sprintf("/guilds/%s/members/%s", guildID, userID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		return makeRequest(ctx, http.MethodGet, path, nil)
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode == http.StatusNotFound {
		return nil, NotFound
	} else if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var msg GuildMember
	err = json.Unmarshal(bodyBytes, &msg)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord message")
	}

	return &msg, nil
}

type MentionType string

const (
	MentionTypeUsers    MentionType = "users"
	MentionTypeRoles                = "roles"
	MentionTypeEveryone             = "everyone"
)

type MessageAllowedMentions struct {
	Parse []MentionType `json:"parse"`
}

const (
	FlagSuppressEmbeds int = 1 << 2
)

type CreateMessageRequest struct {
	Content         string                  `json:"content"`
	Flags           int                     `json:"flags,omitempty"`
	AllowedMentions *MessageAllowedMentions `json:"allowed_mentions,omitempty"`
}

func CreateMessage(ctx context.Context, channelID string, payloadJSON string, files ...FileUpload) (*Message, error) {
	const name = "Create Message"

	contentType, body := makeNewMessageBody(payloadJSON, files)

	path := fmt.Sprintf("/channels/%s/messages", channelID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		req := makeRequest(ctx, http.MethodPost, path, body)
		req.Header.Add("Content-Type", contentType)
		return req
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	// Maybe in the future we could more nicely handle errors like "bad channel",
	// but honestly what are the odds that we mess that up...

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var msg Message
	err = json.Unmarshal(bodyBytes, &msg)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord message")
	}

	return &msg, nil
}

func EditMessage(ctx context.Context, channelID string, messageID string, payloadJSON string, files ...FileUpload) (*Message, error) {
	const name = "Edit Message"

	contentType, body := makeNewMessageBody(payloadJSON, files)

	path := fmt.Sprintf("/channels/%s/messages/%s", channelID, messageID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		req := makeRequest(ctx, http.MethodPatch, path, body)
		req.Header.Add("Content-Type", contentType)
		return req
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	// Maybe in the future we could more nicely handle errors like "bad channel",
	// but honestly what are the odds that we mess that up...

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var msg Message
	err = json.Unmarshal(bodyBytes, &msg)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord message")
	}

	return &msg, nil
}

func DeleteMessage(ctx context.Context, channelID string, messageID string) error {
	const name = "Delete Message"

	path := fmt.Sprintf("/channels/%s/messages/%s", channelID, messageID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		return makeRequest(ctx, http.MethodDelete, path, nil)
	})
	if err != nil {
		return err
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusNoContent {
		logErrorResponse(ctx, name, res, "")
		return oops.New(nil, "got unexpected status code when deleting message")
	}

	return nil
}

func CreateDM(ctx context.Context, recipientID string) (*Channel, error) {
	const name = "Create DM"

	path := "/users/@me/channels"
	body := []byte(fmt.Sprintf(`{"recipient_id":"%s"}`, recipientID))
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		req := makeRequest(ctx, http.MethodPost, path, body)
		req.Header.Add("Content-Type", "application/json")
		return req
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var channel Channel
	err = json.Unmarshal(bodyBytes, &channel)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord channel")
	}

	return &channel, nil
}

type OAuthCodeExchangeResponse struct {
	AccessToken  string `json:"access_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	RefreshToken string `json:"refresh_token"`
	Scope        string `json:"scope"`
}

func ExchangeOAuthCode(ctx context.Context, code, redirectURI string) (*OAuthCodeExchangeResponse, error) {
	const name = "OAuth Code Exchange"

	body := make(url.Values)
	body.Set("client_id", config.Config.Discord.OAuthClientID)
	body.Set("client_secret", config.Config.Discord.OAuthClientSecret)
	body.Set("grant_type", "authorization_code")
	body.Set("code", code)
	body.Set("redirect_uri", redirectURI)
	bodyStr := body.Encode()

	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		req := makeRequest(ctx, http.MethodPost, "/oauth2/token", []byte(bodyStr))
		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

		return req
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var tokenResponse OAuthCodeExchangeResponse
	err = json.Unmarshal(bodyBytes, &tokenResponse)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord OAuth token")
	}

	return &tokenResponse, nil
}

func GetCurrentUserAsOAuth(ctx context.Context, accessToken string) (*User, error) {
	const name = "Get Current User"

	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildUrl("/users/@me"), nil)
		if err != nil {
			panic(err)
		}
		req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
		req.Header.Add("User-Agent", UserAgent)

		return req
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var user User
	err = json.Unmarshal(bodyBytes, &user)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord user")
	}

	return &user, nil
}

func AddGuildMemberRole(ctx context.Context, userID, roleID string) error {
	const name = "Add Guild Member Role"

	path := fmt.Sprintf("/guilds/%s/members/%s/roles/%s", config.Config.Discord.GuildID, userID, roleID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		return makeRequest(ctx, http.MethodPut, path, nil)
	})
	if err != nil {
		return err
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusNoContent {
		logErrorResponse(ctx, name, res, "")
		return oops.New(nil, "got unexpected status code when adding role")
	}

	return nil
}

func RemoveGuildMemberRole(ctx context.Context, userID, roleID string) error {
	const name = "Remove Guild Member Role"

	path := fmt.Sprintf("/guilds/%s/members/%s/roles/%s", config.Config.Discord.GuildID, userID, roleID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		return makeRequest(ctx, http.MethodDelete, path, nil)
	})
	if err != nil {
		return err
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusNoContent {
		logErrorResponse(ctx, name, res, "")
		return oops.New(nil, "got unexpected status code when removing role")
	}

	return nil
}

func GetChannelMessage(ctx context.Context, channelID, messageID string) (*Message, error) {
	const name = "Get Channel Message"

	path := fmt.Sprintf("/channels/%s/messages/%s", channelID, messageID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		return makeRequest(ctx, http.MethodGet, path, nil)
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode == http.StatusNotFound {
		return nil, NotFound
	} else if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var msg Message
	err = json.Unmarshal(bodyBytes, &msg)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord message")
	}

	return &msg, nil
}

type GetChannelMessagesInput struct {
	Around string
	Before string
	After  string
	Limit  int
}

func GetChannelMessages(ctx context.Context, channelID string, in GetChannelMessagesInput) ([]Message, error) {
	const name = "Get Channel Messages"

	path := fmt.Sprintf("/channels/%s/messages", channelID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		req := makeRequest(ctx, http.MethodGet, path, nil)
		q := req.URL.Query()
		if in.Around != "" {
			q.Add("around", in.Around)
		}
		if in.Before != "" {
			q.Add("before", in.Before)
		}
		if in.After != "" {
			q.Add("after", in.After)
		}
		if in.Limit != 0 {
			q.Add("limit", strconv.Itoa(in.Limit))
		}
		req.URL.RawQuery = q.Encode()

		return req
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var msgs []Message
	err = json.Unmarshal(bodyBytes, &msgs)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord message")
	}

	return msgs, nil
}

// See https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command-json-params
type CreateGuildApplicationCommandRequest struct {
	Name              string                     `json:"name"`               // 1-32 character name
	Description       string                     `json:"description"`        // 1-100 character description
	Options           []ApplicationCommandOption `json:"options"`            // the parameters for the command
	DefaultPermission *bool                      `json:"default_permission"` // whether the command is enabled by default when the app is added to a guild
	Type              ApplicationCommandType     `json:"type"`               // the type of command, defaults 1 if not set
}

// See https://discord.com/developers/docs/interactions/application-commands#create-guild-application-command
func CreateGuildApplicationCommand(ctx context.Context, in CreateGuildApplicationCommandRequest) error {
	const name = "Create Guild Application Command"

	if in.Type == 0 {
		in.Type = ApplicationCommandTypeChatInput
	}

	payloadJSON, err := json.Marshal(in)
	if err != nil {
		return oops.New(nil, "failed to marshal request body")
	}

	path := fmt.Sprintf("/applications/%s/guilds/%s/commands", config.Config.Discord.BotUserID, config.Config.Discord.GuildID)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		req := makeRequest(ctx, http.MethodPost, path, []byte(payloadJSON))
		req.Header.Add("Content-Type", "application/json")
		return req
	})
	if err != nil {
		return err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return oops.New(nil, "received error from Discord")
	}

	return nil
}

func CreateInteractionResponse(ctx context.Context, interactionID, interactionToken string, in InteractionResponse) error {
	const name = "Create Interaction Response"

	payloadJSON, err := json.Marshal(in)
	if err != nil {
		return oops.New(nil, "failed to marshal request body")
	}

	path := fmt.Sprintf("/interactions/%s/%s/callback", interactionID, interactionToken)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		req := makeRequest(ctx, http.MethodPost, path, []byte(payloadJSON))
		req.Header.Add("Content-Type", "application/json")
		return req
	})
	if err != nil {
		return err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return oops.New(nil, "received error from Discord")
	}

	return nil
}

func EditOriginalInteractionResponse(ctx context.Context, interactionToken string, payloadJSON string, files ...FileUpload) (*Message, error) {
	const name = "Edit Original Interaction Response"

	contentType, body := makeNewMessageBody(payloadJSON, files)

	path := fmt.Sprintf("/webhooks/%s/%s/messages/@original", config.Config.Discord.BotUserID, interactionToken)
	res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
		req := makeRequest(ctx, http.MethodPatch, path, body)
		req.Header.Add("Content-Type", contentType)
		return req
	})
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		logErrorResponse(ctx, name, res, "")
		return nil, oops.New(nil, "received error from Discord")
	}

	bodyBytes, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}

	var msg Message
	err = json.Unmarshal(bodyBytes, &msg)
	if err != nil {
		return nil, oops.New(err, "failed to unmarshal Discord message")
	}

	return &msg, nil
}

func GetAuthorizeUrl(state string, includeEmail bool) string {
	scope := "identify"
	if includeEmail {
		scope = "identify email"
	}

	params := make(url.Values)
	params.Set("response_type", "code")
	params.Set("client_id", config.Config.Discord.OAuthClientID)
	params.Set("scope", scope)
	params.Set("prompt", "none") // immediately redirect back to HMN if already authorized
	params.Set("state", state)
	params.Set("redirect_uri", hmnurl.BuildDiscordOAuthCallback())
	return fmt.Sprintf("%s?%s", buildUrl("/oauth2/authorize"), params.Encode())
}

type FileUpload struct {
	Name string
	Data []byte
}

func makeNewMessageBody(payloadJSON string, files []FileUpload) (contentType string, body []byte) {
	if len(files) == 0 {
		contentType = "application/json"
		body = []byte(payloadJSON)
	} else {
		var bodyBuffer bytes.Buffer
		w := multipart.NewWriter(&bodyBuffer)
		contentType = w.FormDataContentType()

		jsonHeader := textproto.MIMEHeader{}
		jsonHeader.Set("Content-Disposition", `form-data; name="payload_json"`)
		jsonHeader.Set("Content-Type", "application/json")
		jsonWriter, _ := w.CreatePart(jsonHeader)
		jsonWriter.Write([]byte(payloadJSON))

		for _, f := range files {
			formFile, _ := w.CreateFormFile("file", f.Name)
			formFile.Write(f.Data)
		}

		w.Close()

		body = bodyBuffer.Bytes()
	}

	if len(body) == 0 {
		panic("somehow we generated an empty body for Discord")
	}

	return
}

func logErrorResponse(ctx context.Context, name string, res *http.Response, msg string) {
	dump, err := httputil.DumpResponse(res, true)
	if err != nil {
		panic(err)
	}

	logging.ExtractLogger(ctx).Error().Str("name", name).Msg(msg)
	fmt.Println(string(dump))
}
