package services

// DerivationService manages partner-unit derivation onto project teams
// (t-paliad-139). It owns the project↔unit junction table
// (paliad.project_partner_units) and the read paths the Team tab + the
// approval inbox use to compute "who's effectively on this project via a
// partner unit".
//
// Derivation is computed on read (no materialised state). The visibility
// predicate paliad.can_see_project (extended in migration 055) is the
// authoritative gate for what users can see; this service is the read /
// authoring API on top of it.

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"

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

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

// DerivationService is the read + authoring path for partner-unit derivation.
type DerivationService struct {
	db          *sqlx.DB
	projects    *ProjectService
	partnerUnit *PartnerUnitService
}

// NewDerivationService wires the service.
func NewDerivationService(db *sqlx.DB, projects *ProjectService, partnerUnit *PartnerUnitService) *DerivationService {
	return &DerivationService{db: db, projects: projects, partnerUnit: partnerUnit}
}

// AttachedUnit is one row in paliad.project_partner_units enriched with the
// unit's display name + count of members that would currently derive given
// the configured derive_unit_roles. The frontend renders this on the
// /projects/{id}/settings/team Partner Units section.
type AttachedUnit struct {
	ProjectID             uuid.UUID `db:"project_id"              json:"project_id"`
	PartnerUnitID         uuid.UUID `db:"partner_unit_id"         json:"partner_unit_id"`
	UnitName              string    `db:"unit_name"               json:"unit_name"`
	DeriveUnitRoles       []string  `db:"derive_unit_roles"       json:"derive_unit_roles"`
	DeriveGrantsAuthority bool      `db:"derive_grants_authority" json:"derive_grants_authority"`
	DerivedMemberCount    int       `db:"derived_member_count"    json:"derived_member_count"`
}

// DerivedMembership is one (unit, role) pair through which a user currently
// derives onto a project. A multi-unit user has one DerivedMembership per
// unit they belong to that's attached to the project (or one of its
// ancestors) AND whose unit_role is in the attachment's derive_unit_roles.
type DerivedMembership struct {
	UnitID   uuid.UUID `json:"unit_id"`
	UnitName string    `json:"unit_name"`
	UnitRole string    `json:"unit_role"`
}

// DerivedMembershipList is a []DerivedMembership that scans from a Postgres
// jsonb column (the array_agg/jsonb_agg payload in ListDerivedMembers).
type DerivedMembershipList []DerivedMembership

// Scan implements sql.Scanner over a jsonb array.
func (l *DerivedMembershipList) Scan(src any) error {
	if src == nil {
		*l = nil
		return nil
	}
	var raw []byte
	switch v := src.(type) {
	case []byte:
		raw = v
	case string:
		raw = []byte(v)
	default:
		return fmt.Errorf("DerivedMembershipList.Scan: unsupported type %T", src)
	}
	return json.Unmarshal(raw, (*[]DerivedMembership)(l))
}

// DerivedMember is one user who currently derives onto a project. The user
// may derive via multiple units (e.g. a PA who works with two partners);
// each is one entry in Memberships. DeriveGrantsAuthority is true if any
// of the source attachments have authority enabled.
type DerivedMember struct {
	UserID                uuid.UUID             `db:"user_id"                 json:"user_id"`
	Email                 string                `db:"email"                   json:"user_email"`
	DisplayName           string                `db:"display_name"            json:"user_display_name"`
	Office                string                `db:"office"                  json:"user_office"`
	Memberships           DerivedMembershipList `db:"memberships"             json:"memberships"`
	DeriveGrantsAuthority bool                  `db:"derive_grants_authority" json:"derive_grants_authority"`
}

// AttachUnitOptions controls how a unit is attached. Empty values use the
// migration-055 defaults: derive_unit_roles = {pa, senior_pa},
// derive_grants_authority = false (visibility-only).
type AttachUnitOptions struct {
	DeriveUnitRoles       []string
	DeriveGrantsAuthority bool
}

// requireWritePermission gates project↔unit attach/detach to project lead
// or global_admin. Mirrors the RLS write policy in migration 055.
func (s *DerivationService) requireWritePermission(ctx context.Context, callerID, projectID uuid.UUID) error {
	user, err := s.projects.Users().GetByID(ctx, callerID)
	if err != nil {
		return err
	}
	if user != nil && user.GlobalRole == "global_admin" {
		return nil
	}
	// t-paliad-148: project-management write permission gates on the
	// project responsibility, not on the (firm-tier) profession. A
	// partner with responsibility=observer on this matter cannot manage
	// partner-unit attachments here; conversely a non-partner with
	// responsibility=lead can.
	var responsibility string
	err = s.db.GetContext(ctx, &responsibility,
		`SELECT responsibility FROM paliad.project_teams
		  WHERE project_id = $1 AND user_id = $2`,
		projectID, callerID)
	if errors.Is(err, sql.ErrNoRows) {
		return ErrForbidden
	}
	if err != nil {
		return fmt.Errorf("read project_teams responsibility: %w", err)
	}
	if responsibility != ResponsibilityLead {
		return ErrForbidden
	}
	return nil
}

// AttachUnitToProject creates a project_partner_units row. Idempotent on
// (project_id, partner_unit_id) — a repeat call updates the derive options.
// Caller must be project lead OR global_admin.
func (s *DerivationService) AttachUnitToProject(ctx context.Context, callerID, projectID, unitID uuid.UUID, opts AttachUnitOptions) error {
	if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
		return err
	}
	if err := s.requireWritePermission(ctx, callerID, projectID); err != nil {
		return err
	}
	if _, err := s.partnerUnit.GetByID(ctx, unitID); err != nil {
		return err
	}

	roles := opts.DeriveUnitRoles
	if len(roles) == 0 {
		roles = []string{UnitRolePA, UnitRoleSeniorPA}
	}
	for _, r := range roles {
		if !isValidUnitRole(r) {
			return fmt.Errorf("%w: invalid unit_role %q in derive_unit_roles", ErrInvalidInput, r)
		}
	}

	_, err := s.db.ExecContext(ctx,
		`INSERT INTO paliad.project_partner_units
		     (project_id, partner_unit_id, derive_unit_roles, derive_grants_authority,
		      attached_at, attached_by)
		 VALUES ($1, $2, $3, $4, now(), $5)
		 ON CONFLICT (project_id, partner_unit_id) DO UPDATE
		    SET derive_unit_roles       = EXCLUDED.derive_unit_roles,
		        derive_grants_authority = EXCLUDED.derive_grants_authority`,
		projectID, unitID, pq.StringArray(roles), opts.DeriveGrantsAuthority, callerID)
	if err != nil {
		return fmt.Errorf("attach unit to project: %w", err)
	}
	return nil
}

// DetachUnitFromProject deletes a project_partner_units row. Idempotent —
// repeat detach is a no-op.
func (s *DerivationService) DetachUnitFromProject(ctx context.Context, callerID, projectID, unitID uuid.UUID) error {
	if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
		return err
	}
	if err := s.requireWritePermission(ctx, callerID, projectID); err != nil {
		return err
	}
	if _, err := s.db.ExecContext(ctx,
		`DELETE FROM paliad.project_partner_units
		  WHERE project_id = $1 AND partner_unit_id = $2`,
		projectID, unitID); err != nil {
		return fmt.Errorf("detach unit from project: %w", err)
	}
	return nil
}

// ListAttachedUnits returns the unit attachments anchored on this exact
// project (NOT walking ancestors — the project /settings/team page wants
// to manage its own attachments only). Each row is enriched with the unit
// name and the count of members that would currently derive given the
// configured derive_unit_roles.
func (s *DerivationService) ListAttachedUnits(ctx context.Context, callerID, projectID uuid.UUID) ([]AttachedUnit, error) {
	if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
		return nil, err
	}
	rows := []AttachedUnit{}
	err := s.db.SelectContext(ctx, &rows,
		`SELECT ppu.project_id,
		        ppu.partner_unit_id,
		        pu.name AS unit_name,
		        ppu.derive_unit_roles,
		        ppu.derive_grants_authority,
		        (SELECT COUNT(*) FROM paliad.partner_unit_members pum
		          WHERE pum.partner_unit_id = ppu.partner_unit_id
		            AND pum.unit_role = ANY(ppu.derive_unit_roles)) AS derived_member_count
		   FROM paliad.project_partner_units ppu
		   JOIN paliad.partner_units pu ON pu.id = ppu.partner_unit_id
		  WHERE ppu.project_id = $1
		  ORDER BY pu.name`, projectID)
	if err != nil {
		return nil, fmt.Errorf("list attached units: %w", err)
	}
	return rows, nil
}

// ListDerivedMembers returns users who currently derive onto this project
// via any attached unit on the project's path (this project + ancestors).
// Walks UP the path because a unit attached at the Client level cascades
// down to descendants — derivation honours the same direction as
// can_see_project.
//
// One row per user. Multi-unit users (e.g. a PA working across two partner
// units, both of which are attached to the project's path) carry every
// (unit, role) pair in Memberships so the Herkunft column can list them
// all (t-paliad-143). DeriveGrantsAuthority is bool_or across the
// underlying attachments — a user with at least one authority-granting
// derivation source qualifies as authority-bearing for approval purposes.
func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]DerivedMember, error) {
	project, err := s.projects.GetByID(ctx, callerID, projectID)
	if err != nil {
		return nil, err
	}
	ancestorIDs := pathToIDStrings(project.Path)
	if len(ancestorIDs) == 0 {
		return []DerivedMember{}, nil
	}

	rows := []DerivedMember{}
	err = s.db.SelectContext(ctx, &rows, `
		WITH attached AS (
		    SELECT ppu.project_id      AS attach_project_id,
		           ppu.partner_unit_id,
		           ppu.derive_unit_roles,
		           ppu.derive_grants_authority
		      FROM paliad.project_partner_units ppu
		     WHERE ppu.project_id = ANY($1::uuid[])
		)
		SELECT pum.user_id,
		       u.email, u.display_name, u.office,
		       jsonb_agg(DISTINCT jsonb_build_object(
		           'unit_id',   a.partner_unit_id,
		           'unit_name', pu.name,
		           'unit_role', pum.unit_role
		       )) AS memberships,
		       bool_or(a.derive_grants_authority) AS derive_grants_authority
		  FROM attached a
		  JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = a.partner_unit_id
		  JOIN paliad.users         u  ON u.id  = pum.user_id
		  JOIN paliad.partner_units pu ON pu.id = a.partner_unit_id
		 WHERE pum.unit_role = ANY(a.derive_unit_roles)
		 GROUP BY pum.user_id, u.email, u.display_name, u.office
		 ORDER BY u.display_name`,
		pq.StringArray(ancestorIDs))
	if err != nil {
		return nil, fmt.Errorf("list derived members: %w", err)
	}
	// jsonb_agg(DISTINCT …) doesn't support ORDER BY in the same call.
	// Sort each member's memberships by unit_name in Go so the Herkunft
	// column renders deterministically.
	for i := range rows {
		ms := rows[i].Memberships
		for j := 1; j < len(ms); j++ {
			for k := j; k > 0 && ms[k-1].UnitName > ms[k].UnitName; k-- {
				ms[k-1], ms[k] = ms[k], ms[k-1]
			}
		}
	}
	return rows, nil
}

// ListDescendantStaffed returns users who are directly staffed on a
// descendant of the given project but not on the project itself or its
// ancestors. This is the new "Aus Unterprojekten" subsection on the Team
// tab — explicit Case-level staff that surfaces up to the parent for
// awareness.
//
// Excludes inherited rows (descendant team rows are by definition direct
// at their level — what we filter out are users already on this project
// or its ancestors so the same user doesn't appear in two subsections).
func (s *DerivationService) ListDescendantStaffed(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, `
		WITH descendants AS (
		    SELECT p.id, p.title
		      FROM paliad.projects p
		     WHERE p.id <> $1
		       AND $1 = ANY(string_to_array(p.path, '.')::uuid[])
		),
		ancestor_or_self AS (
		    SELECT pp.id
		      FROM paliad.projects target
		      JOIN paliad.projects pp
		        ON pp.id = ANY(string_to_array(target.path, '.')::uuid[])
		     WHERE target.id = $1
		),
		descendant_rows AS (
		    SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility,
		           pt.added_by, pt.created_at,
		           d.title AS source_title
		      FROM paliad.project_teams pt
		      JOIN descendants d ON d.id = pt.project_id
		     WHERE pt.user_id NOT IN (
		         SELECT user_id FROM paliad.project_teams
		          WHERE project_id IN (SELECT id FROM ancestor_or_self)
		     )
		),
		dedup AS (
		    SELECT dr.*,
		           ROW_NUMBER() OVER (
		               PARTITION BY dr.user_id
		               ORDER BY dr.created_at ASC
		           ) AS rn
		      FROM descendant_rows dr
		)
		SELECT d.id, d.project_id, d.user_id, d.role, d.responsibility,
		       true  AS inherited,
		       d.added_by, d.created_at,
		       u.email        AS user_email,
		       u.display_name AS user_display_name,
		       u.office       AS user_office,
		       u.profession   AS user_profession,
		       d.project_id   AS inherited_from_id,
		       d.source_title AS inherited_from_title
		  FROM dedup d
		  JOIN paliad.users u ON u.id = d.user_id
		 WHERE d.rn = 1
		 ORDER BY d.responsibility, u.display_name`,
		projectID)
	if err != nil {
		return nil, fmt.Errorf("list descendant-staffed: %w", err)
	}
	return rows, nil
}

// UserProjectAuthorityLevel returns the effective approval-ladder level
// for user U on project P, evaluated as a tuple-with-gate:
//
//	profession_level   = approval_role_level(U.profession)        // 0 if NULL
//	responsibility     = direct or ancestor on project P
//	gate_open          = responsibility IN {lead, member}
//	derived_role       = approval_role_from_unit_role(unit_role)  // when grants_authority
//	level              = max( profession_level if gate_open else 0,
//	                          derived_role_level )
//
// Thin wrapper over paliad.user_project_authority_level — kept here so
// any future caller that needs the level without writing raw SQL has a
// single helper to call. The ApprovalService SQL paths inline the
// computation directly for query efficiency.
func (s *DerivationService) UserProjectAuthorityLevel(ctx context.Context, userID, projectID uuid.UUID) (int, error) {
	var lvl int
	err := s.db.GetContext(ctx, &lvl,
		`SELECT paliad.user_project_authority_level($1, $2)`,
		userID, projectID)
	if errors.Is(err, sql.ErrNoRows) {
		return 0, nil
	}
	if err != nil {
		return 0, fmt.Errorf("read user project authority level: %w", err)
	}
	return lvl, nil
}
