/* -*- mode: java; c-basic-offset: 2; indent-tabs-mode: nil -*- */

/*
  Part of the Processing project - http://processing.org

  Copyright (c) 2012-16 The Processing Foundation

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  version 2, as published by the Free Software Foundation.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software Foundation,
  Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
*/

package processing.mode.java.debug;

import java.util.HashSet;
import java.util.Set;

import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import javax.swing.text.Element;
import javax.swing.text.Position;

import processing.app.Messages;


/**
 * Describes an ID for a code line. Comprised of a file name and a (0-based)
 * line number. Can track changes to the line number due to text editing by
 * attaching a {@link Document}. Registered {@link LineListener}s are notified
 * of changes to the line number.
 */
public class LineID implements DocumentListener {
  protected String fileName; // the filename
  protected int lineIdx; // the line number, 0-based
  protected Document doc; // the Document to use for line number tracking
  protected Position pos; // the Position acquired during line number tracking
  protected Set<LineHighlight> listeners = new HashSet<LineHighlight>(); // listeners for line number changes


  public LineID(String fileName, int lineIdx) {
    this.fileName = fileName;
    this.lineIdx = lineIdx;
  }


  /**
   * Get the file name of this line.
   *
   * @return the file name
   */
  public String fileName() {
    return fileName;
  }


  /**
   * Get the (0-based) line number of this line.
   *
   * @return the line index (i.e. line number, starting at 0)
   */
  public synchronized int lineIdx() {
    return lineIdx;
  }


  @Override
  public int hashCode() {
    return toString().hashCode();
  }


  /**
   * Test whether this {@link LineID} is equal to another object. Two
   * {@link LineID}'s are equal when both their fileName and lineNo are equal.
   *
   * @param obj the object to test for equality
   * @return {@code true} if equal
   */
  @Override
  public boolean equals(Object obj) {
    if (obj == null) {
      return false;
    }
    if (getClass() != obj.getClass()) {
      return false;
    }
    final LineID other = (LineID) obj;
    if ((this.fileName == null) ? (other.fileName != null) : !this.fileName.equals(other.fileName)) {
      return false;
    }
    if (this.lineIdx != other.lineIdx) {
      return false;
    }
    return true;
  }


  /**
   * Output a string representation in the form fileName:lineIdx+1. Note this
   * uses a 1-based line number as is customary for human-readable line
   * numbers.
   *
   * @return the string representation of this line ID
   */
  @Override
  public String toString() {
    return fileName + ":" + (lineIdx + 1);
  }


  /**
   * Attach a {@link Document} to enable line number tracking when editing.
   * The position to track is before the first non-whitespace character on the
   * line. Edits happening before that position will cause the line number to
   * update accordingly. Multiple {@link #startTracking} calls will replace
   * the tracked document. Whoever wants a tracked line should track it and
   * add itself as listener if necessary.
   * ({@link LineHighlight}, {@link LineBreakpoint})
   *
   * @param doc the {@link Document} to use for line number tracking
   */
  public synchronized void startTracking(Document doc) {
    //System.out.println("tracking: " + this);
    if (doc == null) {
      return; // null arg
    }
    if (doc == this.doc) {
      return; // already tracking that doc
    }
    try {
      Element line = doc.getDefaultRootElement().getElement(lineIdx);
      if (line == null) {
        return; // line doesn't exist
      }
      String lineText = doc.getText(line.getStartOffset(), line.getEndOffset() - line.getStartOffset());
      // set tracking position at (=before) first non-white space character on line,
      // or, if the line consists of entirely white spaces, just before the newline
      // character
      pos = doc.createPosition(line.getStartOffset() + nonWhiteSpaceOffset(lineText));
      this.doc = doc;
      doc.addDocumentListener(this);
    } catch (BadLocationException ex) {
      Messages.loge(null, ex);
      pos = null;
      this.doc = null;
    }
  }


  /**
   * Notify this {@link LineID} that it is no longer in use. Will stop
   * position tracking. Call this when this {@link LineID} is no longer
   * needed.
   */
  public synchronized void stopTracking() {
    if (doc != null) {
      doc.removeDocumentListener(this);
      doc = null;
    }
  }


  /**
   * Update the tracked position. Will notify listeners if line number has
   * changed.
   */
  protected synchronized void updatePosition() {
    if (doc != null && pos != null) {
      // track position
      int offset = pos.getOffset();
      int oldLineIdx = lineIdx;
      lineIdx = doc.getDefaultRootElement().getElementIndex(offset); // offset to lineNo
      if (lineIdx != oldLineIdx) {
        for (LineHighlight l : listeners) {
          if (l != null) {
            l.lineChanged(this, oldLineIdx, lineIdx);
          } else {
            listeners.remove(l); // remove null listener
          }
        }
      }
    }
  }


  /**
   * Add listener to be notified when the line number changes.
   *
   * @param l the listener to add
   */
  public void addListener(LineHighlight l) {
    listeners.add(l);
  }


  /**
   * Remove a listener for line number changes.
   *
   * @param l the listener to remove
   */
  public void removeListener(LineHighlight l) {
    listeners.remove(l);
  }


  /**
   * Calculate the offset of the first non-whitespace character in a string.
   * @param str the string to examine
   * @return offset of first non-whitespace character in str
   */
  protected static int nonWhiteSpaceOffset(String str) {
    for (int i = 0; i < str.length(); i++) {
      if (!Character.isWhitespace(str.charAt(i))) {
        return i;
      }
    }

    // If we've reached here, that implies the line consists of purely white
    // space. So return at a position just after the whitespace (i.e.,
    // just before the newline).
    //
    // The " - 1" part resolves issue #3552
    return str.length() - 1;
  }


  /**
   * Called when the {@link Document} registered using {@link #startTracking}
   * is edited. This happens when text is inserted or removed.
   */
  protected void editEvent(DocumentEvent de) {
    //System.out.println("document edit @ " + de.getOffset());
    if (de.getOffset() <= pos.getOffset()) {
      updatePosition();
      //System.out.println("updating, new line no: " + lineNo);
    }
  }


  /**
   * {@link DocumentListener} callback. Called when text is inserted.
   */
  @Override
  public void insertUpdate(DocumentEvent de) {
    editEvent(de);
  }


  /**
   * {@link DocumentListener} callback. Called when text is removed.
   */
  @Override
  public void removeUpdate(DocumentEvent de) {
    editEvent(de);
  }


  /**
   * {@link DocumentListener} callback. Called when attributes are changed.
   * Not used.
   */
  @Override
  public void changedUpdate(DocumentEvent de) {
    // not needed.
  }
}
