Item.java

package org.troy.capstone.entities;

import java.time.ZoneId;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import org.apache.commons.text.similarity.JaroWinklerSimilarity;
import org.troy.capstone.constants.ItemSimilarityWeights;
import org.troy.capstone.constants.TableColumnName;
import org.troy.capstone.constants.URL;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import net.datafaker.Faker;
import tech.tablesaw.api.Row;


/**
 * A shopping item with various attributes. Main entity of the application. Implements cloneable to allow deep copy of {@code ItemHashMap}.
 */
@Data
@Builder
@AllArgsConstructor
public class Item implements Cloneable{
    /** Faker instance for generating random data. */
    private static final Faker faker = new Faker();

    /** Unique identifier for the item. */
    private String id;
    /** Index of the item in the collection. */
    private short index;
    /** URL of the item's image. */
    private String imageUrl;
    /** Name of the item. */
    private String name;
    /** Publisher of the item. */
    private String publisher;
    /** Description of the item. */
    private String description;
    /** Category of the item. */
    private String category;
    /** Tags associated with the item. */
    private Set<String> tags;
    /** Author of the item's photo. */
    private String photoAuthor;
    /** URL of the photo's author. */
    private String photoAuthorUrl;
    /** Price of the item. */
    private float price;
    /** Review score of the item. */
    private float reviewScore;
    /** Number of reviews for the item. */
    private short reviewCount;
    /** Quantity of the item in stock. */
    private short stockQuantity;
    /** Date the item was added to the collection. */
    private Date dateAdded;
    /** Jaro-Winkler similarity instance for calculating string similarity. */
    private static final JaroWinklerSimilarity JARO_WINKLER_SIMILARITY = new JaroWinklerSimilarity();

    /** Private constructor to prevent direct instantiation of the Item class and to satisfy a Javadoc warning. */
    @SuppressWarnings("unused")
    private Item() {}
    
    /**
     * Returns the value of the specified attribute for this item. The attribute is determined by the provided {@code TableColumnName} enum value.
     * 
     * @pre column is not null and corresponds to a valid attribute of the {@code Item} class.
     * 
     * @param column An enum value representing the attribute to retrieve from this item.
     * @return The value of the specified attribute for this item, returned as an Object. The caller must cast result.
     * @throws IllegalArgumentException If the provided column does not correspond to a valid attribute of the {@code Item} class.
     */
    public Object getAttribute( TableColumnName column ){
        return switch(column){
            case INDEX -> index;
            case ID -> id;
            case IMAGE_URL -> imageUrl;
            case NAME -> name;
            case PUBLISHER -> publisher;
            case DESCRIPTION -> description;
            case CATEGORY -> category;
            case TAGS -> tags;
            case PRICE -> price;
            case REVIEW_SCORE -> reviewScore;
            case REVIEW_COUNT -> reviewCount;
            case STOCK_QUANTITY -> stockQuantity;
            case DATE_ADDED -> dateAdded;
            case PHOTO_AUTHOR -> photoAuthor;
            case PHOTO_AUTHOR_URL -> photoAuthorUrl;
            default -> throw new IllegalArgumentException("Unexpected value: " + column);
        };
    }

    /**
     * Generates a random {@code Item} object with realistic values for each attribute using the {@code Faker} library.
     * @deprecated Used for testing purposes, not intended for production use.
     * @return A randomly generated {@code Item} object with all attributes populated with realistic random values.
     */
    @Deprecated
    public static Item randomItem(){
        return Item.builder()
            .imageUrl( URL.DEFAULT_IMAGE_URL.getUrl() )
            .name( faker.commerce().productName() )
            .publisher( faker.company().name() )
            .description( String.join(" ", faker.lorem().sentences(2) ) )
            .category( faker.commerce().department() )
            .tags( Set.copyOf( faker.lorem().words(3) ) )
            .price( (float) faker.number().randomDouble(2, 5, 500) )
            .reviewScore( (float) faker.number().randomDouble(1, 1, 5) )
            .reviewCount( (short) faker.number().numberBetween(0, 1000) )
            .stockQuantity( (short) faker.number().numberBetween(0, 100) )
            .dateAdded( Date.from( faker.timeAndDate().future(365, TimeUnit.DAYS) ) )
            .photoAuthor( URL.DEFAULT_AUTHOR_NAME.getUrl() )
            .photoAuthorUrl( URL.DEFAULT_AUTHOR_URL.getUrl() )
            .id( faker.internet().uuid() )
            .build();
    }

    /**
     * Creates an {@code Item} object from a tablesaw {@code Row}. The {@code Row} must contain columns corresponding to the attributes of the {@code Item} class.
     * The method of converting a {@code LocalDate} to a {@code Date} is sourced from [7].
     * @pre itemRow is not null and contains the expected columns for creating an {@code Item} (ID, Name, etc.).
     * 
     * @param itemRow A {@code Row} from a tablesaw {@code Table} containing item info.
     * @return An {@code Item} object created from the data in the provided {@code Row}.
     */
    public static Item fromRow( Row itemRow ){
        return Item.builder()
                .index( itemRow.getShort(TableColumnName.INDEX.getColumnName()) )
                .imageUrl( itemRow.getString(TableColumnName.IMAGE_URL.getColumnName()) )
                .name( itemRow.getString(TableColumnName.NAME.getColumnName()) )
                .publisher( itemRow.getString(TableColumnName.PUBLISHER.getColumnName()) )
                .description( itemRow.getString(TableColumnName.DESCRIPTION.getColumnName()) )
                .category( itemRow.getString(TableColumnName.CATEGORY.getColumnName()) )
                .tags( new HashSet<>( Arrays.asList( itemRow.getString(TableColumnName.TAGS.getColumnName()).split(", ") ) ) )
                .price( itemRow.getFloat(TableColumnName.PRICE.getColumnName()) )
                .reviewScore( itemRow.getFloat(TableColumnName.REVIEW_SCORE.getColumnName()) )
                .reviewCount( itemRow.getShort(TableColumnName.REVIEW_COUNT.getColumnName()) )
                .stockQuantity( itemRow.getShort(TableColumnName.STOCK_QUANTITY.getColumnName()) )
                .id( itemRow.getString(TableColumnName.ID.getColumnName()) )
                .photoAuthor( itemRow.getString(TableColumnName.PHOTO_AUTHOR.getColumnName()) )
                .photoAuthorUrl( itemRow.getString(TableColumnName.PHOTO_AUTHOR_URL.getColumnName()) )
                .dateAdded( Date.from(itemRow.getDate(TableColumnName.DATE_ADDED.getColumnName())
                            .atStartOfDay(ZoneId.systemDefault()).toInstant()) 
                        )
            .build();
    }

    /**
     * Calculates a similarity score between this item and another item based on shared attributes such as category, publisher, tags, and price. The similarity score is a float value between 0.0 and 1.0, where higher values indicate greater similarity.
     * 
     * @param other The other Item to compare against this item for similarity.
     * @return A float value representing the similarity between the two items.
    */
    public float similarity(Item other){
        float similarity = 0.0f;
        similarity += ItemSimilarityWeights.NAME.getWeight() * JARO_WINKLER_SIMILARITY.apply(this.name, other.name);
        similarity += ItemSimilarityWeights.PUBLISHER.getWeight() * (this.publisher.equals(other.publisher) ? 1.0f : 0.0f);
        similarity += ItemSimilarityWeights.DESCRIPTION.getWeight() * JARO_WINKLER_SIMILARITY.apply(this.description, other.description);
        similarity += ItemSimilarityWeights.CATEGORY.getWeight() * (this.category.equals(other.category) ? 1.0f : 0.0f);
        similarity += ItemSimilarityWeights.TAGS.getWeight() * (float) this.tags.stream().filter(other.tags::contains).count();
        similarity += ItemSimilarityWeights.PHOTO_AUTHOR.getWeight() * (this.photoAuthor.equals(other.photoAuthor) ? 1.0f : 0.0f);
        similarity += ItemSimilarityWeights.PRICE.getWeight() * Math.abs(this.price - other.price) / 500.0f; //Normalize price difference to [0, 1] range based on a high price
        similarity += ItemSimilarityWeights.REVIEW_SCORE.getWeight() * Math.abs(this.reviewScore - other.reviewScore) / 5.0f; //Normalize review score difference to [0, 1] range based on max score of 5
        similarity += ItemSimilarityWeights.DATE_ADDED.getWeight() * ((float) Math.abs(this.dateAdded.getTime() - other.dateAdded.getTime()) / TimeUnit.DAYS.toMillis(1000)); //Normalize date difference to [0, 1] range based on 1000 days
        return similarity;
    }

    /** Creates and returns a deep copy of this {@code Item} instance.
     * @return A deep copy of this {@code Item} instance.
     * @throws CloneNotSupportedException if the {@code Item} cannot be cloned.
     */
    @Override
    public Object clone() throws CloneNotSupportedException {
        Item cloned = (Item) super.clone();
        cloned.tags = new HashSet<>(this.tags); //Deep copy of mutable Set
        cloned.dateAdded = (Date) this.dateAdded.clone(); //Deep copy of mutable Date
        return cloned;
    }
}