defmodule Mv.Membership.Member do
  @moduledoc """
  Ash resource representing a club member.

  ## Overview
  Members are the core entity in the membership management system. Each member
  can have:
  - Personal information (name, email, address)
  - Optional link to a User account (1:1 relationship)
  - Dynamic custom field values via CustomField system
  - Full-text searchable profile

  ## Email Synchronization
  When a member is linked to a user account, emails are automatically synchronized
  bidirectionally. User.email is the source of truth on initial link.
  See `Mv.EmailSync` for details.

  ## Relationships
  - `has_many :custom_field_values` - Dynamic custom fields
  - `has_one :user` - Optional authentication account link

  ## Validations
  - Required: email (all other fields are optional)
  - Email format validation (using EctoCommons.EmailValidator)
  - Date validations: join_date not in future, exit_date after join_date
  - Email uniqueness: prevents conflicts with unlinked users
  - Linked member email change: only admins or the linked user may change a linked member's email (see `Mv.Membership.Member.Validations.EmailChangePermission`)

  ## Full-Text Search
  Members have a `search_vector` attribute (tsvector) that is automatically
  updated via database trigger. Search includes name, email, notes, contact fields,
  and all custom field values. Custom field values are automatically included in
  the search vector with weight 'C' (same as city, etc.).
  """
  use Ash.Resource,
    domain: Mv.Membership,
    data_layer: AshPostgres.DataLayer,
    authorizers: [Ash.Policy.Authorizer]

  require Ash.Query
  import Ash.Expr
  alias Mv.Helpers
  require Logger

  alias Mv.Membership.Helpers.VisibilityConfig

  # Module constants
  @member_search_limit 10

  # Similarity threshold for fuzzy name/address matching.
  # Lower value = more results but less accurate (0.1-0.9)
  #
  # Fuzzy matching uses two complementary strategies:
  # 1. % operator: Fast GIN-index-based matching using server-wide threshold (default 0.3)
  #    - Catches exact trigram matches quickly via index
  # 2. similarity/word_similarity functions: Precise matching with this configurable threshold
  #    - Catches partial matches that % operator might miss
  #
  # Value 0.2 chosen based on testing with typical German names:
  # - "Müller" vs "Mueller": similarity ~0.65 ✓
  # - "Schmidt" vs "Schmitt": similarity ~0.75 ✓
  # - "Wagner" vs "Wegner": similarity ~0.55 ✓
  # - Random unrelated names: similarity ~0.15 ✗
  @default_similarity_threshold 0.2

  # Use constants from Mv.Constants for member fields
  # This ensures consistency across the codebase
  @member_fields Mv.Constants.member_fields()

  postgres do
    table "members"
    repo Mv.Repo
  end

  actions do
    defaults [:read, :destroy]

    create :create_member do
      primary? true

      # Note: Custom validation function cannot be done atomically (queries DB for required custom fields)
      # In Ash 3.0, require_atomic? is not available for create actions, but the validation will still work
      # Custom field values can be created along with member
      argument :custom_field_values, {:array, :map}
      # Allow user to be passed as argument for relationship management
      # user_id is NOT in accept list to prevent direct foreign key manipulation
      argument :user, :map, allow_nil?: true

      # Accept member fields plus membership_fee_type_id (belongs_to FK)
      accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]

      change manage_relationship(:custom_field_values, type: :create)

      # Manage the user relationship during member creation
      change manage_relationship(:user, :user,
               # Look up existing user and relate to it
               on_lookup: :relate,
               # Error if user doesn't exist in database
               on_no_match: :error,
               # Error if user is already linked to another member (prevents "stealing")
               on_match: :error,
               # If no user provided, that's fine (optional relationship)
               on_missing: :ignore
             )

      # Sync user email to member when linking (User → Member)
      # Only runs when user relationship is being changed
      change Mv.EmailSync.Changes.SyncUserEmailToMember do
        where [changing(:user)]
      end

      # Auto-assign default membership fee type if not explicitly set
      change Mv.Membership.Member.Changes.SetDefaultMembershipFeeType

      # Auto-calculate membership_fee_start_date if not manually set
      # Requires both join_date and membership_fee_type_id to be present
      change Mv.MembershipFees.Changes.SetMembershipFeeStartDate

      # Sync member to Vereinfacht as finance contact (if configured)
      change Mv.Vereinfacht.Changes.SyncContact

      # Trigger cycle generation after member creation
      # Only runs if membership_fee_type_id is set
      # Note: Cycle generation runs asynchronously to not block the action,
      # but in test environment it runs synchronously for DB sandbox compatibility
      change after_transaction(fn changeset, result, _context ->
               case result do
                 {:ok, member} ->
                   if member.membership_fee_type_id && member.join_date do
                     # Capture initiator for audit trail (if available)
                     initiator = Map.get(changeset.context, :actor)
                     handle_cycle_generation(member, initiator: initiator)
                   end

                 {:error, _} ->
                   :ok
               end

               result
             end)
    end

    update :update_member do
      primary? true
      # Required because custom validation function cannot be done atomically
      require_atomic? false
      # Custom field values can be updated or created along with member
      argument :custom_field_values, {:array, :map}
      # Allow user to be passed as argument for relationship management
      # user_id is NOT in accept list to prevent direct foreign key manipulation
      argument :user, :map, allow_nil?: true

      # Accept member fields plus membership_fee_type_id (belongs_to FK)
      accept @member_fields ++ [:membership_fee_type_id, :membership_fee_start_date]

      change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)

      # When :user argument is present and nil/empty, unrelate (admin-only via policy).
      # Must run before manage_relationship; on_missing: :ignore then does nothing for nil input.
      change Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil

      # Manage the user relationship during member update
      # on_missing: :ignore so that omitting :user does NOT unlink (security: only admins may
      # change the link; unlink is explicit via user: nil, forbidden for non-admins by policy).
      change manage_relationship(:user, :user,
               on_lookup: :relate,
               on_no_match: :error,
               on_match: :error,
               on_missing: :ignore
             )

      # Sync member email to user when email changes (Member → User)
      # Only runs when email is being changed
      change Mv.EmailSync.Changes.SyncMemberEmailToUser do
        where [changing(:email)]
      end

      # Sync user email to member when linking (User → Member)
      # Only runs when user relationship is being changed
      change Mv.EmailSync.Changes.SyncUserEmailToMember do
        where [changing(:user)]
      end

      # Validate that membership fee type changes only allow same-interval types
      change Mv.MembershipFees.Changes.ValidateSameInterval do
        where [changing(:membership_fee_type_id)]
      end

      # Auto-calculate membership_fee_start_date when membership_fee_type_id is set
      # and membership_fee_start_date is not already set
      change Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
        where [changing(:membership_fee_type_id)]
      end

      # Sync member to Vereinfacht as finance contact (if configured)
      change Mv.Vereinfacht.Changes.SyncContact

      # Trigger cycle regeneration when membership_fee_type_id changes
      # This deletes future unpaid cycles and regenerates them with the new type/amount
      # Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
      # CycleGenerator uses advisory locks and transactions internally to prevent race conditions
      # Notifications are returned to Ash and sent automatically after commit
      change after_action(fn changeset, member, _context ->
               fee_type_changed =
                 Ash.Changeset.changing_attribute?(changeset, :membership_fee_type_id)

               if fee_type_changed && member.membership_fee_type_id && member.join_date do
                 case regenerate_cycles_on_type_change(member) do
                   {:ok, notifications} ->
                     # Return notifications to Ash - they will be sent automatically after commit
                     {:ok, member, notifications}

                   {:error, reason} ->
                     Logger.warning(
                       "Failed to regenerate cycles for member #{member.id}: #{inspect(reason)}"
                     )

                     {:ok, member}
                 end
               else
                 {:ok, member}
               end
             end)

      # Trigger cycle regeneration when join_date or exit_date changes
      # Regenerates cycles based on new dates
      # Note: Cycle generation runs synchronously in test environment, asynchronously in production
      # CycleGenerator uses advisory locks and transactions internally to prevent race conditions
      # If both join_date and exit_date are changed simultaneously, this hook runs only once
      # (Ash ensures each after_transaction hook runs once per action, regardless of how many attributes changed)
      change after_transaction(fn changeset, result, _context ->
               case result do
                 {:ok, member} ->
                   join_date_changed = Ash.Changeset.changing_attribute?(changeset, :join_date)
                   exit_date_changed = Ash.Changeset.changing_attribute?(changeset, :exit_date)

                   if (join_date_changed || exit_date_changed) && member.membership_fee_type_id do
                     # Capture initiator for audit trail (if available)
                     initiator = Map.get(changeset.context, :actor)
                     handle_cycle_generation(member, initiator: initiator)
                   end

                 {:error, _} ->
                   :ok
               end

               result
             end)
    end

    # Internal: set vereinfacht_contact_id after syncing with Vereinfacht API.
    # Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact.
    update :set_vereinfacht_contact_id do
      require_atomic? false
      accept [:vereinfacht_contact_id]
    end

    # Action to handle fuzzy search on specific fields
    read :search do
      argument :query, :string, allow_nil?: true
      argument :similarity_threshold, :float, allow_nil?: true

      prepare fn query, _ctx ->
        q = Ash.Query.get_argument(query, :query) || ""

        # Use default similarity threshold if not provided
        # Lower value leads to more results but also more unspecific results
        threshold =
          Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold

        if is_binary(q) and String.trim(q) != "" do
          q2 = String.trim(q)
          # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
          q2_sanitized = sanitize_search_query(q2)
          pat = "%" <> q2_sanitized <> "%"

          # Build search filters grouped by search type for maintainability
          # Priority: FTS > Substring > Custom Fields > Fuzzy Matching
          # Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
          fts_match = build_fts_filter(q2)
          substring_match = build_substring_filter(q2_sanitized, pat)
          custom_field_match = build_custom_field_filter(pat)
          fuzzy_match = build_fuzzy_filter(q2, threshold)

          query
          |> Ash.Query.filter(
            expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
          )
        else
          query
        end
      end
    end

    # Action to find members available for linking to a user account
    # Returns only unlinked members (user_id == nil), limited to 10 results
    #
    # Filtering behavior:
    # - If search_query provided: fuzzy search on names and email
    # - If no search_query: return all unlinked members (up to limit)
    # - user_email should be handled by caller with filter_by_email_match/2
    read :available_for_linking do
      argument :user_email, :string, allow_nil?: true
      argument :search_query, :string, allow_nil?: true

      prepare fn query, _ctx ->
        user_email = Ash.Query.get_argument(query, :user_email)
        search_query = Ash.Query.get_argument(query, :search_query)

        query
        |> Ash.Query.filter(is_nil(user))
        |> apply_linking_filters(user_email, search_query)
        |> Ash.Query.limit(@member_search_limit)
      end
    end
  end

  # Authorization Policies
  # Order matters: Most specific policies first, then general permission check
  policies do
    # SPECIAL CASE: Users can always READ their linked member
    # This allows users with ANY permission set to read their own linked member
    # Check using the inverse relationship: User.member_id → Member.id
    bypass action_type(:read) do
      description "Users can always read member linked to their account"
      authorize_if expr(id == ^actor(:member_id))
    end

    # READ/DESTROY: Check permissions only (no :user argument on these actions)
    policy action_type([:read, :destroy]) do
      description "Check permissions from user's role"
      authorize_if Mv.Authorization.Checks.HasPermission
    end

    # Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change).
    policy action(:set_vereinfacht_contact_id) do
      description "Only system actor may set Vereinfacht contact ID"
      authorize_if Mv.Authorization.Checks.ActorIsSystemUser
    end

    # CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
    # ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
    # HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
    policy action_type([:create, :update]) do
      description "Forbid user link unless admin; then check permissions"
      forbid_if Mv.Authorization.Checks.ForbidMemberUserLinkUnlessAdmin
      authorize_if Mv.Authorization.Checks.HasPermission
    end

    # DEFAULT: Forbid if no policy matched
    # Ash implicitly forbids if no policy authorized
  end

  @doc """
  Filters members list based on email match priority.

  Priority logic:
  1. If email matches a member: return ONLY that member (highest priority)
  2. If email doesn't match: return all members (for display in dropdown)

  This is used with :available_for_linking action to implement email-priority behavior:
  - user_email matches → Only this member
  - user_email does NOT match + NO search_query → All unlinked members
  - user_email does NOT match + search_query provided → search_query filtered members

  ## Parameters
    - `members` - List of Member structs (from :available_for_linking action)
    - `user_email` - Email string to match against member emails

  ## Returns
    - List of Member structs (either single email match or all members)

  ## Examples

      iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
      iex> filter_by_email_match(members, "test@example.com")
      [%Member{email: "test@example.com"}]

      iex> filter_by_email_match(members, "nomatch@example.com")
      [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}]
  """
  @spec filter_by_email_match([t()], String.t()) :: [t()]
  def filter_by_email_match(members, user_email)
      when is_list(members) and is_binary(user_email) do
    email_match = Enum.find(members, &(&1.email == user_email))

    if email_match do
      # Email match found - return only this member (highest priority)
      [email_match]
    else
      # No email match - return all members unchanged
      members
    end
  end

  @spec filter_by_email_match(any(), any()) :: any()
  def filter_by_email_match(members, _user_email), do: members

  validations do
    # Required fields are covered by allow_nil? false

    # Email is required
    validate present(:email)

    # Email uniqueness check for all actions that change the email attribute
    # Validates that member email is not already used by another (unlinked) user
    validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser

    # Only admins or the linked user may change a linked member's email (prevents breaking sync)
    validate Mv.Membership.Member.Validations.EmailChangePermission, on: [:update]

    # Prevent linking to a user that already has a member
    # This validation prevents "stealing" users from other members by checking
    # if the target user is already linked to a different member
    # This is necessary because manage_relationship's on_match: :error only checks
    # if the user is already linked to THIS specific member, not ANY member
    validate fn changeset, _context ->
      user_arg = Ash.Changeset.get_argument(changeset, :user)

      if user_arg && user_arg[:id] do
        user_id = user_arg[:id]
        current_member_id = changeset.data.id

        # Get actor from changeset context (may be nil)
        actor = Map.get(changeset.context || %{}, :actor)

        # Check if authorization is disabled in the parent operation's context
        # Access private context where authorize? flag is stored
        authorize? =
          case get_in(changeset.context, [:private, :authorize?]) do
            false -> false
            _ -> true
          end

        # Use actor for authorization when available and authorize? is true
        # Fall back to authorize?: false only for bootstrap/system operations
        # This ensures normal operations respect authorization while system operations work
        query_opts =
          if actor && authorize? do
            [actor: actor]
          else
            [authorize?: false]
          end

        case Ash.get(Mv.Accounts.User, user_id, query_opts) do
          # User is free to be linked
          {:ok, %{member_id: nil}} ->
            :ok

          # User already linked to this member (update scenario)
          {:ok, %{member_id: ^current_member_id}} ->
            :ok

          {:ok, %{member_id: _other_member_id}} ->
            # User is linked to a different member - prevent "stealing"
            {:error, field: :user, message: "User is already linked to another member"}

          {:error, %Ash.Error.Query.NotFound{}} ->
            {:error, field: :user, message: "User not found"}

          {:error, _} ->
            {:error, field: :user, message: "User not found"}
        end
      else
        :ok
      end
    end

    # Join date not in future
    validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
      where: [present(:join_date)],
      message: "cannot be in the future"

    # Exit date not before join date
    validate compare(:exit_date, greater_than: :join_date),
      where: [present([:join_date, :exit_date])],
      message: "cannot be before join date"

    # Email validation with EctoCommons.EmailValidator
    validate fn changeset, _ ->
      email = Ash.Changeset.get_attribute(changeset, :email)

      changeset2 =
        {%{}, %{email: :string}}
        |> Ecto.Changeset.cast(%{email: email}, [:email])
        |> EctoCommons.EmailValidator.validate_email(:email,
          checks: Mv.Constants.email_validator_checks()
        )

      if changeset2.valid? do
        :ok
      else
        {:error, field: :email, message: "is not a valid email"}
      end
    end

    # Validate required custom fields (actor from validation context only; no fallback).
    # Only for create_member/update_member; skip for set_vereinfacht_contact_id (internal sync
    # only sets vereinfacht_contact_id; custom fields were already validated and saved).
    validate fn changeset, context ->
               provided_values = provided_custom_field_values(changeset)
               actor = context.actor

               case Mv.Membership.list_required_custom_fields(actor: actor) do
                 {:ok, required_custom_fields} ->
                   missing_fields =
                     missing_required_fields(required_custom_fields, provided_values)

                   if Enum.empty?(missing_fields) do
                     :ok
                   else
                     build_custom_field_validation_error(missing_fields)
                   end

                 {:error, %Ash.Error.Forbidden{}} ->
                   Logger.warning(
                     "Required custom fields validation: actor not authorized to read CustomField"
                   )

                   {:error,
                    field: :custom_field_values,
                    message:
                      "You are not authorized to perform this action. Please sign in again or contact support."}

                 {:error, :missing_actor} ->
                   Logger.warning("Required custom fields validation: no actor in context")

                   {:error,
                    field: :custom_field_values,
                    message:
                      "You are not authorized to perform this action. Please sign in again or contact support."}

                 {:error, error} ->
                   Logger.error(
                     "Failed to load custom fields for validation: #{inspect(error)}. Required field validation cannot be performed."
                   )

                   {:error,
                    field: :custom_field_values,
                    message:
                      "Unable to validate required custom fields. Please try again or contact support."}
               end
             end,
             where: [action_is([:create_member, :update_member])]

    # Validate member fields that are marked as required in settings or by Vereinfacht.
    # When settings cannot be loaded, we still enforce email + Vereinfacht-required fields.
    validate fn changeset, _context ->
      vereinfacht_required? = Mv.Config.vereinfacht_configured?()

      required_fields =
        case Mv.Membership.get_settings() do
          {:ok, settings} ->
            required_config = settings.member_field_required || %{}
            normalized = VisibilityConfig.normalize(required_config)

            Enum.filter(Mv.Constants.member_fields(), fn field ->
              field == :email ||
                (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
                Map.get(normalized, field, false)
            end)

          {:error, reason} ->
            Logger.warning(
              "Member required-fields validation: could not load settings (#{inspect(reason)}). " <>
                "Enforcing only email and Vereinfacht-required fields."
            )

            Enum.filter(Mv.Constants.member_fields(), fn field ->
              field == :email ||
                (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field))
            end)
        end

      missing =
        Enum.filter(required_fields, fn field ->
          value = Ash.Changeset.get_attribute(changeset, field)
          not member_field_value_present?(field, value)
        end)

      if Enum.empty?(missing) do
        :ok
      else
        field = hd(missing)

        {:error,
         field: field, message: Gettext.dgettext(MvWeb.Gettext, "default", "can't be blank")}
      end
    end
  end

  attributes do
    uuid_v7_primary_key :id

    attribute :first_name, :string do
      allow_nil? true
      constraints min_length: 1
    end

    attribute :last_name, :string do
      allow_nil? true
      constraints min_length: 1
    end

    # IMPORTANT: Email Synchronization
    # When member and user are linked, emails are automatically synced bidirectionally.
    # User.email is the source of truth - when a link is established, member.email
    # is overridden to match user.email. Subsequent changes to either email will
    # sync to the other resource.
    # See: Mv.EmailSync.Changes.SyncUserEmailToMember
    #      Mv.EmailSync.Changes.SyncMemberEmailToUser
    attribute :email, :string do
      allow_nil? false
      constraints min_length: 5, max_length: 254
    end

    attribute :join_date, :date do
      allow_nil? true
    end

    attribute :exit_date, :date do
      allow_nil? true
    end

    attribute :notes, :string do
      allow_nil? true
    end

    attribute :city, :string do
      allow_nil? true
    end

    attribute :street, :string do
      allow_nil? true
    end

    attribute :house_number, :string do
      allow_nil? true
    end

    attribute :postal_code, :string do
      allow_nil? true
    end

    attribute :country, :string do
      allow_nil? true
    end

    attribute :search_vector, AshPostgres.Tsvector,
      writable?: false,
      public?: false,
      select_by_default?: false

    # Membership fee fields
    # membership_fee_start_date: Date from which membership fees should be calculated
    # If nil, calculated from join_date + global setting
    attribute :membership_fee_start_date, :date do
      allow_nil? true
      public? true
      description "Date from which membership fees should be calculated"
    end

    # Vereinfacht accounting software integration: ID of the finance contact synced via API.
    # Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions.
    attribute :vereinfacht_contact_id, :string do
      allow_nil? true
      public? true
      description "ID of the finance contact in Vereinfacht (set by sync)"
    end
  end

  relationships do
    has_many :custom_field_values, Mv.Membership.CustomFieldValue
    # 1:1 relationship - Member can optionally have one User
    # This references the User's member_id attribute
    # The relationship is optional (allow_nil? true by default)
    has_one :user, Mv.Accounts.User

    # Membership fee relationships
    # belongs_to: The fee type assigned to this member
    # Optional for MVP - can be nil if no fee type assigned yet
    belongs_to :membership_fee_type, Mv.MembershipFees.MembershipFeeType do
      allow_nil? true
    end

    # has_many: All fee cycles for this member
    has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle

    # Groups relationships
    # has_many: All member-group associations for this member
    has_many :member_groups, Mv.Membership.MemberGroup
    # many_to_many: All groups this member belongs to (through MemberGroup)
    many_to_many :groups, Mv.Membership.Group, through: Mv.Membership.MemberGroup
  end

  calculations do
    calculate :current_cycle_status, :atom do
      description "Status of the current cycle (the one that is active today)"
      # Automatically load cycles with all attributes and membership_fee_type
      load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]

      calculation fn [member], _context ->
        case get_current_cycle(member) do
          nil -> [nil]
          cycle -> [cycle.status]
        end
      end

      constraints one_of: [:unpaid, :paid, :suspended]
    end

    calculate :last_cycle_status, :atom do
      description "Status of the last completed cycle (the most recent cycle that has ended)"
      # Automatically load cycles with all attributes and membership_fee_type
      load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]

      calculation fn [member], _context ->
        case get_last_completed_cycle(member) do
          nil -> [nil]
          cycle -> [cycle.status]
        end
      end

      constraints one_of: [:unpaid, :paid, :suspended]
    end

    calculate :overdue_count, :integer do
      description "Count of unpaid cycles that have already ended (cycle_end < today)"
      # Automatically load cycles with all attributes and membership_fee_type
      load membership_fee_cycles: [:cycle_start, :status, membership_fee_type: [:interval]]

      calculation fn [member], _context ->
        overdue = get_overdue_cycles(member)
        count = if is_list(overdue), do: length(overdue), else: 0
        [count]
      end
    end
  end

  # Define identities for upsert operations
  identities do
    identity :unique_email, [:email]
  end

  @doc """
  Checks if a member field should be shown in the overview.

  Reads the visibility configuration from Settings resource. If a field is not
  configured in settings, it defaults to `true` (visible).

  ## Parameters
    - `field` - Atom representing the member field name (e.g., `:email`, `:street`)

  ## Returns
    - `true` if the field should be shown in overview (default)
    - `false` if the field is configured as hidden in settings

  ## Examples

      iex> Member.show_in_overview?(:email)
      true

      iex> Member.show_in_overview?(:street)
      true  # or false if configured in settings

  """
  @spec show_in_overview?(atom()) :: boolean()
  def show_in_overview?(field) when is_atom(field) do
    # exit_date defaults to false (hidden) instead of true
    default_visibility = if field == :exit_date, do: false, else: true

    case Mv.Membership.get_settings() do
      {:ok, settings} ->
        visibility_config = settings.member_field_visibility || %{}
        # Normalize map keys to atoms (JSONB may return string keys)
        normalized_config = VisibilityConfig.normalize(visibility_config)

        # Get value from normalized config, use field-specific default
        Map.get(normalized_config, field, default_visibility)

      {:error, _} ->
        # If settings can't be loaded, use field-specific default
        default_visibility
    end
  end

  def show_in_overview?(_), do: true

  # Helper functions for cycle status calculations
  #
  # These functions expect membership_fee_cycles to be loaded with membership_fee_type
  # preloaded. The calculations explicitly load this relationship, but if called
  # directly, ensure membership_fee_type is loaded or the functions will return
  # nil/[] when membership_fee_type is missing.

  @doc false
  @spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
  def get_current_cycle(member) do
    today = Date.utc_today()

    # Check if cycles are already loaded
    cycles = Map.get(member, :membership_fee_cycles)

    if is_list(cycles) and cycles != [] do
      Enum.find(cycles, &current_cycle?(&1, today))
    else
      nil
    end
  end

  # Checks if a cycle is the current cycle (active today)
  defp current_cycle?(cycle, today) do
    case Map.get(cycle, :membership_fee_type) do
      %{interval: interval} ->
        cycle_end =
          Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)

        Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
          Date.compare(today, cycle_end) in [:lt, :eq]

      _ ->
        false
    end
  end

  @doc false
  @spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
  def get_last_completed_cycle(member) do
    today = Date.utc_today()

    # Check if cycles are already loaded
    cycles = Map.get(member, :membership_fee_cycles)

    if is_list(cycles) and cycles != [] do
      cycles
      |> filter_completed_cycles(today)
      |> sort_cycles_by_end_date()
      |> List.first()
    else
      nil
    end
  end

  # Filters cycles that have ended (cycle_end < today)
  defp filter_completed_cycles(cycles, today) do
    Enum.filter(cycles, fn cycle ->
      case Map.get(cycle, :membership_fee_type) do
        %{interval: interval} ->
          cycle_end =
            Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)

          Date.compare(today, cycle_end) == :gt

        _ ->
          false
      end
    end)
  end

  # Sorts cycles by end date in descending order
  defp sort_cycles_by_end_date(cycles) do
    Enum.sort_by(
      cycles,
      fn cycle ->
        interval = Map.get(cycle, :membership_fee_type).interval
        Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
      end,
      {:desc, Date}
    )
  end

  @doc false
  @spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
  def get_overdue_cycles(member) do
    today = Date.utc_today()

    # Check if cycles are already loaded
    cycles = Map.get(member, :membership_fee_cycles)

    if is_list(cycles) and cycles != [] do
      filter_overdue_cycles(cycles, today)
    else
      []
    end
  end

  # Filters cycles that are unpaid and have ended (cycle_end < today)
  defp filter_overdue_cycles(cycles, today) do
    Enum.filter(cycles, fn cycle ->
      case Map.get(cycle, :membership_fee_type) do
        %{interval: interval} ->
          cycle_end =
            Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)

          cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt

        _ ->
          false
      end
    end)
  end

  # Regenerates cycles when membership fee type changes
  # Deletes future unpaid cycles and regenerates them with the new type/amount
  # Uses advisory lock to prevent concurrent modifications
  # Returns {:ok, notifications} or {:error, reason} where notifications are collected
  # to be sent after transaction commits
  @doc false
  # Uses system actor for cycle regeneration (mandatory side effect)
  def regenerate_cycles_on_type_change(member, _opts \\ []) do
    alias Mv.Helpers
    alias Mv.Helpers.SystemActor

    today = Date.utc_today()
    lock_key = :erlang.phash2(member.id)

    # Use advisory lock to prevent concurrent deletion and regeneration
    # This ensures atomicity when multiple updates happen simultaneously
    if Mv.Repo.in_transaction?() do
      regenerate_cycles_in_transaction(member, today, lock_key)
    else
      regenerate_cycles_new_transaction(member, today, lock_key)
    end
  end

  # Already in transaction: use advisory lock directly
  # Returns {:ok, notifications} - notifications should be returned to after_action hook
  defp regenerate_cycles_in_transaction(member, today, lock_key) do
    Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
    do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
  end

  # Not in transaction: start new transaction with advisory lock
  # Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
  defp regenerate_cycles_new_transaction(member, today, lock_key) do
    Mv.Repo.transaction(fn ->
      Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])

      case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
        {:ok, notifications} ->
          # Return notifications - they will be sent by the caller
          notifications

        {:error, reason} ->
          Mv.Repo.rollback(reason)
      end
    end)
    |> case do
      {:ok, notifications} -> {:ok, notifications}
      {:error, reason} -> {:error, reason}
    end
  end

  # Performs the actual cycle deletion and regeneration
  # Returns {:ok, notifications} or {:error, reason}
  # notifications are collected to be sent after transaction commits
  # Uses system actor for all operations
  defp do_regenerate_cycles_on_type_change(member, today, opts) do
    alias Mv.Helpers
    alias Mv.Helpers.SystemActor

    require Ash.Query

    skip_lock? = Keyword.get(opts, :skip_lock?, false)
    system_actor = SystemActor.get_system_actor()
    actor_opts = Helpers.ash_actor_opts(system_actor)

    # Find all unpaid cycles for this member
    # We need to check cycle_end for each cycle using its own interval
    all_unpaid_cycles_query =
      Mv.MembershipFees.MembershipFeeCycle
      |> Ash.Query.filter(member_id == ^member.id)
      |> Ash.Query.filter(status == :unpaid)
      |> Ash.Query.load([:membership_fee_type])

    case Ash.read(all_unpaid_cycles_query, actor_opts) do
      {:ok, all_unpaid_cycles} ->
        cycles_to_delete = filter_future_cycles(all_unpaid_cycles, today)

        delete_and_regenerate_cycles(
          cycles_to_delete,
          member.id,
          today,
          actor_opts,
          skip_lock?: skip_lock?
        )

      {:error, reason} ->
        {:error, reason}
    end
  end

  # Filters cycles that haven't ended yet (cycle_end >= today)
  # These are the "future" cycles that should be regenerated
  defp filter_future_cycles(all_unpaid_cycles, today) do
    Enum.filter(all_unpaid_cycles, fn cycle ->
      case cycle.membership_fee_type do
        %{interval: interval} ->
          cycle_end =
            Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)

          Date.compare(today, cycle_end) in [:lt, :eq]

        _ ->
          false
      end
    end)
  end

  # Deletes future cycles and regenerates them with the new type/amount
  # Passes today to ensure consistent date across deletion and regeneration
  # Returns {:ok, notifications} or {:error, reason}
  # Uses system actor for cycle generation and deletion
  defp delete_and_regenerate_cycles(cycles_to_delete, member_id, today, actor_opts, opts) do
    skip_lock? = Keyword.get(opts, :skip_lock?, false)

    if Enum.empty?(cycles_to_delete) do
      # No cycles to delete, just regenerate
      regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
    else
      case delete_cycles(cycles_to_delete, actor_opts) do
        :ok -> regenerate_cycles(member_id, today, skip_lock?: skip_lock?)
        {:error, reason} -> {:error, reason}
      end
    end
  end

  # Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
  # Uses system actor for authorization to ensure deletion always works
  defp delete_cycles(cycles_to_delete, actor_opts) do
    delete_results =
      Enum.map(cycles_to_delete, fn cycle ->
        Ash.destroy(cycle, actor_opts)
      end)

    if Enum.any?(delete_results, &match?({:error, _}, &1)) do
      {:error, :deletion_failed}
    else
      :ok
    end
  end

  # Regenerates cycles with new type/amount
  # Passes today to ensure consistent date across deletion and regeneration
  # skip_lock?: true means advisory lock is already set by caller
  # Returns {:ok, notifications} - notifications should be returned to after_action hook
  defp regenerate_cycles(member_id, today, opts) do
    skip_lock? = Keyword.get(opts, :skip_lock?, false)

    case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
           member_id,
           today: today,
           skip_lock?: skip_lock?
         ) do
      {:ok, _cycles, notifications} when is_list(notifications) ->
        {:ok, notifications}

      {:error, reason} ->
        {:error, reason}
    end
  end

  # Handles cycle generation for a member, choosing sync or async execution
  # based on environment (test vs production)
  # This function encapsulates the common logic for cycle generation
  # to avoid code duplication across different hooks
  # Uses system actor for cycle generation (mandatory side effect)
  # Captures initiator for audit trail (if available in opts)
  defp handle_cycle_generation(member, opts) do
    initiator = Keyword.get(opts, :initiator)

    if Mv.Config.sql_sandbox?() do
      handle_cycle_generation_sync(member, initiator)
    else
      handle_cycle_generation_async(member, initiator)
    end
  end

  # Runs cycle generation synchronously (for test environment)
  defp handle_cycle_generation_sync(member, initiator) do
    case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
           member.id,
           today: Date.utc_today(),
           initiator: initiator
         ) do
      {:ok, cycles, notifications} ->
        send_notifications_if_any(notifications)

        log_cycle_generation_success(member, cycles, notifications,
          sync: true,
          initiator: initiator
        )

      {:error, reason} ->
        log_cycle_generation_error(member, reason, sync: true, initiator: initiator)
    end
  end

  # Runs cycle generation asynchronously (for production environment)
  defp handle_cycle_generation_async(member, initiator) do
    Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
      case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
             initiator: initiator
           ) do
        {:ok, cycles, notifications} ->
          send_notifications_if_any(notifications)

          log_cycle_generation_success(member, cycles, notifications,
            sync: false,
            initiator: initiator
          )

        {:error, reason} ->
          log_cycle_generation_error(member, reason, sync: false, initiator: initiator)
      end
    end)
    |> Task.await(:infinity)
  end

  # Sends notifications if any are present
  defp send_notifications_if_any(notifications) do
    if Enum.any?(notifications) do
      Ash.Notifier.notify(notifications)
    end
  end

  # Logs successful cycle generation
  defp log_cycle_generation_success(member, cycles, notifications,
         sync: sync?,
         initiator: initiator
       ) do
    sync_label = if sync?, do: "", else: " (async)"
    initiator_info = get_initiator_info(initiator)

    Logger.debug(
      "Successfully generated cycles for member#{sync_label} (initiator: #{initiator_info})",
      member_id: member.id,
      cycles_count: length(cycles),
      notifications_count: length(notifications)
    )
  end

  # Logs cycle generation errors
  defp log_cycle_generation_error(member, reason, sync: sync?, initiator: initiator) do
    sync_label = if sync?, do: "", else: " (async)"
    initiator_info = get_initiator_info(initiator)

    Logger.error(
      "Failed to generate cycles for member#{sync_label} (initiator: #{initiator_info})",
      member_id: member.id,
      member_email: member.email,
      error: inspect(reason),
      error_type: error_type(reason)
    )
  end

  # Extracts initiator information for audit trail
  defp get_initiator_info(nil), do: "system"
  defp get_initiator_info(%{email: email}), do: email
  defp get_initiator_info(_), do: "unknown"

  # Helper to extract error type for structured logging
  defp error_type(%{__struct__: struct_name}), do: struct_name
  defp error_type(error) when is_atom(error), do: error
  defp error_type(_), do: :unknown

  @doc """
  Performs fuzzy search on members using PostgreSQL trigram similarity.

  Wraps the `:search` action with convenient opts-based argument passing.
  Searches across first_name, last_name, email, and other text fields using
  full-text search combined with trigram similarity.

  ## Parameters
    - `query` - Ash.Query.t() to apply search to
    - `opts` - Keyword list or map with search options:
      - `:query` or `"query"` - Search string

  ## Returns
    - Modified Ash.Query.t() with search filters applied

  ## Examples

      iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!()
      [%Member{first_name: "Greta", ...}]

      iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!()  # typo-tolerant
      [%Member{first_name: "Greta", ...}]

  """
  @spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t()
  def fuzzy_search(query, opts) do
    q = (opts[:query] || opts["query"] || "") |> to_string()

    if String.trim(q) == "" do
      query
    else
      Ash.Query.for_read(query, :search, %{query: q})
    end
  end

  # ============================================================================
  # Search Input Sanitization
  # ============================================================================

  # Sanitizes search input to prevent LIKE pattern injection.
  # Escapes SQL LIKE wildcards (% and _) and limits query length.
  #
  # ## Examples
  #
  #     iex> sanitize_search_query("test%injection")
  #     "test\\%injection"
  #
  #     iex> sanitize_search_query("very_long_search")
  #     "very\\_long\\_search"
  #
  defp sanitize_search_query(query) when is_binary(query) do
    query
    |> String.slice(0, 100)
    |> String.replace("\\", "\\\\")
    |> String.replace("%", "\\%")
    |> String.replace("_", "\\_")
  end

  defp sanitize_search_query(_), do: ""

  # ============================================================================
  # Search Filter Builders
  # ============================================================================
  # These functions build search filters grouped by search type for maintainability.
  # Priority order: FTS > Substring > Custom Fields > Fuzzy Matching

  # Builds full-text search filter using tsvector (highest priority, fastest)
  # Uses GIN index on search_vector for optimal performance
  defp build_fts_filter(query) do
    expr(
      fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^query) or
        fragment("search_vector @@ plainto_tsquery('simple', ?)", ^query)
    )
  end

  # Builds substring search filter for structured fields
  # Note: contains/2 uses ILIKE '%value%' which is not index-optimized
  # Performance: Good for small datasets, may be slow on large tables
  defp build_substring_filter(query, _pattern) do
    expr(
      contains(postal_code, ^query) or
        contains(house_number, ^query) or
        contains(email, ^query) or
        contains(city, ^query) or
        contains(country, ^query)
    )
  end

  # Builds search filter for custom field values using ILIKE on JSONB
  # Note: ILIKE on JSONB is not index-optimized, may be slow with many custom fields
  # This is a fallback for substring matching in custom fields (e.g., phone numbers)
  # Uses ->> operator which always returns TEXT directly (no need for -> + ::text fallback)
  # Important: `id` must be passed as parameter to correctly reference the outer members table
  defp build_custom_field_filter(pattern) do
    expr(
      fragment(
        "EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = ? AND (value->>'_union_value' ILIKE ? OR value->>'value' ILIKE ?))",
        id,
        ^pattern,
        ^pattern
      )
    )
  end

  # Builds fuzzy/trigram matching filter for name and street fields.
  # Uses pg_trgm extension with GIN indexes for performance.
  #
  # Two-tier matching strategy:
  # - % operator: Uses server-wide pg_trgm.similarity_threshold (typically 0.3)
  #   for fast index-based initial filtering
  # - similarity/word_similarity: Uses @default_similarity_threshold (0.2)
  #   for more lenient matching to catch edge cases
  #
  # Note: Requires trigram GIN indexes on first_name, last_name, street.
  defp build_fuzzy_filter(query, threshold) do
    expr(
      fragment("? % first_name", ^query) or
        fragment("? % last_name", ^query) or
        fragment("? % street", ^query) or
        fragment("word_similarity(?, first_name) > ?", ^query, ^threshold) or
        fragment("word_similarity(?, last_name) > ?", ^query, ^threshold) or
        fragment("word_similarity(?, street) > ?", ^query, ^threshold) or
        fragment("similarity(first_name, ?) > ?", ^query, ^threshold) or
        fragment("similarity(last_name, ?) > ?", ^query, ^threshold) or
        fragment("similarity(street, ?) > ?", ^query, ^threshold)
    )
  end

  # Private helper to apply filters for :available_for_linking action
  # user_email: may be nil/empty when creating new user, or populated when editing
  # search_query: optional search term for fuzzy matching
  #
  # Logic: (email == user_email) OR (fuzzy_search on search_query)
  # - Empty user_email ("") → email == "" is always false → only fuzzy search matches
  # - This allows a single filter expression instead of duplicating fuzzy search logic
  #
  # Note: Custom field search is intentionally excluded from linking to optimize
  # autocomplete performance. Custom fields are still searchable via the main
  # member search which uses the indexed search_vector.
  defp apply_linking_filters(query, user_email, search_query) do
    has_search = search_query && String.trim(search_query) != ""
    # Use empty string instead of nil to simplify filter logic
    trimmed_email = if user_email, do: String.trim(user_email), else: ""

    if has_search do
      # Search query provided: return email-match OR fuzzy-search candidates
      trimmed_search = String.trim(search_query)
      # Sanitize for LIKE patterns (contains uses ILIKE internally)
      sanitized_search = sanitize_search_query(trimmed_search)

      # Build search filters - excluding custom_field_filter for performance
      fts_match = build_fts_filter(trimmed_search)
      fuzzy_match = build_fuzzy_filter(trimmed_search, @default_similarity_threshold)
      email_substring_match = expr(contains(email, ^sanitized_search))

      query
      |> Ash.Query.filter(
        expr(
          # Email exact match has highest priority (for filter_by_email_match)
          # If email is "", this is always false and search filters take over
          email == ^trimmed_email or
            ^fts_match or
            ^fuzzy_match or
            ^email_substring_match
        )
      )
    else
      # No search query: return all unlinked (filter_by_email_match will prioritize email if provided)
      query
    end
  end

  # Extracts provided custom field values from changeset
  # Handles both create (from argument) and update (from existing data) scenarios
  defp provided_custom_field_values(changeset) do
    custom_field_values_arg = Ash.Changeset.get_argument(changeset, :custom_field_values)

    if is_nil(custom_field_values_arg) do
      extract_existing_values(changeset.data, changeset)
    else
      extract_argument_values(custom_field_values_arg)
    end
  end

  # Extracts custom field values from existing member data (update scenario).
  # Actor must come from context; no system-actor fallback (per guidelines).
  # When no actor is present we skip the load and return empty map.
  defp extract_existing_values(member_data, changeset) do
    case Map.get(changeset.context, :actor) do
      nil ->
        %{}

      actor ->
        opts = Helpers.ash_actor_opts(actor)

        case Ash.load(member_data, :custom_field_values, opts) do
          {:ok, %{custom_field_values: existing_values}} ->
            Enum.reduce(existing_values, %{}, &extract_value_from_cfv/2)

          _ ->
            %{}
        end
    end
  end

  # Extracts value from a CustomFieldValue struct
  defp extract_value_from_cfv(cfv, acc) do
    value = extract_union_value(cfv.value)
    Map.put(acc, cfv.custom_field_id, value)
  end

  # Extracts value from union type (map or direct value)
  defp extract_union_value(value) when is_map(value), do: Map.get(value, :value)
  defp extract_union_value(value), do: value

  # Extracts custom field values from provided argument (create/update scenario)
  defp extract_argument_values(custom_field_values_arg) do
    Enum.reduce(custom_field_values_arg, %{}, &extract_value_from_arg/2)
  end

  # Extracts value from argument map
  defp extract_value_from_arg(cfv, acc) do
    custom_field_id = Map.get(cfv, "custom_field_id")
    value_map = Map.get(cfv, "value", %{})
    actual_value = extract_value_from_map(value_map)
    Map.put(acc, custom_field_id, actual_value)
  end

  # Extracts value from map, supporting both "value" and "_union_value" keys
  # Also handles Ash.Union structs (which have atom keys :value and :type)
  # Uses cond instead of || to preserve false values
  defp extract_value_from_map(value_map) do
    cond do
      # Handle Ash.Union struct - check if it's a struct with __struct__ == Ash.Union
      match?({:ok, Ash.Union}, Map.fetch(value_map, :__struct__)) ->
        Map.get(value_map, :value)

      # Handle map with string keys
      Map.has_key?(value_map, "value") ->
        Map.get(value_map, "value")

      Map.has_key?(value_map, "_union_value") ->
        Map.get(value_map, "_union_value")

      # Handle map with atom keys
      Map.has_key?(value_map, :value) ->
        Map.get(value_map, :value)

      true ->
        nil
    end
  end

  # Finds which required custom fields are missing from provided values
  defp missing_required_fields(required_custom_fields, provided_values) do
    Enum.filter(required_custom_fields, fn cf ->
      value = Map.get(provided_values, cf.id)
      not value_present?(value, cf.value_type)
    end)
  end

  # Builds validation error message for missing required custom fields
  defp build_custom_field_validation_error(missing_fields) do
    # Sort missing fields alphabetically for consistent error messages
    sorted_missing_fields = Enum.sort_by(missing_fields, & &1.name)
    missing_names = Enum.map_join(sorted_missing_fields, ", ", & &1.name)

    {:error,
     field: :custom_field_values,
     message:
       Gettext.dgettext(MvWeb.Gettext, "default", "Required custom fields missing: %{fields}",
         fields: missing_names
       )}
  end

  # Helper function to check if a value is present for a given custom field type
  # Boolean: false is valid, only nil is invalid
  # String: nil or empty strings are invalid
  # Integer: nil or empty strings are invalid, 0 is valid
  # Date: nil or empty strings are invalid
  # Email: nil or empty strings are invalid
  defp value_present?(nil, _type), do: false

  defp value_present?(value, :boolean), do: not is_nil(value)

  defp value_present?(value, :string), do: is_binary(value) and String.trim(value) != ""

  defp value_present?(value, :integer) when is_integer(value), do: true

  defp value_present?(value, :integer) when is_binary(value), do: String.trim(value) != ""

  defp value_present?(_value, :integer), do: false

  defp value_present?(value, :date) when is_struct(value, Date), do: true

  defp value_present?(value, :date) when is_binary(value), do: String.trim(value) != ""

  defp value_present?(_value, :date), do: false

  defp value_present?(value, :email) when is_binary(value), do: String.trim(value) != ""

  defp value_present?(_value, :email), do: false

  defp value_present?(_value, _type), do: false

  # Used by member-field-required validation (settings-driven required fields)
  defp member_field_value_present?(_field, nil), do: false

  defp member_field_value_present?(_, value) when is_binary(value),
    do: String.trim(value) != ""

  defp member_field_value_present?(_, %Date{}), do: true
  defp member_field_value_present?(_, value) when is_struct(value, Date), do: true
  defp member_field_value_present?(_, _), do: false
end
