package main

import (
	"bytes"
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

type client struct {
	http *http.Client
}

type availabilityType int

const (
	availabilityUnknown availabilityType = iota
	availabilityClosed
	availabilityOpen
)

func (c *client) GetHomeAvailability(ctx context.Context, testType string) (availabilityType, error) {
	req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://api.test-for-coronavirus.service.gov.uk/ser/app/homeOrderAvailabilityStatus/%s", testType), nil)
	if err != nil {
		return availabilityUnknown, fmt.Errorf("creating request: %w", err)
	}
	req.Header.Set("Origin", "https://test-for-coronavirus.service.gov.uk")
	req.Header.Set("User-Agent", "TestAvailabilityScraper/1.0 (nhs-test-scraper@lukegb.com)")
	req.Header.Set("Accept", "application/json")
	resp, err := c.http.Do(req)
	if err != nil {
		return availabilityUnknown, fmt.Errorf("performing request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return availabilityUnknown, fmt.Errorf("bad HTTP status %d", resp.StatusCode)
	}

	type bodyData struct {
		Status string `json:"status"`
	}
	var d bodyData
	if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
		return availabilityUnknown, fmt.Errorf("parsing response: %w", err)
	}

	switch d.Status {
	case "CLOSE":
		return availabilityClosed, nil
	case "OPEN":
		return availabilityOpen, nil
	default:
		return availabilityUnknown, fmt.Errorf("unknown status %q", d.Status)
	}
}

type homeAvailabilityData struct {
	status      availabilityType
	err         error
	lastUpdate  time.Time
	lastSuccess time.Time
}

func (d *homeAvailabilityData) Clone() *homeAvailabilityData {
	return &homeAvailabilityData{
		status:      d.status,
		err:         d.err,
		lastUpdate:  d.lastUpdate,
		lastSuccess: d.lastSuccess,
	}
}

type bot struct {
	http                *http.Client
	token               string
	targetChannel       string
	targetPinnedMessage int
	tickInterval        time.Duration

	nhsClient        *client
	homeLFDPublic    *homeAvailabilityData
	homePCRPublic    *homeAvailabilityData
	homePCRKeyWorker *homeAvailabilityData
}

func (b *bot) callTelegramNoReply(ctx context.Context, api string, payload interface{}) error {
	var payloadBuf bytes.Buffer
	if err := json.NewEncoder(&payloadBuf).Encode(payload); err != nil {
		return fmt.Errorf("marshalling message payload: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("https://api.telegram.org/bot%s/%s", b.token, api), &payloadBuf)
	if err != nil {
		return fmt.Errorf("creating new HTTP request to %v: %w", api, err)
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := b.http.Do(req)
	if err != nil {
		return fmt.Errorf("performing %v: %w", api, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
		return nil
	}
	io.Copy(os.Stderr, resp.Body)
	return fmt.Errorf("%v HTTP status was %d", api, resp.StatusCode)
}

func (b *bot) editMessageText(ctx context.Context, msgID int, msg string) error {
	return b.callTelegramNoReply(ctx, "editMessageText", struct {
		ChatID                string `json:"chat_id"`
		MessageID             int    `json:"message_id"`
		Text                  string `json:"text"`
		ParseMode             string `json:"parse_mode"`
		DisableWebPagePreview bool   `json:"disable_web_page_preview"`
	}{
		ChatID:                b.targetChannel,
		MessageID:             msgID,
		Text:                  msg,
		ParseMode:             "MarkdownV2",
		DisableWebPagePreview: true,
	})
}

func (b *bot) sendMessage(ctx context.Context, msg string) error {
	return b.callTelegramNoReply(ctx, "sendMessage", struct {
		ChatID                string `json:"chat_id"`
		Text                  string `json:"text"`
		ParseMode             string `json:"parse_mode"`
		DisableWebPagePreview bool   `json:"disable_web_page_preview"`
	}{
		ChatID:                b.targetChannel,
		Text:                  msg,
		ParseMode:             "MarkdownV2",
		DisableWebPagePreview: true,
	})
}

func (b *bot) updateAvailabilityFor(ctx context.Context, outData **homeAvailabilityData, testType string) (bool, error) {
	t := time.Now()
	avType, err := b.nhsClient.GetHomeAvailability(ctx, testType)

	newData := &homeAvailabilityData{lastUpdate: t}
	if *outData != nil {
		newData = (*outData).Clone()
		newData.lastUpdate = t
	}

	if err != nil {
		newData.err = err
		*outData = newData
		return false, fmt.Errorf("updating status for %v: %w", testType, err)
	}

	newData.status = avType
	newData.err = nil
	newData.lastSuccess = t

	updated := *outData == nil || (*outData).status != newData.status
	*outData = newData
	return updated, nil
}

type availabilityUpdate struct {
	homePCRKeyWorker bool
	homePCRPublic    bool
	homeLFDPublic    bool
}

func (au availabilityUpdate) Any() bool {
	return au.homePCRKeyWorker || au.homePCRPublic || au.homeLFDPublic
}

func (b *bot) updateAvailability(ctx context.Context) (availabilityUpdate, error) {
	updatedPCRKW, errPCRKW := b.updateAvailabilityFor(ctx, &b.homePCRKeyWorker, "antigen-keyworkers")
	updatedPCRPub, errPCRPub := b.updateAvailabilityFor(ctx, &b.homePCRPublic, "antigen-public")
	updatedLFDPub, errLFDPub := b.updateAvailabilityFor(ctx, &b.homeLFDPublic, "lfd3-public")
	updated := availabilityUpdate{
		homePCRKeyWorker: updatedPCRKW,
		homePCRPublic:    updatedPCRPub,
		homeLFDPublic:    updatedLFDPub,
	}

	// If we had only one error, use that.
	errs := []error{errPCRKW, errPCRPub, errLFDPub}
	errCount := 0
	var lastErr error
	allErrsStr := []string{"multiple errors: "}
	for _, err := range errs {
		if err != nil {
			errCount++
			lastErr = err
			allErrsStr = append(allErrsStr, err.Error())
		}
	}
	if errCount == 0 || errCount == 1 {
		return updated, lastErr
	}
	return updated, fmt.Errorf("%v", strings.Join(allErrsStr, ""))
}

func (b *bot) sendUpdatedAvailability(ctx context.Context, au availabilityUpdate) error {
	var msg []string
	if au.homePCRKeyWorker && b.homePCRKeyWorker.status == availabilityOpen {
		msg = append(msg, "\\[Key workers\\] At\\-home PCR tests are now available\\.")
	}
	if au.homePCRPublic && b.homePCRPublic.status == availabilityOpen {
		msg = append(msg, "\\[Public\\] At\\-home PCR tests are now available\\.")
	}
	if au.homeLFDPublic && b.homeLFDPublic.status == availabilityOpen {
		msg = append(msg, "\\[Public\\] At\\-home LFD tests are now available\\.")
	}
	if len(msg) == 0 {
		return nil
	}

	log.Printf("sending updated availability:\n%v", strings.Join(msg, "\n"))
	return b.sendMessage(ctx, strings.Join(msg, "\n"))
}

func (b *bot) updatePinnedMessage(ctx context.Context) error {
	format := func(had *homeAvailabilityData, orderURL string) string {
		var bits []string
		switch had.status {
		case availabilityOpen:
			bits = append(bits, fmt.Sprintf("[✅ AVAILABLE](%v)", orderURL))
		case availabilityClosed:
			bits = append(bits, "❌ out of stock")
		case availabilityUnknown:
			bits = append(bits, "🤷 unknown")
		}
		if had.err != nil {
			bits = append(bits, "(an error occurred during the last update)")
		}
		return strings.Join(bits, " ")
	}
	newMsg := fmt.Sprintf(`Last updated: %v

At\-home PCR test \(key workers\): %v
At\-home PCR test \(public\): %v
At\-home LFD test \(public\): %v`,
		time.Now().Format("2006\\-01\\-02 15\\:04"),
		format(b.homePCRKeyWorker, "https://self-referral.test-for-coronavirus.service.gov.uk/antigen/confirm-eligible"),
		format(b.homePCRPublic, "https://self-referral.test-for-coronavirus.service.gov.uk/antigen/confirm-eligible"),
		format(b.homeLFDPublic, "https://test-for-coronavirus.service.gov.uk/order-lateral-flow-kits"),
	)
	log.Printf("updating pinned message:\n%v", newMsg)
	return b.editMessageText(ctx, b.targetPinnedMessage, newMsg)
}

func (b *bot) runOnce(ctx context.Context) {
	updated, err := b.updateAvailability(ctx)
	if err != nil {
		log.Printf("updateAvailability: %v", err)
	}
	if updated.Any() {
		if err := b.sendUpdatedAvailability(ctx, updated); err != nil {
			log.Printf("sendUpdatedAvailability: %v", err)
		}
	}
	if err := b.updatePinnedMessage(ctx); err != nil {
		log.Printf("updatePinnedMessage: %v", err)
	}
}

func (b *bot) Run(ctx context.Context) error {
	t := time.NewTicker(b.tickInterval)
	defer t.Stop()

	log.Printf("performing initial run")
	b.runOnce(ctx)
	log.Printf("here we go")

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-t.C:
			log.Print("tick")
			b.runOnce(ctx)
		}
	}
}

var (
	telegramToken   = flag.String("telegram_token", "", "Bot token to use with Telegram Bot API.")
	telegramChannel = flag.String("telegram_channel", "@nhsenglandtestavailability", "Channel to send updates to.")
	telegramPinned  = flag.Int("telegram_pinned_msg_id", 3, "Pinned message to update.")
	tickInterval    = flag.Duration("tick_interval", 1*time.Minute, "How often to run the main check loop.")
)

func main() {
	flag.Parse()

	if *telegramToken == "" {
		log.Fatal("--telegram_token is mandatory")
	}

	nhsClient := &client{
		http: new(http.Client),
	}
	myBot := &bot{
		http:                new(http.Client),
		token:               *telegramToken,
		targetChannel:       *telegramChannel,
		targetPinnedMessage: *telegramPinned,
		tickInterval:        *tickInterval,

		nhsClient: nhsClient,
	}
	if err := myBot.Run(context.Background()); err != nil {
		log.Fatal(err)
	}
}