/**
 * Makes decisions when and where scheduled recordings will
 * be made, and triggers them.
 * It's part of Core and and assumes ownership over the cards
 * it manages, i.e. there should be only one instance in the system.
 */
package org.bucksch.zeipis.core;
import org.bucksch.zeipis.all.*;
import org.bucksch.zeipis.recorder.*;
import org.bucksch.zeipis.store.LocalfileTarget;
import java.util.*;
import java.sql.*;

public class Scheduler
{
  Scheduler()
  {
    try {
      createSQLStatements();
    } catch (SQLException e) { error("DB", e); }
  }
  protected PreparedStatement findChannels;
  protected PreparedStatement findDevices;
  protected PreparedStatement findRecordings;
  protected PreparedStatement findSeries;
  protected PreparedStatement findEpisodes;
  protected PreparedStatement findOtherBroadcast;
  protected void createSQLStatements()
    throws SQLException
  {
    String fields = "SELECT title, subtitle, original_title, original_subtitle, description, genre, showtype, firstshown, starttime, endtime, stationid, broadcastid, searchtype, priority, rec_stationid "; // needed by PlannedRecording and supertypes
    findChannels = DB.me.database().prepareStatement("SELECT channelid FROM device_channel LEFT JOIN channel USING (channelid) LEFT JOIN station USING (stationid) WHERE station.stationid = ? AND channel.disabled IS NOT TRUE AND station.disabled IS NOT TRUE ORDER BY quality DESC");
    //findChannels = DB.me.database().prepareStatement("SELECT channelid FROM device_channel LEFT JOIN channel USING (channelid)  LEFT JOIN station USING (stationid) WHERE station.stationid = ? AND channel.disabled != TRUE AND station.disabled != TRUE ORDER BY quality"); MySQL 4.x
    findDevices = DB.me.database().prepareStatement("SELECT device.deviceid FROM device_channel LEFT JOIN device USING (deviceid) WHERE channelid = ?");
    findRecordings = DB.me.database().prepareStatement(fields + ", film.filmid, recording.recordid FROM recording LEFT JOIN recording_plan USING (recordid) LEFT JOIN film ON (recording.filmid = film.filmid) LEFT JOIN broadcast ON (film.filmid = broadcast.filmid) WHERE done = false AND starttime > NOW() AND (searchtype < 4 || searchtype > 6 || rec_stationid = stationid) GROUP BY recording.recordid ORDER BY starttime ASC");
    findSeries = DB.me.database().prepareStatement("SELECT recording.recordid, searchtype, title, film.filmid FROM recording LEFT JOIN recording_plan USING (recordid) LEFT JOIN film ON (recording.filmid = film.filmid) WHERE done = false AND (searchtype = 3 OR searchtype = 4) GROUP BY title");
    findEpisodes = DB.me.database().prepareStatement(fields + ", film.filmid, plan.recordid FROM film LEFT JOIN broadcast USING (filmid) LEFT JOIN recording AS existing USING (filmid) LEFT JOIN recording AS plan ON (1=1) LEFT JOIN recording_plan ON (recording_plan.recordid = plan.recordid) WHERE starttime > NOW() AND (existing.recordid IS NULL OR existing.done = FALSE) AND plan.recordid = ? AND title = ? GROUP BY film.filmid ORDER BY starttime ASC");
  }
  public void schedule()
    throws Exception
  {
    Vector<PlannedRecording> all = createObjects();
    Vector<Vector<PlannedRecording>> overlaps = overlapsOnDevice(overlapsInTime(all));
    Vector<PlannedRecording> conflicts = moveAroundInTimeAll(overlaps, all);
    all.removeAll(conflicts);
    fixBackToBackRecordings(all);

    // debug
    all = sortByStarttime(all);
    info("Scheduling recordings to: (" + all.size() + " programs)");
    for (PlannedRecording r : all)
      info("  " + r.starttime() + " " + r.channel().stationShortName() + " \t " + r.title() + (r.subtitle().length() > 0 ? " - " + r.subtitle() : "") + "  dev " + r.device().id() + " pri " + r.priority());
    if (conflicts.size() > 0)
      info("Conflicts (won't be recorded): (" + conflicts.size() + " conflicts)");
    for (PlannedRecording r : conflicts)
      info("  " + r.starttime() + " " + r.channel().stationShortName() + " \t " + r.title() + (r.subtitle().length() > 0 ? " - " + r.subtitle() : "") + "  dev " + r.device().id() + " pri " + r.priority());

    myTimers = createTimers(all);
  }
  public Vector<? extends TVChannel> channelsForStation(int stationid)
    throws Exception
  {
    findChannels.setInt(1, stationid);
    ResultSet rs = findChannels.executeQuery();
    Vector<TVChannel> result = Util.newVector(); 
    while (rs.next())
    {
      int id = -1;
      try {
        result.add(CoreData.me.myChannels.get(rs.getInt("channelid")));
      } catch (Exception e) {
        throw new Misconfiguration("Station " + stationid + " has channel " + id + ", but that channel is not configured properly: " + e);
      }
    }
    if (result.isEmpty())
      throw new Misconfiguration("No channel found for station ID " + stationid);
    return result;
  }
  public Vector<? extends TVCard> devicesForChannel(TVChannel channel)
    throws Exception
  {
    findDevices.setInt(1, channel.id());
    ResultSet rs = findDevices.executeQuery();
    Vector<TVCard> result = Util.newVector(); 
    while (rs.next())
    {
      int id = -1;
      TVCard card = CoreData.me.myCards.get(id = rs.getInt("deviceid"));
      if (card == null && id == 0) // TODO why is id 0?
        continue;
      if (card == null)
        throw new Misconfiguration("Channel " + channel.id() + " (Station " + channel.stationLongName() + ") has been found on device " + id + ", but that device is not configured properly");
      result.add(card);
    }
    if (result.isEmpty())
      throw new Misconfiguration("No card found for channel " + channel.id() + " (Station " + channel.stationLongName() + "). Check mapping in database table device_channel.");
    return result;
  }
  /**
   * Queries database for planned recordings and makes PlannedRecording objects
   * out of them.
   * The result array is sorted by start time (the later algorithms will assume that).
   */
  protected Vector<PlannedRecording> createObjects()
    throws SQLException
  {
    Map<Integer, PlannedRecording> plan = Util.newMap(); // key: filmid

    plan.putAll(findMovies());
    plan.putAll(findSeries());

    if (plan.isEmpty())
      Util.warning("No recordings planned");
    Vector<PlannedRecording> result = sortByStarttime(new Vector<PlannedRecording>(plan.values()));

    // Find channels and devices
    Set<PlannedRecording> misconfig = Util.newSet();
    for (PlannedRecording r : result)
    {
      try {
        r.channel(channelsForStation(r.stationID()).firstElement()); // TODO limits to one channel per station
        r.device(devicesForChannel(r.channel()).firstElement()); // XXX using only first card
      } catch (Exception e) {
        error("Series " + r.title() + " could not be scheduled", e);
        //result.remove(r);
        misconfig.add(r);
      }
    }
    result.removeAll(misconfig);
 
    // debug
    debug("Scheduling recordings:");
    for (PlannedRecording r : result)
      debug("  " + r.starttime() + " stationid " + r.stationID() + ": " + r.title() + " device " + r.device().id() + " priority " + r.priority());
    return result;
  }
  /**
   * Schedule where we have the exact filmid mentioned in the record table.
   */
  protected Map<Integer, PlannedRecording> findMovies()
    throws SQLException
  {
    Map<Integer, PlannedRecording> plan = Util.newMap(); // key: filmid
    long time = System.currentTimeMillis();
    ResultSet rsDirect = findRecordings.executeQuery();
    debug("Finding recordings took " + (System.currentTimeMillis() - time) + "ms");
    time = System.currentTimeMillis();
    // TODO error if references invalid
    while (rsDirect.next())
    {
      int id = -1;
      try {
        PlannedRecording r = new PlannedRecording(id = rsDirect.getInt("recordid"));
        plan.put(r.filmID(), r);
      } catch (Exception e) {
        error("Recording " + id + " could not be scheduled", e);
      }
    }
    debug("Finding channels and devices took took " + (System.currentTimeMillis() - time) + "ms");
    return plan;
  }
  /**
   * Search for episodes of series, i.e. we have one episode of "Star Trek" scheduled, but
   * with searchtype 3/4, which means we should also record the other episodes.
   * Lacking better IDs, we define "other episodes" as other programs with the exact same title,
   * but different filmid.    
   */
  protected Map<Integer, PlannedRecording> findSeries()
    throws SQLException
  {
    Map<Integer, PlannedRecording> plan = Util.newMap(); // key: filmid
    long time = System.currentTimeMillis();
    ResultSet rsSeries = findSeries.executeQuery();
    debug("Finding series took " + (System.currentTimeMillis() - time) + "ms");
    time = System.currentTimeMillis();
    while (rsSeries.next())
    {
      String title = "";
      try {
        int recordid = rsSeries.getInt("recordid");
        title = rsSeries.getString("title");
        //int searchtype = rsSeries.getInt("searchtype");

        findEpisodes.setInt(1, recordid);
        findEpisodes.setString(2, title);
        ResultSet rsEpisodes = findEpisodes.executeQuery();
        debug("Finding episodes for " + title + " took " + (System.currentTimeMillis() - time) + "ms");
        time = System.currentTimeMillis();
        while (rsEpisodes.next())
        {
          // see above
          PlannedRecording r = new PlannedRecording(rsEpisodes);
          if ( ! plan.containsKey(r.filmID())) // just in case we hit the exact filmid
            plan.put(r.filmID(), r);
        }
        debug("Finding channels and devices took took " + (System.currentTimeMillis() - time) + "ms");
        time = System.currentTimeMillis();
      } catch (Exception e) {
        error("Series " + title + " could not be scheduled", e);
      }
    }
    return plan;
  }

  /**
   * 
   * @param all
   * @return Sets of recordings running at the same time:
   * if B and C are running at the same time as A,
   * then there'll be (A, B, C).
   * Same pair/set won't appear twice,
   * but a recording may appear twice;
   * when A overlaps with B, and C with B, but A not with C,
   * then there'll be sets (A, B) and (C, B).
   */
  protected Vector<Vector<PlannedRecording>> overlapsInTime(Vector<PlannedRecording> all)
  {
    all = sortByStarttime(all);

    //debug("Checking for overlaps in time:");
    //for (PlannedRecording r : all)
    //  debug("  " + r.starttime() + " stationid " + r.stationID() + ": " + r.title());

    Vector<Vector<PlannedRecording>> result = Util.newVector();
    for (int i1 = 0; i1 < all.size(); i1++)
    {
      PlannedRecording test1 = all.elementAt(i1);
      Vector<PlannedRecording> cur = null;
      for (int i2 = i1 + 1; i2 < all.size(); i2++)
      {
        PlannedRecording test2 = all.elementAt(i2); 
        if (test2.starttime().before(test1.endtime())) // overlap
        {
          if (cur == null)
          {
            cur = Util.newVector();
            result.add(cur);
            cur.add(test1);
          }
          cur.add(test2);
          // !XXX buggy i1 = i2 + 1; // avoid duplicates
        }
      }
    }
    // debug
    debug("Overlaps in time:");
    for (Vector<PlannedRecording> s : result)
    {
      debug("  Overlap:");
      for (PlannedRecording r : s)
        debug("    " + r.starttime() + " stationid " + r.stationID() + ": " + r.title());
    }
    return result;
  }
  /**
   * Takes a number of time overlaps and returns those which can not be recorded
   * at the same time, because we don't have enough devices.
   *
   * Options for rescheduling, decreasing order of preference:
   * - device supports several recordings at same time,
   *   e.g. two recordings on channels which are on same DVB multiplex,
   *   and dev supports two channels (2 video PIDs etc.) from same multiplex
   * - 2 devices for same channel
   * - not implemented yet: 2 channels for same station. same quality?
   * @param name
   * @return
   * @throws Exception 
   */
  protected Vector<Vector<PlannedRecording>> overlapsOnDevice(Vector<Vector<PlannedRecording>> overlaps)
    throws Exception
  {
    Vector<Vector<PlannedRecording>> result = Util.newVector();
    for (Vector<PlannedRecording> overlap : overlaps)
    {
      if (!canRecordSimultanously(overlap))
        result.add(overlap);
    }

    // debug
    debug("Overlaps on device:");
    for (Vector<PlannedRecording> s : result)
    {
      debug("  Overlap:");
      for (PlannedRecording r : s)
        debug("    " + r.starttime() + " stationid " + r.stationID() + ": " + r.title());
    }
    return result;
  }
  protected boolean canRecordSimultanously(Vector<PlannedRecording> overlap)
    throws Exception
  {
    // maybe one of the devices can record them all
    Collection<TVChannel> allChannels = Util.newCollection();
    for (PlannedRecording r : overlap)
      allChannels.add(r.channel()); // unnecessary restriction: 1 channel per station
    Set<TVCard> allDevices = Util.newSet();
    for (PlannedRecording r : overlap)
      allDevices.addAll(devicesForChannel(r.channel()));
    for (TVCard device : allDevices)
    {
      if (device.canRecordSimultanously(allChannels))
      {
        for (PlannedRecording r : overlap)
          r.device(device);
        return true;
      }
    }

    // simple, really stupid device allocation: one device per recording
    // then allocate the recording with the least number of devices first
    debug("trying simple allocation, if " + allDevices.size() + " possible devices >= " + allChannels.size() + " simultanous recordings");
    if (allDevices.size() >= allChannels.size())
    {
      // make a list so that the first entry has the least number of devices to record from
      Vector<PlannedRecording> byChoice = Util.sort(overlap, new Comparator<PlannedRecording>() {
        public int compare(PlannedRecording o1, PlannedRecording o2)
        {
          // -1: o1 < o2,  0: o1 == o2, +1: o1 > o2
          try {
            return devicesForChannel(o1.channel()).size() -
                   devicesForChannel(o2.channel()).size();
          } catch (Exception e) { return 0; }
        }
      });
      Vector<TVCard> remaining = Util.newVector();
      remaining.addAll(allDevices);
      // try to put one recording on each device
      for (PlannedRecording r : byChoice)
      {
        // try preferred device
        if (remaining.contains(r.device()))
          remaining.remove(r.device()); // alright
        else
        {
          // try alternative devices
          boolean found = false;
          for (Device dev : devicesForChannel(r.channel()))
          {
            if (!remaining.contains(dev))
              continue;
            // alright
            r.device(dev);
            remaining.remove(dev);
            found = true;
            break;
          }
          if (!found)
            break; // continue below with other approaches
        }
      }
      debug("simple allocation succeeded");
      return true;
    }
    debug("simple allocation failed");

    // TODO use smart or brute-force (e.g. recursive) mechanism
    // to find a combination that works, if one exists

    return false;
  }
  /**
   * @see moveAroundInTime
   */
  static int loop = 0;
  protected Vector<PlannedRecording> moveAroundInTimeAll(
          Vector<Vector<PlannedRecording>> overlaps,
          Vector<PlannedRecording> all)
    throws Exception
  {
    Vector<PlannedRecording> conflicts = Util.newVector();
    if (overlaps.isEmpty())
      return conflicts;

    for (Vector<PlannedRecording> overlap : overlaps)
    {
      conflicts.addAll(moveAroundInTime(overlap));
    }

    // reschedule
    all.removeAll(conflicts);
    // debug
    debug("Reschedule (loop " + ++loop + "):");
    for (PlannedRecording r : all)
      debug("  " + r.starttime() + " stationid " + r.stationID() + ": " + r.title());

    Vector<Vector<PlannedRecording>> newOverlaps = overlapsInTime(all);
    newOverlaps = overlapsOnDevice(newOverlaps); // XXX does unnecessary calculations
    if (!newOverlaps.isEmpty())
      conflicts.addAll(moveAroundInTimeAll(newOverlaps, all)); // XXX recursing. check

    return conflicts;
  }
  /**
   * Moves around a few of the overlapping recordings, by finding a later
   * broadcast and scheduling them for that time instead.
   * 
   * Algorithm:
   * - pick one with lowest priority
   * - move that to next broadcast, or mark conflict if none
   * - trigger reschedule (of this overlap only) by calling overlapInTime again
   * 
   * @param overlaps sets of problematic recordings
   * @return conflicts - those recordings for which no time could be found
   * and which won't be recorded
   * @throws Exception 
   */
  protected Vector<PlannedRecording> moveAroundInTime(
          Vector<PlannedRecording> overlap)
    throws Exception
  {
    Vector<PlannedRecording> byPriority = Util.sort(overlap, new Comparator<PlannedRecording>() {
      public int compare(PlannedRecording o1, PlannedRecording o2)
      {
        return o1.priority() - o2.priority();
      }
    });

    debug("movearoundintime");
    for (PlannedRecording r : overlap)
      debug("  " + r.starttime() + " stationid " + r.stationID() + ": " + r.title());
    for (PlannedRecording change : byPriority)
    {
      // try to find other broadcast for this
      debug("find later broadcast for " + change.starttime() + " stationid " + change.stationID() + ": " + change.title() + " priority " + change.priority());
      boolean laterFound = change.findLaterBroadcast();
      if (laterFound) 
      {
        debug("changed to " + change.starttime() + " stationid " + change.stationID() + ": " + change.title() + " priority " + change.priority());
        return Util.newVector();
      }
    }

    PlannedRecording conflict = byPriority.firstElement();
    debug("conflict " + conflict.starttime() + "-" + conflict.endtime() + ": " + conflict.title() + " priority " + conflict.priority());
    conflict.conflicts(true);
    Vector<PlannedRecording> conflicts = Util.newVector();
    conflicts.add(conflict);
    return conflicts;
  }
  public Vector<PlannedRecording> sortByStarttime(Vector<PlannedRecording> a)
  {
    return Util.sort(a, new Comparator<PlannedRecording>() {
      public int compare(PlannedRecording o1, PlannedRecording o2)
      {
        long diff = o1.starttime().getTime() - o2.starttime().getTime();
        // need to return int, not long, and conversion goes wrong
        // (is there any way to preserve the sign during long -> int?)
        if (diff == 0) return 0; else if (diff > 0) return 1; else return -1;
      }});
  }
  /**
   * Ensures that subsequent recordings really can be made.
   *
   * If one recordings starts exactly when another ends, on the same device,
   * it can cause problems of the device still being locked, because the
   * previous recording thread is still ongoing with the shutdown when the
   * new recording thread tries to start.
   * In my case at least, it's just fractions of a second, so the workaround
   * (maybe a hack?) is to stop the earlier recording one second earlier.
   *
   * @param recordings (So far) Final plan of what to record when
   * @throws Exception 
   */
  protected void fixBackToBackRecordings(Vector<PlannedRecording> recordings)
    throws Exception
  {
    final int kTimeBetweenRecordings = 10000; // in ms. My card needs 5-6 seconds to settle down :-(, so set to 10s.
    for (PlannedRecording earlier : recordings)
    {
      for (PlannedRecording later : recordings)
      {
        if (earlier.endtime().compareTo(later.starttime()) == 0 && // equals = is back to back
            earlier.starttime().before(earlier.endtime())) // earlier != later
        {
          earlier.endtime().setTime(earlier.endtime().getTime() - kTimeBetweenRecordings); // stop the earlier rec a bit earlier.
        }
      }
    }
  }
  /**
   * Creates a timer for each Recording, which will, when it fires, call recordNow()
   */
  protected Map<Recording, Timer> createTimers(Vector<PlannedRecording> recordings)
  {
    Map<Recording, Timer> result = Util.newMap(); // goes to myTimers
    for (PlannedRecording recording : recordings)
    {
      try {
        debug("Planning " + recording.toString() + " on " + recording.channel().toString());
        /* create one timer per recording, because firing of later events wait
         * for finish of previous ones. should not be a problem if we just
         * issue a HTTP call to Recorder, but if that stalls due to network
         * problems...
         */
        Timer timer = new Timer("Timer for " + recording.title(), false);
        final PlannedRecording r = recording;
        timer.schedule(new TimerTask() {
          public void run() {
            try {
              r.recordNow();
            } catch (Exception e) {
              Util.error("Could not start planned recording", e);
              recordingError(r, e);
            }
          }
        }, recording.starttime());
        result.put(recording, timer);
      } catch (Exception e) {
        error("Recording " + recording.title() + "(recordid " + recording.recordID() + "): ", e);
      }
    }
    return result;
  }
  public void recordingDone(PlannedRecording recording)
  {
    myTimers.remove(recording);

    try {
      final PreparedStatement findRecordingPlan = DB.me.database().prepareStatement("SELECT * FROM recording_plan WHERE recordid = ?");
      final PreparedStatement createRecording = DB.me.database().prepareStatement("INSERT INTO recording (filmid, rec_group, keep) SELECT ?, rec_group, keep FROM recording WHERE recordid = ?", Statement.RETURN_GENERATED_KEYS);
      final PreparedStatement markDone = DB.me.database().prepareStatement("UPDATE recording SET done = true, filename = ?, storename = ? WHERE recordid = ?");
      final PreparedStatement deletePlan1 = DB.me.database().prepareStatement("DELETE FROM recording WHERE recordid = ?");
      final PreparedStatement deletePlan2 = DB.me.database().prepareStatement("DELETE FROM recording_plan WHERE recordid = ?");

      findRecordingPlan.setInt(1, recording.recordID());
      ResultSet rs = findRecordingPlan.executeQuery();
      if (!rs.next())
        throw new Exception("Recording ID " + recording.recordID() + " not found");
      int searchtype = rs.getInt("searchtype");
      createRecording.setInt(1, recording.filmID());
      createRecording.setInt(2, recording.recordID());
      createRecording.executeUpdate();
      rs = createRecording.getGeneratedKeys();
      if (!rs.first())
        throw new Exception("New recording ID not found");
      int newID = rs.getInt(1);
      if ( ! (recording.target() instanceof LocalfileTarget))
        throw new Exception("Recording to remote targets not yet implemented"); // TODO
      markDone.setString(1, ((LocalfileTarget) recording.target()).relFilename());
      markDone.setString(2, recording.store().storeName());
      markDone.setInt(3, newID);
      markDone.executeUpdate();
      if (searchtype == 1 || searchtype == 2)
      {
        deletePlan1.setInt(1, recording.recordID());
        deletePlan2.setInt(1, recording.recordID());
        deletePlan1.executeUpdate();
        deletePlan2.executeUpdate();
      }
    } catch (Exception e) {
      Util.error("Marking recording as done, in DB", e);
    }
  }
  public void recordingError(PlannedRecording recording, Exception error)
  {
    myTimers.remove(recording);

    try {
      final PreparedStatement logError = DB.me.database().prepareStatement("UPDATE recording SET error_msg = ?, error_log = ? WHERE recordid = ?");
      logError.setString(1, error.getLocalizedMessage());
      logError.setString(2, error.toString());
      logError.setInt(3, recording.recordID());
      logError.executeUpdate();
    } catch (Exception e) {
      Util.error("Marking recording as failed in DB", e);
    }
  }

  /**
   * Scheduling errors which don't abort the whole scheduling
   */
  protected void error(String msg, Exception e)
  {
    Util.error("Scheduling: " + msg, e);
  }
  protected void debug(String msg)
  {
    //Util.debug(msg);
  }
  protected void info(String msg)
  {
    Util.debug(msg);
  }

  // Data
  //protected Map<Integer, Recording> mySchedule; // int = recordid
  protected Map<Recording, Timer> myTimers;
}
