/**
 * This file is part of LibLaserCut.
 * Copyright (C) 2011 - 2014 Thomas Oster <mail@thomas-oster.de>
 *
 * LibLaserCut is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * LibLaserCut 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 Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with LibLaserCut. If not, see <http://www.gnu.org/licenses/>.
 *
 **/

/**
 * Author: Sven Jung <sven.jung@rwth-aachen.de>
 */

package com.t_oster.liblasercut.drivers;

import com.t_oster.liblasercut.IllegalJobException;
import com.t_oster.liblasercut.*;
import com.t_oster.liblasercut.LaserJob;
import com.t_oster.liblasercut.ProgressListener;
import com.t_oster.liblasercut.platform.Point;
import com.t_oster.liblasercut.platform.Util;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import purejavacomm.CommPortIdentifier;
import purejavacomm.NoSuchPortException;
import purejavacomm.PortInUseException;
import purejavacomm.SerialPort;

/**
 *
 * @author Sven Jung
 */
public class MakeBlockXYPlotter extends LaserCutter
{
  private enum ToolState {
    ON, OFF
  }
  
  /*
   * Internal Settings
  */
  private final boolean debug = false; // print to command line
  private static final String MODELNAME = "MakeBlockXYPlotter";
  private double addSpacePerRasterLine = 0.5;
  private String hostname = ""; 
  private double bedWidth = 300;
  private double bedHeight = 210;
  private int delayRate = 5000;
  private int powerRate = 255;
  private String usedTool = "PEN"; // PEN, Laser
  private List<Double> resolutions = Arrays.asList(new Double[]{
                64d // fine liner
              });
  
  private int chosenDelay;
  private int chosenPower;
  private ToolState toolState;
  
  private PrintWriter w = null;
  BufferedReader portReader = null;
  private BufferedOutputStream out = null;
  private SerialPort port = null;
    
  /*
   * Global Settings
  */
  private static final String SETTING_HOSTNAME = "Target port:// or file://";
  private static final String SETTING_RASTER_WHITESPACE = "Additional space per Raster line (mm)";
  private static final String SETTING_BEDWIDTH = "Laserbed width (mm)";
  private static final String SETTING_BEDHEIGHT = "Laserbed height (mm)";
  private static final String SETTING_DELAY_RATE = "Max. Delay Rate (abs. us)";
  private static final String SETTING_POWER_RATE = "Max. Power Rate (abs. pwm)";
  private static final String SETTING_TOOL = "Tool (PEN, LASER)";
  private static String[] settingAttributes = new String[]{
    SETTING_HOSTNAME,
    SETTING_RASTER_WHITESPACE,
    SETTING_BEDWIDTH,
    SETTING_BEDHEIGHT,
    SETTING_DELAY_RATE,
    SETTING_POWER_RATE,
    SETTING_TOOL
  };
  
  
  /**
   * Get the value of MODELNAME
   * 
   * @return the value of MODELNAME
   */
  @Override
  public String getModelName() {
    return MODELNAME;
  }
  
  @Override
  public List<Double> getResolutions() {
    return resolutions;
  }
  
  @Override
  public MakeBlockXYPlotterProperty getLaserPropertyForVectorPart() {
    return new MakeBlockXYPlotterProperty(this.usedTool.equals("LASER")); // show power and speed only if laser
  }

  @Override
  public MakeBlockXYPlotterProperty getLaserPropertyForRasterPart()
  {
    return new MakeBlockXYPlotterProperty(this.usedTool.equals("LASER")); // show power and speed only if laser
  }
  
  /**
   * Get the value of bedWidth
   *
   * @return the value of bedWidth
   */
  @Override
  public double getBedWidth()
  {
    return bedWidth;
  }

  /**
   * Get the value of bedHeight
   *
   * @return the value of bedHeight
   */
  @Override
  public double getBedHeight()
  {
    return bedHeight;
  }

  
  private void generateInitializationGCode() throws Exception {
    toolOff();
    this.sendCommand("G28 X Y");//move to 0 0
  }

  private void generateShutdownGCode() throws Exception {
    //back to origin and shutdown
    toolOff();
    this.sendCommand("G28 X Y");//move to 0 0
  }
  
  private void toolOff() throws Exception {
    if(toolState != ToolState.OFF) {
      if(usedTool.equals("PEN")) {
        this.sendCommand("M1 90");
        this.sendCommand(String.format("M3 %d", 0)); // to ensure fastest speed
      } else if(usedTool.equals("LASER")) {
        this.sendCommand(String.format("M4 %d", 0));
        this.sendCommand(String.format("M3 %d", 0)); // to move faster with tool off
      } else {
        throw new Exception("Tool " + this.usedTool + " not supported!");
      }
      toolState = ToolState.OFF;
    }
  }
  
  private void toolOn() throws Exception {
    if(toolState != ToolState.ON) {
      if(usedTool.equals("PEN")) {
        this.sendCommand(String.format("M3 %d", 0)); // to ensure fastest speed
        this.sendCommand("M1 130");
      } else if(usedTool.equals("LASER")) {
        this.sendCommand(String.format("M3 %d", (int) ((double) delayRate * this.chosenDelay / 100)));
        this.sendCommand(String.format("M4 %d", (int) ((double) powerRate * this.chosenPower / 100)));
      } else {
        throw new Exception("Tool " + this.usedTool + " not supported!");
      }
      toolState = ToolState.ON;
    }
  }
  
  private void setDelay(int value) throws Exception{
    // saves just the chosen delay value
    // delay of the plotter really set on toolOn(), to move faster with tool off
    if(usedTool.equals("LASER")) { // property option only supported if laser
      if (value != chosenDelay) {
        chosenDelay = value;
      }
    }
  }
  
  private void setPower(int value) throws Exception{
    // saves just the chosen power value
    // power of the laser really set on toolOn()
    if(usedTool.equals("LASER")) { // property option only supported if laser
      if (value != chosenPower) {
        chosenPower = value;
      }
    }
  }
  
  private void move(int x, int y, double resolution) throws Exception{
    toolOff();
    this.sendCommand(String.format(Locale.US, "G0 X%f Y%f", Util.px2mm(x, resolution), Util.px2mm(y, resolution)));
  }

  private void line(int x, int y, double resolution) throws Exception{
    toolOn();
    this.sendCommand(String.format(Locale.US, "G1 X%f Y%f", Util.px2mm(x, resolution), Util.px2mm(y, resolution)));
  }
  
  private void generateVectorGCode(VectorPart vp, double resolution, ProgressListener pl, int startProgress, int maxProgress) throws UnsupportedEncodingException, Exception {
    int i = 0;
    int progress;
    int max = vp.getCommandList().length;
    for (VectorCommand cmd : vp.getCommandList()) {
      switch (cmd.getType()) {
        case MOVETO:
          int x = cmd.getX();
          int y = cmd.getY();
          this.move(x, y, resolution);
          break;
        case LINETO:
          x = cmd.getX();
          y = cmd.getY();
          this.line(x, y, resolution);
          break;
        case SETPROPERTY: // called once per part to set chosen properties
          MakeBlockXYPlotterProperty p = (MakeBlockXYPlotterProperty) cmd.getProperty(); // only set with LASER tool
          // ensure percent power
          int pPercent = p.getPower();
          pPercent = pPercent<0?0:pPercent;
          pPercent = pPercent>100?100:pPercent;
          this.setPower(pPercent);
          // ensure percent speed
          int sPercent = p.getSpeed();
          sPercent = sPercent<0?0:sPercent;
          sPercent = sPercent>100?100:sPercent;
          int dPercent = 100-sPercent; // convert speed to delay
          this.setDelay(dPercent);
          break;
      }
      i++;
      progress = (startProgress + (int) (i*(double) maxProgress/max));
      pl.progressChanged(this, progress);
    }
  }
  
  private void generatePseudoRasterGCode(RasterPart rp, double resolution, ProgressListener pl, int startProgress, int maxProgress) throws UnsupportedEncodingException, Exception {
    int i = 0;
    int progress;
    int max = rp.getRasterHeight();
    
    boolean dirRight = true;
    Point rasterStart = rp.getRasterStart();
    
    // called once per part to set chosen properties
    PowerSpeedFocusProperty prop = (PowerSpeedFocusProperty) rp.getLaserProperty();
    this.setDelay(prop.getSpeed());
    this.setPower(prop.getPower());
    
    for (int line = 0; line < rp.getRasterHeight(); line++) {
      Point lineStart = rasterStart.clone();
      lineStart.y += line;
      List<Byte> bytes = new LinkedList<Byte>();
      boolean lookForStart = true;
      for (int x = 0; x < rp.getRasterWidth(); x++) {
        if (lookForStart) {
          if (rp.isBlack(x, line)) {
            lookForStart = false;
            bytes.add((byte) 255);
          } else {
            lineStart.x += 1;
          }
        } else {
          bytes.add(rp.isBlack(x, line) ? (byte) 255 : (byte) 0);
        }
      }
      //remove trailing zeroes
      while (bytes.size() > 0 && bytes.get(bytes.size() - 1) == 0) {
        bytes.remove(bytes.size() - 1);
      }
      if (bytes.size() > 0) {
        if (dirRight) {
          //add some space to the left
          this.move(Math.max(0, (int) (lineStart.x - Util.mm2px(this.addSpacePerRasterLine, resolution))), lineStart.y, resolution);
          //move to the first nonempyt point of the line
          this.move(lineStart.x, lineStart.y, resolution);
          byte old = bytes.get(0);
          for (int pix = 0; pix < bytes.size(); pix++) {
            if (bytes.get(pix) != old) {
              if (old == 0) {
                this.move(lineStart.x + pix, lineStart.y, resolution);
              } else {
                this.setPower(prop.getPower() * (0xFF & old) / 255);
                this.line(lineStart.x + pix - 1, lineStart.y, resolution);
                this.move(lineStart.x + pix, lineStart.y, resolution);
              }
              old = bytes.get(pix);
            }
          }
          //last point is also not "white"
          this.setPower(prop.getPower() * (0xFF & bytes.get(bytes.size() - 1)) / 255);
          this.line(lineStart.x + bytes.size() - 1, lineStart.y, resolution);
          //add some space to the right
          this.move(Math.min((int) Util.mm2px(bedWidth, resolution), (int) (lineStart.x + bytes.size() - 1 + Util.mm2px(this.addSpacePerRasterLine, resolution))), lineStart.y, resolution);
        } else {
          //add some space to the right
          this.move(Math.min((int) Util.mm2px(bedWidth, resolution), (int) (lineStart.x + bytes.size() - 1 + Util.mm2px(this.addSpacePerRasterLine, resolution))), lineStart.y, resolution);
          //move to the last nonempty point of the line
          this.move(lineStart.x + bytes.size() - 1, lineStart.y, resolution);
          byte old = bytes.get(bytes.size() - 1);
          for (int pix = bytes.size() - 1; pix >= 0; pix--) {
            if (bytes.get(pix) != old || pix == 0) {
              if (old == 0) {
                this.move(lineStart.x + pix, lineStart.y, resolution);
              } else {
                this.setPower(prop.getPower() * (0xFF & old) / 255);
                this.line(lineStart.x + pix + 1, lineStart.y, resolution);
                this.move(lineStart.x + pix, lineStart.y, resolution);
              }
              old = bytes.get(pix);
            }
          }
          //last point is also not "white"
          this.setPower(prop.getPower() * (0xFF & bytes.get(0)) / 255);
          this.line(lineStart.x, lineStart.y, resolution);
          //add some space to the left
          this.move(Math.max(0, (int) (lineStart.x - Util.mm2px(this.addSpacePerRasterLine, resolution))), lineStart.y, resolution);
        }
      }
      dirRight = !dirRight;
      
      i = line + 1;
      progress = (startProgress + (int) (i*(double) maxProgress/max));
      pl.progressChanged(this, progress);
    }
  }
  
  private void connect() throws NoSuchPortException, PortInUseException, Exception {
    if(!this.debug){
      if (this.hostname.startsWith("port://")) {
        String portString = this.hostname.replace("port://", "");
        
        try{
          CommPortIdentifier cpi = CommPortIdentifier.getPortIdentifier(portString);
          port = (SerialPort) cpi.open("VisiCut", 2000);
        }
        catch(Exception e) {
          throw new Exception("Port '"+portString+"' is not available.");
        }
        
        if (port == null)
        {
          throw new Exception("Error: Could not Open COM-Port '"+portString+"'");
        }
        if (!(port instanceof SerialPort))
        {
          throw new Exception("Port '"+portString+"' is not a serial port.");
        }
        port.setSerialPortParams(115200, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE);
        out = new BufferedOutputStream(port.getOutputStream());
        portReader = new BufferedReader(new InputStreamReader(port.getInputStream()));
        
        // wake up firmware
        String command = "\r\n\r\n";
        out.write(command.getBytes("US-ASCII"));
        out.flush();
        Thread.sleep(2000);
        portReader.readLine(); // "ok"
        portReader.readLine(); // "ok"
        
        this.checkVersion();
      }
      else if (hostname.startsWith("file://")) {
        String filename = this.hostname.replace("file://", "");
        try {
          w = new PrintWriter(filename);
        }
        catch(Exception e) {
          throw new Exception(String.format("No correct absolute file path: %s Exception %s", this.hostname, e));
        }
      }
      else {
        throw new Exception(String.format("Unknown hostname: %s", this.hostname));
      }
    }
  }
  
  private void disconnect() throws Exception{
    if(w != null) {
      w.close();
      w = null;
    }
    
    if(out != null) {
      out.close();
      out = null;
    }
    
    if(port != null){
      port.close();
      port = null;
    }
  }
  
  private void checkResponse(String command, String response, String expectedAnswer) throws Exception {
    if(!response.toLowerCase().contains(expectedAnswer.toLowerCase())) {
        throw new Exception(String.format("Got wrong response to command \"%s\":\n\"%s\" instead of \"%s\"", command, response, expectedAnswer));
      }
  }
  private void sendCommand(String command) throws Exception {
    this.send(command);
    
    if(!debug) {
      if (this.hostname.startsWith("port://")) {
        String resp = this.receive();
        this.checkResponse(command, resp, "ok");
      }    
    }
  }
  
  private void checkVersion() throws Exception {
    // check if firmware matches implemented protocol
    this.send("M115");
    
    if(!debug) {
      if (this.hostname.startsWith("port://")) {
        String resp = this.receive();
        this.checkResponse("Version", resp, "Version");
        String resp2 = this.receive();
        this.checkResponse("Version", resp2, "ok");
      }    
    }
  }
  
  private void send(String command) throws Exception {
    if(!debug) {
      if (this.hostname.startsWith("port://")) {
        // send
        String sendString = command + "\n";
        out.write(sendString.getBytes("US-ASCII"));
        out.flush();
      }
      else if (hostname.startsWith("file://")) {
        w.println(command);
      }
      else {
        throw new Exception(String.format("Unknown hostname: %s", this.hostname));
      }
      
    } else {
      System.out.println(command);
    }
  }
  
  private String receive() throws Exception{
    if(!debug) {
      if (this.hostname.startsWith("port://")) {
            String line;
        try {
          line = portReader.readLine();
          line = line.replace("\n", "").replace("\r", "");
          return line;
        } catch(IOException e) { 
          throw new IOException("IO Exception, e.g. timeout");
        }
      }
    }
    return "";
  }

  @Override
  public void sendJob(LaserJob job, ProgressListener pl, List<String> warnings) throws IllegalJobException, Exception
  {
    this.chosenPower = 0;
    this.chosenDelay = 0;
    this.toolState = ToolState.ON; // assume worst case, set to OFF in initialization code
    pl.progressChanged(this, 0); 
    pl.taskChanged(this, "checking job");
    checkJob(job);
    job.applyStartPoint();
    pl.taskChanged(this, "connecting");
    this.connect();
    pl.taskChanged(this, "sending");
    this.generateInitializationGCode();
    int startProgress = 20;
    pl.progressChanged(this, startProgress);
    int i = 0;
    int progress = startProgress;
    int max = job.getParts().size();
    for (JobPart p : job.getParts())
    {
      if (p instanceof Raster3dPart)
      {
        throw new Exception("Raster 3D parts are not implemented for " + this.getModelName());
      }
      else if (p instanceof RasterPart)
      {
        this.generatePseudoRasterGCode((RasterPart) p, p.getDPI(), pl, progress, ((int) ((i+1)*(double) 80/max)));
      }
      else if (p instanceof VectorPart)
      {
        this.generateVectorGCode((VectorPart) p, p.getDPI(), pl, progress, ((int) ((i+1)*(double) 80/max)));
      }
      i++;
      progress = (startProgress + (int) (i*(double) 80/max));
      pl.progressChanged(this, progress);
    }
    this.generateShutdownGCode();
    pl.taskChanged(this, "disconnecting");
    this.disconnect();
    pl.taskChanged(this, "sent");
    pl.progressChanged(this, 100);
  }

  @Override
  public LaserCutter clone()
  {
    MakeBlockXYPlotter clone = new MakeBlockXYPlotter();
    clone.addSpacePerRasterLine = addSpacePerRasterLine;
    clone.hostname = hostname;
    clone.bedWidth = bedWidth;
    clone.bedHeight = bedHeight;
    clone.delayRate = delayRate;
    clone.powerRate = powerRate;
    clone.usedTool = usedTool;
    return clone;
  }

  @Override
  public String[] getPropertyKeys() {
    return settingAttributes;
  }

  @Override
  public Object getProperty(String attribute) {
    if (SETTING_HOSTNAME.equals(attribute)) {
      return this.hostname;
    } else if (SETTING_RASTER_WHITESPACE.equals(attribute)) {
      return this.addSpacePerRasterLine;
    } else if (SETTING_BEDWIDTH.equals(attribute)) {
      return this.bedWidth;
    } else if (SETTING_BEDHEIGHT.equals(attribute)) {
      return this.bedHeight;
    } else if (SETTING_DELAY_RATE.equals(attribute)) {
      return this.delayRate;
    } else if (SETTING_POWER_RATE.equals(attribute)) {
      return this.powerRate;
    } else if (SETTING_TOOL.equals(attribute)) {
      return this.usedTool;
    } 
    return null;
  }

  @Override
  public void setProperty(String attribute, Object value) {
    if (SETTING_HOSTNAME.equals(attribute)) {
      this.hostname = (String) value;
    } else if (SETTING_RASTER_WHITESPACE.equals(attribute)) {
      this.addSpacePerRasterLine = (Double) value;
    } else if (SETTING_BEDWIDTH.equals(attribute)) {
      this.bedWidth = (Double) value;
    } else if (SETTING_BEDHEIGHT.equals(attribute)) {
      this.bedHeight = (Double) value;
    } else if (SETTING_DELAY_RATE.equals(attribute)) {
      this.delayRate = (Integer) value;
    } else if (SETTING_POWER_RATE.equals(attribute)) {
      this.powerRate = (Integer) value;
    } else if (SETTING_TOOL.equals(attribute)) {
      this.usedTool = (String) value;
    }  
  }
  
}