// Package lidarrrequests owns the lifecycle of user requests to add
// music via Lidarr. The synchronous Service handles Create/List/
// Approve/Reject/Cancel; the async Reconciler (separate file) closes
// approved requests once their target track lands in the library.
package lidarrrequests

import (
	"context"
	"errors"
	"fmt"

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

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

// Public errors. Handlers map these to API error codes.
var (
	ErrInvalidKindFields = errors.New("lidarrrequests: missing required fields for kind")
	ErrNotPending        = errors.New("lidarrrequests: request is not pending")
	// ErrNotFound aliases apierror.ErrNotFound so handlers can errors.Is
	// against the shared sentinel; existing lidarrrequests.ErrNotFound
	// callsites still resolve to the same pointer.
	ErrNotFound       = apierror.ErrNotFound
	ErrLidarrDisabled = errors.New("lidarrrequests: lidarr not configured")
	// ErrDefaultsIncomplete fires when Lidarr is enabled but the operator
	// hasn't picked a default quality profile or root folder. Without those,
	// Lidarr's add-artist/add-album endpoints reject the payload with a
	// generic 5xx — surfacing a typed error here lets the admin UI tell the
	// operator exactly what to fix.
	ErrDefaultsIncomplete = errors.New("lidarrrequests: default quality profile or root folder not configured")
)

// CreateParams is the input for a new request from a user.
type CreateParams struct {
	Kind             string // "artist", "album", or "track"
	LidarrArtistMBID string
	LidarrAlbumMBID  string // required for kind=album/track
	LidarrTrackMBID  string // required for kind=track
	ArtistName       string
	AlbumTitle       string // required for kind=album/track
	TrackTitle       string // required for kind=track
}

// ApproveOverrides lets the admin override the snapshot defaults for one
// approval. Zero values mean "use config default."
type ApproveOverrides struct {
	QualityProfileID int
	RootFolderPath   string
}

// Service owns the request lifecycle. clientFn is a factory called per
// Approve so that BaseURL/APIKey changes in lidarrconfig take effect
// immediately without restarting. scanFn is called after a successful
// Approve to trigger a library scan.
type Service struct {
	pool      *pgxpool.Pool
	lidarrCfg *lidarrconfig.Service
	clientFn  func() *lidarr.Client // factory, called per Approve
	scanFn    func()
}

// NewService constructs a Service. Pass nil for clientFn to disable Lidarr
// (Approve returns ErrLidarrDisabled). Pass nil for scanFn to no-op.
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, scanFn func()) *Service {
	if scanFn == nil {
		scanFn = func() {}
	}
	if clientFn == nil {
		clientFn = func() *lidarr.Client { return nil }
	}
	return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, scanFn: scanFn}
}

// Create validates the kind→required-fields invariant and inserts a
// pending row.
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.LidarrRequest, error) {
	if err := validateKindFields(p); err != nil {
		return dbq.LidarrRequest{}, err
	}
	q := dbq.New(s.pool)
	// Dedup: if a non-terminal request for this MBID already exists,
	// return it instead of inserting a duplicate. The Discover/search UI
	// already hides requested candidates; this backstops races and direct
	// API callers so we don't accrue duplicate request rows. Match on the
	// new request's own-kind MBID (mirrors HasNonTerminalRequestForMBID).
	dedupMBID := p.LidarrArtistMBID
	switch p.Kind {
	case "album":
		dedupMBID = p.LidarrAlbumMBID
	case "track":
		dedupMBID = p.LidarrTrackMBID
	}
	if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
		return existing, nil
	} else if !errors.Is(derr, pgx.ErrNoRows) {
		return dbq.LidarrRequest{}, fmt.Errorf("create: dedup check: %w", derr)
	}
	row, err := q.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
		UserID:           userID,
		Kind:             dbq.LidarrRequestKind(p.Kind),
		LidarrArtistMbid: p.LidarrArtistMBID,
		LidarrAlbumMbid:  strPtr(p.LidarrAlbumMBID),
		LidarrTrackMbid:  strPtr(p.LidarrTrackMBID),
		ArtistName:       p.ArtistName,
		AlbumTitle:       strPtr(p.AlbumTitle),
		TrackTitle:       strPtr(p.TrackTitle),
	})
	if err != nil {
		return dbq.LidarrRequest{}, fmt.Errorf("create: %w", err)
	}
	return row, nil
}

func validateKindFields(p CreateParams) error {
	if p.LidarrArtistMBID == "" || p.ArtistName == "" {
		return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields)
	}
	switch p.Kind {
	case "artist":
		// fine
	case "album":
		if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" {
			return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields)
		}
	case "track":
		if p.LidarrAlbumMBID == "" || p.AlbumTitle == "" {
			return fmt.Errorf("%w: track kind requires album_mbid and album_title (track will be promoted)", ErrInvalidKindFields)
		}
		if p.LidarrTrackMBID == "" || p.TrackTitle == "" {
			return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields)
		}
	default:
		return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind)
	}
	return nil
}

// ListPending returns pending requests ordered by requested_at DESC.
func (s *Service) ListPending(ctx context.Context, limit int32) ([]dbq.LidarrRequest, error) {
	return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{
		Status: dbq.LidarrRequestStatusPending, Limit: limit,
	})
}

// ListByStatus returns requests with the given status, ordered by requested_at DESC.
func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.LidarrRequest, error) {
	return dbq.New(s.pool).ListLidarrRequestsByStatus(ctx, dbq.ListLidarrRequestsByStatusParams{
		Status: dbq.LidarrRequestStatus(status), Limit: limit,
	})
}

// ListForUser returns all requests by a user, ordered by requested_at DESC.
func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.LidarrRequest, error) {
	return dbq.New(s.pool).ListLidarrRequestsForUser(ctx, dbq.ListLidarrRequestsForUserParams{
		UserID: userID, Limit: limit,
	})
}

// Approve records the admin's approval DURABLY first (snapshotting the
// chosen quality profile + root folder), then makes a best-effort Lidarr
// add and triggers a library scan. The add is no longer a gate: if Lidarr
// is unreachable the request still becomes approved and the reconciler
// idempotently (re)sends the add on each tick until it sticks (see
// sendLidarrAdd + Reconciler.ensureLidarrAdd) — "Lidarr just keeps
// trying" without the admin re-clicking. Pre-conditions (lidarr disabled,
// not pending, defaults incomplete) still fail fast before any approval.
func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, ov ApproveOverrides) (dbq.LidarrRequest, error) {
	cfg, err := s.lidarrCfg.Get(ctx)
	if err != nil {
		return dbq.LidarrRequest{}, fmt.Errorf("approve: load config: %w", err)
	}
	client := s.clientFn()
	if !cfg.Enabled || client == nil {
		return dbq.LidarrRequest{}, ErrLidarrDisabled
	}
	row, err := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return dbq.LidarrRequest{}, ErrNotFound
		}
		return dbq.LidarrRequest{}, fmt.Errorf("approve: get: %w", err)
	}
	if row.Status != dbq.LidarrRequestStatusPending {
		return dbq.LidarrRequest{}, ErrNotPending
	}

	qp := cfg.DefaultQualityProfileID
	if ov.QualityProfileID != 0 {
		qp = ov.QualityProfileID
	}
	rf := cfg.DefaultRootFolderPath
	if ov.RootFolderPath != "" {
		rf = ov.RootFolderPath
	}
	if qp == 0 || rf == "" {
		return dbq.LidarrRequest{}, ErrDefaultsIncomplete
	}

	// Record the approval durably FIRST so a Lidarr outage can't lose the
	// admin's decision. The snapshotted qp/rf also feed the reconciler's
	// retry, so a deferred add uses exactly what the admin approved with.
	approved, err := dbq.New(s.pool).ApproveLidarrRequest(ctx, dbq.ApproveLidarrRequestParams{
		ID:               requestID,
		QualityProfileID: int32Ptr(qp),
		RootFolderPath:   strPtr(rf),
		DecidedBy:        adminID,
	})
	if err != nil {
		return dbq.LidarrRequest{}, fmt.Errorf("approve: persist: %w", err)
	}

	// Best-effort immediate add for the common (Lidarr-up) path. Any
	// failure is deliberately swallowed: the request is already durably
	// approved and the reconciler retries the add on its next tick,
	// leaving lidarr_add_confirmed_at NULL until it succeeds.
	if addErr := sendLidarrAdd(ctx, client, cfg, approved); addErr == nil {
		_ = dbq.New(s.pool).MarkLidarrRequestAddConfirmed(ctx, requestID)
	}
	s.scanFn()
	return approved, nil
}

// sendLidarrAdd performs the idempotent Lidarr add for an approved
// request. Used by Service.Approve (best-effort, common path) and by the
// Reconciler (retry path). qp/rf come from the request's snapshot (set at
// Approve time) with a config-default fallback; the metadata profile is
// resolved from config, falling back to Lidarr's first profile for
// operators who upgraded from before metadata-profile support. Treats
// lidarr.ErrAlreadyExists as success — the artist/album is already in
// Lidarr, which is exactly the desired end state.
func sendLidarrAdd(ctx context.Context, client *lidarr.Client, cfg lidarrconfig.Config, row dbq.LidarrRequest) error {
	qp := cfg.DefaultQualityProfileID
	if row.QualityProfileID != nil && *row.QualityProfileID != 0 {
		qp = int(*row.QualityProfileID)
	}
	rf := cfg.DefaultRootFolderPath
	if row.RootFolderPath != nil && *row.RootFolderPath != "" {
		rf = *row.RootFolderPath
	}
	if qp == 0 || rf == "" {
		return ErrDefaultsIncomplete
	}
	mdProfileID := cfg.DefaultMetadataProfileID
	if mdProfileID == 0 {
		mdProfiles, mdErr := client.ListMetadataProfiles(ctx)
		if mdErr != nil {
			return fmt.Errorf("list metadata profiles: %w", mdErr)
		}
		if len(mdProfiles) == 0 {
			return fmt.Errorf("no metadata profiles configured in lidarr")
		}
		mdProfileID = mdProfiles[0].ID
	}
	var err error
	switch row.Kind {
	case dbq.LidarrRequestKindArtist:
		err = client.AddArtist(ctx, lidarr.AddArtistParams{
			ForeignArtistID:   row.LidarrArtistMbid,
			ArtistName:        row.ArtistName,
			QualityProfileID:  qp,
			MetadataProfileID: mdProfileID,
			RootFolderPath:    rf,
			MonitorAll:        true,
		})
	case dbq.LidarrRequestKindAlbum, dbq.LidarrRequestKindTrack:
		// Track-kind requests promote to album-add; the spec is explicit.
		albumMBID := ""
		if row.LidarrAlbumMbid != nil {
			albumMBID = *row.LidarrAlbumMbid
		}
		err = client.AddAlbum(ctx, lidarr.AddAlbumParams{
			ForeignAlbumID:    albumMBID,
			ForeignArtistID:   row.LidarrArtistMbid,
			ArtistName:        row.ArtistName,
			QualityProfileID:  qp,
			MetadataProfileID: mdProfileID,
			RootFolderPath:    rf,
		})
	}
	if err != nil && !errors.Is(err, lidarr.ErrAlreadyExists) {
		return err
	}
	return nil
}

// Reject transitions a pending request to rejected, recording notes and
// who decided. Returns ErrNotPending if the request is not pending,
// ErrNotFound if no such request exists.
func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.LidarrRequest, error) {
	row, err := dbq.New(s.pool).RejectLidarrRequest(ctx, dbq.RejectLidarrRequestParams{
		ID: requestID, Notes: strPtr(notes), DecidedBy: adminID,
	})
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			// Either not found OR not pending — check which.
			cur, gerr := dbq.New(s.pool).GetLidarrRequestByID(ctx, requestID)
			if gerr != nil {
				return dbq.LidarrRequest{}, ErrNotFound
			}
			if cur.Status != dbq.LidarrRequestStatusPending {
				return dbq.LidarrRequest{}, ErrNotPending
			}
			return dbq.LidarrRequest{}, ErrNotFound
		}
		return dbq.LidarrRequest{}, fmt.Errorf("reject: %w", err)
	}
	return row, nil
}

// Cancel lets a user withdraw their own pending request. The SQL WHERE
// clause enforces both ownership (user_id = $2) and pending status
// (status = 'pending'). A zero-rows result always means ErrNotPending
// from the caller's perspective.
func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.LidarrRequest, error) {
	row, err := dbq.New(s.pool).CancelLidarrRequest(ctx, dbq.CancelLidarrRequestParams{
		ID: requestID, DecidedBy: userID,
	})
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return dbq.LidarrRequest{}, ErrNotPending
		}
		return dbq.LidarrRequest{}, fmt.Errorf("cancel: %w", err)
	}
	return row, nil
}

func strPtr(s string) *string {
	if s == "" {
		return nil
	}
	return &s
}

func int32Ptr(i int) *int32 {
	if i == 0 {
		return nil
	}
	v := int32(i)
	return &v
}
