class Application < ApplicationRecord
  has_secure_password :client_secret, validations: false

  # Virtual attribute to control client type during creation
  # When true, no client_secret will be generated (public client)
  attr_accessor :is_public_client

  # Virtual setters for TTL fields - accept human-friendly durations
  # e.g., "1h", "30m", "1d", or plain numbers "3600"
  def access_token_ttl=(value)
    parsed = DurationParser.parse(value)
    super(parsed)
  end

  def refresh_token_ttl=(value)
    parsed = DurationParser.parse(value)
    super(parsed)
  end

  def id_token_ttl=(value)
    parsed = DurationParser.parse(value)
    super(parsed)
  end

  after_commit :bust_forward_auth_cache, if: :forward_auth?

  has_one_attached :icon
  has_one_attached :icon_dark

  ICON_ATTACHMENTS = %i[icon icon_dark].freeze

  before_validation :sanitize_svg_icons
  after_save :fix_icon_content_types

  has_many :application_groups, dependent: :destroy
  has_many :allowed_groups, through: :application_groups, source: :group
  has_many :application_user_claims, dependent: :destroy
  has_many :oidc_authorization_codes, dependent: :destroy
  has_many :oidc_access_tokens, dependent: :destroy
  has_many :oidc_refresh_tokens, dependent: :destroy
  has_many :oidc_user_consents, dependent: :destroy
  has_many :api_keys, dependent: :destroy

  validates :name, presence: true
  validates :slug, presence: true, uniqueness: {case_sensitive: false},
    format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
  validates :app_type, presence: true,
    inclusion: {in: %w[oidc forward_auth]}
  validates :client_id, uniqueness: {allow_nil: true}
  validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
  validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
  validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
  validates :backchannel_logout_uri, format: {
    with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
    allow_nil: true,
    message: "must be a valid HTTP or HTTPS URL"
  }
  validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
  validate :backchannel_logout_uri_not_internal, if: -> { backchannel_logout_uri.present? }

  # Icon validation using ActiveStorage validators
  validate :icon_validation

  # Token TTL validations (for OIDC apps)
  validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc?  # 5 min - 24 hours
  validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 7776000}, if: :oidc?  # 5 min - 90 days
  validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc?  # 5 min - 24 hours

  normalizes :slug, with: ->(slug) { slug.strip.downcase }
  normalizes :domain_pattern, with: ->(pattern) {
    normalized = pattern&.strip&.downcase
    normalized.blank? ? nil : normalized
  }
  normalizes :backchannel_logout_uri, with: ->(uri) {
    normalized = uri&.strip
    normalized.blank? ? nil : normalized
  }

  before_validation :generate_client_credentials, on: :create, if: :oidc?

  # Default header configuration for ForwardAuth
  DEFAULT_HEADERS = {
    user: "X-Remote-User",
    email: "X-Remote-Email",
    name: "X-Remote-Name",
    username: "X-Remote-Username",
    groups: "X-Remote-Groups",
    admin: "X-Remote-Admin"
  }.freeze

  # Scopes
  scope :active, -> { where(active: true) }
  scope :oidc, -> { where(app_type: "oidc") }
  scope :forward_auth, -> { where(app_type: "forward_auth") }
  scope :ordered, -> { order(domain_pattern: :asc) }

  # Type checks
  def oidc?
    app_type == "oidc"
  end

  def forward_auth?
    app_type == "forward_auth"
  end

  DCR_SETTING_KEY = "dynamic_client_registration".freeze

  # OAuth 2.0 Dynamic Client Registration (RFC 7591) is opt-in: it lets anyone
  # anonymously create an OIDC client, so it is disabled by default. An admin
  # toggles it at runtime (open the window, let a client self-register, attach it
  # to a group, close the window again). Newly registered clients are still
  # default-deny (no allowed_groups) until an admin grants access.
  #
  # The persisted Setting is authoritative once set; until then we fall back to
  # the CLINCH_DCR_ENABLED env var (bootstrap/headless default, off if unset).
  def self.dynamic_registration_enabled?
    stored = Setting.boolean(DCR_SETTING_KEY)
    return stored unless stored.nil?
    ActiveModel::Type::Boolean.new.cast(ENV["CLINCH_DCR_ENABLED"])
  end

  # Client type checks (for OIDC)
  def public_client?
    client_secret_digest.blank?
  end

  def confidential_client?
    !public_client?
  end

  # PKCE requirement check
  # Public clients MUST use PKCE (no client secret to protect auth code)
  # Confidential clients can optionally require PKCE (OAuth 2.1 recommendation)
  def requires_pkce?
    return false unless oidc?
    return true if public_client?  # Always require PKCE for public clients
    require_pkce?  # Check the flag for confidential clients
  end

  # Access control
  # Default-deny: an empty allowed_groups list means no one gets in.
  # To make an app accessible to "everyone", attach the seeded auto-assign
  # group (or any group every user is in).
  def user_allowed?(user)
    return false unless active?
    return false unless user.active?
    (user.groups & allowed_groups).any?
  end

  # OIDC helpers
  def parsed_redirect_uris
    return [] unless redirect_uris.present?
    JSON.parse(redirect_uris)
  rescue JSON::ParserError
    redirect_uris.split("\n").map(&:strip).reject(&:blank?)
  end

  def parsed_metadata
    return {} unless metadata.present?
    JSON.parse(metadata)
  rescue JSON::ParserError
    {}
  end

  # ForwardAuth helpers
  def parsed_headers_config
    return {} unless headers_config.present?
    headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
  rescue JSON::ParserError
    {}
  end

  # Check if a domain matches this application's pattern (for ForwardAuth)
  def matches_domain?(domain)
    return false if domain.blank? || !forward_auth?

    pattern = domain_pattern.gsub(".", '\.')
    pattern = pattern.gsub("*", "[^.]*")

    regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
    regex.match?(domain.downcase)
  end

  # Policy determination based on user status (for ForwardAuth)
  def policy_for_user(user)
    return "deny" unless active?
    return "deny" unless user.active?

    if user_allowed?(user)
      # Require 2FA if user has TOTP configured, otherwise one factor
      user.totp_enabled? ? "two_factor" : "one_factor"
    else
      "deny"
    end
  end

  # Get effective header configuration (for ForwardAuth)
  def effective_headers
    DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys)
  end

  # Generate headers for a specific user (for ForwardAuth)
  def headers_for_user(user)
    headers = {}
    effective = effective_headers

    # Only generate headers that are configured (not set to nil/false)
    effective.each do |key, header_name|
      next unless header_name.present?  # Skip disabled headers

      case key
      when :user, :email
        headers[header_name] = user.email_address
      when :name
        headers[header_name] = user.name.presence || user.email_address
      when :username
        headers[header_name] = user.username if user.username.present?
      when :groups
        headers[header_name] = user.groups.map(&:name).join(",") if user.groups.any?
      when :admin
        headers[header_name] = user.admin? ? "true" : "false"
      end
    end

    headers
  end

  # Check if all headers are disabled (for ForwardAuth)
  def headers_disabled?
    headers_config.present? && effective_headers.values.all?(&:blank?)
  end

  # Generate and return a new client secret
  def generate_new_client_secret!
    secret = SecureRandom.urlsafe_base64(48)
    self.client_secret = secret
    save!
    secret
  end

  # Token TTL helper methods (for OIDC)
  def access_token_expiry
    (access_token_ttl || 3600).seconds.from_now
  end

  def refresh_token_expiry
    (refresh_token_ttl || 2592000).seconds.from_now
  end

  def id_token_expiry_seconds
    id_token_ttl || 3600
  end

  # Human-readable TTL for display
  def access_token_ttl_human
    duration_to_human(access_token_ttl || 3600)
  end

  def refresh_token_ttl_human
    duration_to_human(refresh_token_ttl || 2592000)
  end

  def id_token_ttl_human
    duration_to_human(id_token_ttl || 3600)
  end

  # Get app-specific custom claims for a user
  def custom_claims_for_user(user)
    app_claim = application_user_claims.find_by(user: user)
    app_claim&.parsed_custom_claims || {}
  end

  # Check if this application supports backchannel logout
  def supports_backchannel_logout?
    backchannel_logout_uri.present?
  end

  # Check if a user has an active session with this application
  # (i.e., has valid, non-revoked tokens)
  def user_has_active_session?(user)
    oidc_access_tokens.where(user: user).valid.exists? ||
      oidc_refresh_tokens.where(user: user).valid.exists?
  end

  private

  def bust_forward_auth_cache
    Rails.application.config.forward_auth_cache&.delete("fa_apps")
  end

  def fix_icon_content_types
    ICON_ATTACHMENTS.each do |attr|
      attachment = public_send(attr)
      next unless attachment.attached?
      # Fix SVG content type if it was detected incorrectly
      if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
        attachment.blob.update(content_type: "image/svg+xml")
      end
    end
  end

  def sanitize_svg_icons
    # Runs in before_validation. The blob has NOT yet been uploaded to disk at
    # this point (Active Storage uploads in before_save), so we cannot call
    # download — we must read from the pending attachable.
    #
    # attach below re-sets attachment_changes and would re-fire this callback;
    # we skip if the pending attachable is the cleaned hash we just installed
    # (tracked by object identity, per-attribute).
    @svg_sanitized_attachables ||= {}

    ICON_ATTACHMENTS.each do |attr|
      change = attachment_changes[attr.to_s]
      next unless change
      attachable = change.attachable
      next if attachable.equal?(@svg_sanitized_attachables[attr])

      raw_svg, filename, content_type = read_pending_icon(attachable)
      next unless raw_svg
      next unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")

      doc = Loofah.xml_document(raw_svg)
      doc.scrub!(SvgScrubber.new)
      clean_svg = doc.to_xml

      sanitized = {
        io: StringIO.new(clean_svg),
        filename: filename,
        content_type: "image/svg+xml"
      }
      @svg_sanitized_attachables[attr] = sanitized
      public_send(attr).attach(sanitized)
    end
  end

  def read_pending_icon(attachable)
    case attachable
    when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
      content = attachable.read
      attachable.rewind
      [content, attachable.original_filename, attachable.content_type]
    when Hash
      io = attachable[:io] || attachable["io"]
      return [nil, nil, nil] unless io
      content = io.read
      io.rewind if io.respond_to?(:rewind)
      [content,
        attachable[:filename] || attachable["filename"],
        attachable[:content_type] || attachable["content_type"]]
    else
      [nil, nil, nil]
    end
  end

  def icon_validation
    allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]

    ICON_ATTACHMENTS.each do |attr|
      attachment = public_send(attr)
      next unless attachment.attached?

      unless allowed_types.include?(attachment.content_type)
        errors.add(attr, "must be a PNG, JPG, GIF, or SVG image")
      end

      if attachment.blob.byte_size > 2.megabytes
        errors.add(attr, "must be less than 2MB")
      end
    end
  end

  def duration_to_human(seconds)
    if seconds < 3600
      "#{seconds / 60} minutes"
    elsif seconds < 86400
      "#{seconds / 3600} hours"
    else
      "#{seconds / 86400} days"
    end
  end

  def generate_client_credentials
    self.client_id ||= SecureRandom.urlsafe_base64(32)
    # Generate client secret only for confidential clients
    # Public clients (is_public_client checked) don't get a secret - they use PKCE only
    if new_record? && client_secret.blank? && !is_public_client_selected?
      secret = SecureRandom.urlsafe_base64(48)
      self.client_secret = secret
    end
  end

  # Check if the user selected public client option
  def is_public_client_selected?
    ActiveModel::Type::Boolean.new.cast(is_public_client)
  end

  def backchannel_logout_uri_must_be_https_in_production
    return unless Rails.env.production?
    return unless backchannel_logout_uri.present?

    begin
      uri = URI.parse(backchannel_logout_uri)
      unless uri.scheme == "https"
        errors.add(:backchannel_logout_uri, "must use HTTPS in production")
      end
    rescue URI::InvalidURIError
      # Let the format validator handle invalid URIs
    end
  end

  # SSRF guard: the backchannel logout URI is dialled server-side on every user
  # logout, so it must not target internal infrastructure (loopback, private
  # ranges, or the link-local cloud metadata endpoint). This is the fast,
  # config-time check; BackchannelLogoutJob re-checks with DNS resolution.
  def backchannel_logout_uri_not_internal
    uri = URI.parse(backchannel_logout_uri)
    if uri.host.present? && PrivateAddressCheck.internal_host?(uri.host)
      errors.add(:backchannel_logout_uri, "must not point to a private, loopback, or link-local address")
    end
  rescue URI::InvalidURIError
    # Let the format validator handle invalid URIs
  end
end
