package services

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"

	"mgit.msbls.de/m/paliad/internal/models"
)

// NoteService reads and writes paliad.notes — polymorphic notes anchored
// to exactly one of { Project, Deadline, Appointment, ProjectEvent }. Visibility
// follows the parent row.
//
// Edit: only the author (created_by) may edit their own note.
// Delete: author, or partner/admin.
type NoteService struct {
	db          *sqlx.DB
	projects    *ProjectService
	appointment *AppointmentService
}

func NewNoteService(db *sqlx.DB, projects *ProjectService, appointment *AppointmentService) *NoteService {
	return &NoteService{db: db, projects: projects, appointment: appointment}
}

const noteColumns = `n.id, n.project_id, n.deadline_id, n.appointment_id, n.project_event_id,
	n.content, n.created_by, n.created_at, n.updated_at,
	u.display_name AS author_name,
	u.email        AS author_email`

const noteSelect = `SELECT ` + noteColumns + `
	FROM paliad.notes n
	LEFT JOIN paliad.users u ON u.id = n.created_by`

// CreateNoteInput is the POST payload.
type CreateNoteInput struct {
	Content string `json:"content"`
}

// UpdateNoteInput is the PATCH payload.
type UpdateNoteInput struct {
	Content *string `json:"content,omitempty"`
}

// ListForProject returns all notes attached directly to the given Project.
func (s *NoteService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Note, error) {
	if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
		return nil, err
	}
	return s.list(ctx, `n.project_id = $1`, projectID)
}

// ListForDeadline returns all notes attached to a specific Deadline.
func (s *NoteService) ListForDeadline(ctx context.Context, userID, deadlineID uuid.UUID) ([]models.Note, error) {
	projectID, err := s.deadlineProjectID(ctx, deadlineID)
	if err != nil {
		return nil, err
	}
	if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
		return nil, err
	}
	return s.list(ctx, `n.deadline_id = $1`, deadlineID)
}

// ListForAppointment returns all notes attached to a specific Appointment.
func (s *NoteService) ListForAppointment(ctx context.Context, userID, appointmentID uuid.UUID) ([]models.Note, error) {
	if err := s.requireAppointmentVisible(ctx, userID, appointmentID); err != nil {
		return nil, err
	}
	return s.list(ctx, `n.appointment_id = $1`, appointmentID)
}

// ListForProjectEvent returns all notes attached to a specific project_event row.
func (s *NoteService) ListForProjectEvent(ctx context.Context, userID, eventID uuid.UUID) ([]models.Note, error) {
	projectID, err := s.eventProjectID(ctx, eventID)
	if err != nil {
		return nil, err
	}
	if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
		return nil, err
	}
	return s.list(ctx, `n.project_event_id = $1`, eventID)
}

// CreateForProject inserts a note attached directly to a Project.
func (s *NoteService) CreateForProject(ctx context.Context, userID, projectID uuid.UUID, input CreateNoteInput) (*models.Note, error) {
	if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
		return nil, err
	}
	content, err := validateContent(input.Content)
	if err != nil {
		return nil, err
	}
	id, err := s.insertWithAudit(ctx, userID, content, noteParent{ProjectID: &projectID}, &projectID, "project")
	if err != nil {
		return nil, err
	}
	return s.getByIDUnchecked(ctx, id)
}

// CreateForDeadline inserts a note attached to a Deadline.
func (s *NoteService) CreateForDeadline(ctx context.Context, userID, deadlineID uuid.UUID, input CreateNoteInput) (*models.Note, error) {
	projectID, err := s.deadlineProjectID(ctx, deadlineID)
	if err != nil {
		return nil, err
	}
	if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
		return nil, err
	}
	content, err := validateContent(input.Content)
	if err != nil {
		return nil, err
	}
	id, err := s.insertWithAudit(ctx, userID, content, noteParent{DeadlineID: &deadlineID}, &projectID, "deadline")
	if err != nil {
		return nil, err
	}
	return s.getByIDUnchecked(ctx, id)
}

// CreateForAppointment inserts a note attached to an Appointment. Personal
// Appointment notes skip the audit trail; Project-attached Appointment notes
// append events.
func (s *NoteService) CreateForAppointment(ctx context.Context, userID, appointmentID uuid.UUID, input CreateNoteInput) (*models.Note, error) {
	t, err := s.appointment.GetByID(ctx, userID, appointmentID)
	if err != nil {
		return nil, err
	}
	content, err := validateContent(input.Content)
	if err != nil {
		return nil, err
	}
	id, err := s.insertWithAudit(ctx, userID, content, noteParent{AppointmentID: &appointmentID}, t.ProjectID, "appointment")
	if err != nil {
		return nil, err
	}
	return s.getByIDUnchecked(ctx, id)
}

// GetByID returns a single note, visibility-checked.
func (s *NoteService) GetByID(ctx context.Context, userID, id uuid.UUID) (*models.Note, error) {
	n, err := s.getByIDUnchecked(ctx, id)
	if err != nil {
		return nil, err
	}
	if err := s.requireVisible(ctx, userID, n); err != nil {
		return nil, err
	}
	return n, nil
}

// Update edits a note's content. Only the original author may edit.
func (s *NoteService) Update(ctx context.Context, userID, id uuid.UUID, input UpdateNoteInput) (*models.Note, error) {
	current, err := s.GetByID(ctx, userID, id)
	if err != nil {
		return nil, err
	}
	if current.CreatedBy == nil || *current.CreatedBy != userID {
		return nil, fmt.Errorf("%w: only the author can edit a Note", ErrForbidden)
	}
	if input.Content == nil {
		return current, nil
	}
	content, err := validateContent(*input.Content)
	if err != nil {
		return nil, err
	}
	_, err = s.db.ExecContext(ctx,
		`UPDATE paliad.notes SET content = $1, updated_at = NOW() WHERE id = $2`,
		content, id)
	if err != nil {
		return nil, fmt.Errorf("update note: %w", err)
	}
	return s.getByIDUnchecked(ctx, id)
}

// Delete removes a note. Author, partner, or admin only.
func (s *NoteService) Delete(ctx context.Context, userID, id uuid.UUID) error {
	current, err := s.GetByID(ctx, userID, id)
	if err != nil {
		return err
	}
	isAuthor := current.CreatedBy != nil && *current.CreatedBy == userID
	if !isAuthor {
		user, err := s.projects.Users().GetByID(ctx, userID)
		if err != nil {
			return err
		}
		if user.GlobalRole != "global_admin" {
			return fmt.Errorf("%w: only the author or a partner/admin can delete a Note", ErrForbidden)
		}
	}
	if _, err := s.db.ExecContext(ctx,
		`DELETE FROM paliad.notes WHERE id = $1`, id); err != nil {
		return fmt.Errorf("delete note: %w", err)
	}
	return nil
}

// --- internals -------------------------------------------------------------

type noteParent struct {
	ProjectID      *uuid.UUID
	DeadlineID     *uuid.UUID
	AppointmentID  *uuid.UUID
	ProjectEventID *uuid.UUID
}

func (s *NoteService) list(ctx context.Context, where string, arg any) ([]models.Note, error) {
	query := noteSelect + ` WHERE ` + where + ` ORDER BY n.created_at DESC`
	rows := []models.Note{}
	if err := s.db.SelectContext(ctx, &rows, query, arg); err != nil {
		return nil, fmt.Errorf("list notes: %w", err)
	}
	return rows, nil
}

// insertWithAudit inserts one notes row and, when an owning Project exists,
// appends a project_events audit row in the same transaction.
func (s *NoteService) insertWithAudit(ctx context.Context, userID uuid.UUID, content string, parent noteParent, projectAuditID *uuid.UUID, parentLabel string) (uuid.UUID, error) {
	id := uuid.New()
	now := time.Now().UTC()

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return uuid.Nil, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.notes
		   (id, project_id, deadline_id, appointment_id, project_event_id,
		    content, created_by, created_at, updated_at)
		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`,
		id, parent.ProjectID, parent.DeadlineID, parent.AppointmentID, parent.ProjectEventID,
		content, userID, now,
	); err != nil {
		return uuid.Nil, fmt.Errorf("insert note: %w", err)
	}

	if projectAuditID != nil {
		// Description carries the value-only payload (the parent slug); the
		// frontend renders it via the localized event.note.added_to template.
		// Metadata carries the most-specific anchor so the Verlauf entry can
		// deep-link to the deadline/appointment/project the note hangs on
		// (notes don't have their own page — t-paliad-102).
		title := "Note added"
		desc := parentLabel
		descPtr := &desc
		meta := map[string]any{"note_id": id}
		switch {
		case parent.DeadlineID != nil:
			meta["deadline_id"] = *parent.DeadlineID
		case parent.AppointmentID != nil:
			meta["appointment_id"] = *parent.AppointmentID
		case parent.ProjectID != nil:
			meta["project_id"] = *parent.ProjectID
		}
		if err := insertProjectEventWithMeta(ctx, tx, *projectAuditID, userID, "note_created", title, descPtr, meta); err != nil {
			return uuid.Nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return uuid.Nil, fmt.Errorf("commit insert note: %w", err)
	}
	return id, nil
}

// getByIDUnchecked fetches a note without a visibility check.
func (s *NoteService) getByIDUnchecked(ctx context.Context, id uuid.UUID) (*models.Note, error) {
	var n models.Note
	err := s.db.GetContext(ctx, &n, noteSelect+` WHERE n.id = $1`, id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotVisible
	}
	if err != nil {
		return nil, fmt.Errorf("fetch note: %w", err)
	}
	return &n, nil
}

// requireVisible re-runs the parent-visibility check.
func (s *NoteService) requireVisible(ctx context.Context, userID uuid.UUID, n *models.Note) error {
	switch {
	case n.ProjectID != nil:
		return s.requireProjectVisible(ctx, userID, *n.ProjectID)
	case n.DeadlineID != nil:
		projectID, err := s.deadlineProjectID(ctx, *n.DeadlineID)
		if err != nil {
			return err
		}
		return s.requireProjectVisible(ctx, userID, projectID)
	case n.AppointmentID != nil:
		return s.requireAppointmentVisible(ctx, userID, *n.AppointmentID)
	case n.ProjectEventID != nil:
		projectID, err := s.eventProjectID(ctx, *n.ProjectEventID)
		if err != nil {
			return err
		}
		return s.requireProjectVisible(ctx, userID, projectID)
	default:
		return ErrNotVisible
	}
}

// requireProjectVisible returns ErrNotVisible if the user can't see the
// Project. Used to gate note operations without paying for a full row read.
func (s *NoteService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
	visible, err := s.projects.CanSee(ctx, userID, projectID)
	if err != nil {
		return err
	}
	if !visible {
		return ErrNotVisible
	}
	return nil
}

// requireAppointmentVisible returns ErrNotVisible if the user can't see the
// Appointment. Used to gate note operations without paying for a full row read.
func (s *NoteService) requireAppointmentVisible(ctx context.Context, userID, appointmentID uuid.UUID) error {
	visible, err := s.appointment.CanSee(ctx, userID, appointmentID)
	if err != nil {
		return err
	}
	if !visible {
		return ErrNotVisible
	}
	return nil
}

func (s *NoteService) deadlineProjectID(ctx context.Context, deadlineID uuid.UUID) (uuid.UUID, error) {
	var projectID uuid.UUID
	err := s.db.GetContext(ctx, &projectID,
		`SELECT project_id FROM paliad.deadlines WHERE id = $1`, deadlineID)
	if errors.Is(err, sql.ErrNoRows) {
		return uuid.Nil, ErrNotVisible
	}
	if err != nil {
		return uuid.Nil, fmt.Errorf("lookup deadline parent: %w", err)
	}
	return projectID, nil
}

func (s *NoteService) eventProjectID(ctx context.Context, eventID uuid.UUID) (uuid.UUID, error) {
	var projectID uuid.UUID
	err := s.db.GetContext(ctx, &projectID,
		`SELECT project_id FROM paliad.project_events WHERE id = $1`, eventID)
	if errors.Is(err, sql.ErrNoRows) {
		return uuid.Nil, ErrNotVisible
	}
	if err != nil {
		return uuid.Nil, fmt.Errorf("lookup event parent: %w", err)
	}
	return projectID, nil
}

func validateContent(raw string) (string, error) {
	content := strings.TrimSpace(raw)
	if content == "" {
		return "", fmt.Errorf("%w: content is required", ErrInvalidInput)
	}
	if len(content) > 10000 {
		return "", fmt.Errorf("%w: content exceeds 10000 characters", ErrInvalidInput)
	}
	return content, nil
}
