Author.java

/*
 * Copyright © 2023-2025 The CTAN Team and individual authors
 *
 * This file is distributed under the 3-clause BSD license.
 * See file LICENSE for details.
 */
package org.ctan.site.domain.catalogue;

import java.io.IOException;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.apache.lucene.index.CorruptIndexException;
import org.ctan.site.CtanConfiguration.CtanConfig;
import org.ctan.site.domain.AbstractEntity;
import org.ctan.site.domain.Gender;
import org.ctan.site.services.search.base.IndexType;
import org.ctan.site.services.search.base.IndexingSession;
import org.ctan.site.services.search.base.IndexingSession.IndexArgs;
import org.ctan.site.services.search.base.Searchable;

import com.fasterxml.jackson.annotation.JsonIgnore;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder.Default;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;

/**
 * This is the domain class for an author in the CTAN Catalogue.
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Entity
@Table(name = "author")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
@EqualsAndHashCode(callSuper = false)
@SuppressFBWarnings(value = "EI_EXPOSE_REP")
public class Author extends AbstractEntity implements Searchable {

    /**
     * This enumeration lists known name formats.
     */
    public enum NameFormat {

        /**
         * The field <code>WESTERN</code> indicates a Western name.
         */
        WESTERN(0) {

            @Override
            public String formatName(Author name) {

                return Author.fmt(name.givenname, name.von, name.familyname,
                    name.junior);
            }

            @Override
            public String formatNameSortable(Author name) {

                return Author.fmt(name.familyname, ",", name.givenname,
                    name.von, name.junior);
            }
        },
        /**
         * The field <code>FILE</code> contains the indicates a Chinese type of
         * name.
         */
        CHINESE(1) {

            @Override
            public String formatName(Author name) {

                return Author.fmt(name.familyname, name.von, name.givenname,
                    name.junior);
            }

            @Override
            public String formatNameSortable(Author name) {

                return Author.fmt(name.givenname, ",", name.familyname,
                    name.von, name.junior);
            }
        };

        /**
         * Finder for the message type by its numerical id.
         *
         * @param id the id
         * @return the associated element or {@code null}
         */
        static NameFormat of(int id) {

            return switch (id) {
                case 0 -> WESTERN;
                case 1 -> CHINESE;
                default -> null;
            };
        }

        /**
         * The field <code>id</code> contains the numerical representation.
         */
        final int id;

        /**
         * Creates a new object.
         *
         * @param id the id
         */
        NameFormat(int id) {

            this.id = id;
        }

        /**
         * Format the name of the author.
         *
         * @param author the author
         * @return the name of the author
         */
        abstract String formatName(Author author);

        /**
         * The method <code>formatNameSortable</code> provides means to format a
         * name for sorting.
         *
         * @param author the author
         * @return the sorting criterion
         */
        abstract String formatNameSortable(Author author);

        /**
         * Getter for the value.
         *
         * @return the value
         */
        public int value() {

            return id;
        }
    }

    /**
     * The method <code>fmt</code> provides a function of formatting several
     * strings into one. The arguments are separated by a single space unless
     * the argument is a comma. Then the comma is attached directly.
     *
     * @param args the arguments
     * @return the concatenated string
     */
    static String fmt(String... args) {

        var buffer = new StringBuilder();
        for (String it : args) {
            if (it != null && !"".equals(it)) {
                if (buffer.length() != 0) {
                    if (",".equals(it)) {
                        buffer.append(',');
                    } else {
                        buffer.append(' ');
                        buffer.append(it);
                    }
                } else if (!",".equals(it)) {
                    buffer.append(it);
                }
            }
        }
        return buffer.toString().replaceAll("[ ,]+$", "");
    }

    /**
     * Test for non-equality.
     *
     * @param a the first parameter
     * @param b the second parameter
     * @return {@code true} if the two arguments are not equal
     */
    static boolean neq(String a, String b) {

        return a == null ? b != null : !a.equals(b);
    }

    /**
     * The field <code>key</code> contains the unique key of the author.
     *
     * <p>
     * The key is assigned by the CTAN team.
     * </p>
     *
     * <p>
     * By convention the key consists of a lower-case letter followed by
     * lower-case letters, digits or hyphens. In the simplest case it is the
     * family name. If this is not unique the the initial of the given name is
     * added after a hyphen.
     * </p>
     */
    @Column(length = 96, unique = true, nullable = false)
    private String key;

    /**
     * The field <code>format</code> contains the indicator how the name should
     * be formatted.
     */
    @Enumerated(EnumType.ORDINAL)
    @Column
    @JsonIgnore
    @Default
    @EqualsAndHashCode.Exclude
    private NameFormat format = NameFormat.WESTERN;

    /**
     * The field <code>givenname</code> contains the given or first name of the
     * author.
     */
    @Column(length = 128)
    @EqualsAndHashCode.Exclude
    private String givenname;

    /**
     * The field <code>von</code> contains the von part of the name; cf. BibTeX.
     */
    @Column(length = 128)
    @EqualsAndHashCode.Exclude
    private String von;

    /**
     * The field <code>familyname</code> contains the family or last name of the
     * author. For companies or groups of people the value is empty.
     */
    @Column(length = 128)
    @EqualsAndHashCode.Exclude
    private String familyname;

    /**
     * The field <code>junior</code> contains the junior part of the name; cf.
     * BibTeX.
     */
    @Column(length = 128)
    @EqualsAndHashCode.Exclude
    private String junior;

    /**
     * The field <code>title</code> contains the optional title of the author.
     */
    @Column(length = 128)
    @EqualsAndHashCode.Exclude
    private String title;

    /**
     * The field <code>pseudonym</code> contains the optional pseudonym of the
     * author. In case a pseudonym is present then the real name of the author
     * is not exposed to the public.
     */
    @Column(length = 64)
    @EqualsAndHashCode.Exclude
    private String pseudonym;

    /**
     * The field <code>sortText</code> contains the cached sort text.
     */
    @Column(name = "sort_text", length = 255)
    @Default
    @EqualsAndHashCode.Exclude
    private String sortText = "";

    /**
     * The field <code>died</code> contains the indicator whether the author is
     * known to be dead.
     */
    @Column
    @Default
    @EqualsAndHashCode.Exclude
    private Boolean died = Boolean.FALSE;

    /**
     * The field <code>gender</code> contains the gender classification of the
     * author.
     */
    @Column(length = 1)
    @Enumerated(EnumType.STRING)
    @Default
    @EqualsAndHashCode.Exclude
    private Gender gender = Gender.M;

    /**
     * The field <code>refs</code> contains references leading to the packages
     * of this author.
     */
    @ManyToMany(mappedBy = "author", fetch = FetchType.LAZY)
    @EqualsAndHashCode.Exclude
    @Default
    private Set<AuthorRef> refs = new HashSet<>();

    /**
     * The field <code>emails</code> contains a list of known email addresses of
     * the author. Those email addresses are treated confidential and are not
     * exposed to the public.
     */
    @OneToMany(mappedBy = "author", fetch = FetchType.LAZY,
        orphanRemoval = true)
    // @JsonBackReference
    @JsonIgnore
    @Default
    @EqualsAndHashCode.Exclude
    private List<AuthorEmail> emails = new ArrayList<>();

    /**
     * Add a reference to a package.
     *
     * @param pkg the package
     * @param active the active indicator
     * @return this
     */
    public Author addPkg(Pkg pkg, boolean active) {

        refs.add(new AuthorRef(pkg, this, active));
        return this;
    }

    /**
     * Getter for the first email.
     *
     * @return the first email or {@code null}
     */
    public String getEmail() {

        return (emails == null || emails.isEmpty()
            ? null
            : emails.get(0).toString());
    }

    /**
     * {@inheritDoc}
     *
     * @see org.ctan.site.services.search.base.Searchable#indexPath()
     */
    @Override
    public String indexPath() {

        return "/author/" + key;
    }

    /**
     * The method <code>setEmails</code> provides means to set the emails.
     *
     * <p>
     * If a value starts with * then the address is marked as inactive
     *
     * @param values the encoded values
     */
    public void setEmails(String... values) {

        emails = Arrays.asList(values).stream()
            .map(it -> AuthorEmail.builder()
                .address(it.replaceAll("^[*]", ""))
                .inactive(it.startsWith("*"))
                .build())
            .collect(Collectors.toList());
    }

    /**
     * This method is the setter for family name.
     *
     * @param familyname the new family name
     */
    public void setFamilyname(String familyname) {

        if (neq(this.familyname, familyname)) {
            this.familyname = familyname;
            sortText = updateSortText();
        }
    }

    /**
     * This method is the setter for given name.
     *
     * @param givenname the new given name
     */
    public void setGivenname(String givenname) {

        if (neq(this.givenname, givenname)) {
            this.givenname = givenname;
            sortText = updateSortText();
        }
    }

    /**
     * This method is the setter for junior name.
     *
     * @param junior the new junior name part
     */
    public void setJunior(String junior) {

        if (neq(this.junior, junior)) {
            this.junior = junior;
            sortText = updateSortText();
        }
    }

    /**
     * This method is the setter for pseudonym.
     *
     * @param pseudonym the new pseudonym
     */
    public void setPseudonym(String pseudonym) {

        if ("".equals(pseudonym)) {
            pseudonym = null;
        }
        if (neq(this.pseudonym, pseudonym)) {
            this.pseudonym = pseudonym;
            sortText = updateSortText();
        }
    }

    /**
     * This method is the setter for von name.
     *
     * @param von the new von name part
     */
    public void setVon(String von) {

        if (neq(this.von, von)) {
            this.von = von;
            sortText = updateSortText();
        }
    }

    /**
     * The method <code>toLexString</code> provides means to get a string
     * representation for lexical ordering.
     *
     * @return the string for lexical ordering
     */
    public String toLexString() {

        return (pseudonym != null && pseudonym.length() > 0
            ? pseudonym
            : (format != null ? format : NameFormat.WESTERN)
                .formatNameSortable(this)).toLowerCase();
    }

    /**
     * The method <code>toMap</code> provides means to translate the instance
     * into a Map.
     *
     * @return the key-value map for the author
     */
    public Map<String, Object> toMap() {

        Map<String, Object> map = new HashMap<String, Object>();
        map.put("key", key);
        var mails = new ArrayList<Map<String, String>>();
        for (var em : emails) {
            mails.add(Map.of(
                "email", em.getAddress(),
                "inactive", em.getInactive() ? "true" : "false",
                "note", em.getNote() != null ? em.getNote() : ""));
        }
        map.put("email", mails);
        if (pseudonym != null) {
            map.put("pseudonym", pseudonym);
        } else {
            map.put("title", title);
            map.put("familyname", familyname);
            map.put("givenname", givenname);
            map.put("von", von);
            map.put("junior", junior);
        }
        map.put("died", died);
        map.put("format", format.toString());
        map.put("gender", gender.getValue());
        map.put("name", format.formatName(this));
        return map;
    }

    /**
     * {@inheritDoc}
     *
     * @see java.lang.Object#toString()
     */
    @Override
    public String toString() {

        return pseudonym != null && pseudonym.length() > 0
            ? pseudonym
            : (format != null ? format : NameFormat.WESTERN)
                .formatName(this);
    }

    /**
     * {@inheritDoc}
     *
     * @see org.ctan.site.services.search.base.Searchable#updateIndex(IndexingSession)
     */
    @Override
    public void updateIndex(IndexingSession session)
        throws CorruptIndexException,
            IOException {

        IndexArgs args = IndexArgs.builder()
            .type(IndexType.AUTHORS)
            .title(toString())
            .display(toString())
            .content(new String[]{toString()})
            .build();
        for (var locale : CtanConfig.LOCALES) {
            args.setLocale(locale);
            session.updateIndex(indexPath(), args);
        }
    }

    /**
     * Compute the internal attribute for sorting.
     *
     * @return the string for ordering
     */
    String updateSortText() {

        var text = (pseudonym != null
            ? pseudonym
            : (format != null
                ? format
                : NameFormat.WESTERN)
                    .formatNameSortable(this))
                        .toLowerCase()
                        .replaceAll("^ ?(the )?", "")
                        .replaceAll(" +", " ");
        return Normalizer.normalize(text, Normalizer.Form.NFD)
            .replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
    }
}