package com.google.daq.mqtt.util;

import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.http.HttpRequestInitializer;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.services.cloudiot.v1.CloudIot;
import com.google.api.services.cloudiot.v1.CloudIotScopes;
import com.google.api.services.cloudiot.v1.model.BindDeviceToGatewayRequest;
import com.google.api.services.cloudiot.v1.model.Device;
import com.google.api.services.cloudiot.v1.model.DeviceCredential;
import com.google.api.services.cloudiot.v1.model.GatewayConfig;
import com.google.api.services.cloudiot.v1.model.ModifyCloudToDeviceConfigRequest;
import com.google.api.services.cloudiot.v1.model.PublicKeyCredential;
import com.google.auth.http.HttpCredentialsAdapter;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

import static com.google.daq.mqtt.util.ConfigUtil.readCloudIotConfig;
import static java.util.stream.Collectors.toList;

/**
 * Encapsulation of all Cloud IoT interaction functions.
 */
public class CloudIotManager {

  private static final String DEVICE_UPDATE_MASK = "blocked,credentials,metadata";
  private static final String REGISTERED_KEY = "registered";
  private static final String SCHEMA_KEY = "schema_name";
  private static final int LIST_PAGE_SIZE = 1000;

  private final CloudIotConfig cloudIotConfig;

  private final String registryId;
  private final String projectId;
  private final String cloudRegion;

  private CloudIot cloudIotService;
  private String projectPath;
  private CloudIot.Projects.Locations.Registries cloudIotRegistries;
  private Map<String, Device> deviceMap = new HashMap<>();
  private String schemaName;

  public CloudIotManager(String projectId, File iotConfigFile, String schemaName) {
    this.projectId = projectId;
    this.schemaName = schemaName;
    cloudIotConfig = validate(readCloudIotConfig(iotConfigFile));
    registryId = cloudIotConfig.registry_id;
    cloudRegion = cloudIotConfig.cloud_region;
    initializeCloudIoT();
  }

  private static CloudIotConfig validate(CloudIotConfig cloudIotConfig) {
    Preconditions.checkNotNull(cloudIotConfig.registry_id, "registry_id not defined");
    Preconditions.checkNotNull(cloudIotConfig.cloud_region, "cloud_region not defined");
    Preconditions.checkNotNull(cloudIotConfig.site_name, "site_name not defined");
    return cloudIotConfig;
  }

  private String getRegistryPath(String registryId) {
    return projectPath + "/registries/" + registryId;
  }

  private String getDevicePath(String registryId, String deviceId) {
    return getRegistryPath(registryId) + "/devices/" + deviceId;
  }

  private void initializeCloudIoT() {
    projectPath = "projects/" + projectId + "/locations/" + cloudRegion;
    try {
      System.err.println("Initializing with default credentials...");
      GoogleCredentials credential =
          GoogleCredentials.getApplicationDefault().createScoped(CloudIotScopes.all());
      JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();
      HttpRequestInitializer init = new HttpCredentialsAdapter(credential);
      cloudIotService =
          new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(), jsonFactory, init)
              .setApplicationName("com.google.iot.bos")
              .build();
      cloudIotRegistries = cloudIotService.projects().locations().registries();
      System.err.println("Created service for project " + projectPath);
    } catch (Exception e) {
      throw new RuntimeException("While initializing Cloud IoT project " + projectPath, e);
    }
  }

  public boolean registerDevice(String deviceId, CloudDeviceSettings settings) {
    try {
      Preconditions.checkNotNull(cloudIotService, "CloudIoT service not initialized");
      Preconditions.checkNotNull(deviceMap, "deviceMap not initialized");
      Device device = deviceMap.get(deviceId);
      boolean isNewDevice = device == null;
      if (isNewDevice) {
        createDevice(deviceId, settings);
      } else {
        updateDevice(deviceId, settings, device);
      }
      writeDeviceConfig(deviceId, settings.config);
      return isNewDevice;
    } catch (Exception e) {
      throw new RuntimeException("While registering device " + deviceId, e);
    }
  }

  private void writeDeviceConfig(String deviceId, String config) {
    try {
      cloudIotRegistries.devices().modifyCloudToDeviceConfig(getDevicePath(registryId, deviceId),
          new ModifyCloudToDeviceConfigRequest().setBinaryData(
              Base64.getEncoder().encodeToString(config.getBytes()))
      ).execute();
    } catch (Exception e) {
      throw new RuntimeException("While modifying device config", e);
    }
  }

  public void blockDevice(String deviceId, boolean blocked) {
    try {
      Device device = new Device();
      device.setBlocked(blocked);
      String path = getDevicePath(registryId, deviceId);
      cloudIotRegistries.devices().patch(path, device).setUpdateMask("blocked").execute();
    } catch (Exception e) {
      throw new RuntimeException(String.format("While (un)blocking device %s/%s=%s", registryId, deviceId, blocked), e);
    }
  }

  private Device makeDevice(String deviceId, CloudDeviceSettings settings,
      Device oldDevice) {
    Map<String, String> metadataMap = oldDevice == null ? null : oldDevice.getMetadata();
    if (metadataMap == null) {
      metadataMap = new HashMap<>();
    }
    metadataMap.put(REGISTERED_KEY, settings.metadata);
    metadataMap.put(SCHEMA_KEY, schemaName);
    return new Device()
        .setId(deviceId)
        .setGatewayConfig(getGatewayConfig(settings))
        .setCredentials(getCredentials(settings))
        .setMetadata(metadataMap);
  }

  private ImmutableList<DeviceCredential> getCredentials(CloudDeviceSettings settings) {
    if (settings.credential != null) {
      return ImmutableList.of(settings.credential);
    } else {
      return ImmutableList.of();
    }
  }

  private GatewayConfig getGatewayConfig(CloudDeviceSettings settings) {
    boolean isGateway = settings.proxyDevices != null;
    GatewayConfig gwConfig = new GatewayConfig();
    gwConfig.setGatewayType(isGateway ? "GATEWAY" : "NON_GATEWAY");
    gwConfig.setGatewayAuthMethod("ASSOCIATION_ONLY");
    return gwConfig;
  }

  private void createDevice(String deviceId, CloudDeviceSettings settings) throws IOException {
    try {
      cloudIotRegistries.devices().create(getRegistryPath(registryId),
          makeDevice(deviceId, settings, null)).execute();
    } catch (GoogleJsonResponseException e) {
      throw new RuntimeException("Remote error creating device " + deviceId, e);
    }
  }

  private void updateDevice(String deviceId, CloudDeviceSettings settings,
      Device oldDevice) {
    try {
      Device device = makeDevice(deviceId, settings, oldDevice)
          .setId(null)
          .setNumId(null);
      cloudIotRegistries
          .devices()
          .patch(getDevicePath(registryId, deviceId), device).setUpdateMask(DEVICE_UPDATE_MASK)
          .execute();
    } catch (Exception e) {
      throw new RuntimeException("Remote error patching device " + deviceId, e);
    }
  }

  public static DeviceCredential makeCredentials(String keyFormat, String keyData) {
    PublicKeyCredential publicKeyCredential = new PublicKeyCredential();
    publicKeyCredential.setFormat(keyFormat);
    publicKeyCredential.setKey(keyData);

    DeviceCredential deviceCredential = new DeviceCredential();
    deviceCredential.setPublicKey(publicKeyCredential);
    return deviceCredential;
  }

  public List<Device> fetchDeviceList(Pattern devicePattern) {
    Preconditions.checkNotNull(cloudIotService, "CloudIoT service not initialized");
    try {
      List<Device> devices = cloudIotRegistries
          .devices()
          .list(getRegistryPath(registryId))
          .setPageSize(LIST_PAGE_SIZE)
          .execute()
          .getDevices();
      if (devices == null) {
        return new ArrayList<>();
      }
      if (devices.size() == LIST_PAGE_SIZE) {
        throw new RuntimeException("Returned exact page size, likely not fetched all devices");
      }
      return devices.stream().filter(device -> devicePattern.matcher(device.getId()).find()).collect(toList());
    } catch (Exception e) {
      throw new RuntimeException("While listing devices for registry " + registryId, e);
    }
  }

  public Device fetchDevice(String deviceId) {
    return deviceMap.computeIfAbsent(deviceId, this::fetchDeviceFromCloud);
  }

  private Device fetchDeviceFromCloud(String deviceId) {
    try {
      return cloudIotRegistries.devices().get(getDevicePath(registryId, deviceId)).execute();
    } catch (Exception e) {
      if (e instanceof GoogleJsonResponseException
          && ((GoogleJsonResponseException) e).getDetails().getCode() == 404) {
        return null;
      }
      throw new RuntimeException("While fetching " + deviceId, e);
    }
  }

  public String getRegistryId() {
    return registryId;
  }

  public String getProjectId() {
    return projectId;
  }

  public String getSiteName() {
    return cloudIotConfig.site_name;
  }

  public Object getCloudRegion() {
    return cloudRegion;
  }

  public void bindDevice(String proxyDeviceId, String gatewayDeviceId) throws IOException {
    cloudIotRegistries.bindDeviceToGateway(getRegistryPath(registryId),
        getBindRequest(proxyDeviceId, gatewayDeviceId)).execute();
  }

  private BindDeviceToGatewayRequest getBindRequest(String deviceId, String gatewayId) {
    return new BindDeviceToGatewayRequest().setDeviceId(deviceId).setGatewayId(gatewayId);
  }
}