package org.bucksch.zeipis.importer;
import org.bucksch.zeipis.all.*;
import org.bucksch.zeipis.recorder.*;
import org.bucksch.zeipis.recorder.DVBChannel.ChannelContentType;
import java.sql.*;
import java.util.*;

/**
 * Takes a |Transponder| object with |DVBChannel|s and updates the database
 * with this new information.
 * It tries to be moderately careful, merging and preserving the existing IDs in the database etc..
 * 
 * @author ben
 */
public class ChannelsToDBImporter
{
  protected PreparedStatement findTransponderByFreq;
  protected PreparedStatement findTransponderByDVBID;
  protected PreparedStatement addTransponder;
  protected PreparedStatement updateTransponder;
  protected PreparedStatement findChannelByDVBID;
  protected PreparedStatement findChannelByDVBName;
  protected PreparedStatement findChannelByDVBNameHDSD;
  protected PreparedStatement addStation;
  protected PreparedStatement updateStation;
  protected PreparedStatement addChannel;
  protected PreparedStatement updateChannel;
  protected PreparedStatement addChannelPID;
  protected PreparedStatement deleteChannelPIDs;
  protected PreparedStatement addChannelToDevice;
  protected PreparedStatement getLanguages;
  protected PreparedStatement getCASystemsAnyDevice;
  protected PreparedStatement getCASystemsThisDevice;
  protected void createSQLStatements()
    throws SQLException
  {
    Connection db = DB.me.database();
    findTransponderByFreq = db.prepareStatement("SELECT * FROM dvb_transponder WHERE frequency = ? AND dvbsubtype = ?");
    findTransponderByDVBID = db.prepareStatement("SELECT * FROM dvb_transponder WHERE transportid = ? AND networkid = ? AND dvbsubtype = ?");
    addTransponder = db.prepareStatement("INSERT INTO dvb_transponder (transportid, networkid, frequency, symbolrate, modulation, polarity, rolloff, pilot, fec_inner, hp_code_rate, lp_code_rate, bandwidth, transmission_mode, hierarchy, inversion, guard_interval, dvbsubtype) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", Statement.RETURN_GENERATED_KEYS);    
    updateTransponder = db.prepareStatement("UPDATE dvb_transponder SET transportid = ?, networkid = ?, frequency = ?, symbolrate = ?, modulation = ?, polarity = ?, rolloff = ?, pilot = ?, fec_inner = ?, hp_code_rate = ?, lp_code_rate = ?, bandwidth = ?, transmission_mode = ?, hierarchy = ?, inversion = ?, guard_interval = ?, dvbsubtype = ? WHERE dvb_transponderid = ?");    
    findChannelByDVBID = db.prepareStatement("SELECT * FROM channel LEFT JOIN dvb_transponder USING (dvb_transponderid) WHERE dvb_serviceid = ? AND transportid = ? AND networkid = ?");
    findChannelByDVBName = db.prepareStatement("SELECT * FROM channel WHERE tech_name = ?");
    findChannelByDVBNameHDSD = db.prepareStatement("SELECT * FROM channel WHERE tech_name = ? OR tech_name = concat(?, ' HD') OR tech_name = concat(?, ' SD')");
    addStation = db.prepareStatement("INSERT INTO station (long_name, short_name, auto) VALUES (?, ?, TRUE)", Statement.RETURN_GENERATED_KEYS);
    updateStation = db.prepareStatement("UPDATE station SET long_name = ?, short_name = ? WHERE stationid = ?");
    addChannel = db.prepareStatement("INSERT INTO channel (stationid, dvb_transponderid, dvb_serviceid, tech_name, dvb_provider_name, languages, dvb_encrypted, dvb_encrypt_systems, channeltype, auto) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DVB', TRUE)", Statement.RETURN_GENERATED_KEYS);
    updateChannel = db.prepareStatement("UPDATE channel SET stationid = ?, dvb_transponderid = ?, dvb_serviceid = ?, tech_name = ?, dvb_provider_name = ?, languages = ?, dvb_encrypted = ?, dvb_encrypt_systems = ?, channeltype = 'DVB' WHERE channelid = ? AND auto = TRUE");
    addChannelPID = db.prepareStatement("INSERT INTO pidcache (channelid, pid) VALUES (?, ?)");
    deleteChannelPIDs = db.prepareStatement("DELETE FROM pidcache WHERE channelid = ?");
    addChannelToDevice = db.prepareStatement("INSERT INTO device_channel (channelid, deviceid) VALUES (?, ?) ON DUPLICATE KEY UPDATE channelid=channelid");
    getLanguages = db.prepareStatement("SELECT value_str FROM prefs WHERE prefkey = 'user.languages';");
    getCASystemsAnyDevice = db.prepareStatement("SELECT device.dvb_encrypt_systems FROM device LEFT JOIN device AS runDevice USING (devicesubtype) WHERE runDevice.deviceid = ?;"); // TODO allow DVB-S2 cards when scanning with DVB-S
    getCASystemsThisDevice = db.prepareStatement("SELECT device.dvb_encrypt_systems FROM device WHERE deviceid = ?;");
  }
  /**
   * @param card  The card that can tune the channels.
   * @param updateonly   Only update (alter) channels and transponders already in the database.
   *    Do not add new channels/transponders.
   * @param addonly    Only add new channels/transponders, so not alter the existing ones in the DB.
   */
  public ChannelsToDBImporter(DVBCard card, boolean updateonly, boolean addonly)
    throws Exception
  {
    // given that we get a DVBCard, CoreData is already instantiated
    createSQLStatements();
    myCardID = card.id();

    myUpdateNames = ! addonly;
    myUpdatePIDs = ! addonly;
    myUpdateChannel = ! addonly;
    myUpdateTransponderTuningParams = ! addonly;
    myAddTransponders = ! updateonly;
    myAddStations = ! updateonly;
    myAddChannels = ! updateonly;
    myAddMoreChannelsForStations = ! updateonly;
    myRemoveChannels = ! updateonly;

    // User languages
    ResultSet rs = getLanguages.executeQuery();
    if (rs.first())
      setAllowedLanguages(rs.getString(1));
    // CA systems
    getCASystemsAnyDevice.clearParameters();
    getCASystemsAnyDevice.setInt(1, card.id());
    rs = getCASystemsAnyDevice.executeQuery();
    while (rs.next())
      setAllowedCASystemsAnyDevice(rs.getString(1));
    getCASystemsThisDevice.clearParameters();
    getCASystemsThisDevice.setInt(1, card.id());
    rs = getCASystemsThisDevice.executeQuery();
    while (rs.next())
      setAllowedCASystemsThisDevice(rs.getString(1));
    Util.debug("Allowed languages: " + (myAllowedLanguages == null ? "none known" : Util.joinToStrin(myAllowedLanguages, ", ")));
    Util.debug("Supported CA systems on any card of this type: " + (myAllowedCASystemsAnyDevices == null ? "none known" : Util.joinToStrin(myAllowedCASystemsAnyDevices, ", ")));
    Util.debug("Supported CA systems on this device: " + (myAllowedCASystemsThisDevice == null ? "none known" : Util.joinToStrin(myAllowedCASystemsThisDevice, ", ")));
  }
  public void importTransponder(DVBScan.Transponder transponder)
  {
    Util.debug("importing transponder freq " + transponder.tuning.frequency + ", transonder ID " +  transponder.transponderID + ", network ID (org) " + transponder.originalNetworkID + " / (actual) " + transponder.networkID + ", network name " + transponder.networkName);
    try {
      // find transponder
      findTransponderByFreq.clearParameters();
      findTransponderByFreq.setInt(1, transponder.tuning.frequency);
      findTransponderByFreq.setString(2, transponder.tuning.dvbSubtype());
      ResultSet rs = findTransponderByFreq.executeQuery();
      int dbTransponder = -1;
      if (rs.first())
      {
        dbTransponder = rs.getInt("dvb_transponderid");
        Util.debug("Transponder found per frequency");
      }
      else if (transponder.transponderID > -1 && transponder.originalNetworkID > -1)
      {
        findTransponderByDVBID.clearParameters();
        findTransponderByDVBID.setInt(1, transponder.transponderID);
        findTransponderByDVBID.setInt(2, transponder.originalNetworkID); // TODO or direct transportID?
        findTransponderByDVBID.setString(3, transponder.tuning.dvbSubtype());
        rs = findTransponderByDVBID.executeQuery();
        if (rs.first())
          dbTransponder = rs.getInt("dvb_transponderid");
        // TODO good idea?
        if (dbTransponder > 1)
          Util.debug("Transponder found per DVB ID, not per frequency");
      }
      if (dbTransponder == -1)
      {
        // add transponder
        if ( ! myAddTransponders)
          return;
        writeTransponder(addTransponder, transponder, -1);
        rs = addTransponder.getGeneratedKeys();
        if ( ! rs.first())
          throw new SQLException("Transponder could not be added");
        dbTransponder = rs.getInt(1);
      }
      else if (myUpdateTransponderTuningParams) // transponder is already known, so (possibly) update it
      {
        // TODO compare?
        writeTransponder(updateTransponder, transponder, dbTransponder);
      }

      // channels
      for (DVBChannel ch : transponder.channels)
      {
        try {
          importChannel(ch,
                  transponder.transponderID, transponder.originalNetworkID, dbTransponder);
        } catch (Exception e) { error(e); }
      }
    } catch (Exception e) { error(e); }
  }
  public void importChannel(DVBChannel ch, int dvbTransponderID, int dvbNetworkID, int dbTransponderID)
    throws SQLException
  {
    if (ch.contentType != ChannelContentType.TV)
      return;
    if (ch.dvbServiceName == null || ch.dvbServiceName.length() == 0)
      return;
    Util.debug("found channel service ID " + ch.serviceID + ", name " + ch.dvbServiceName + " (Provider " + ch.dvbProviderName + ")");
    if (ch.isHDTV)
      Util.debug(ch.dvbServiceName + " a HDTV channel, per DVB SDT");
    // find channel
    int dbChannel = -1;
    int dbChannelOtherDVBID = -1;
    int dbChannelOtherHDSD = -1;
    int dbStation = -1;
    boolean updatePIDs = false;
    String dvbServiceNameInDB = null;
    // dvb_serviceid = ? AND transportid = ? AND networkid = ?
    findChannelByDVBID.clearParameters();
    findChannelByDVBID.setInt(1, ch.serviceID);
    findChannelByDVBID.setInt(2, dvbTransponderID);
    findChannelByDVBID.setInt(3, dvbNetworkID);
    ResultSet rs = findChannelByDVBID.executeQuery();
    if (rs.first())
    {
      dbChannel = rs.getInt("channelid");
      dbStation = rs.getInt("stationid");
      dvbServiceNameInDB = rs.getString("tech_name");
    }
    else
    {
      findChannelByDVBName.clearParameters();
      findChannelByDVBName.setString(1, ch.dvbServiceName);
      rs = findChannelByDVBName.executeQuery();
      if (rs.first())
      {
        dbChannelOtherDVBID = rs.getInt("channelid");
        dbStation = rs.getInt("stationid");
        dvbServiceNameInDB = rs.getString("tech_name");
        Util.debug("channel " + ch.dvbServiceName + " found via name: channel DB ID " + dbChannelOtherDVBID + ", station DB ID " + dbStation);
      }
      else if (ch.dvbServiceName.endsWith(" HD") || ch.dvbServiceName.endsWith(" SD"))  
      {
        /* HACK for HD channels.
         * Based on common pattern of appending " HD" and " SD"
         * for channels which have both SD and HD versions */
        String baseName = ch.dvbServiceName.substring(0, ch.dvbServiceName.length() - 3); // without " SD"/" HD"
        Util.debug("Searching for basename " + baseName);
        findChannelByDVBNameHDSD.clearParameters();
        findChannelByDVBNameHDSD.setString(1, baseName);
        findChannelByDVBNameHDSD.setString(2, baseName);
        findChannelByDVBNameHDSD.setString(3, baseName);
        rs = findChannelByDVBNameHDSD.executeQuery();
        if (rs.first())
        {
          dbChannelOtherHDSD = rs.getInt("channelid");
          dbStation = rs.getInt("stationid");
          dvbServiceNameInDB = rs.getString("tech_name");
          Util.debug("channel " + ch.dvbServiceName + " found via HD/SD name: channel DB ID " + dbChannelOtherHDSD + ", station DB ID " + dbStation);
          if ( ! ch.isHDTV && ch.dvbServiceName.endsWith(" HD")) // HACK
          {
            ch.isHDTV = true;
            Util.debug(ch.dvbServiceName + " now marked as HDTV, per hack");
          }
        }
      }
    }

    if (dbChannel == -1)
    {
      // add station and channel
      if ( ! myAddChannels) // TODO false, but more channels for stations = true
        return;
      if ( ! shallAddChannel(ch, false))
        return;

      if (dbStation == -1)
      {
        // add station
        writeStation(addStation, ch.dvbServiceName, -1);
        rs = addStation.getGeneratedKeys();
        if ( ! rs.first())
          throw new SQLException("Station could not be added");
        dbStation = rs.getInt(1);
        Util.debug("Station " + ch.dvbServiceName + " added");
      }
      else if ( ! myAddMoreChannelsForStations)
            //&& dbChannelOtherDVBID != -1)
        return;

      // add channel
      writeChannel(addChannel, ch, dbStation, dbTransponderID, -1);
      rs = addChannel.getGeneratedKeys();
      if ( ! rs.first())
        throw new SQLException("Channel could not be added");
      dbChannel = rs.getInt(1);
      updatePIDs = true;
      Util.debug("Channel " + ch.dvbServiceName + " added");
    }
    // channel found by ID
    else if (myUpdateChannel)
    {
      writeChannel(updateChannel, ch, dbStation, dbTransponderID, dbChannel);
    }

    if (updatePIDs || myUpdatePIDs)
    {
      deleteChannelPIDs.clearParameters();
      deleteChannelPIDs.setInt(1, dbChannel);
      deleteChannelPIDs.executeUpdate();
      for (int pid : ch.pids)
      {
        addChannelPID.clearParameters();
        addChannelPID.setInt(1, dbChannel);
        addChannelPID.setInt(2, pid);
        addChannelPID.executeUpdate();
      }
    }

    writeChannelDeviceMapping(dbChannel, ch);

    // update station
    // Just in case the station name changed.
    // Be very conservative when assuming a station name change.
    if (myUpdateNames &&
       dvbServiceNameInDB != null && // if tech_name is NULL in DB, getString() returns |null|
       dvbServiceNameInDB.length() > 0 &&
       ! dvbServiceNameInDB.equals(ch.dvbServiceName) &&
       ! dvbServiceNameInDB.toLowerCase(Locale.ENGLISH).equals(ch.dvbServiceName.toLowerCase(Locale.ENGLISH)) &&
       ! dvbServiceNameInDB.toLowerCase().equals(ch.dvbServiceName.toLowerCase()) )
    {
      // TODO what if there are 2 channels for 1 station with different dvbServiceNames,
      // e.g. "arte" and "arte HD"?
      writeStation(updateStation, ch.dvbServiceName, dbStation);
    }
  }
  protected void writeChannel(PreparedStatement sql,
          DVBChannel ch, int dbStation, int dbTransponder, int dbChannel)
    throws SQLException
  {
    assert(sql == addChannel || sql == updateChannel && dbChannel == -1);
    assert(dbStation != -1 && dbTransponder != -1);
    // stationid, dvb_transponderid, dvb_serviceid, tech_name,
    // 5 = languages, dvb_encrypted, dvb_encrypt_systems
    sql.clearParameters();
    sql.setInt(1, dbStation);
    sql.setInt(2, dbTransponder);
    sql.setInt(3, ch.serviceID);
    sql.setString(4, ch.dvbServiceName);
    sql.setString(5, ch.dvbProviderName);
    sql.setString(6, Util.joinToStrin(ch.languages, ","));
    sql.setBoolean(7, ch.encrypted);
    sql.setString(8, Util.joinToStrin(ch.encryptSystems, ","));
    if (sql == updateChannel)
      sql.setInt(9, dbChannel);
    int rowCount = sql.executeUpdate();
    if (rowCount < 0 || rowCount > 1)
      throw new SQLException("write channel: unexpected number of rows changed");
  }
  protected void writeTransponder(PreparedStatement sql,
          DVBScan.Transponder transponder, int dbTransponder)
    throws Exception
  {
    assert(sql == addTransponder || sql == updateTransponder && dbTransponder == -1);
    DVBTransponder t = transponder.tuning;
    // transportid, networkid, frequency, symbolrate, 5 = modulation, polarity, rolloff, pilot, fec_inner
    // 10 = hp_code_rate, lp_code_rate, bandwidth, transmission_mode, hierarchy,
    // 15 = inversion, guard_intervall, dvbsubtype
    sql.clearParameters();
    sql.setInt(1, transponder.transponderID);
    sql.setInt(2, transponder.originalNetworkID);
    sql.setInt(3, t.frequency);
    sql.setInt(4, t.symbolrate);
    sql.setString(5, t.modulation);
    sql.setString(6, (t instanceof DVBSTransponder) ? ((DVBSTransponder) t).polarity + "" : "a");
    sql.setString(7, (t instanceof DVBS2Transponder) ? ((DVBS2Transponder) t).rolloff : "auto");
    sql.setString(8, (t instanceof DVBS2Transponder) ? ((DVBS2Transponder) t).pilot + "" : "a");
    sql.setString(9, (t instanceof DVBSTransponder) ? ((DVBSTransponder) t).fec : "auto");
    sql.setString(10, (t instanceof DVBTTransponder) ? ((DVBTTransponder) t).hp_code_rate : "auto");
    sql.setString(11, (t instanceof DVBTTransponder) ? ((DVBTTransponder) t).lp_code_rate : "auto");
    sql.setString(12, (t instanceof DVBTTransponder) ? ((DVBTTransponder) t).bandwidth + "" : "a");
    sql.setString(13, (t instanceof DVBTTransponder) ? ((DVBTTransponder) t).transmission_mode + "" : "a");
    sql.setString(14, (t instanceof DVBTTransponder) ? ((DVBTTransponder) t).hierarchy + "" : "a");
    sql.setString(15, (t instanceof DVBTTransponder) ? ((DVBTTransponder) t).inversion + "" : "a");
    sql.setString(16, (t instanceof DVBTTransponder) ? ((DVBTTransponder) t).guard_interval: "auto");
    sql.setString(17, t.dvbSubtype());
    if (sql == updateTransponder)
      sql.setInt(18, dbTransponder);
    int rowCount = sql.executeUpdate();
    if (rowCount < 0 || rowCount > 1)
      throw new SQLException("write transponder: unexpected number of rows changed");
  }
  protected void writeStation(PreparedStatement sql,
          String name, int dbStation)
    throws SQLException
  {
    if (name.endsWith(" HD") || name.endsWith(" SD")) // HACK for HD  
      name = name.substring(0, name.length() - 3);
    String longname = name;
    String shortname = name;
    if (shortname.length() > 10)
      shortname = shortname.substring(0, 7) + "."
      + shortname.substring(shortname.length() - 2, shortname.length()); 
    // long_name, short_name
    sql.clearParameters();
    sql.setString(1, longname);
    sql.setString(2, shortname);
    if (sql == updateStation)
      sql.setInt(3, dbStation);
    int rowCount = sql.executeUpdate();
    if (rowCount < 0 || rowCount > 1)
      throw new SQLException("write station: unexpected number of rows changed");
  }
  protected void writeChannelDeviceMapping(int dbChannel, DVBChannel ch)
    throws SQLException
  {
    if ( ! shallAddChannel(ch, true))
      return;
    // channelid, deviceid
    addChannelToDevice.clearParameters();
    addChannelToDevice.setInt(1, dbChannel);
    addChannelToDevice.setInt(2, myCardID);
    int rowCount = addChannelToDevice.executeUpdate();
    if (rowCount < 0 || rowCount > 1)
      throw new SQLException("add channel to device: unexpected number of rows changed");
  }

  protected boolean shallAddChannel(DVBChannel ch, boolean thisDevice)
  {
    if (myAllowedLanguages != null &&
        ch.languages != null && ! ch.languages.isEmpty() &&
        Collections.disjoint(myAllowedLanguages, ch.languages))
      return false;
    Set<Integer> allowedCASystems = thisDevice ? myAllowedCASystemsThisDevice : myAllowedCASystemsAnyDevices;
    // Need to be restrictive, because there are tons of encrypted channels which are not interesting for most people.
    if (ch.encrypted &&
        (allowedCASystems == null || // user or our setup need to configure it first
         ch.encryptSystems == null || ch.encryptSystems.isEmpty() || // if channel is too stupid ot provide proper data, don't add
         Collections.disjoint(allowedCASystems, ch.encryptSystems)))
      return false;
    return true;
  }

  // ISO 639-2 2-letter language codes, e.g. "de"
  public void addAllowedLanguage(String language)
  {
    if (myAllowedLanguages == null)
      myAllowedLanguages = Util.newSet();
    myAllowedLanguages.add(language);
  }
  // comma-separated list of ISO 639-2 2-letter language codes, e.g. "de"
  public void setAllowedLanguages(String languages)
  {
    try {
      if (languages == null || languages.length() == 0)
        myAllowedLanguages = null;
      else
      {
        myAllowedLanguages = Util.newSet();
        myAllowedLanguages.addAll(Arrays.asList(languages.split(",")));
      }
    } catch (Exception e) {
      Util.error("Can't parse languages. Is: \"" + languages + "\", but should be comma-separated list of 2-letter language codes, no spaces.", e);
      // Fatal error?
    }
  }
  public void addAllowedCASystemAnyDevice(int caSystem)
  {
    if (myAllowedCASystemsAnyDevices == null)
      myAllowedCASystemsAnyDevices = Util.newSet();
    myAllowedCASystemsAnyDevices.add(caSystem);
  }
  // comma-separated list of integers (decimal, not hex)
  public void setAllowedCASystemsAnyDevice(String caSystems)
  {
    try {
      if (caSystems == null || caSystems.length() == 0)
        myAllowedCASystemsAnyDevices = null;
      else
      {
        myAllowedCASystemsAnyDevices = Util.newSet();
        for (String str : caSystems.split(","))
        {
          if (str.startsWith("0x") && str.length() > 2)
            myAllowedCASystemsAnyDevices.add(Integer.parseInt(str.substring(2), 16));
          else if (str.length() > 0)
            myAllowedCASystemsAnyDevices.add(Integer.parseInt(str));
        }
      }
    } catch (Exception e) {
      Util.error("Can't parse CA encryption systems. Is: \"" + caSystems + "\", but should be comma-separated list of numeric CA system IDs, no spaces", e);
      // Fatal error?
    }
  }
  public void addAllowedCASystemThisDevice(int caSystem)
  {
    if (myAllowedCASystemsThisDevice == null)
      myAllowedCASystemsThisDevice = Util.newSet();
    myAllowedCASystemsThisDevice.add(caSystem);
  }
  protected void setAllowedCASystemsThisDevice(String caSystems)
  {
    try {
      if (caSystems == null || caSystems.length() == 0)
        myAllowedCASystemsThisDevice = null;
      else
      {
        myAllowedCASystemsThisDevice = Util.newSet();
        for (String str : caSystems.split(","))
        {
          if (str.startsWith("0x") && str.length() > 2)
            myAllowedCASystemsThisDevice.add(Integer.parseInt(str.substring(2), 16));
          else if (str.length() > 0)
            myAllowedCASystemsThisDevice.add(Integer.parseInt(str));
        }
      }
    } catch (Exception e) {
      Util.error("Can't parse CA encryption systems. Is: \"" + caSystems + "\", but should be comma-separated list of numeric CA system IDs, no spaces", e);
      // Fatal error?
    }
  }

  protected void error(Exception e)
  {
    warning(e.getMessage());
    e.printStackTrace(System.err);
  }
  protected void warning(String msg)
  {
    System.err.println(msg);
  }
  protected boolean myUpdateNames = true;
  protected boolean myUpdatePIDs = true;
  protected boolean myUpdateChannel = true;
  protected boolean myUpdateTransponderTuningParams = true;
  protected boolean myAddTransponders = true;
  protected boolean myAddStations = true;
  protected boolean myAddChannels = true;
  protected boolean myAddMoreChannelsForStations = true;
  protected boolean myRemoveChannels = true; // not implemented yet: Difficult to do properly. Add "auto" flag to DB, and only delete channels with that flag and which have no recordings
  protected Set<String> myAllowedLanguages = null;
  protected Set<Integer> myAllowedCASystemsAnyDevices = null;
  protected Set<Integer> myAllowedCASystemsThisDevice = null;
  protected int myCardID;
}
