package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"
	"time"

	"github.com/flowy-live/llink/internal/utils/flog"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	"github.com/flowy-live/llink/internal/billing"
	"github.com/flowy-live/llink/internal/db"
	"github.com/flowy-live/llink/internal/depot"
	"github.com/flowy-live/llink/internal/human"
	"github.com/flowy-live/llink/internal/human/pushnotify"
	"github.com/flowy-live/llink/internal/network"
	"github.com/flowy-live/llink/internal/particle"
	"github.com/flowy-live/llink/internal/speech"
	"github.com/flowy-live/llink/internal/utils"

	"cloud.google.com/go/firestore"
	"cloud.google.com/go/storage"
)

func createClient(ctx context.Context) *firestore.Client {
	projectId := utils.MustGetEnv("GCP_PROJECT")

	client, err := firestore.NewClient(ctx, projectId)
	if err != nil {
		log.Fatalf("Failed to create client: %v", err)
	}
	return client
}

// Listens for new particles and runs per-particle side effects: transcripts,
// transcode, parent stream's last_child_created_at, freemium usage, and push
// notifications for offline recipients.
func main() {
	ctx := context.Background()

	db.Init()
	defer db.Cleanup()
	processingRepo := particle.NewProcessingRepository(db.Pool())
	billingSvc := billing.NewServiceForWorker(db.Pool())

	storageClient, err := storage.NewClient(ctx)
	if err != nil {
		flog.Error("failed to create GCS client", "error", err)
		os.Exit(1)
	}
	defer storageClient.Close()

	gcsBucket := utils.MustGetEnv("GCS_BUCKET")
	depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
		GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
		BucketName:                gcsBucket,
	})

	speechSvc := speech.NewSpeechService(ctx)

	humanSvc := human.NewService(db.Pool())
	networkReader := network.NewReader(db.Pool())
	pushTokenSvc := pushnotify.NewService(db.Pool())
	// EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo
	// project (otherwise anyone holding one of our push tokens could spam users).
	expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN"))
	notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, expoClient)

	client := createClient(ctx)
	defer client.Close()

	cutoff := time.Now().Add(-5 * time.Minute)
	it := client.CollectionGroup("children").
		Where("created_at", ">", cutoff).
		Snapshots(ctx)

	for {
		snap, err := it.Next()
		if e := status.Code(err); e == codes.DeadlineExceeded || e == codes.Canceled {
			panic(fmt.Errorf("error: %w", err))
		}
		if err != nil {
			flog.Error("error in processing snapshot", "error", err)
			continue
		}

		if snap == nil {
			continue
		}

		for _, change := range snap.Changes {
			if change.Kind != firestore.DocumentAdded {
				continue
			}

			particleID := change.Doc.Ref.ID

			processed, err := processingRepo.IsProcessed(ctx, particleID)
			if err != nil {
				flog.Error("failed to check processing status", "particleID", particleID, "error", err)
				continue
			}
			if processed {
				flog.Debug("skipping already processed particle", "particleID", particleID)
				continue
			}

			flog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())

			// Side effects below are best-effort — failures don't prevent
			// marking the particle as processed.
			parentDoc := loadParentParticle(ctx, change.Doc)
			updateParentLastChildCreatedAt(ctx, change.Doc, parentDoc)
			transcript := transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
			particle.Transcode(ctx, depotSvc, change.Doc)
			recordFreemiumUsage(ctx, billingSvc, change.Doc)
			notifyForParticle(ctx, notifier, humanSvc, change.Doc, parentDoc, transcript)

			if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
				flog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
			}
		}
	}
}

// Writes the structured transcript to Firestore and returns the raw text;
// returns "" for non-media particles or on any error (logged internally).
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) string {
	var mediaParticle particle.FirestoreMediaParticle
	err := doc.DataTo(&mediaParticle)
	if err != nil {
		flog.Error("unable to marshal particle data", "error", err)
		return ""
	}

	particleType, err := particle.ParseParticleType(mediaParticle.Type)
	if err != nil {
		flog.Error("invalid particle type", "error", err)
		return ""
	}

	if particleType != particle.TypeMedia {
		flog.Info("received a particle of type", "particle type", particleType)
		return ""
	}

	downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
	if err != nil {
		flog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
		return ""
	}

	result, err := speechSvc.Transcribe(ctx, downloadURL)
	if err != nil {
		flog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
		return ""
	}

	transcript := toFirestoreTranscript(result)

	_, err = doc.Ref.Set(ctx, map[string]interface{}{
		"properties": map[string]interface{}{
			"transcript": transcript,
		},
	}, firestore.MergeAll)
	if err != nil {
		flog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID)
		return ""
	}

	flog.Info("transcribed media particle", "particleID", doc.Ref.ID)
	return transcript.Transcript
}

func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
	words := make([]particle.FirestoreTranscriptWord, len(result.Words))
	for i, w := range result.Words {
		words[i] = particle.FirestoreTranscriptWord{
			Word:  w.Word,
			Start: w.Start,
			End:   w.End,
		}
	}

	paragraphs := make([]particle.FirestoreTranscriptParagraph, len(result.Paragraphs))
	for i, p := range result.Paragraphs {
		sentences := make([]particle.FirestoreTranscriptSentence, len(p.Sentences))
		for j, s := range p.Sentences {
			sentences[j] = particle.FirestoreTranscriptSentence{
				Text:  s.Text,
				Start: s.Start,
				End:   s.End,
			}
		}
		paragraphs[i] = particle.FirestoreTranscriptParagraph{
			Sentences: sentences,
			Start:     p.Start,
			End:       p.End,
		}
	}

	return particle.FirestoreTranscript{
		Transcript: result.Transcript,
		Words:      words,
		Paragraphs: paragraphs,
	}
}

// Bumps the network's daily message counter for non-container particles.
// The surrounding processed_particles guard keeps this idempotent across
// crashes/restarts.
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
	rawType, err := doc.DataAt("type")
	if err != nil {
		flog.Error("failed to read particle type", "error", err, "particleID", doc.Ref.ID)
		return
	}
	typeStr, ok := rawType.(string)
	if !ok {
		flog.Error("particle type is not a string", "particleID", doc.Ref.ID, "type", rawType)
		return
	}
	particleType, err := particle.ParseParticleType(typeStr)
	if err != nil {
		flog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
		return
	}
	// Containers don't count toward the daily message cap.
	if particleType == particle.TypeStream || particleType == particle.TypeFolder {
		return
	}

	networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
	if err != nil {
		flog.Error("failed to derive network id", "error", err, "path", doc.Ref.Path)
		return
	}

	if err := billingSvc.IncrementDailyUsage(ctx, networkID, doc.CreateTime); err != nil {
		flog.Error("failed to increment daily usage", "error", err, "networkID", networkID, "particleID", doc.Ref.ID)
	}
}

// Returns nil (and logs) if the path has no parent or the read fails.
func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot {
	parentChildrenCollectionRef := doc.Ref.Parent
	if parentChildrenCollectionRef == nil {
		return nil
	}
	parentParticleDocRef := parentChildrenCollectionRef.Parent
	if parentParticleDocRef == nil {
		flog.Error("particle has no parent document", "particleID", doc.Ref.ID)
		return nil
	}
	parentParticleDoc, err := parentParticleDocRef.Get(ctx)
	if err != nil {
		flog.Error("failed to get parent particle", "error", err, "particleID", doc.Ref.ID)
		return nil
	}
	return parentParticleDoc
}

// Sets last_child_created_at to the child's created_at so it stays directly
// comparable with playback markers (which also store child created_at values).
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot, parent *firestore.DocumentSnapshot) {
	if parent == nil {
		return
	}

	var streamParticle particle.FirestoreStreamParticle
	if err := parent.DataTo(&streamParticle); err != nil {
		flog.Error("failed to parse stream particle", "error", err)
		return
	}

	particleType, err := particle.ParseParticleType(streamParticle.Type)
	if err != nil {
		flog.Error("invalid particle type", "error", err)
		return
	}

	if particleType != particle.TypeStream {
		return
	}

	childCreatedAt, err := doc.DataAt("created_at")
	if err != nil {
		flog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID)
		return
	}

	_, err = parent.Ref.Update(ctx, []firestore.Update{
		{
			Path:  "last_child_created_at",
			Value: childCreatedAt,
		},
	})
	if err != nil {
		flog.Error("unable to update parent particle `last_child_created_at`", "error", err)
	}
}

// Skips containers and particles whose parent isn't a stream — notifications
// are scoped to stream messages today. The transcript arg becomes the preview
// body for media particles when available.
func notifyForParticle(
	ctx context.Context,
	notifier *pushnotify.Notifier,
	humanSvc human.Service,
	doc *firestore.DocumentSnapshot,
	parent *firestore.DocumentSnapshot,
	transcript string,
) {
	if parent == nil {
		flog.Info("notify: skip — no parent", "particleID", doc.Ref.ID)
		return
	}

	typeStr, _ := doc.DataAt("type")
	typeName, _ := typeStr.(string)
	pType, err := particle.ParseParticleType(typeName)
	if err != nil {
		flog.Info("notify: skip — unparseable particle type",
			"particleID", doc.Ref.ID, "type", typeName, "error", err)
		return
	}
	if pType == particle.TypeStream || pType == particle.TypeFolder {
		flog.Info("notify: skip — container particle",
			"particleID", doc.Ref.ID, "type", pType)
		return
	}

	var parentStream particle.FirestoreStreamParticle
	if err := parent.DataTo(&parentStream); err != nil {
		flog.Error("notify: failed to parse parent stream", "error", err)
		return
	}
	parentType, err := particle.ParseParticleType(parentStream.Type)
	if err != nil || parentType != particle.TypeStream {
		flog.Info("notify: skip — parent isn't a stream",
			"particleID", doc.Ref.ID, "parentType", parentType, "parseErr", err)
		return
	}

	networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
	if err != nil {
		flog.Error("notify: failed to derive network id", "error", err, "path", doc.Ref.Path)
		return
	}

	senderHumanID := parentStream.CreatedByHumanId
	if v, err := doc.DataAt("created_by_human_id"); err == nil {
		if s, ok := v.(string); ok && s != "" {
			senderHumanID = s
		}
	}

	streamName := ""
	if v, err := parent.DataAt("properties.name"); err == nil {
		if s, ok := v.(string); ok {
			streamName = s
		}
	}

	senderEmailPrefix := ""
	if senderHumanID != "" {
		if sender, err := humanSvc.GetByID(ctx, senderHumanID); err == nil {
			senderEmailPrefix = sender.EmailPrefix
		} else {
			flog.Warn("notify: failed to look up sender", "error", err, "humanID", senderHumanID)
		}
	}

	if err := notifier.NotifyParticleCreated(ctx, pushnotify.NotifyInput{
		NetworkID:         networkID,
		SenderHumanID:     senderHumanID,
		SenderEmailPrefix: senderEmailPrefix,
		ParticleID:        doc.Ref.ID,
		ParticleKind:      string(pType),
		StreamID:          parent.Ref.ID,
		StreamName:        streamName,
		StreamVisibleTo:   parentStream.VisibleTo,
		Body:              previewForParticle(pType, doc, transcript),
	}); err != nil {
		flog.Error("notify: dispatch failed", "error", err, "particleID", doc.Ref.ID, "networkID", networkID)
	}
}

// Builds the notification body. Kept short — lockscreens truncate aggressively.
// Media prefers transcript text and falls back to a generic "Sent a …" line.
func previewForParticle(pType particle.ParticleType, doc *firestore.DocumentSnapshot, transcript string) string {
	switch pType {
	case particle.TypeText:
		if v, err := doc.DataAt("properties.content"); err == nil {
			if s, ok := v.(string); ok {
				return truncatePreview(s, 140)
			}
		}
		return "Sent a message"
	case particle.TypeMedia:
		if t := strings.TrimSpace(transcript); t != "" {
			return truncatePreview(t, 140)
		}
		mime := ""
		if v, err := doc.DataAt("properties.mime_type"); err == nil {
			if s, ok := v.(string); ok {
				mime = s
			}
		}
		if strings.HasPrefix(mime, "video/") {
			return "Sent a video"
		}
		return "Sent a voice message"
	case particle.TypeFile:
		return "Sent a file"
	case particle.TypeQuest:
		if v, err := doc.DataAt("properties.title"); err == nil {
			if s, ok := v.(string); ok && s != "" {
				return "Quest: " + truncatePreview(s, 120)
			}
		}
		return "Added a quest"
	case particle.TypePaper:
		if v, err := doc.DataAt("properties.title"); err == nil {
			if s, ok := v.(string); ok && s != "" {
				return "Paper: " + truncatePreview(s, 120)
			}
		}
		return "Added a paper"
	default:
		return "New activity"
	}
}

func truncatePreview(s string, n int) string {
	s = strings.TrimSpace(s)
	if len(s) <= n {
		return s
	}
	return s[:n] + "…"
}
