package com.gmail.dpierron.calibre.datamodel;

import com.gmail.dpierron.calibre.configuration.Configuration;
import com.gmail.dpierron.tools.Helper;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import java.io.File;
import java.util.*;
import java.util.regex.*;

public class Book extends GenericDataObject   {
  private final static Logger logger = LogManager.getLogger(Book.class);

  private File bookFolder;
  private final String id;
  private final String uuid;
  private String title;
  private String titleSort;
  private final String path;
  private String comment;
  private String summary;
  private Integer summaryMaxLength;
  private final Float serieIndex;
  private final Date timestamp;
  private final Date modified;
  private final Date publicationDate;
  private final String isbn;
  private List<Author> authors;
  private String listOfAuthors;
  private String authorSort;
  private Publisher publisher;
  private Series series;
  private List<Tag> tags;
  private List<EBookFile> files;
  private EBookFile preferredFile;
  private EBookFile epubFile;
  private String epubFileName;
  private long latestFileModifiedDate = -1;
  private final BookRating rating;
  private List<Language> bookLanguages = new LinkedList<Language>();
  private List<CustomColumnValue> customColumnValues;
  private static Date ZERO;
  private static final Pattern tag_br = Pattern.compile("\\<br\\>", Pattern.CASE_INSENSITIVE);
  protected Book copyOfBook;          // If a book is copied then this points to the original

  // Flags
  // NOTE: Using byte plus bit settings is more memory efficient than using boolean types
  private final static byte FLAG_ALL_CLEAR = 0;
  private final static byte FLAG_DONE = 0x01;                   // Set when the Book full details have been generated
  private final static byte FLAG_REFERENCED = 0x02;             // Set if book full details must be generated as referenced
  private final static byte FLAG_FILES_SORTED = 0x04;
  private final static byte FLAG_EPUBFILE_COMPUTED = 0x08;
  private final static byte FLAG_PREFERREDFILECOMPUTED = 0x10;
  private final static byte FLAG_CHANGED = 0x20;
  private final static byte FLAG_FLAGGED = 0x40;
  private byte flags = FLAG_ALL_CLEAR;

  static {
    Calendar c = Calendar.getInstance();
    c.setTimeInMillis(0);
    ZERO = c.getTime();
  }

  // CONSTRUCTORS

  public Book(String id,
      String uuid,
      String title,
      String title_sort,
      String path,
      Float serieIndex,
      Date timestamp,
      Date modified,
      Date publicationDate,
      String isbn,
      String authorSort,
      BookRating rating) {
    super();
    copyOfBook = null;
    this.id = id;
    this.uuid = uuid;

    if (title != null) {
      // Do some (possibly unnecessary) tidyong of title
      title = title.trim();
      this.title = title.substring(0, 1).toUpperCase() + title.substring(1);
      // title_sort is normally set by Calibre autoamtically, but it can be cleared
      // by users and may not be set by older versions of Calibre.  In these
      // cases we fall back to using the (mandatory) title field and issue a warning
      if (Helper.isNullOrEmpty(title_sort)) {
        logger.warn("Title_Sort not set - using Title for book '" + this.title + "'"); Helper.statsWarnings++;
        this.titleSort = DataModel.getNoiseword(getFirstBookLanguage()).removeLeadingNoiseWords(this.title);
      } else {
        this.titleSort = title_sort;
      }
      // Small memory optimisation to re-use title object if possible
      // TODO check if unecessary if Java does this automatically?
      if (this.title.equalsIgnoreCase(this.titleSort)) {
        this.titleSort = this.title;
      }
    }

    this.path = path;
    this.serieIndex = serieIndex;
    this.timestamp = timestamp;
    this.modified = modified;
    this.publicationDate = publicationDate;
    this.tags = new LinkedList<Tag>();
    this.files = new LinkedList<EBookFile>();
    this.authors = new LinkedList<Author>();
    this.isbn = isbn;
    this.authorSort = authorSort;
    this.rating = rating;

  }

  // METHODS and PROPERTIES implementing Abstract ones from Base class)

  /**
   * Method that gets the name for this 'data' type
   *
   * @return
   */
  public String getColumnName () {
    return "book";
  }

  public ColumType getColumnType() {
    return ColumType.COLUMN_BOOK;
  }

  public String getDisplayName() {
    return (copyOfBook == null) ? title : copyOfBook.getDisplayName();
  }

  public String getSortName() {
    return (copyOfBook == null) ? titleSort : copyOfBook.getSortName();
  }

  public String getTextToDisplay() {
    return DataModel.displayTitleSort ? getSortName() : getDisplayName() ;
  }

  public String getTextToSort() {
    return DataModel.librarySortTitle ? getDisplayName() : getSortName();
  }

  // METHODS and PROPERTIES

  /**
   *   Helper routine to set flags bits state
   */
  private void setFlags (boolean b, int f) {
    if (b == true)
      flags |= f;           // Set flag bits
    else
      flags &= ~f;           // Clear flag bits;
  }

  /**
   * Helper routine to check if specified kags set
   * @param f
   * @return
   */
  private boolean isFlags( int f) {
    return ((flags & f) == f);
  }


  public String getId() {
    return (copyOfBook == null) ? id : copyOfBook.getId();
  }

  public String getUuid() {
    return (copyOfBook == null) ? uuid : copyOfBook.getUuid();
  }

  /**
   * Return the first listed language for a book
   * (this is on the assumption it is the most important one)
   * @return
   */
  public Language getFirstBookLanguage() {
    if (copyOfBook != null) return copyOfBook.getFirstBookLanguage();
    List<Language> languages = getBookLanguages();
    if ((languages == null) || (languages.size() == 0))
      return null;
    else
      return languages.get(0);
  }

  /**
   * Get all the languages for a book.
   * Most of the time there will only be one, but Ca;ibre
   * allows for multpiple languages on the same book.
   * @return
   */
  public List<Language> getBookLanguages() {
    return  (copyOfBook  == null) ?  bookLanguages :   copyOfBook.getBookLanguages();
  }

  /**
   * Add a language to the book.
   *
   * @param bookLanguage
   */
  public void addBookLanguage(Language bookLanguage) {
    assert copyOfBook == null;        // Never expect this to be used on a copy of the book!
    if (getBookLanguages() == null) {
      bookLanguages = new LinkedList<Language>();
    }
    if (!bookLanguages.contains(bookLanguage)) {
      bookLanguages.add(bookLanguage);
    }
  }

  public String getIsbn() {
    return (copyOfBook == null) ? (isbn == null ? "" : isbn) :  copyOfBook.getIsbn();
  }

  public String getPath() {
    return  (copyOfBook  == null) ?  path :   copyOfBook.getPath();
  }

  public String getComment() {
    return  (copyOfBook  == null) ?  comment :   copyOfBook.getComment();
  }

  /**
   * Remove leading text from the given XHTNL string.  This is
   * used to remove words such as SUMMARY that we add  ourselves
   * in the catalog.   It is also used to remove other common
   * expressions from the Summary to try and leave text that is more
   * useful as a summary.
   *
   *        Special processing requirements:
   *        - Any leading HTML tags or spaces are ignored
   *        - Any trailing ':' or spaces are removed
   *        - If after removing the desired text there are
   *          HTML tags that were purely surrounding the text
   *          that has been removed, they are also removed.
   * @param text            Text that is being worked on
   * @param leadingText     String to be checked for (case ignored)
   * @return                Result of removing text, or null if input was null
   */
  private String removeLeadingText (String text, String leadingText) {
    // Check for fast exit conditions
    if (Helper.isNullOrEmpty(text) || Helper.isNullOrEmpty(leadingText) )
      return text;
    int textLength = text.length();
    int leadingLength = leadingText.length();

    int cutStart = 0;          // Start scanning from beginning of string
    int cutStartMax = textLength - leadingLength - 1 ;

    // skip over leading tags and spaces
    boolean scanning = true;
    while (scanning) {
      // Give up no room left to match text
      if (cutStart > cutStartMax)
        return text;
      // Look for special characters
      switch (text.charAt(cutStart)) {
        case ' ':
                cutStart++;
                break;
        case '<':
                // Look for end of tag
                int tagEnd = text.indexOf('>',cutStart);
                // If not found then give up But should this really occur)
                if (tagEnd == -1)
                  return text;
                else
                  cutStart = tagEnd + 1;
                break;
        default:
                scanning = false;
                break;
      }
    }

   // Exit if text does not match
    if (! text.substring(cutStart).toUpperCase(Locale.ENGLISH).startsWith(leadingText.toUpperCase(Locale.ENGLISH)))
      return text;
    // Set end of text to remove
    int cutEnd = cutStart + leadingLength;

    // After removing leading text, now remove any tags that are now empty of content
    // TODO - complete the logic here.  Currently does not remove such empty tags
    scanning=true;
    while (scanning) {
      if (cutEnd >= textLength)  {
        scanning = false;
      }
      else {
        switch (text.charAt(cutEnd)) {
            case ' ':
            case ':':
                cutEnd++;
                break;
            case '<':
                if (text.charAt(cutEnd+1) != '/'){
                  // Handle case of BR tag following removed text
                  if (text.substring(cutEnd).toUpperCase().startsWith("<BR")) {
                      int tagEnd = text.indexOf('>', cutEnd+1);
                      if (tagEnd != -1)  {
                        cutEnd = tagEnd + 1;
                        break;
                      }
                  }
                  scanning = false;
                  break;
                }
                else {
                  int tagEnd = text.indexOf('>');
                  if (tagEnd == -1)  {
                    scanning = false;
                    break;
                  }
                  else {
                      cutEnd = tagEnd + 1;
                  }
                }
                break;
            default:
                scanning = false;
                break;
        } // End of switch
      }
    }   // End of while

    if (cutStart > 0)
      return (text.substring(0, cutStart) + text.substring(cutEnd)).trim();
    else
      return text.substring(cutEnd).trim();
  }

  /**
   * Sets the comment value
   * If it starts with 'SUMMARY' then this is removed as superfluous
   * NOTE.  Comments are allowed to contain (X)HTML) tags
   * @param value the new comment
   */
  public void setComment(String value) {
    assert copyOfBook == null;    // Never expect this to be used on a copy
    summary = null;
    summaryMaxLength = -1;
    if (Helper.isNotNullOrEmpty(value)) {
      if (value.startsWith("<")) {
        if (value.contains("<\\br") || value.contains("<\\BR")) {
          int dummy = 1;
        }
        // Remove newlines from HTML as they are meangless in that context
          value = value.replaceAll("\\n","");
        // Remove any empty paragraphs.
        value= value.replaceAll("<p></p>","");
      }
      comment = removeLeadingText(value, "SUMMARY");
      comment = removeLeadingText(comment, "PRODUCT DESCRIPTION");
      // The following log entry can be useful if trying to debug character encoding issues
      // logger.info("Book " + id + ", setComment (Hex): " + Database.stringToHex(comment));
    
      if (comment != null && comment.matches("(?i)<br>")) {
        logger.warn("<br> tag in comment changed to <br /> for Book: Id=" + id + " Title=" + title); Helper.statsWarnings++;
        comment.replaceAll("(?i)<br>", "<br />");
      }
    }
  }

  /**
   * Get the book summary
   * This starts with any series information, and then as much of the book comment as
   * will fit in the space allowed.
   *
   * Special processing Requirements
   * - The word 'SUMMARY' is removed as superfluous at the start of the comment text
   * - The words 'PRODUCT DESCRIPTION' are removed as superfluous at the start of the comment text
   * - The calculated value is stored for later re-use (which is very likely to happen).
   * NOTE.  Summary must be pure text (no (X)HTML tags) for OPDS compatibility
   * @param maxLength   Maximum length of text allowed in summary
   * @return The value of the calculated summary field
   */
  public String getSummary(int maxLength) {
    if  (copyOfBook  != null) return copyOfBook.getSummary(maxLength);
    if (summary == null  || maxLength != summaryMaxLength) {
      summary = "";
      summaryMaxLength = maxLength;
      // Check for series info to include
      if (Helper.isNotNullOrEmpty(getSeries())) {
        float seriesIndexFloat = getSerieIndex();
        if (seriesIndexFloat == 0 ) {
          // We do not add the index when it is zero
          summary += getSeries().getDisplayName() + ": ";
        } else {
          int seriesIndexInt = (int) seriesIndexFloat;
          String seriesIndexText;
          // For the commonest case of integers we want to truncate off the fractional part
          if (seriesIndexFloat == (float) seriesIndexInt)
            seriesIndexText = String.format("%d", seriesIndexInt);
          else {
            // For fractions we want only 2 decimal places to match calibre
            seriesIndexText = String.format("%.2f", seriesIndexFloat);
          }
          summary += getSeries().getDisplayName() + " [" + seriesIndexText + "]: ";
        }
        if (maxLength != -1) {
          summary = Helper.shorten(summary, maxLength);
        }
      }
      // See if still space for comment info
      // allow for special case of -1 which means no limit.
      if (maxLength == -1 || (maxLength > (summary.length() + 3))) {
        String noHtml = Helper.removeHtmlElements(getComment());
        if (noHtml != null) {
          noHtml = removeLeadingText(noHtml, "SUMMARY");
          noHtml = removeLeadingText(noHtml, "PRODUCT DESCRIPTION");
          if (maxLength == -1 ) {
            summary += noHtml;
          } else {
            summary += Helper.shorten(noHtml, maxLength - summary.length());
          }
        }
      }
    }
    return summary;
  }

  /**&
   * Get the series index.
   * It is thought that Calibre always sets this but it is better to play safe!
   * @return
   */
  public Float getSerieIndex() {
    if  (copyOfBook  != null) return copyOfBook.getSerieIndex();
    if (Helper.isNotNullOrEmpty(serieIndex)) {
      return  serieIndex;
    }
    // We never expect to get here!
    logger.warn("Unexpected null/empty Series Index for book " + getDisplayName() + "(" + getId() + ")"); Helper.statsWarnings++;
    return (float)1.0;
  }

  public Date getTimestamp() {
    if  (copyOfBook  != null) return copyOfBook.getTimestamp();
    // ITIMPI:  Return 'now' if timestamp not set - would 0 be better?
    if (timestamp == null) {
      logger.warn("Date/Time Added not set for book '" + title + "'"); Helper.statsWarnings++;
      return new Date();
    }
    return timestamp;
  }

  public Date getModified() {
    if  (copyOfBook  != null) return copyOfBook.getModified();
    // ITIMPI:  Return 'now' if modified not set - would 0 be better?
    if (modified == null) {
      logger.warn("Date/Time Modified not set for book '" + title + "'"); Helper.statsWarnings++;
      return new Date();
    }
    return modified;
  }

  public Date getPublicationDate() {
    if  (copyOfBook  != null) return copyOfBook.getPublicationDate();
    if (publicationDate == null) {
      logger.warn("Publication Date not set for book '" + title + "'"); Helper.statsWarnings++;
      return ZERO;
    }
    return (publicationDate);
  }

  public boolean hasAuthor() {
    return  Helper.isNotNullOrEmpty(getAuthors());
  }

  public boolean hasSingleAuthor() {
    if  (copyOfBook  != null) return copyOfBook.hasSingleAuthor();
    return (Helper.isNotNullOrEmpty(authors) && authors.size() == 1);
  }

  public List<Author> getAuthors() {
    if (copyOfBook != null) return copyOfBook.getAuthors();
    assert authors != null && authors.size() > 0;
    return authors;
  }

  /**
   * Create a comma separated list of authors
   * @return
   */
  public String getListOfAuthors() {
    if  (copyOfBook  != null) return copyOfBook.getListOfAuthors();
    if (listOfAuthors == null)
      listOfAuthors = Helper.concatenateList(" & ", getAuthors(), "getDisplayName");
    return listOfAuthors;
  }

  public Author getMainAuthor() {
    if (copyOfBook != null) return copyOfBook.getMainAuthor();
    if (getAuthors() == null || getAuthors().size() == 0)
      return null;
    return  getAuthors().get(0);
  }

  public String getAuthorSort() {
    return  (copyOfBook == null) ? authorSort : copyOfBook.getAuthorSort();
  }

  public Publisher getPublisher() {
    return  (copyOfBook == null) ? publisher : copyOfBook.getPublisher();
  }

  public void setPublisher(Publisher publisher) {
    assert copyOfBook == null;    // Do not expect this on a copy
    this.publisher = publisher;
  }

  public Series getSeries() {
    return  (copyOfBook == null) ? series : copyOfBook.getSeries();
  }

  public void setSeries(Series value) {
    assert copyOfBook == null;    // Do not expect this on a copy
    this.series = value;
  }

  /**
   * Note that the list of tags for a books can be different
   * in the master and in any copies of the book object
   *
   * @return
   */
  public List<Tag> getTags() {
    return tags;
  }

  /**
   * Get the list of eBook files associated with this book.
   * @return
   */
  public List<EBookFile> getFiles() {
    if (copyOfBook != null) return copyOfBook.getFiles();
    if (! isFlags(FLAG_FILES_SORTED)) {
      if (files != null && files.size() > 1) {
        Collections.sort(files, new Comparator<EBookFile>() {
          public int compare(EBookFile o1, EBookFile o2) {
            if (o1 == null) {
              if (o2 == null)
                return 0;
              else
                return 1;
            }
            if (o2 == null) {
              return -1;
            }
            //   Now allow for empty format entries
            if (o1.getFormat() == null) {
              if (o2.getFormat() == null)
                return 0;
              else
                return 1;
            }
            if (o2.getFormat() == null) {
              return -1;
            }
            return Helper.checkedCompare(o1.getFormat().toString(), o2.getFormat().toString());
          }
        });
      }
       setFlags(true, FLAG_FILES_SORTED);;
    }
    return files;
  }

  public void removeFile(EBookFile file) {
    assert copyOfBook == null;    // Do not expect this on a copy
    files.remove(file);
    epubFile = null;
    preferredFile = null;
    latestFileModifiedDate = -1;
    setFlags(false,FLAG_EPUBFILE_COMPUTED + FLAG_PREFERREDFILECOMPUTED + FLAG_FILES_SORTED);
  }

  /**
   *
   * @param file
   */
  public void addFile(EBookFile file) {
    assert copyOfBook == null;      // Fo not expect this on a copy
    files.add(file);
    epubFile = null;
    preferredFile = null;
    latestFileModifiedDate = -1;
    setFlags(false,FLAG_EPUBFILE_COMPUTED + FLAG_PREFERREDFILECOMPUTED + FLAG_FILES_SORTED);
  }

  public EBookFile getPreferredFile() {
    if (copyOfBook != null) return copyOfBook.getPreferredFile();
    if (! isFlags(FLAG_PREFERREDFILECOMPUTED)) {
      for (EBookFile file : getFiles()) {
        if (preferredFile == null || file.getFormat().getPriority() > preferredFile.getFormat().getPriority())
          preferredFile = file;
      }
      setFlags(true, FLAG_PREFERREDFILECOMPUTED);
    }
    return preferredFile;
  }

  /**
   * @param author
   */
  public void addAuthor(Author author) {
    assert copyOfBook == null;    // Do not expect this on a copy
    listOfAuthors = null;     // Force display list to be recalculated
    if (authors == null)
      authors = new LinkedList<Author>();
    if (!authors.contains(author))
      authors.add(author);
  }

  public String toString() {
    return getId() + " - " + getDisplayName();
  }

  @Override
  public boolean equals(Object obj) {
    if (obj == null)
      return false;
    if (obj instanceof Book) {
      return (Helper.checkedCompare(((Book) obj).getId(), getId()) == 0);
    } else
      return super.equals(obj);
  }

  public String toDetailedString() {
    return getId() + " - " + getMainAuthor().getDisplayName() + " - " + getDisplayName() + " - " + Helper.concatenateList(getTags()) + " - " + getPath();
  }

  public File getBookFolder() {
    if (copyOfBook != null) return copyOfBook.getBookFolder();
    if (bookFolder == null) {
      File calibreLibraryFolder = Configuration.instance().getDatabaseFolder();
      bookFolder = new File(calibreLibraryFolder, getPath());
    }
    return bookFolder;
  }

  public String getEpubFilename() {
    if (copyOfBook != null) return copyOfBook.getEpubFilename();
    if (! isFlags(FLAG_EPUBFILE_COMPUTED)) {
      getEpubFile();
    }
    return epubFileName;
  }

  public EBookFile getEpubFile() {
    if (copyOfBook != null) return copyOfBook.getEpubFile();
    if (!isFlags(FLAG_EPUBFILE_COMPUTED)) {
      epubFile = null;
      epubFileName = null;
      for (EBookFile file : getFiles()) {
        if (file.getFormat() == EBookFormat.EPUB) {
          epubFile = file;
          epubFileName = epubFile.getName() + epubFile.getExtension();
        }
      }
      setFlags(true, FLAG_EPUBFILE_COMPUTED);
    }
    return epubFile;
  }

  public boolean doesEpubFileExist() {
    if (copyOfBook != null) return copyOfBook.doesEpubFileExist();
    EBookFile file = getEpubFile();
    if (file == null)
      return false;
    File f = file.getFile();
    return (f != null && f.exists());
  }

  public long getLatestFileModifiedDate() {
    if (copyOfBook != null) return copyOfBook.getLatestFileModifiedDate();
    if (latestFileModifiedDate == -1) {
      latestFileModifiedDate = 0;
      for (EBookFile file : getFiles()) {
        File f = file.getFile();
        if (f.exists()) {
          long m = f.lastModified();
          if (m > latestFileModifiedDate)
            latestFileModifiedDate = m;
        }
      }
    }
    return latestFileModifiedDate;
  }

  public BookRating getRating() {
    if (copyOfBook != null)  return copyOfBook.getRating();
    return rating;
  }

  /**
   * Make a copy of the book object.
   *
   * The copy has some special behavior in that most properties a@e read
   * from the original, but the tags one is still private.
   *
   * NOTE:  Can pass as null values that are always read from parent!
   *        Not sure if this reduces RAM usage or not!
   *
   * @return  Book object that is the copy
   */
  public Book copy() {
    Book result = new Book(
        null,
        null,
        null,
        null,
        null,
        null,timestamp,modified,publicationDate,isbn,authorSort,rating);

    // The tags aassciated with this entry may be changed, so we make
    // a copy of the ones currently associated
    result.tags = new LinkedList<Tag>(this.getTags());

    // Indicate this is a copy by setting a reference to the parent
    // This is used to read/set variables that must be in parent.
    result.copyOfBook = (this.copyOfBook == null) ? this : this.copyOfBook;
    return result;
  }

  public boolean isFlagged() {
    if (copyOfBook != null) return copyOfBook.isFlagged();
    return isFlags(FLAG_FLAGGED);
  }

  public void setFlagged() {
    if (copyOfBook != null) {
      copyOfBook.setFlagged();
      return;
    }
    setFlags(true, FLAG_FLAGGED);
  }

  public void clearFlagged() {
    if (copyOfBook != null) {
      copyOfBook.clearFlagged();
      return;
    }
    setFlags(false, FLAG_FLAGGED);
  }

  /**
   * Return whether we believe book has been changed since last run
   * @return
   */
  public boolean isChanged() {
    if (copyOfBook != null) return copyOfBook.isChanged();
    return isFlags(FLAG_CHANGED);
  }

  /**
   * Set the changed status to be true
   * (default is false, -so should only need to set to true.
   * If neccesary could change to pass in required state).
   */
  public void setChanged()  {
    if (copyOfBook != null) {
      copyOfBook.setChanged();
      return;
    }
    setFlags(true, FLAG_CHANGED);
  }

  public List<CustomColumnValue> getCustomColumnValues() {
    return customColumnValues;
  }

  public void setCustomColumnValues (List <CustomColumnValue> values) {
    assert  copyOfBook == null:
    customColumnValues = values;
  }

  public CustomColumnValue getCustomColumnValue (String name) {
    assert false : "getCustomColumnValue() not yet ready for use";
    return null;
  }

  public void setCustomColumnValue (String name, String value) {
    assert false : "setCustomColumnValue() not yet ready for use";
  }

  public void setDone() {
    if (copyOfBook != null) {
      copyOfBook.setDone();
      return;
    }
    setFlags(true, FLAG_DONE);
  }

  public boolean isDone() {
    if (copyOfBook != null) return copyOfBook.isDone();
    return isFlags(FLAG_DONE);
  }

  /**
   * Set referenced flag.
   */
  public void setReferenced() {
    if (copyOfBook != null) {
      copyOfBook.setReferenced();
      return;
    }
    setFlags(true, FLAG_REFERENCED);
  }

  /**
   * Get referenced flag
   *
   * @return
   */
  public boolean isReferenced() {
    if (copyOfBook != null) return copyOfBook.isReferenced();
    return isFlags(FLAG_REFERENCED);
  }
}
