package tagwriter

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"time"

	"github.com/wailsapp/wails/v3/pkg/application"

	"yellowjacket/backend/database"
	"yellowjacket/backend/events"
)

// errNoChanges is returned when WriteTrackTags is called with an
// empty TagChanges map.
var errNoChanges = errors.New("tagwriter: no changes provided")

// BatchFailure records a single track that failed during a batch write.
type BatchFailure struct {
	FilePath string `json:"filePath"`
	Error    string `json:"error"`
}

// BatchResult summarises the outcome of a batch tag write.
type BatchResult struct {
	Total     int            `json:"total"`
	Succeeded int            `json:"succeeded"`
	Failed    int            `json:"failed"`
	Cancelled bool           `json:"cancelled"`
	Failures  []BatchFailure `json:"failures"`
}

// PlayerStopper checks whether a file is currently playing and
// stops playback if needed.  Defined as an interface to break the
// import cycle between tagwriter and player.
type PlayerStopper interface {
	// CurrentFilePath returns the file path of the currently-
	// loaded track, or empty string if nothing is loaded.
	CurrentFilePath() string
	// StopAndRelease stops playback and releases the file handle.
	StopAndRelease()
}

// PipelineLocker abstracts the library's pipeline mutex for
// scan/write mutual exclusion.
type PipelineLocker interface {
	AcquirePipelineLock()
	ReleasePipelineLock()
}

// TagWriter orchestrates the complete tag writing pipeline:
// file write → DB sync → event emission.
type TagWriter struct {
	logger         *slog.Logger
	db             *database.DB
	ctx            context.Context // Wails context for event emission
	player         PlayerStopper
	library        PipelineLocker
	cancelBatch    chan struct{} // Signals batch cancellation.
	suppressEvents bool          // Suppresses per-track events during batch.
}

// NewTagWriter creates a TagWriter with the given dependencies.
// Call SetContext after the Wails runtime is available.
func NewTagWriter(
	logger *slog.Logger,
	db *database.DB,
	player PlayerStopper,
	library PipelineLocker,
) *TagWriter {
	return &TagWriter{
		logger:  logger.WithGroup("tagwriter"),
		db:      db,
		player:  player,
		library: library,
	}
}

// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down.  It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (tw *TagWriter) ServiceStartup(
	ctx context.Context,
	_ application.ServiceOptions,
) error {
	tw.ctx = ctx

	return nil
}

// WriteTrackTags is the single entry point for writing metadata to
// a track's audio file and synchronising all changes to the
// database.  It accepts a track ID (audio_file.id) and a diff map
// of changed fields.
//
// The pipeline:
//  1. Look up track from DB.
//  2. Detect audio format.
//  3. Acquire pipeline lock (mutual exclusion with scan).
//  4. Stop player if this file is currently playing.
//  5. Write tags to file (format-specific writer).
//  6. Sync database (entity relink, FTS5, orphan cleanup).
//  7. Emit TrackMetadataChanged event.
func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error {
	start := time.Now()
	ctx := context.Background()

	if len(changes) == 0 {
		return errNoChanges
	}

	// 1. Look up track.
	audioFile, err := tw.db.Queries.GetAudioFile(ctx, trackID)
	if err != nil {
		return fmt.Errorf("get audio file %d: %w", trackID, err)
	}

	// 2. Detect format.
	format, err := DetectFormat(audioFile.FilePath)
	if err != nil {
		return fmt.Errorf("detect format: %w", err)
	}

	// 3. Acquire pipeline lock.
	tw.library.AcquirePipelineLock()
	defer tw.library.ReleasePipelineLock()

	// 4. Player safety check.
	if tw.player != nil && tw.player.CurrentFilePath() == audioFile.FilePath {
		tw.logger.Info("stopping playback for tag write",
			"path", audioFile.FilePath)
		tw.player.StopAndRelease()
	}

	// 5. Write file tags.
	switch format {
	case FormatMP3:
		err = writeMp3Tags(tw.logger, audioFile.FilePath, changes)
	case FormatFLAC:
		err = writeFlacTags(tw.logger, audioFile.FilePath, changes)
	case FormatWAV:
		err = writeWavTags(tw.logger, audioFile.FilePath, changes)
	case FormatOGG:
		err = writeOggTags(tw.logger, audioFile.FilePath, changes)
	default:
		err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
	}

	if err != nil {
		return fmt.Errorf("write file tags: %w", err)
	}

	// 6. Sync database.
	if syncErr := syncDatabase(ctx, tw.logger, tw.db, dbSyncParams{
		audioFileID: audioFile.ID,
		filePath:    audioFile.FilePath,
		changes:     changes,
		oldFile:     audioFile,
	}); syncErr != nil {
		tw.logger.Error("db sync failed after successful file write",
			"err", syncErr,
			"trackID", trackID,
			"path", audioFile.FilePath,
		)

		return fmt.Errorf("sync database: %w", syncErr)
	}

	// 7. Emit event (suppressed during batch writes).
	if !tw.suppressEvents {
		events.Emit(tw.ctx, events.TrackMetadataChanged,
			map[string]any{
				"trackId":  trackID,
				"filePath": audioFile.FilePath,
			},
		)
	}

	tw.logger.Info("tag write complete",
		"trackID", trackID,
		"path", audioFile.FilePath,
		"duration", time.Since(start),
		"changedFields", len(changes),
	)

	return nil
}

// WriteUntrackedFileTags writes tags to a file that is not in the
// library, skipping every step of the full pipeline that assumes it is:
// no audio_file lookup, no database sync, no event.
//
// This exists for the download import path, which tags files while they
// are still in the staging directory.  Tagging before the move is what
// makes the import atomic from the library's point of view — the
// scanner only ever sees a finished, correctly tagged file, instead of
// ingesting a mislabelled one and being corrected afterwards.
//
// Callers are responsible for ensuring the file is not in a library
// path; using this on a tracked file would leave the database stale.
func (tw *TagWriter) WriteUntrackedFileTags(
	filePath string,
	changes TagChanges,
) error {
	if len(changes) == 0 {
		return errNoChanges
	}

	return WriteFileTags(tw.logger, filePath, changes)
}

// WriteFileTags writes tags straight to an audio file, with no
// database, player or lock involvement.  It is the format-dispatch
// half of WriteUntrackedFileTags, exported so tooling that has no
// app to construct — the fixture generator in cmd/gentestdata — can
// tag files with the same writers the app uses, rather than growing a
// second tagger that is free to drift from this one.
//
// Callers owning a *TagWriter should use WriteUntrackedFileTags.
func WriteFileTags(
	logger *slog.Logger,
	filePath string,
	changes TagChanges,
) error {
	format, err := DetectFormat(filePath)
	if err != nil {
		return fmt.Errorf("detect format: %w", err)
	}

	switch format {
	case FormatMP3:
		err = writeMp3Tags(logger, filePath, changes)
	case FormatFLAC:
		err = writeFlacTags(logger, filePath, changes)
	case FormatWAV:
		err = writeWavTags(logger, filePath, changes)
	case FormatOGG:
		err = writeOggTags(logger, filePath, changes)
	default:
		err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
	}

	if err != nil {
		return fmt.Errorf("write file tags: %w", err)
	}

	return nil
}

// WriteTrackTagsByPath resolves a file path to its audio_file.id and
// delegates to WriteTrackTags.  This is the frontend-facing entry
// point since the frontend identifies tracks by FilePath.
func (tw *TagWriter) WriteTrackTagsByPath(filePath string, changes TagChanges) error {
	ctx := context.Background()

	audioFile, err := tw.db.Queries.GetAudioFileByPath(ctx, filePath)
	if err != nil {
		return fmt.Errorf("resolve track by path %q: %w", filePath, err)
	}

	return tw.WriteTrackTags(audioFile.ID, changes)
}

// CancelBatchWrite signals the in-progress batch write to stop after
// the current track completes.
func (tw *TagWriter) CancelBatchWrite() {
	ch := tw.cancelBatch
	if ch != nil {
		select {
		case <-ch:
			// Already closed.
		default:
			close(ch)
		}
	}
}

// BatchWriteTrackTags applies the same TagChanges to every file in
// filePaths.  It processes tracks sequentially, emits a
// BatchWriteProgress event after each track, and continues past
// individual failures.  Returns a BatchResult summarising outcomes.
func (tw *TagWriter) BatchWriteTrackTags(
	filePaths []string,
	changes TagChanges,
) BatchResult {
	start := time.Now()
	total := len(filePaths)

	result := BatchResult{
		Total:    total,
		Failures: []BatchFailure{},
	}

	if total == 0 || len(changes) == 0 {
		return result
	}

	// Set up cancellation channel.
	tw.cancelBatch = make(chan struct{})

	defer func() { tw.cancelBatch = nil }()

	// Suppress per-track TrackMetadataChanged events — we emit one
	// at the end instead.
	tw.suppressEvents = true

	defer func() { tw.suppressEvents = false }()

	for i, filePath := range filePaths {
		// Check for cancellation before each track.
		select {
		case <-tw.cancelBatch:
			result.Cancelled = true

			tw.logger.Info("batch write cancelled",
				"at", i,
				"total", total,
			)
		default:
		}

		if result.Cancelled {
			break
		}

		err := tw.WriteTrackTagsByPath(filePath, changes)
		if err != nil {
			result.Failed++
			result.Failures = append(result.Failures, BatchFailure{
				FilePath: filePath,
				Error:    err.Error(),
			})

			tw.logger.Warn("batch track failed",
				"path", filePath,
				"err", err,
				"index", i+1,
				"total", total,
			)
		} else {
			result.Succeeded++
		}

		// Emit progress after each track (success or failure).
		events.Emit(tw.ctx,
			events.BatchWriteProgress,
			map[string]any{
				"current":   i + 1,
				"total":     total,
				"filePath":  filePath,
				"succeeded": result.Succeeded,
				"failed":    result.Failed,
			},
		)
	}

	// Emit a single TrackMetadataChanged after the batch completes
	// so the library store invalidates once rather than per-track.
	events.Emit(tw.ctx, events.TrackMetadataChanged,
		map[string]any{
			"batch": true,
			"total": result.Succeeded,
		},
	)

	tw.logger.Info("batch write complete",
		"total", total,
		"succeeded", result.Succeeded,
		"failed", result.Failed,
		"cancelled", result.Cancelled,
		"duration", time.Since(start),
	)

	return result
}
