// Package lidarrquarantine owns the per-user track quarantine workflow.
// Users flag a track as broken (Flag/Unflag), the SPA hides the track
// from their views, and admins resolve the resulting reports via the
// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr).
package lidarrquarantine

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"git.fabledsword.com/bvandeusen/minstrel/internal/library"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
	"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)

// Public errors. Handlers map these to API codes.
var (
	ErrBadReason           = errors.New("lidarrquarantine: invalid reason")
	ErrTrackNotFound       = errors.New("lidarrquarantine: track not found")
	ErrQuarantineNotFound  = errors.New("lidarrquarantine: quarantine row not found")
	ErrAlbumMBIDMissing    = errors.New("lidarrquarantine: track has no parent album mbid")
	ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid")
	ErrLidarrDisabled      = errors.New("lidarrquarantine: lidarr is not configured")
)

// Service owns the request lifecycle. clientFn is a per-call factory so
// config changes in lidarrconfig take effect immediately. clientFn returns
// nil when Lidarr is disabled.
type Service struct {
	pool      *pgxpool.Pool
	lidarrCfg *lidarrconfig.Service
	clientFn  func() *lidarr.Client
}

// NewService constructs a Service. Pass nil for clientFn to disable the
// Lidarr-using methods (DeleteViaLidarr will return ErrLidarrDisabled).
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client) *Service {
	if clientFn == nil {
		clientFn = func() *lidarr.Client { return nil }
	}
	return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn}
}

// Flag inserts or updates a quarantine row for the caller. Re-flagging
// the same (user, track) overwrites reason+notes (and bumps created_at).
func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) {
	if !validReason(reason) {
		return dbq.LidarrQuarantine{}, ErrBadReason
	}
	var notesPtr *string
	if notes != "" {
		notesPtr = &notes
	}
	row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{
		UserID:  userID,
		TrackID: trackID,
		Reason:  dbq.LidarrQuarantineReason(reason),
		Notes:   notesPtr,
	})
	if err != nil {
		// FK violation on track_id (no such track) → ErrTrackNotFound so the
		// handler can return 404 instead of a generic 500. Code 23503 is the
		// Postgres SQLSTATE for foreign_key_violation; the constraint check
		// guards against future FKs (e.g. user_id) firing the same code.
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23503" &&
			(pgErr.ConstraintName == "" || strings.Contains(pgErr.ConstraintName, "track")) {
			return dbq.LidarrQuarantine{}, ErrTrackNotFound
		}
		return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err)
	}
	return row, nil
}

// Unflag removes the caller's row. Returns ErrQuarantineNotFound if no
// row exists for that (user, track).
func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error {
	_, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{
		UserID: userID, TrackID: trackID,
	})
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return ErrQuarantineNotFound
		}
		return fmt.Errorf("delete: %w", err)
	}
	return nil
}

// ListMine returns the caller's quarantines with track + album + artist
// detail. Drives /library/hidden. Ordered newest-first.
func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) {
	return dbq.New(s.pool).ListQuarantineForUser(ctx, userID)
}

// AdminQueueRow is the assembled aggregated row served by the admin
// queue endpoint. The handler post-processes the SQL result + a per-track
// ListQuarantineReports call to materialize reason_counts and the
// per-user reports list.
type AdminQueueRow struct {
	TrackID         pgtype.UUID
	TrackTitle      string
	ArtistName      string
	AlbumTitle      string
	AlbumID         pgtype.UUID
	LidarrAlbumMBID *string
	ReportCount     int32
	LatestAt        pgtype.Timestamptz
	ReasonCounts    map[string]int
	Reports         []UserReport
}

// UserReport is the per-user detail under an aggregated admin row.
type UserReport struct {
	UserID    pgtype.UUID
	Username  string
	Reason    string
	Notes     *string
	CreatedAt pgtype.Timestamptz
}

// ListAdminQueue returns the aggregated admin queue. One row per track.
// Performs an N+1 (one ListQuarantineReportsForTrack per aggregated row)
// because materializing reason_counts in pure SQL is awkward; the queue
// is small in practice (≲100 tracks at household scale). Revisit with
// json_object_agg if the queue grows.
func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) {
	q := dbq.New(s.pool)
	aggregated, err := q.ListAdminQuarantineQueue(ctx)
	if err != nil {
		return nil, fmt.Errorf("aggregate: %w", err)
	}
	out := make([]AdminQueueRow, 0, len(aggregated))
	for _, r := range aggregated {
		reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID)
		if err != nil {
			return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err)
		}
		rc := make(map[string]int, len(reports))
		userReports := make([]UserReport, 0, len(reports))
		for _, rep := range reports {
			rc[string(rep.Reason)]++
			userReports = append(userReports, UserReport{
				UserID:    rep.UserID,
				Username:  rep.Username,
				Reason:    string(rep.Reason),
				Notes:     rep.Notes,
				CreatedAt: rep.CreatedAt,
			})
		}
		out = append(out, AdminQueueRow{
			TrackID:         r.TrackID,
			TrackTitle:      r.TrackTitle,
			ArtistName:      r.ArtistName,
			AlbumTitle:      r.AlbumTitle,
			AlbumID:         r.AlbumID,
			LidarrAlbumMBID: r.LidarrAlbumMbid,
			ReportCount:     r.ReportCount,
			LatestAt:        r.LatestAt,
			ReasonCounts:    rc,
			Reports:         userReports,
		})
	}
	return out, nil
}

func validReason(r string) bool {
	switch r {
	case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other":
		return true
	}
	return false
}

// Resolve clears all per-user quarantine rows for a track and writes an
// audit log row. Idempotent — a track with no rows still writes an audit
// entry with affected_users=0 (so admin can see "I clicked resolve on a
// track that already had no reports").
func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
	q := dbq.New(s.pool)
	track, err := q.GetTrackByID(ctx, trackID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return dbq.LidarrQuarantineAction{}, ErrTrackNotFound
		}
		return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err)
	}
	snap, err := s.snapshot(ctx, q, track)
	if err != nil {
		return dbq.LidarrQuarantineAction{}, err
	}

	affected, err := q.CountQuarantineForTrack(ctx, trackID)
	if err != nil {
		return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
	}
	if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil {
		return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err)
	}
	// LidarrAlbumMbid stays nil here: Lidarr was not contacted. The audit
	// log uses the field as a "this row triggered Lidarr" marker, so only
	// DeleteViaLidarr sets it.
	return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
		TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
		AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindResolved,
		AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected,
	})
}

// quarantineSnapshot is the audit-row-ready snapshot of track + album +
// artist text columns. Used by the three admin actions.
type quarantineSnapshot struct {
	TrackTitle      string
	ArtistName      string
	AlbumTitle      *string
	LidarrAlbumMBID *string
}

// snapshot pulls the audit-row text columns from track / album / artist.
// AlbumTitle is materialized as *string because WriteQuarantineActionParams
// expects nullable; we coerce the schema's NOT NULL value into a pointer.
func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) {
	album, err := q.GetAlbumByID(ctx, track.AlbumID)
	if err != nil {
		return quarantineSnapshot{}, fmt.Errorf("get album: %w", err)
	}
	artist, err := q.GetArtistByID(ctx, track.ArtistID)
	if err != nil {
		return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err)
	}
	titlePtr := album.Title
	return quarantineSnapshot{
		TrackTitle:      track.Title,
		ArtistName:      artist.Name,
		AlbumTitle:      &titlePtr,
		LidarrAlbumMBID: album.Mbid,
	}, nil
}

// DeleteFile removes the track file from disk and the tracks row, then
// (via FK ON DELETE CASCADE) clears all per-user quarantine rows for that
// track and writes an audit row. If the file deletion fails, the per-user
// rows stay so admin can retry. No partial state.
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
	q := dbq.New(s.pool)
	track, err := q.GetTrackByID(ctx, trackID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return dbq.LidarrQuarantineAction{}, ErrTrackNotFound
		}
		return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err)
	}
	snap, err := s.snapshot(ctx, q, track)
	if err != nil {
		return dbq.LidarrQuarantineAction{}, err
	}

	affected, err := q.CountQuarantineForTrack(ctx, trackID)
	if err != nil {
		return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
	}
	if err := library.DeleteTrackFile(ctx, s.pool, trackID); err != nil {
		return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err)
	}
	// tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id
	// already cleared the per-user rows. We don't call
	// DeleteQuarantineForTrack separately. LidarrAlbumMbid stays nil —
	// Lidarr was not contacted.
	return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
		TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
		AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedFile,
		AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected,
	})
}

// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove
// the parent album with deleteFiles=true + addImportListExclusion=true,
// then removes Minstrel rows for all tracks of that album. The cascade
// on lidarr_quarantine.track_id clears per-user rows automatically.
//
// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing
// changes locally. Admin retries.
//
// Returns the audit row + the count of tracks deleted from Minstrel.
func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) {
	cfg, err := s.lidarrCfg.Get(ctx)
	if err != nil {
		return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err)
	}
	client := s.clientFn()
	if !cfg.Enabled || client == nil {
		return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled
	}

	q := dbq.New(s.pool)
	track, err := q.GetTrackByID(ctx, trackID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound
		}
		return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err)
	}
	snap, err := s.snapshot(ctx, q, track)
	if err != nil {
		return dbq.LidarrQuarantineAction{}, 0, err
	}
	if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" {
		return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing
	}

	affected, err := q.CountQuarantineForTrack(ctx, trackID)
	if err != nil {
		return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err)
	}

	// Lidarr lookup: MBID -> internal album ID.
	album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID)
	if err != nil {
		if errors.Is(err, lidarr.ErrNotFound) {
			return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound
		}
		return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err)
	}

	// Lidarr DELETE — both flags true.
	if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil {
		return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err)
	}

	// Now remove the local rows. CASCADE on lidarr_quarantine.track_id
	// handles per-user rows.
	res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID)
	if err != nil {
		// Lidarr is already done; we couldn't update locally. Operator-
		// recoverable: re-running picks up where we left off.
		return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err)
	}
	deletedCount := int(res.RowsAffected())

	action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
		TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
		AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedViaLidarr,
		AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected,
	})
	return action, deletedCount, err
}
