package services

// TeamService manages paliad.project_teams — project team memberships.
//
// Inheritance model (t-paliad-024): a user added at any ancestor of a Project
// is implicitly a member of every descendant. Writes only ever touch the
// direct level; inherited memberships are computed at read time by walking
// UP the materialised path.
//
// The `inherited` column in the DB is reserved for potential future caching
// of inherited rows. This service does not write inherited=true rows.

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

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

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

// TeamService reads and writes paliad.project_teams.
type TeamService struct {
	db       *sqlx.DB
	projects *ProjectService
}

// NewTeamService wires the service.
func NewTeamService(db *sqlx.DB, projects *ProjectService) *TeamService {
	return &TeamService{db: db, projects: projects}
}

// AddMember inserts a direct team membership. The caller must have
// visibility on the Project (RLS + service-layer gate). Responsibility
// defaults to 'member' if empty. Idempotent on (project_id, user_id) —
// a repeat call updates the responsibility.
//
// t-paliad-148: this method writes the per-project responsibility only.
// The user's firm-level profession is NEVER touched here — it lives on
// paliad.users.profession and is set during onboarding / by global_admin
// via /admin/team. The legacy `role` column is kept synchronised
// (mapped from the responsibility) until migration 058 drops it.
func (s *TeamService) AddMember(ctx context.Context, callerID, projectID, userID uuid.UUID, responsibility string) (*models.ProjectTeamMember, error) {
	if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
		return nil, err
	}
	if responsibility == "" {
		responsibility = ResponsibilityMember
	}
	if !IsValidResponsibility(responsibility) {
		return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, responsibility)
	}

	// Map responsibility → legacy role for the deprecated shadow column.
	// Drop this mapping when migration 058 removes the column.
	legacyRole := legacyRoleFromResponsibility(responsibility)

	var m models.ProjectTeamMember
	err := s.db.GetContext(ctx, &m,
		`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
		 VALUES ($1, $2, $3, $4, false, $5)
		 ON CONFLICT (project_id, user_id) DO UPDATE
		    SET role = EXCLUDED.role,
		        responsibility = EXCLUDED.responsibility
		 RETURNING id, project_id, user_id, role, responsibility, inherited, added_by, created_at`,
		projectID, userID, legacyRole, responsibility, callerID)
	if err != nil {
		return nil, fmt.Errorf("add team member: %w", err)
	}
	return &m, nil
}

// legacyRoleFromResponsibility maps the new project-responsibility value
// to the closest legacy project_teams.role value, so the deprecated
// shadow column stays consistent. Drop when migration 058 retires the
// column. external → 'local_counsel' is intentionally narrower than the
// new enum (loses the expert distinction); we accept that for the short
// transition window.
//
// ResponsibilityAdmin (t-paliad-223) maps to legacy 'lead' — the closest
// legacy match. The legacy column is dead either way; the mapping is
// purely cosmetic until the column is dropped.
func legacyRoleFromResponsibility(r string) string {
	switch r {
	case ResponsibilityAdmin, ResponsibilityLead:
		return "lead"
	case ResponsibilityObserver:
		return "observer"
	case ResponsibilityExternal:
		return "local_counsel"
	default:
		// 'member' has no single legacy mapping — pick 'associate' (the
		// default the legacy code used). Real authority comes from
		// users.profession now, so this label is purely cosmetic.
		return "associate"
	}
}

// RemoveMember deletes a direct team membership. Inherited memberships (from
// ancestors) can't be removed at the child level — the caller must remove
// the ancestor row to break the inheritance.
//
// t-paliad-223 last-admin guard: if the row being removed carries
// responsibility='admin', refuse when it would leave the project + its
// ancestor chain with zero admins. Wrapped in a tx so the count + delete
// are atomic; ErrLastProjectAdmin bubbles up unchanged for the handler
// to map to 409.
func (s *TeamService) RemoveMember(ctx context.Context, callerID, projectID, userID uuid.UUID) error {
	if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
		return err
	}

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

	// Look up the row first so we know whether to run the guard.
	var existing models.ProjectTeamMember
	if err := tx.GetContext(ctx, &existing,
		`SELECT id, project_id, user_id, role, responsibility, inherited, added_by, created_at
		   FROM paliad.project_teams
		  WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
		projectID, userID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return sql.ErrNoRows
		}
		return fmt.Errorf("lookup team member: %w", err)
	}

	if existing.Responsibility == ResponsibilityAdmin {
		if err := assertProjectKeepsAdmin(ctx, tx, projectID, userID); err != nil {
			return err
		}
	}

	res, err := tx.ExecContext(ctx,
		`DELETE FROM paliad.project_teams
		  WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
		projectID, userID)
	if err != nil {
		return fmt.Errorf("remove team member: %w", err)
	}
	if rows, _ := res.RowsAffected(); rows == 0 {
		return sql.ErrNoRows
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit remove team member: %w", err)
	}
	return nil
}

// ChangeResponsibility updates a direct team member's responsibility.
// RLS enforces the authorisation (only effective_project_admin can pass
// the project_teams_update WITH CHECK); this method handles validation
// + the last-admin guard when the change is AWAY from admin.
//
// Inherited rows can't be edited here — the caller must change the
// ancestor row. Trying to update an inherited row returns sql.ErrNoRows.
func (s *TeamService) ChangeResponsibility(ctx context.Context, callerID, projectID, userID uuid.UUID, newResponsibility string) (*models.ProjectTeamMember, error) {
	if !IsValidResponsibility(newResponsibility) {
		return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, newResponsibility)
	}
	if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
		return nil, err
	}

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

	// Read current row so we know whether the guard needs to fire and so
	// we can short-circuit no-op writes.
	var current models.ProjectTeamMember
	if err := tx.GetContext(ctx, &current,
		`SELECT id, project_id, user_id, role, responsibility, inherited, added_by, created_at
		   FROM paliad.project_teams
		  WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
		projectID, userID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, sql.ErrNoRows
		}
		return nil, fmt.Errorf("lookup team member: %w", err)
	}
	if current.Responsibility == newResponsibility {
		// No-op; commit the empty tx so caller still gets a typed result.
		_ = tx.Commit()
		return &current, nil
	}

	if current.Responsibility == ResponsibilityAdmin && newResponsibility != ResponsibilityAdmin {
		if err := assertProjectKeepsAdmin(ctx, tx, projectID, userID); err != nil {
			return nil, err
		}
	}

	legacyRole := legacyRoleFromResponsibility(newResponsibility)

	var updated models.ProjectTeamMember
	if err := tx.GetContext(ctx, &updated,
		`UPDATE paliad.project_teams
		    SET responsibility = $3, role = $4
		  WHERE project_id = $1 AND user_id = $2 AND inherited = false
		RETURNING id, project_id, user_id, role, responsibility, inherited, added_by, created_at`,
		projectID, userID, newResponsibility, legacyRole); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, sql.ErrNoRows
		}
		return nil, fmt.Errorf("change responsibility: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit change responsibility: %w", err)
	}
	return &updated, nil
}

// assertProjectKeepsAdmin returns ErrLastProjectAdmin iff removing the
// (projectID, excludeUserID) admin row would leave the project's ancestor
// chain (project + every ancestor up to the root) with zero admins.
//
// Counts admin rows on every row in the ancestor chain, excluding the row
// being changed. Uses the same ltree path-walk as paliad.can_see_project.
//
// This is a service-layer guard; we don't put it in an RLS WITH CHECK
// because the count happens post-mutation in a typical WITH CHECK, and
// the natural place to express it is here where we already hold the tx.
func assertProjectKeepsAdmin(ctx context.Context, tx *sqlx.Tx, projectID, excludeUserID uuid.UUID) error {
	var remaining int
	if err := tx.GetContext(ctx, &remaining, `
		SELECT count(*)
		  FROM paliad.projects p
		  JOIN paliad.project_teams pt
		    ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
		   AND pt.responsibility = 'admin'
		 WHERE p.id = $1
		   AND NOT (pt.project_id = $1 AND pt.user_id = $2)
	`, projectID, excludeUserID); err != nil {
		return fmt.Errorf("count remaining admins: %w", err)
	}
	if remaining == 0 {
		return ErrLastProjectAdmin
	}
	return nil
}

// ListDirectMembers returns only the direct (non-inherited) team members,
// enriched with user display fields.
func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) {
	if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
		return nil, err
	}
	rows := []models.ProjectTeamMemberWithUser{}
	err := s.db.SelectContext(ctx, &rows,
		`SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility, pt.inherited,
		        pt.added_by, pt.created_at,
		        u.email AS user_email,
		        u.display_name AS user_display_name,
		        u.office AS user_office,
		        u.profession AS user_profession,
		        NULL::uuid AS inherited_from_id,
		        NULL::text AS inherited_from_title
		   FROM paliad.project_teams pt
		   LEFT JOIN paliad.users u ON u.id = pt.user_id
		  WHERE pt.project_id = $1
		  ORDER BY pt.responsibility, u.display_name`, projectID)
	if err != nil {
		return nil, fmt.Errorf("list direct team: %w", err)
	}
	return rows, nil
}

// ListEffectiveMembers returns direct + inherited members of a Project.
// Rows coming from an ancestor carry Inherited=true + InheritedFromID/Title.
// If the same user is both direct and inherited, the direct row wins.
func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) {
	project, err := s.projects.GetByID(ctx, callerID, projectID)
	if err != nil {
		return nil, err
	}
	ancestorIDs := pathToIDStrings(project.Path)

	query := `
		WITH candidate AS (
		    SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility,
		           pt.added_by, pt.created_at,
		           (pt.project_id <> $1) AS inherited,
		           CASE WHEN pt.project_id <> $1 THEN pt.project_id END AS inherited_from_id,
		           CASE WHEN pt.project_id <> $1 THEN parent.title  END AS inherited_from_title
		      FROM paliad.project_teams pt
		      LEFT JOIN paliad.projects parent ON parent.id = pt.project_id
		     WHERE pt.project_id = ANY($2::uuid[])
		),
		ranked AS (
		    SELECT c.*, ROW_NUMBER() OVER (
		        PARTITION BY c.user_id
		        ORDER BY c.inherited ASC, c.created_at ASC
		    ) AS rn FROM candidate c
		)
		SELECT r.id, r.project_id, r.user_id, r.role, r.responsibility, r.inherited,
		       r.added_by, r.created_at,
		       u.email AS user_email,
		       u.display_name AS user_display_name,
		       u.office AS user_office,
		       u.profession AS user_profession,
		       r.inherited_from_id,
		       r.inherited_from_title
		  FROM ranked r
		  LEFT JOIN paliad.users u ON u.id = r.user_id
		 WHERE r.rn = 1
		 ORDER BY r.inherited ASC, r.responsibility, u.display_name`

	rows := []models.ProjectTeamMemberWithUser{}
	if err := s.db.SelectContext(ctx, &rows, query, projectID, pq.StringArray(ancestorIDs)); err != nil {
		return nil, fmt.Errorf("list effective team: %w", err)
	}
	return rows, nil
}

// MembershipEntry is one row in the team-memberships index.
// Powers the /team page project-multi-select filter (t-paliad-147):
// the frontend pulls the index once, then filters users locally
// by intersecting the UI-selected project_ids against each user's
// project_ids list.
type MembershipEntry struct {
	UserID     uuid.UUID `json:"user_id"`
	ProjectIDs []string  `json:"project_ids"`
	// LeadProjectIDs is the subset of project_ids on which this
	// user has role='lead'. Surfaces the "I am a lead on N projects"
	// state the broadcast send-button needs.
	LeadProjectIDs []string `json:"lead_project_ids"`
	// Role on each project — same indexing as project_ids — so the
	// frontend can offer a project_teams.role filter.
	Roles []string `json:"roles"`
}

// ListMembershipsIndex returns one row per user × project_team membership
// the caller can see. global_admin sees everything; non-admin only sees
// memberships on projects whose visibility predicate they pass.
//
// Membership rows are direct (paliad.project_teams.project_id) only —
// inherited memberships are left to the client to compute, since the
// project-multi-select filter wants "user is on this exact project"
// semantics, not "user inherits from somewhere up the tree".
func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UUID) ([]MembershipEntry, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT pt.user_id::text, pt.project_id::text, pt.role
		  FROM paliad.project_teams pt
		  JOIN paliad.projects p ON p.id = pt.project_id
		 WHERE `+visibilityPredicatePositional("p", 1)+`
		 ORDER BY pt.user_id, pt.project_id`,
		callerID,
	)
	if err != nil {
		return nil, fmt.Errorf("list memberships index: %w", err)
	}
	defer rows.Close()

	byUser := map[uuid.UUID]*MembershipEntry{}
	for rows.Next() {
		var userIDStr, projectIDStr, role string
		if err := rows.Scan(&userIDStr, &projectIDStr, &role); err != nil {
			return nil, fmt.Errorf("scan membership: %w", err)
		}
		uid, err := uuid.Parse(userIDStr)
		if err != nil {
			continue
		}
		entry, ok := byUser[uid]
		if !ok {
			entry = &MembershipEntry{UserID: uid}
			byUser[uid] = entry
		}
		entry.ProjectIDs = append(entry.ProjectIDs, projectIDStr)
		entry.Roles = append(entry.Roles, role)
		if role == RoleLead {
			entry.LeadProjectIDs = append(entry.LeadProjectIDs, projectIDStr)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iter memberships: %w", err)
	}
	out := make([]MembershipEntry, 0, len(byUser))
	for _, e := range byUser {
		out = append(out, *e)
	}
	return out, nil
}

// IsEffectiveProjectAdmin reports whether the user is global_admin OR has
// responsibility='admin' on the project itself or any ancestor in the
// materialised ltree path.
//
// Delegates to paliad.effective_project_admin SQL (t-paliad-223 mig 111).
// The function is STABLE SECURITY DEFINER so it sees rows regardless of
// the caller's RLS context — the boolean answer doesn't leak data.
//
// Used by the project-detail handler to drive the inline-select affordance
// in the team panel: only effective_project_admins see the editable
// <select>; everyone else sees a read-only <span>.
func (s *TeamService) IsEffectiveProjectAdmin(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
	var b bool
	if err := s.db.GetContext(ctx, &b,
		`SELECT paliad.effective_project_admin($1, $2)`,
		userID, projectID); err != nil {
		return false, fmt.Errorf("effective_project_admin: %w", err)
	}
	return b, nil
}

// ---------------------------------------------------------------------------

// pathToIDStrings splits a materialised path into its UUID labels as strings,
// suitable for pq.StringArray → uuid[] cast.
func pathToIDStrings(path string) []string {
	if path == "" {
		return nil
	}
	parts := strings.Split(path, ".")
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		if p != "" {
			out = append(out, p)
		}
	}
	return out
}
