package app

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"runtime"
	"strings"
	"sync/atomic"
	"time"

	"github.com/uptrace/bun"
	tele "gopkg.in/telebot.v4"

	"git.savin.nyc/alex/go-receipt-tracker/bus"
	"git.savin.nyc/alex/go-receipt-tracker/config"
	"git.savin.nyc/alex/go-receipt-tracker/listeners"
	"git.savin.nyc/alex/go-receipt-tracker/models"
	"git.savin.nyc/alex/go-receipt-tracker/system"
	"git.savin.nyc/alex/go-receipt-tracker/workers"
)

const (
	AppVersion = "1.0.0"              // the current application version.
	AppName    = "go-receipt-tracker" // the short app name
)

var (
	ErrWorkerIDExists   = errors.New("worker id already exists")
	ErrListenerIDExists = errors.New("listener id already exists")
)

type App struct {
	Options       *config.Config            // configurable server options
	Workers       *workers.Workers          // worker are network interfaces which listen for new connections
	Listeners     *listeners.Listeners      // listeners are network interfaces which listen for new connections
	Info          *system.Info              // values about the server commonly known as $SYS topics
	Log           *slog.Logger              // minimal no-alloc logger
	db            *bun.DB                   //
	subscriptions map[string]chan bus.Event //
	bus           *bus.Bus                  //
	loop          *loop                     // loop contains tickers for the system event loop
	cancel        context.CancelFunc        //
	hooks         *Hooks                    // hooks contains hooks for extra functionality such as auth and persistent storage
	// done    chan bool          // indicate that the server is ending
}

// loop contains interval tickers for the system events loop.
type loop struct {
	sysInfo *time.Ticker // interval ticker for sending updating $SYS topics
}
type Info struct {
	AppVersion  string `json:"version"`      // the current version of the server
	Started     int64  `json:"started"`      // the time the server started in unix seconds
	MemoryAlloc int64  `json:"memory_alloc"` // memory currently allocated
	Threads     int64  `json:"threads"`      // number of active goroutines, named as threads for platform ambiguity
	Time        int64  `json:"time"`         // current time on the server
	Uptime      int64  `json:"uptime"`       // the number of seconds the server has been online
}

// EstablishConnection establishes a new client when a listener accepts a new connection.
// func (a *App) EstablishConnection(listener string, c net.Conn) error {
// 	cl := a.NewClient(c, listener, "", false)
// 	return a.attachClient(cl, listener)
// }

// New returns a new instance of mochi mqtt broker. Optional parameters
// can be specified to override some default settings (see Options).
func New(opts *config.Config, b *bus.Bus, db *bun.DB, logger *slog.Logger) *App {
	if opts == nil {
		slog.Error("empty config")
	}

	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	a := &App{
		Options:   opts,
		Workers:   workers.New(),
		Listeners: listeners.New(),
		Info: &system.Info{
			Version:     AppVersion,
			Started:     time.Now().Unix(),
			MemoryAlloc: int64(m.HeapInuse),
			Threads:     int64(runtime.NumGoroutine()),
			// Time:        time.Now().Unix(),
			// Uptime:      time.Now().Unix() - atomic.LoadInt64(&a.Started),
		},
		Log:           logger,
		db:            db,
		subscriptions: make(map[string]chan bus.Event),
		bus:           b,
		hooks: &Hooks{
			Log: logger,
		},
		loop: &loop{
			sysInfo: time.NewTicker(time.Second * time.Duration(1)),
		},
		// done:   make(chan bool),
	}

	return a
}

// AddHook attaches a new Hook to the server. Ideally, this should be called
// before the server is started with s.Serve().
func (a *App) AddHook(hook Hook, config any) error {
	nl := a.Log.With("hook", hook.ID())
	hook.SetOpts(nl)

	a.Log.Info("added hook", "hook", hook.ID())
	return a.hooks.Add(hook, config)
}

// AddListener adds a new network listener to the server, for receiving incoming client connections.
func (a *App) AddWorker(w workers.Worker) error {
	if _, ok := a.Workers.Get(w.ID()); ok {
		return ErrWorkerIDExists
	}

	nl := a.Log.With(slog.String("worker", w.ID()))
	err := w.Init(nl)
	if err != nil {
		return err
	}

	a.Workers.Add(w)

	a.Log.Info("attached worker", "id", w.ID(), "type", w.Type())
	return nil
}

// AddListener adds a new network listener to the server, for receiving incoming client connections.
func (a *App) AddListener(l listeners.Listener) error {
	if _, ok := a.Listeners.Get(l.ID()); ok {
		return ErrListenerIDExists
	}

	nl := a.Log.With(slog.String("listener", l.ID()))
	err := l.Init(nl)
	if err != nil {
		return err
	}

	a.Listeners.Add(l)

	a.Log.Info("attached listener", "id", l.ID(), "protocol", l.Protocol(), "address", l.Address())
	return nil
}

// Serve starts the event loops responsible for establishing client connections
// on all attached listeners, publishing the system topics, and starting all hooks.
func (a *App) Serve() error {
	a.Log.Info(AppName+" starting", "version", AppVersion)
	defer a.Log.Info(AppName + " is started")

	a.subscribe("processor:user_add")
	a.subscribe("processor:user_edit")
	a.subscribe("processor:user_delete")
	a.subscribe("processor:image")
	a.subscribe("processor:receipt_add")
	a.subscribe("processor:items_list")

	ctx, cancel := context.WithCancel(context.Background())
	a.cancel = cancel

	go a.eventLoop(ctx)

	a.Workers.ServeAll()   // start listening on all workers.
	a.Listeners.ServeAll() // start listening on all listeners.
	a.publishSysTopics()
	a.hooks.OnStarted()

	return nil
}

// Close attempts to gracefully shut down the server, all listeners, clients, and stores.
func (a *App) Close() error {
	// close(a.done)
	a.cancel()
	a.Log.Info("gracefully stopping " + AppName)
	a.Workers.CloseAll()
	a.Listeners.CloseAll()
	a.hooks.OnStopped()
	a.hooks.Stop()

	a.Log.Info(AppName + " is stopped")
	return nil
}

func (a *App) subscribe(chn string) error {
	s, err := a.bus.Subscribe(chn, AppName)
	if err != nil {
		a.Log.Error("couldn't subscribe to a channel", "channel", chn, "error", err.Error())
		return err
	}
	a.subscriptions[chn] = s

	return nil
}

func (a *App) eventLoop(ctx context.Context) {
	a.Log.Debug(AppName + " main communication event loop started")
	defer a.Log.Debug(AppName + " main communication event loop halted")

	for {
		select {
		// case time.Time:
		// 	a.publishSysTopics()

		case event := <-a.subscriptions["processor:user_add"]:
			a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
			msg := event.Payload.(*bus.Message)

			if models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {
				msg.Text = "Welcome back, " + event.Payload.(*bus.Message).TbContext.Sender().FirstName + " " + event.Payload.(*bus.Message).TbContext.Sender().LastName
				msg.InlineKeyboard = &tele.ReplyMarkup{
					InlineKeyboard: [][]tele.InlineButton{
						{
							{Text: "Edit Profile", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["user_edit"], ID: msg.TbContext.Sender().ID})},
						},
						{
							{Text: "Create Group", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["group_add"], ID: msg.TbContext.Sender().ID})},
						},
						{
							{Text: "List my Receipts", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["receipts_list"], ID: msg.TbContext.Sender().ID})},
						},
					},
				}
			} else {
				user := &models.User{
					Name:             fmt.Sprintf("%s %s", event.Payload.(*bus.Message).TbContext.Sender().FirstName, event.Payload.(*bus.Message).TbContext.Sender().LastName),
					TelegramID:       event.Payload.(*bus.Message).TbContext.Sender().ID,
					TelegramUsername: event.Payload.(*bus.Message).TbContext.Sender().Username,
				}

				ctx := context.Background()
				user.Add(a.db, ctx)

				msg.Text = config.Message_welcome
			}
			a.Log.Debug("publishing message to a channel", "channel", "telegram:send", "app", AppName)
			err := a.bus.Publish("telegram:send", msg)
			if err != nil {
				a.Log.Error("couldn't publish to the channel", "channel", "telegram:send", "error", err.Error())
			}

		case event := <-a.subscriptions["processor:user_delete"]:
			a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
			msg := event.Payload.(*bus.Message)

			if models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {
				msg.Text = "Hey " + event.Payload.(*bus.Message).TbContext.Sender().FirstName + " it looks like that you would like to DELETE your account, please confirm it:\n"
				msg.InlineKeyboard = &tele.ReplyMarkup{
					InlineKeyboard: [][]tele.InlineButton{
						{
							{Text: "Yes, DELETE IT", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["user_delete"], ID: msg.TbContext.Sender().ID, RefCmd: models.BtnCmd["user_delete"], Extra: "1"})},
							{Text: "No, KEEP IT", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["user_edit"], ID: msg.TbContext.Sender().ID})},
						},
					},
				}
			} else {
				msg.Text = "The user does not exist"
			}

			a.Log.Debug("publishing message to a channel", "channel", "telegram:user_delete", "app", AppName)
			err := a.bus.Publish("telegram:user_delete", msg)
			if err != nil {
				a.Log.Error("couldn't publish to the channel", "channel", "telegram:user_delete", "error", err.Error())
			}

		case event := <-a.subscriptions["processor:image"]:
			a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
			msg := event.Payload.(*bus.Message)

			ctx := context.Background()
			if !models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {
				a.Log.Error("user is not registered", "user", event.Payload.(*bus.Message).TbContext.Sender().ID)
			}

			msg.Image.Type = imgType(msg.Image.Filename)
			msg.Image.Base64 = readImage(msg.Image.Filename)
			msg.Image.Parsed = make(map[string]string)

			err := a.bus.Publish("parser:openai", msg)
			if err != nil {
				a.Log.Error("couldn't publish to the channel", "channel", "parser:openai", "error", err.Error())
			}

			// err = a.bus.Publish("parser:gemini", msg)
			// if err != nil {
			// 	a.Log.Error("couldn't publish to the channel", "channel", "parser:gemini", "error", err.Error())
			// }

			// err = a.bus.Publish("parser:claude", msg)
			// if err != nil {
			// 	a.Log.Error("couldn't publish to the channel", "channel", "parser:claude", "error", err.Error())
			// }
			// }

		// ADDING A NEWLY PARSED RECEIPT
		case event := <-a.subscriptions["processor:receipt_add"]:
			a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
			msg := event.Payload.(*bus.Message)

			if models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {

				user := new(models.User)
				a.db.NewSelect().Model(user).Where("telegram_id = ?", event.Payload.(*bus.Message).TbContext.Sender().ID).Scan(ctx)

				group := new(models.Group)
				if event.Payload.(*bus.Message).TbContext.Sender().ID != event.Payload.(*bus.Message).TbContext.Chat().ID {
					if models.GroupExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Chat().ID) {
						a.db.NewSelect().Model(group).Where("telegram_chat_id = ?", event.Payload.(*bus.Message).TbContext.Chat().ID).Scan(ctx)
					} else {
						group.TelegramChatID = event.Payload.(*bus.Message).TbContext.Chat().ID
						group.Add(a.db, ctx)
						group.UserAdd(a.db, ctx, user.UserID)
					}
				}

				var receipt models.Receipt
				if err := json.Unmarshal([]byte(msg.Image.Parsed["openai"]), &receipt); err != nil {
					a.Log.Error("cannot parse receipt json", "error", err)
				}

				ctx := context.Background()
				receipt.UserID = user.UserID
				receipt.Merchant.Add(a.db, ctx)
				receipt.MerchantID = receipt.Merchant.MerchantID
				receipt.CreditCard.UserID = user.UserID
				receipt.CreditCard.Add(a.db, ctx)
				receipt.CreditCardID = receipt.CreditCard.CreditCardID
				receipt.Add(a.db, ctx)

				msg.Text = "We got your receipt"

				msg.InlineKeyboard = &tele.ReplyMarkup{
					InlineKeyboard: [][]tele.InlineButton{
						{
							{Text: fmt.Sprintf("Merchant: %s", strings.Title(strings.ToLower(receipt.Merchant.Title))), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["merchant"]})},
						},
						{
							{Text: fmt.Sprintf("Category: %s", strings.Title(strings.ToLower(receipt.Category))), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["category"]})},
						},
						{
							{Text: fmt.Sprintf("Tax: %.2f", receipt.Tax), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["tax"], ID: receipt.ReceiptID})},
							{Text: fmt.Sprintf("Total: %.2f", receipt.Total), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["total"], ID: receipt.ReceiptID})},
						},
						{
							{Text: fmt.Sprintf("List of the receipt items (%d)", len(receipt.Items)), Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["items_list"], ID: receipt.ReceiptID})},
						},
					},
				}

				a.Log.Debug("publishing message to a channel", "channel", "telegram:send", "app", AppName)
				err := a.bus.Publish("telegram:send", msg)
				if err != nil {
					a.Log.Error("couldn't publish to the channel", "channel", "telegram:send", "error", err.Error())
				}
			}

		case event := <-a.subscriptions["processor:items_list"]:
			a.Log.Debug("service got a new message from a channel", "channel", event.ChannelName, "app", AppName)
			msg := event.Payload.(*bus.Message)

			if models.UserExists(a.db, ctx, event.Payload.(*bus.Message).TbContext.Sender().ID) {

				user := new(models.User)
				a.db.NewSelect().Model(user).Where("telegram_id = ?", event.Payload.(*bus.Message).TbContext.Sender().ID).Scan(ctx)

				r := new(models.Receipt)
				err := a.db.NewSelect().Model(r).Where("receipt_id = ?", event.Payload.(*bus.Message).TBCmd.ID).Scan(ctx)
				if err != nil {
					panic(err)
				}

				r.CreditCard = new(models.CreditCard)
				err = a.db.NewSelect().Model(r.CreditCard).Where("credit_card_id = ?", r.CreditCardID).Scan(ctx)
				if err != nil {
					panic(err)
				}

				r.Merchant = new(models.Merchant)
				err = a.db.NewSelect().Model(r.Merchant).Where("merchant_id = ?", r.MerchantID).Scan(ctx)
				if err != nil {
					panic(err)
				}

				err = a.db.NewSelect().Model(&r.Items).Where("receipt_id = ?", r.ReceiptID).Scan(ctx)
				if err != nil {
					panic(err)
				}

				message := "<code>List of the Receipt Items:\n" + strings.Repeat("-", 32) + "\n"

				merchant := truncateText(r.Merchant.Title, 16)
				spaces := 32 - (len("Merchant:") + len(merchant))
				message += "Merchant:" + strings.Repeat(" ", spaces) + merchant + "\n"
				spaces = 32 - (len("Date/Time:") + len(r.PaidAt))
				message += "Date/Time:" + strings.Repeat(" ", spaces) + r.PaidAt + "\n" + strings.Repeat("-", 32) + "\n"

				keyboard := &tele.ReplyMarkup{InlineKeyboard: [][]tele.InlineButton{}}

				for _, item := range r.Items {
					// b := tele.InlineButton{Text: fmt.Sprintf("%s (%.2f) $%.2f", strings.Title(strings.ToLower(item.Title)), item.Quantity, item.Price), Data: String(&TelegramBotCommand{Cmd: btnCmd["item_edit"], ID: item.ItemID})}
					// keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, []tele.InlineButton{b})
					// if item.Quantity == math.Trunc(item.Quantity) {
					// 	fmt.Sprintf("%d", math.Trunc(item.Quantity))
					// } else {
					// 	fmt.Sprintf("%.2f", item.Quantity)
					// }
					title := truncateText(item.Title, 16)
					spaces := 32 - (len(title) + len("x"+fmt.Sprintf("%+v", item.Quantity)) + len(fmt.Sprintf("$%.2f", item.Price)) + 3)
					message += title + strings.Repeat(".", spaces) + "x" + fmt.Sprintf("%+v", item.Quantity) + "..." + fmt.Sprintf("$%.2f", item.Price) + "\n"
					a.Log.Debug("list of items", "title", strings.Title(strings.ToLower(item.Title)), "quantity", item.Quantity, "price", item.Price)
				}
				// keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, []tele.InlineButton{{Text: "«", Data: String(&TelegramBotCommand{Cmd: btnPage["prev"], ID: tbc.ID})}, {Text: "»", Data: String(&TelegramBotCommand{Cmd: btnPage["next"], ID: tbc.ID})}})
				keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, []tele.InlineButton{{Text: "« Back to Receipt", Data: models.String(&models.TelegramBotCommand{Cmd: models.BtnCmd["receipt_edit"], ID: r.ReceiptID})}})

				message += strings.Repeat("-", 32) + "\n"
				spaces = 32 - (len("Tax:") + len(fmt.Sprintf("$%.2f", r.Tax)))
				message += "Tax:" + strings.Repeat(" ", spaces) + fmt.Sprintf("$%.2f", r.Tax) + "\n"
				if r.CCFee > 0 {
					spaces = 32 - (len("CC Fee:") + len(fmt.Sprintf("$%.2f", r.CCFee)))
					message += "CC Fee:" + strings.Repeat(" ", spaces) + fmt.Sprintf("$%.2f", r.CCFee) + "\n"
				}
				spaces = 32 - (len("Total:") + len(fmt.Sprintf("$%.2f", r.Total)))
				message += "Total:" + strings.Repeat(" ", spaces) + fmt.Sprintf("<b>$%.2f</b>", r.Total) + "\n</code>"

				msg.TBParseMode = "HTML"
				msg.InlineKeyboard = keyboard

				a.Log.Debug("publishing message to a channel", "channel", "telegram:send", "app", AppName)
				err = a.bus.Publish("telegram:send", msg)
				if err != nil {
					a.Log.Error("couldn't publish to the channel", "channel", "telegram:send", "error", err.Error())
				}
			}

		case <-ctx.Done():
			a.Log.Info("stopping " + AppName + " main communication event loop")
			return
		}
	}
}

// eventLoop loops forever
// func (a *App) eventLoop(ctx context.Context, chAppWorker chan bus.Event) {
// 	a.Log.Info("app communication event loop started")
// 	defer a.Log.Info("app communication event loop halted")

// 	for {
// 		select {
// 		case <-a.loop.sysInfo.C:
// 			a.publishSysTopics()
// 		case event := <-chAppWorker:
// 			message := event.Data.(*bus.ConnectionStatus)
// 			a.Log.Info("got a message from worker", "worker", message.WorkerID, "type", message.WorkerType, "isconnected", message.IsConnected)
// 			if !message.IsConnected {
// 				a.hooks.OnWorkerDisconnected()
// 				a.Workers.Close(message.WorkerID)
// 			}
// 			a.hooks.OnWorkerConnected()
// 		case <-ctx.Done():
// 			a.Log.Info("stopping app communication event loop")
// 			return
// 		}
// 	}
// }

// publishSysTopics publishes the current values to the server $SYS topics.
// Due to the int to string conversions this method is not as cheap as
// some of the others so the publishing interval should be set appropriately.
func (a *App) publishSysTopics() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	atomic.StoreInt64(&a.Info.MemoryAlloc, int64(m.HeapInuse))
	atomic.StoreInt64(&a.Info.Threads, int64(runtime.NumGoroutine()))
	atomic.StoreInt64(&a.Info.Time, time.Now().Unix())
	atomic.StoreInt64(&a.Info.Uptime, time.Now().Unix()-atomic.LoadInt64(&a.Info.Started))

	a.hooks.OnSysInfoTick(a.Info)
}

func truncateText(s string, max int) string {
	if max > len(s) {
		return s
	}

	return s[:strings.LastIndex(s[:max], " ")]
}
