package api

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"strconv"
	"strings"
	"time"

	"github.com/alcionai/clues"
	"github.com/microsoft/kiota-abstractions-go/serialization"
	kjson "github.com/microsoft/kiota-serialization-json-go"
	"github.com/microsoftgraph/msgraph-sdk-go/models"
	"github.com/microsoftgraph/msgraph-sdk-go/users"

	"github.com/alcionai/corso/src/internal/common/ptr"
	"github.com/alcionai/corso/src/internal/common/sanitize"
	"github.com/alcionai/corso/src/internal/common/str"
	"github.com/alcionai/corso/src/pkg/backup/details"
	"github.com/alcionai/corso/src/pkg/dttm"
	"github.com/alcionai/corso/src/pkg/fault"
	"github.com/alcionai/corso/src/pkg/logger"
	"github.com/alcionai/corso/src/pkg/services/m365/api/graph"
)

// ---------------------------------------------------------------------------
// controller
// ---------------------------------------------------------------------------

func (c Client) Events() Events {
	return Events{c}
}

// Events is an interface-compliant provider of the client.
type Events struct {
	Client
}

// ---------------------------------------------------------------------------
// containers
// ---------------------------------------------------------------------------

// CreateContainer makes an event Calendar with the name in the user's M365 exchange account
// Reference: https://docs.microsoft.com/en-us/graph/api/user-post-calendars?view=graph-rest-1.0&tabs=go
func (c Events) CreateContainer(
	ctx context.Context,
	// parentContainerID needed for iface, doesn't apply to events
	userID, _, containerName string,
) (graph.Container, error) {
	body := models.NewCalendar()
	body.SetName(&containerName)

	container, err := c.Stable.
		Client().
		Users().
		ByUserId(userID).
		Calendars().
		Post(ctx, body, nil)

	return CalendarDisplayable{Calendarable: container}, clues.Wrap(err, "creating calendar").OrNil()
}

// DeleteContainer removes a calendar from user's M365 account
// Reference: https://docs.microsoft.com/en-us/graph/api/calendar-delete?view=graph-rest-1.0&tabs=go
func (c Events) DeleteContainer(
	ctx context.Context,
	userID, containerID string,
) error {
	// deletes require unique http clients
	// https://github.com/alcionai/corso/issues/2707
	srv, err := NewService(c.Credentials, c.counter)
	if err != nil {
		return clues.StackWC(ctx, err)
	}

	err = srv.Client().
		Users().
		ByUserId(userID).
		Calendars().
		ByCalendarId(containerID).
		Delete(ctx, nil)

	return clues.Stack(err).OrNil()
}

func (c Events) GetContainerByID(
	ctx context.Context,
	userID, containerID string,
) (graph.Container, error) {
	config := &users.ItemCalendarsCalendarItemRequestBuilderGetRequestConfiguration{
		QueryParameters: &users.ItemCalendarsCalendarItemRequestBuilderGetQueryParameters{
			Select: idAnd("name", "owner"),
		},
	}

	resp, err := c.Stable.
		Client().
		Users().
		ByUserId(userID).
		Calendars().
		ByCalendarId(containerID).
		Get(ctx, config)

	return graph.CalendarDisplayable{Calendarable: resp}, clues.Stack(err).OrNil()
}

// GetContainerByName fetches a calendar by name
func (c Events) GetContainerByName(
	ctx context.Context,
	// parentContainerID needed for iface, doesn't apply to events
	userID, _, containerName string,
) (graph.Container, error) {
	filter := fmt.Sprintf("name eq '%s'", containerName)
	options := &users.ItemCalendarsRequestBuilderGetRequestConfiguration{
		QueryParameters: &users.ItemCalendarsRequestBuilderGetQueryParameters{
			Filter: &filter,
		},
	}

	ctx = clues.Add(ctx, "container_name", containerName)

	resp, err := c.Stable.
		Client().
		Users().
		ByUserId(userID).
		Calendars().
		Get(ctx, options)
	if err != nil {
		return nil, clues.Stack(err)
	}

	gv := resp.GetValue()

	if len(gv) == 0 {
		return nil, clues.NewWC(ctx, "container not found")
	}

	// We only allow the api to match one calendar with the provided name.
	// If we match multiples, we'll eagerly return the first one.
	logger.Ctx(ctx).Debugw("calendars matched the name search", "calendar_count", len(gv))

	// Sanity check ID and name
	cal := gv[0]
	container := graph.CalendarDisplayable{Calendarable: cal}

	if err := graph.CheckIDAndName(container); err != nil {
		return nil, clues.StackWC(ctx, err)
	}

	return container, nil
}

func (c Events) PatchCalendar(
	ctx context.Context,
	userID, containerID string,
	body models.Calendarable,
) error {
	_, err := c.Stable.
		Client().
		Users().
		ByUserId(userID).
		Calendars().
		ByCalendarId(containerID).
		Patch(ctx, body, nil)

	return clues.Wrap(err, "patching event calendar").OrNil()
}

const (
	// Beta version cannot have /calendars/%s for get and Patch
	// https://stackoverflow.com/questions/50492177/microsoft-graph-get-user-calendar-event-with-beta-version
	eventExceptionsBetaURLTemplate = "https://graph.microsoft.com/beta/users/%s/events/%s?$expand=exceptionOccurrences"
	eventPostBetaURLTemplate       = "https://graph.microsoft.com/beta/users/%s/calendars/%s/events"
	eventPatchBetaURLTemplate      = "https://graph.microsoft.com/beta/users/%s/events/%s"
)

// ---------------------------------------------------------------------------
// items
// ---------------------------------------------------------------------------

// GetItem retrieves an Eventable item.
func (c Events) GetItem(
	ctx context.Context,
	userID, itemID string,
	errs *fault.Bus,
) (serialization.Parsable, *details.ExchangeInfo, error) {
	var (
		err    error
		event  models.Eventable
		config = &users.ItemEventsEventItemRequestBuilderGetRequestConfiguration{
			Headers: newPreferHeaders(preferImmutableIDs(c.options.ToggleFeatures.ExchangeImmutableIDs)),
		}
	)

	// Beta endpoint helps us fetch the event exceptions, but since we
	// don't use the beta SDK, the exceptionOccurrences and
	// cancelledOccurrences end up in AdditionalData
	// https://learn.microsoft.com/en-us/graph/api/resources/event?view=graph-rest-beta#properties
	rawURL := fmt.Sprintf(eventExceptionsBetaURLTemplate, userID, itemID)

	event, err = users.
		NewItemEventsEventItemRequestBuilder(rawURL, c.Stable.Adapter()).
		Get(ctx, config)
	if err != nil {
		return nil, nil, clues.Stack(err)
	}

	_, err = GetCancelledEventDateStrings(event)
	if err != nil {
		return nil, nil, clues.Wrap(err, "verify cancelled occurrences")
	}

	err = fixupExceptionOccurrences(ctx, c, event, userID)
	if err != nil {
		return nil, nil, clues.Wrap(err, "fixup exception occurrences")
	}

	var attachments []models.Attachmentable
	if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) {
		attachments, err = c.GetAttachments(ctx, userID, itemID)
		if err != nil {
			return nil, nil, err
		}
	}

	event.SetAttachments(attachments)

	return event, EventInfo(event), nil
}

// fixupExceptionOccurrences gets attachments and converts the data
// into a format that gets serialized when storing to kopia
func fixupExceptionOccurrences(
	ctx context.Context,
	client Events,
	event models.Eventable,
	userID string,
) error {
	// Fetch attachments for exceptions
	exceptionOccurrences := event.GetAdditionalData()["exceptionOccurrences"]
	if exceptionOccurrences == nil {
		return nil
	}

	eo, ok := exceptionOccurrences.([]any)
	if !ok {
		return clues.New("converting exceptionOccurrences to []any").
			With("type", fmt.Sprintf("%T", exceptionOccurrences))
	}

	for _, instance := range eo {
		instance, ok := instance.(map[string]any)
		if !ok {
			return clues.New("converting instance to map[string]any").
				With("type", fmt.Sprintf("%T", instance))
		}

		evt, err := EventFromMap(instance)
		if err != nil {
			return clues.Wrap(err, "parsing exception event")
		}

		// OPTIMIZATION: We don't have to store any of the
		// attachments that carry over from the original

		var attachments []models.Attachmentable
		if ptr.Val(event.GetHasAttachments()) || HasAttachments(event.GetBody()) {
			attachments, err = client.GetAttachments(ctx, userID, ptr.Val(evt.GetId()))
			if err != nil {
				return clues.Wrap(err, "getting event instance attachments").
					With("event_instance_id", ptr.Val(evt.GetId()))
			}
		}

		// This odd roundabout way of doing this is required as
		// the json serialization at the end does not serialize if
		// you just pass in a models.Attachmentable
		convertedAttachments := []map[string]any{}

		for _, attachment := range attachments {
			am, err := parseableToMap(attachment)
			if err != nil {
				return clues.Wrap(err, "converting attachment")
			}

			convertedAttachments = append(convertedAttachments, am)
		}

		instance["attachments"] = convertedAttachments
	}

	return nil
}

func GetCancelledEventDateStrings(event models.Eventable) ([]string, error) {
	cancelledOccurrences := event.GetAdditionalData()["cancelledOccurrences"]
	if cancelledOccurrences == nil {
		return nil, nil
	}

	co, ok := cancelledOccurrences.([]any)
	if !ok {
		return nil, clues.New("converting cancelledOccurrences to []any").
			With("type", fmt.Sprintf("%T", cancelledOccurrences))
	}

	dates := []string{}

	for _, instance := range co {
		instance, err := str.AnyToString(instance)
		if err != nil {
			return nil, err
		}

		// There might be multiple `.` in the ID and hence >2
		splits := strings.Split(instance, ".")
		if len(splits) < 2 {
			return nil, clues.New("unexpected cancelled event format").
				With("instance", instance)
		}

		startStr := splits[len(splits)-1]

		_, err = dttm.ParseTime(startStr)
		if err != nil {
			return nil, clues.Wrap(err, "parsing cancelled event date").
				With("instance", instance).
				With("start_string", startStr)
		}

		dates = append(dates, startStr)
	}

	return dates, nil
}

func parseableToMap(att serialization.Parsable) (map[string]any, error) {
	var item map[string]any

	writer := kjson.NewJsonSerializationWriter()
	defer writer.Close()

	if err := writer.WriteObjectValue("", att); err != nil {
		return nil, err
	}

	ats, err := writer.GetSerializedContent()
	if err != nil {
		return nil, err
	}

	err = json.Unmarshal(ats, &item)
	if err != nil {
		return nil, clues.Wrap(err, "unmarshalling serialized attachment")
	}

	return item, nil
}

func (c Events) GetAttachments(
	ctx context.Context,
	userID, itemID string,
) ([]models.Attachmentable, error) {
	config := &users.ItemEventsItemAttachmentsRequestBuilderGetRequestConfiguration{
		QueryParameters: &users.ItemEventsItemAttachmentsRequestBuilderGetQueryParameters{
			Expand: []string{"microsoft.graph.itemattachment/item"},
		},
		Headers: newPreferHeaders(
			preferPageSize(maxNonDeltaPageSize),
			preferImmutableIDs(c.options.ToggleFeatures.ExchangeImmutableIDs)),
	}

	attached, err := c.LargeItem.
		Client().
		Users().
		ByUserId(userID).
		Events().
		ByEventId(itemID).
		Attachments().
		Get(ctx, config)
	if err != nil {
		return nil, clues.Wrap(err, "event attachment download")
	}

	return attached.GetValue(), nil
}

func (c Events) DeleteAttachment(
	ctx context.Context,
	userID, calendarID, eventID, attachmentID string,
) error {
	return c.Stable.
		Client().
		Users().
		ByUserId(userID).
		Calendars().
		ByCalendarId(calendarID).
		Events().
		ByEventId(eventID).
		Attachments().
		ByAttachmentId(attachmentID).
		Delete(ctx, nil)
}

func (c Events) GetItemInstances(
	ctx context.Context,
	userID, itemID, startDate, endDate string,
) ([]models.Eventable, error) {
	config := &users.ItemEventsItemInstancesRequestBuilderGetRequestConfiguration{
		QueryParameters: &users.ItemEventsItemInstancesRequestBuilderGetQueryParameters{
			Select:        []string{"id"},
			StartDateTime: ptr.To(startDate),
			EndDateTime:   ptr.To(endDate),
		},
	}

	events, err := c.Stable.
		Client().
		Users().
		ByUserId(userID).
		Events().
		ByEventId(itemID).
		Instances().
		Get(ctx, config)
	if err != nil {
		return nil, clues.Stack(err)
	}

	return events.GetValue(), nil
}

func (c Events) PostItem(
	ctx context.Context,
	userID, containerID string,
	body models.Eventable,
) (models.Eventable, error) {
	rawURL := fmt.Sprintf(eventPostBetaURLTemplate, userID, containerID)
	builder := users.NewItemCalendarsItemEventsRequestBuilder(rawURL, c.Stable.Adapter())

	itm, err := builder.Post(ctx, body, nil)

	return itm, clues.Wrap(err, "creating calendar event").OrNil()
}

func (c Events) PatchItem(
	ctx context.Context,
	userID, eventID string,
	body models.Eventable,
) (models.Eventable, error) {
	rawURL := fmt.Sprintf(eventPatchBetaURLTemplate, userID, eventID)
	builder := users.NewItemCalendarsItemEventsEventItemRequestBuilder(rawURL, c.Stable.Adapter())

	itm, err := builder.Patch(ctx, body, nil)

	return itm, clues.Wrap(err, "updating calendar event").OrNil()
}

func (c Events) DeleteItem(
	ctx context.Context,
	userID, itemID string,
) error {
	// deletes require unique http clients
	// https://github.com/alcionai/corso/issues/2707
	srv, err := c.Service(c.counter)
	if err != nil {
		return clues.StackWC(ctx, err)
	}

	err = srv.
		Client().
		Users().
		ByUserId(userID).
		Events().
		ByEventId(itemID).
		Delete(ctx, nil)

	return clues.Wrap(err, "deleting calendar event").OrNil()
}

func (c Events) PostSmallAttachment(
	ctx context.Context,
	userID, containerID, parentItemID string,
	body models.Attachmentable,
) error {
	_, err := c.Stable.
		Client().
		Users().
		ByUserId(userID).
		Calendars().
		ByCalendarId(containerID).
		Events().
		ByEventId(parentItemID).
		Attachments().
		Post(ctx, body, nil)

	return clues.Wrap(err, "uploading small event attachment").OrNil()
}

func (c Events) PostLargeAttachment(
	ctx context.Context,
	userID, containerID, parentItemID, itemName string,
	content []byte,
) (string, error) {
	size := int64(len(content))
	session := users.NewItemCalendarEventsItemAttachmentsCreateUploadSessionPostRequestBody()
	session.SetAttachmentItem(makeSessionAttachment(itemName, size))

	us, err := c.LargeItem.
		Client().
		Users().
		ByUserId(userID).
		Calendars().
		ByCalendarId(containerID).
		Events().
		ByEventId(parentItemID).
		Attachments().
		CreateUploadSession().
		Post(ctx, session, nil)
	if err != nil {
		return "", clues.Wrap(err, "uploading large event attachment")
	}

	var (
		url        = ptr.Val(us.GetUploadUrl())
		w          = graph.NewLargeItemWriter(parentItemID, url, size, c.counter)
		reader     = bytes.NewReader(content)
		copyBuffer = make([]byte, graph.AttachmentChunkSize)
	)

	_, err = io.CopyBuffer(w, reader, copyBuffer)

	return w.ID, clues.WrapWC(ctx, err, "buffering large attachment content").OrNil()
}

// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------

func bytesToEventable(body []byte) (serialization.Parsable, error) {
	v, err := CreateFromBytes(body, models.CreateEventFromDiscriminatorValue)
	if err != nil {
		if !strings.Contains(err.Error(), invalidJSON) {
			return nil, clues.Wrap(err, "deserializing bytes to message")
		}

		// If the JSON was invalid try sanitizing and deserializing again.
		// Sanitizing should transform characters < 0x20 according to the spec where
		// possible. The resulting JSON may still be invalid though.
		body = sanitize.JSONBytes(body)
		v, err = CreateFromBytes(body, models.CreateEventFromDiscriminatorValue)
	}

	return v, clues.Stack(err).OrNil()
}

func BytesToEventable(body []byte) (models.Eventable, error) {
	v, err := bytesToEventable(body)
	if err != nil {
		return nil, clues.Stack(err)
	}

	return v.(models.Eventable), nil
}

func (c Events) Serialize(
	ctx context.Context,
	item serialization.Parsable,
	userID, itemID string,
) ([]byte, error) {
	event, ok := item.(models.Eventable)
	if !ok {
		return nil, clues.New(fmt.Sprintf("item is not an Eventable: %T", item))
	}

	ctx = clues.Add(ctx, "item_id", ptr.Val(event.GetId()))

	writer := kjson.NewJsonSerializationWriter()
	defer writer.Close()

	if err := writer.WriteObjectValue("", event); err != nil {
		return nil, clues.StackWC(ctx, err)
	}

	bs, err := writer.GetSerializedContent()

	return bs, clues.WrapWC(ctx, err, "serializing event").OrNil()
}

// ---------------------------------------------------------------------------
// helper funcs
// ---------------------------------------------------------------------------

// CalendarDisplayable is a wrapper that complies with the
// models.Calendarable interface with the graph.Container
// interfaces. Calendars do not have a parentFolderID.
// Therefore, that value will always return nil.
type CalendarDisplayable struct {
	models.Calendarable
}

// GetDisplayName returns the *string of the models.Calendable
// variant:  calendar.GetName()
func (c CalendarDisplayable) GetDisplayName() *string {
	return c.GetName()
}

// GetParentFolderId returns the default calendar name address
// EventCalendars have a flat hierarchy and Calendars are rooted
// at the default
//
//nolint:revive
func (c CalendarDisplayable) GetParentFolderId() *string {
	return nil
}

func EventInfo(evt models.Eventable) *details.ExchangeInfo {
	var (
		organizer string
		subject   = ptr.Val(evt.GetSubject())
		recurs    bool
		start     = time.Time{}
		end       = time.Time{}
		created   = ptr.Val(evt.GetCreatedDateTime())
	)

	if evt.GetOrganizer() != nil &&
		evt.GetOrganizer().GetEmailAddress() != nil {
		organizer = ptr.Val(evt.GetOrganizer().GetEmailAddress().GetAddress())
	}

	if evt.GetRecurrence() != nil {
		recurs = true
	}

	if evt.GetStart() != nil && len(ptr.Val(evt.GetStart().GetDateTime())) > 0 {
		// timeString has 'Z' literal added to ensure the stored
		// DateTime is not: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)
		startTime := ptr.Val(evt.GetStart().GetDateTime()) + "Z"

		output, err := dttm.ParseTime(startTime)
		if err == nil {
			start = output
		}
	}

	if evt.GetEnd() != nil && len(ptr.Val(evt.GetEnd().GetDateTime())) > 0 {
		// timeString has 'Z' literal added to ensure the stored
		// DateTime is not: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)
		endTime := ptr.Val(evt.GetEnd().GetDateTime()) + "Z"

		output, err := dttm.ParseTime(endTime)
		if err == nil {
			end = output
		}
	}

	return &details.ExchangeInfo{
		ItemType:    details.ExchangeEvent,
		Organizer:   organizer,
		Subject:     subject,
		EventStart:  start,
		EventEnd:    end,
		EventRecurs: recurs,
		Created:     created,
		Modified:    ptr.OrNow(evt.GetLastModifiedDateTime()),
	}
}

func EventFromMap(ev map[string]any) (models.Eventable, error) {
	instBytes, err := json.Marshal(ev)
	if err != nil {
		return nil, clues.Wrap(err, "marshaling event exception instance")
	}

	body, err := BytesToEventable(instBytes)
	if err != nil {
		return nil, clues.Wrap(err, "converting exception event bytes to Eventable")
	}

	return body, nil
}

func eventCollisionKeyProps() []string {
	// Do not use attendees here.  We slice out attendees from the
	// restored event so that they do not receive an email for every
	// restoration item.  Attendees will guarantee non-overlapping keys.
	return idAnd(
		"subject",
		"type",
		"start",
		"end",
		recurrence,
		isCancelled,
		isDraft)
}

// EventCollisionKey constructs a key from the eventable's creation time, subject, and organizer.
// collision keys are used to identify duplicate item conflicts for handling advanced restoration config.
func EventCollisionKey(item models.Eventable) string {
	if item == nil {
		return ""
	}

	var (
		subject   = ptr.Val(item.GetSubject())
		oftype    = ptr.Val(item.GetTypeEscaped()).String()
		startTime = item.GetStart()
		start     string
		endTime   = item.GetEnd()
		end       string
		recurs    = item.GetRecurrence()
		recur     string
		cancelled = ptr.Val(item.GetIsCancelled())
		draft     = ptr.Val(item.GetIsDraft())
	)

	if startTime != nil {
		start = ptr.Val(startTime.GetDateTime())
	}

	if endTime != nil {
		end = ptr.Val(endTime.GetDateTime())
	}

	if recurs != nil && recurs.GetPattern() != nil {
		recur = ptr.Val(recurs.GetPattern().GetOdataType())
	}

	// this result gets hashed to ensure that an enormous list of attendees
	// doesn't generate a multi-kb collision key.
	return subject +
		oftype +
		start + end + recur +
		strconv.FormatBool(draft) +
		strconv.FormatBool(cancelled)
}
