# frozen_string_literal: true

# Un sitio es un directorio dentro del directorio de sitios de cada
# Usuaria
class Site < ApplicationRecord
  include FriendlyId
  include Site::Forms
  include Site::FindAndReplace

  # Cifrar la llave privada que cifra y decifra campos ocultos.  Sutty
  # tiene acceso pero los datos se guardan cifrados en el sitio.  Esto
  # protege información privada en repositorios públicos, pero no la
  # protege de acceso al panel de Sutty!
  encrypts :private_key

  # TODO: Hacer que los diferentes tipos de deploy se auto registren
  # @see app/services/site_service.rb
  DEPLOYS = %i[local private www zip hidden_service].freeze

  validates :name, uniqueness: true, hostname: {
    allow_root_label: true
  }

  validates :design_id, presence: true
  validates_inclusion_of :status, in: %w[waiting enqueued building]
  validates_presence_of :title
  validates :description, length: { in: 50..160 }
  validate :deploy_local_presence
  validate :compatible_layouts, on: :update

  attr_reader :incompatible_layouts

  friendly_id :name, use: %i[finders]

  belongs_to :design
  belongs_to :licencia

  has_many :log_entries
  has_many :deploys
  has_many :build_stats, through: :deploys
  has_many :roles
  has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') },
           through: :roles
  has_many :invitades, -> { where('roles.rol = ?', 'invitade') },
           through: :roles, source: :usuarie

  # Mantenemos el nombre que les da Jekyll
  has_many_attached :static_files

  # Clonar el directorio de esqueleto antes de crear el sitio
  before_create :clone_skel!
  # Elimina el directorio al destruir un sitio
  before_destroy :remove_directories!
  # Carga el sitio Jekyll una vez que se inicializa el modelo o después
  # de crearlo
  after_initialize :load_jekyll
  after_create :load_jekyll, :static_file_migration!
  # Cambiar el nombre del directorio
  before_update :update_name!
  before_save :add_private_key_if_missing!
  # Guardar la configuración si hubo cambios
  after_save :sync_attributes_with_config!

  accepts_nested_attributes_for :deploys, allow_destroy: true

  # El sitio en Jekyll
  attr_reader :jekyll

  # No permitir HTML en estos atributos
  def title=(title)
    super(title.strip_tags)
  end

  def description=(description)
    super(description.strip_tags)
  end

  # El repositorio git para este sitio
  def repository
    @repository ||= Site::Repository.new path
  end

  def hostname
    sub = name || I18n.t('deploys.deploy_local.ejemplo')

    if sub.ends_with? '.'
      sub.gsub(/\.\Z/, '')
    else
      "#{sub}.#{Site.domain}"
    end
  end

  # Devuelve la URL siempre actualizada a través del hostname
  #
  # @param slash Boolean Agregar / al final o no
  # @return String La URL con o sin / al final
  def url(slash: true)
    'https://' + hostname + (slash ? '/' : '')
  end

  # Obtiene los dominios alternativos
  #
  # @return Array
  def alternative_hostnames
    deploys.where(type: 'DeployAlternativeDomain').map(&:hostname).map do |h|
      h.end_with?('.') ? h[0..-2] : h + '.' + Site.domain
    end
  end

  # Obtiene todas las URLs alternativas para este sitio
  #
  # @return Array
  def alternative_urls(slash: true)
    alternative_hostnames.map do |h|
      'https://' + h + (slash ? '/' : '')
    end
  end

  # Todas las URLs posibles para este sitio
  #
  # @return Array
  def urls(slash: true)
    alternative_urls(slash: slash) << url(slash: slash)
  end

  def invitade?(usuarie)
    !invitades.find_by(id: usuarie.id).nil?
  end

  def usuarie?(usuarie)
    !usuaries.find_by(id: usuarie.id).nil?
  end

  # Este sitio acepta invitades?
  def invitades?
    acepta_invitades || config.fetch('invitades', false)
  end

  # Traer la ruta del sitio
  def path
    File.join(Site.site_path, name)
  end

  # La ruta anterior
  def path_was
    File.join(Site.site_path, name_was)
  end

  # Obtiene la lista de traducciones actuales
  #
  # Siempre tiene que tener algo porque las traducciones están
  # incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan
  # sus sitios.
  def locales
    config.fetch('locales', I18n.available_locales.map(&:to_s))
  end

  # Similar a site.i18n en jekyll-locales
  def i18n
    data[I18n.locale.to_s]
  end

  # Devuelve el idioma por defecto del sitio, el primero de la lista.
  def default_locale
    locales.first
  end
  alias default_lang default_locale

  def read?
    @read ||= false
  end

  # Lee el sitio y todos los artículos
  def read
    # No hacer nada si ya se leyó antes
    return if read?

    @jekyll.read
    @read = true
  end

  # Trae los datos del directorio _data dentro del sitio
  #
  # XXX: Leer directamente sin pasar por Jekyll
  def data
    read

    @jekyll.data
  end

  # Traer las colecciones.  Todos los artículos van a estar dentro de
  # colecciones.
  def collections
    read

    @jekyll.collections
  end

  # Traer la configuración de forma modificable
  def config
    @config ||= Site::Config.new(self)
  end

  # Los posts en el idioma actual o en uno en particular
  #
  # @param lang: [String|Symbol] traer los artículos de este idioma
  def posts(lang: nil)
    read

    # Traemos los posts del idioma actual por defecto
    lang ||= I18n.locale

    # Crea un Struct dinámico con los valores de los locales, si
    # llegamos a pasar un idioma que no existe vamos a tener una
    # excepción NoMethodError
    @posts ||= Struct.new(*locales.map(&:to_sym)).new

    return @posts[lang] unless @posts[lang].blank?

    @posts[lang] = PostRelation.new site: self

    # No fallar si no existe colección para este idioma
    # XXX: queremos fallar silenciosamente?
    (collections[lang.to_s].try(:docs) || []).each do |doc|
      layout = layouts[Post.find_layout(doc)]

      @posts[lang].build(document: doc, layout: layout, lang: lang)
    end

    @posts[lang]
  end

  # Todos los Post del sitio para poder buscar en todos.
  #
  # @return PostRelation
  def docs
    @docs ||= PostRelation.new(site: self).push(locales.flat_map do |locale|
      posts(lang: locale)
    end).flatten!
  end

  # Elimina un artículo de la colección
  def delete_post(post)
    lang = post.lang.value

    collections[lang.to_s].docs.delete(post.document) &&
      posts(lang: lang).delete(post)

    post
  end

  # Obtiene todas las plantillas de artículos
  #
  # @return { post: Layout }
  def layouts
    # Crea un Struct dinámico cuyas llaves son los nombres de todos los
    # layouts.  Si pasamos un layout que no existe, obtenemos un
    # NoMethodError
    @layouts_struct ||= Struct.new(*layout_keys, keyword_init: true)
    @layouts ||= @layouts_struct.new(**data.fetch('layouts', {}).map do |name, metadata|
      { name.to_sym => Layout.new(site: self,
                                  name: name.to_sym,
                                  metadata: metadata.with_indifferent_access) }
    end.inject(:merge))
  end

  def layout_keys
    @layout_keys ||= data.fetch('layouts', {}).keys.map(&:to_sym)
  end

  # Consulta si el Layout existe
  #
  # @param [String,Symbol] El nombre del Layout
  # @return [Boolean]
  def layout?(layout)
    layout_keys.include? layout.to_sym
  end

  # Trae todos los valores disponibles para un campo
  #
  # TODO: Traer recursivamente, si el campo contiene Hash
  #
  # TODO: Mover a PostRelation#pluck
  #
  # @param attr [Symbol|String] El atributo a buscar
  # @return Array
  def everything_of(attr, lang: nil)
    attr = attr.to_sym

    posts(lang: lang).flat_map do |p|
      p.send(attr).try(:value) if p.attribute? attr
    end.uniq.compact
  end

  # Poner en la cola de compilación
  def enqueue!
    !enqueued? && update_attribute(:status, 'enqueued')
  end

  # Está en la cola de compilación?
  def enqueued?
    status == 'enqueued'
  end

  # Cargar el sitio Jekyll
  #
  # TODO: En lugar de leer todo junto de una vez, extraer la carga de
  # documentos de Jekyll hacia Sutty para que podamos leer los datos que
  # necesitamos.
  def load_jekyll
    return unless name.present? && File.directory?(path)

    reload_jekyll!
  end

  def reload_jekyll!
    reset

    Dir.chdir(path) do
      @jekyll = Jekyll::Site.new(configuration)
    end
  end

  def reload
    super
    reload_jekyll!
  end

  def configuration
    return @configuration if @configuration

    # Pasamos destination porque configuration() toma el directorio
    # actual
    #
    # Especificamos `safe` para no cargar los _plugins, que interfieren
    # entre sitios incluso.
    #
    # excerpt_separator está vacío para no incorporar el Excerpt en los
    # metadatos de Document
    @configuration =
      ::Jekyll.configuration('source' => path,
                             'destination' => File.join(path, '_site'),
                             'safe' => true, 'watch' => false,
                             'quiet' => true, 'excerpt_separator' => '')

    # No necesitamos cargar plugins en este momento
    %w[plugins gems].each do |unneeded|
      @configuration[unneeded] = [] if @configuration.key? unneeded
    end

    # Eliminar el theme si no es una gema válida
    @configuration.delete('theme') unless theme_available?

    # Si estamos usando nuestro propio plugin de i18n, los posts están
    # en "colecciones"
    locales.each do |i|
      @configuration['collections'][i] = {}
    end

    @configuration
  end

  # Lista los nombres de las plantillas disponibles como gemas,
  # tomándolas dinámicamente de las que agreguemos en el grupo :themes
  # del Gemfile.
  def available_themes
    @available_themes ||= Bundler.load.current_dependencies.select do |gem|
      gem.groups.include? :themes
    end.map(&:name)
  end

  # Detecta si el tema actual es una gema
  def theme_available?
    available_themes.include? design.gem
  end

  # Devuelve el dominio actual
  def self.domain
    ENV.fetch('SUTTY', 'sutty.nl')
  end

  # El directorio donde se almacenan los sitios
  def self.site_path
    File.join(Rails.root, '_sites')
  end

  def self.default
    find_by(name: Site.domain + '.')
  end

  def reset
    @read = false
    @layouts = nil
    @layouts_struct = nil
    @layout_keys = nil
    @configuration = nil
    @repository = nil
    @incompatible_layouts = nil
    @jekyll = nil
    @config = nil
    @posts = nil
    @docs = nil
  end

  private

  # Asegurarse que el sitio tenga una llave privada
  def add_private_key_if_missing!
    self.private_key ||= Lockbox.generate_key
  end

  # Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada
  # si el sitio ya existe
  def clone_skel!
    return if File.directory? path

    Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path
  end

  # Elimina el directorio del sitio
  def remove_directories!
    FileUtils.rm_rf path
  end

  def update_name!
    return unless name_changed?

    FileUtils.mv path_was, path
    reload_jekyll!
  end

  # Sincroniza algunos atributos del sitio con su configuración y
  # guarda los cambios
  #
  # TODO: Guardar la configuración también, quizás aprovechando algún
  # método de ActiveRecord para que lance un salvado recursivo.
  def sync_attributes_with_config!
    config.theme = design.gem unless design.no_theme?
    config.description = description
    config.title = title
    config.url = url
    config.hostname = hostname
  end

  # Migra los archivos a Sutty
  def static_file_migration!
    Site::StaticFileMigration.new(site: self).migrate!
  end

  # Valida si el sitio tiene al menos una forma de alojamiento asociada
  # y es la local
  #
  # TODO: Volver opcional el alojamiento local, pero ahora mismo está
  # atado a la generación del sitio así que no puede faltar
  def deploy_local_presence
    # Usamos size porque queremos saber la cantidad de deploys sin
    # guardar también
    return if deploys.size.positive? && deploys.map(&:type).include?('DeployLocal')

    errors.add(:deploys, I18n.t('activerecord.errors.models.site.attributes.deploys.deploy_local_presence'))
  end

  # Valida que al cambiar de plantilla no tengamos artículos en layouts
  # inexistentes.
  def compatible_layouts
    return unless design_id_changed?

    new_configuration = configuration.dup

    if design.no_theme?
      new_configuration.delete 'theme'
    else
      new_configuration['theme'] = design.gem
    end

    new_site = Jekyll::Site.new(new_configuration)
    new_site.read

    # No vale la pena seguir procesando si no hay artículos!
    return if new_site.documents.empty?

    new_site.documents.map(&:read!)

    old_layouts = new_site.documents.map do |doc|
      doc.data['layout']
    end.uniq.compact

    @incompatible_layouts = old_layouts - new_site.layouts.keys

    return if @incompatible_layouts.empty?

    @incompatible_layouts.map! do |layout|
      i18n.dig('layouts', layout) || layout
    end

    errors.add(:design_id,
               I18n.t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.error'))
  end
end