<?php

/**
 * Any type of thermostat.
 *
 * @author Jon Ziebell
 */
class thermostat extends cora\crud {

  public static $exposed = [
    'private' => [
      'read_id',
      'sync',
      'dismiss_alert',
      'restore_alert',
      'set_reported_system_types',
      'generate_profile',
      'generate_profiles',
      'get_metrics',
      'update'
    ],
    'public' => []
  ];

  public static $cache = [
    'sync' => 180, // 3 Minutes
    'generate_profile' => 604800, // 7 Days
    'get_metrics' => 604800 // 7 Days
  ];

  /**
   * Updates a thermostat normally, plus does the generated columns.
   *
   * @param array $attributes
   *
   * @return array
   */
  public function update($attributes) {
    return parent::update(array_merge($attributes, $this->get_generated_columns($attributes)));
  }

  /**
   * Get all of the generated columns.
   *
   * @param array $attributes The thermostat.
   *
   * @return array The generated columns only.
   */
  private function get_generated_columns($attributes) {
    $generated_columns = [];

    if(isset($attributes['system_type']) === true) {
      foreach(['heat', 'auxiliary_heat', 'cool'] as $mode) {
        if($attributes['system_type']['reported'][$mode]['equipment'] !== null) {
          $generated_columns['system_type_' . $mode] = $attributes['system_type']['reported'][$mode]['equipment'];
        } else {
          $generated_columns['system_type_' . $mode] = $attributes['system_type']['detected'][$mode]['equipment'];
        }
        if($attributes['system_type']['reported'][$mode]['stages'] !== null) {
          $generated_columns['system_type_' . $mode . '_stages'] = $attributes['system_type']['reported'][$mode]['stages'];
        } else {
          $generated_columns['system_type_' . $mode . '_stages'] = $attributes['system_type']['detected'][$mode]['stages'];
        }
      }
    }

    if(isset($attributes['property']) === true) {
      foreach(['age', 'square_feet', 'stories', 'structure_type'] as $characteristic) {
        $generated_columns['property_' . $characteristic] = $attributes['property'][$characteristic];
      }
    }

    if(isset($attributes['address_id']) === true) {
      $address = $this->api('address', 'get', $attributes['address_id']);
      if(
        isset($address['normalized']['metadata']) === true &&
        $address['normalized']['metadata'] !== null &&
        isset($address['normalized']['metadata']['latitude']) === true &&
        $address['normalized']['metadata']['latitude'] !== null &&
        isset($address['normalized']['metadata']['longitude']) === true &&
        $address['normalized']['metadata']['longitude'] !== null
      ) {
        $generated_columns['address_latitude'] = $address['normalized']['metadata']['latitude'];
        $generated_columns['address_longitude'] = $address['normalized']['metadata']['longitude'];
      } else {
        $generated_columns['address_latitude'] = null;
        $generated_columns['address_longitude'] = null;
      }
    }

    return $generated_columns;
  }

  /**
   * Update the reported system type of this thermostat.
   *
   * @param int $thermostat_id
   * @param array $system_types
   *
   * @return array The updated thermostat.
   */
  public function set_reported_system_types($thermostat_id, $system_types) {
    // Redundant, but makes sure you have access to edit the thermostat you
    // submitted.
    $thermostat = $this->get($thermostat_id);

    foreach($system_types as $system_type => $value) {
      if(in_array($system_type, ['heat', 'auxiliary_heat', 'cool']) === true) {
        $thermostat['system_type']['reported'][$system_type]['equipment'] = $value;
      }
    }

    return $this->update($thermostat);
  }

  /**
   * Normal read, but filter out generated columns. These columns exist only
   * for indexing and searching purposes.
   *
   * @param array $attributes
   * @param array $columns
   *
   * @return array
   */
  public function read($attributes = [], $columns = []) {
    $thermostats = parent::read($attributes, $columns);

    foreach($thermostats as &$thermostat) {
      unset($thermostat['system_type_heat']);
      unset($thermostat['system_type_heat_stages']);
      unset($thermostat['system_type_auxiliary_heat']);
      unset($thermostat['system_type_auxiliary_heat_stages']);
      unset($thermostat['system_type_cool']);
      unset($thermostat['system_type_cool_stages']);
      unset($thermostat['property_age']);
      unset($thermostat['property_square_feet']);
      unset($thermostat['property_stories']);
      unset($thermostat['property_structure_type']);
      unset($thermostat['address_latitude']);
      unset($thermostat['address_longitude']);
    }

    return $thermostats;
  }

  /**
   * Sync all thermostats for the current user. If we fail to get a lock, fail
   * silently (catch the exception) and just return false.
   *
   * @return boolean true if the sync ran, false if not.
   */
  public function sync() {
    // Skip this for the demo
    if($this->setting->is_demo() === true) {
      return true;
    }

    try {
      $lock_name = 'thermostat->sync(' . $this->session->get_user_id() . ')';
      $this->database->get_lock($lock_name);

      $this->api('ecobee_thermostat', 'sync');

      $this->api(
        'user',
        'update_sync_status',
        ['key' => 'thermostat']
      );

      $this->database->release_lock($lock_name);

      return true;
    } catch(cora\exception $e) {
      if($e->getCode() === 10511) {
        throw new cora\exception($e->getMessage(), $e->getCode(), $e->getReportable(), $e->getExtraInfo(), $e->getRollback());
      } else {
        return false;
      }
    }
  }

  /**
   * Dismiss an alert.
   *
   * @param int $thermostat_id
   * @param string $guid
   */
  public function dismiss_alert($thermostat_id, $guid) {
    $thermostat = $this->get($thermostat_id);
    foreach($thermostat['alerts'] as &$alert) {
      if($alert['guid'] === $guid) {
        $alert['dismissed'] = true;
        break;
      }
    }
    $this->update(
      [
        'thermostat_id' => $thermostat_id,
        'alerts' => $thermostat['alerts']
      ]
    );
  }

  /**
   * Restore a dismissed alert.
   *
   * @param int $thermostat_id
   * @param string $guid
   */
  public function restore_alert($thermostat_id, $guid) {
    $thermostat = $this->get($thermostat_id);
    foreach($thermostat['alerts'] as &$alert) {
      if($alert['guid'] === $guid) {
        $alert['dismissed'] = false;
        break;
      }
    }
    $this->update(
      [
        'thermostat_id' => $thermostat_id,
        'alerts' => $thermostat['alerts']
      ]
    );
  }

  /**
   * Generate profiles for all thermostats. This pretty much only exists for
   * the cron job.
   */
  public function generate_profiles() {
    $thermostats = $this->read([
      'inactive' => 0
    ]);
    foreach($thermostats as $thermostat) {
      $this->generate_profile(
        $thermostat['thermostat_id']
      );
    }

    $this->api(
      'user',
      'update_sync_status',
      ['key' => 'thermostat.generate_profiles']
    );
  }

  /**
   * Generate a new profile for this thermostat. This is called from the GUI
   * often but is cached.
   *
   * @param int $thermostat_id
   */
  public function generate_profile($thermostat_id) {
    $this->update([
      'thermostat_id' => $thermostat_id,
      'profile' => $this->api('profile', 'generate', $thermostat_id)
    ]);
  }

  /**
   * Compare this thermostat to all other matching ones.
   *
   * @param array $thermostat_id The base thermostat_id.
   * @param array $attributes Optional attributes:
   * property_structure_type
   * property_age
   * property_square_feet
   * property_stories
   *
   * @return array
   */
  public function get_metrics($thermostat_id, $attributes) {
    $thermostat = $this->get($thermostat_id);
    $generated_columns = $this->get_generated_columns($thermostat);

    if(
      $generated_columns['system_type_heat'] === null ||
      $generated_columns['system_type_heat_stages'] === null ||
      $generated_columns['system_type_cool'] === null ||
      $generated_columns['system_type_cool_stages'] === null
    ) {
      throw new cora\exception('System type is not defined.', 10700);
    }

    $where = [];

    $where[] = '`profile` is not null';

    $keys_generated_columns = [
      'system_type_heat',
      'system_type_heat_stages',
      'system_type_cool',
      'system_type_cool_stages'
    ];
    foreach($keys_generated_columns as $key) {
      $where[] = $this->database->column_equals_value_where(
        $key,
        $generated_columns[$key]
      );
    }

    $keys_required_in_query = [
      'property_age',
      'property_square_feet',
      'property_stories'
    ];
    foreach($keys_required_in_query as $key) {
      if(isset($attributes[$key]) === true) {
        $where[] = $this->database->column_equals_value_where(
          $key,
          $attributes[$key]
        );
      } else {
        // Fill these in for query performance.
        $where[] = $this->database->column_equals_value_where(
          $key,
          ['operator' => '>', 'value' => 0]
        );
      }
    }

    $keys_optional_in_query = [
      'property_structure_type',
    ];
    foreach($keys_optional_in_query as $key) {
      if(isset($attributes[$key]) === true) {
        $where[] = $this->database->column_equals_value_where(
          $key,
          $attributes[$key]
        );
      }
    }

    /**
     * Normally radius implies a circle. In this case it's a square as this
     * helps with some optimization.
     */
    if(isset($attributes['radius']) === true) {
      if(
        is_array($attributes['radius']) === false ||
        $attributes['radius']['operator'] !== '<'
      ) {
        throw new \Exception('Radius must be defined as less than a value.', 10702);
      }

      $radius = (int) $attributes['radius']['value'];
      if(
        isset($generated_columns['address_latitude']) === false ||
        isset($generated_columns['address_longitude']) === false
      ) {
        // Require a valid address (latitude/longitude) when using radius.
        throw new cora\exception('Cannot compare by radius if address is invalid.', 10701);
      } else {
          // Latitude is 69mi / °
          $degrees_latitude_delta = $radius / 69 / 2;
          $minimum_latitude = $generated_columns['address_latitude'] - $degrees_latitude_delta;
          $maximum_latitude = $generated_columns['address_latitude'] + $degrees_latitude_delta;
          if ($minimum_latitude < -90) {
            $overflow = abs($minimum_latitude + 90);
            $between_a = [-90, $maximum_latitude];
            sort($between_a);
            $between_b = [90, (90 - $overflow)];
            sort($between_b);
            $where[] = '(`address_latitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_latitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')';
          }
          else if ($maximum_latitude > 90) {
            $overflow = abs($maximum_latitude - 90);
            $between_a = [90, $minimum_latitude];
            sort($between_a);
            $between_b = [-90, (-90 + $overflow)];
            sort($between_b);
            $where[] = '(`address_latitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_latitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')';
          }
          else {
            $between_a = [$minimum_latitude, $maximum_latitude];
            sort($between_a);
            $where[] = '`address_latitude` between ' . $between_a[0] . ' and ' . $between_a[1];
          }

          // Longitude is 69mi / ° at the equator and then shrinks towards the poles.
          $degrees_longitude_delta = $radius / 69 / 2;
          $minimum_longitude = $generated_columns['address_longitude'] - $degrees_longitude_delta;
          $maximum_longitude = $generated_columns['address_longitude'] + $degrees_longitude_delta;
          if ($minimum_longitude < -180) {
            $overflow = abs($minimum_longitude + 180);
            $between_a = [-180, $maximum_longitude];
            sort($between_a);
            $between_b = [180, (180 - $overflow)];
            sort($between_b);
            $where[] = '(`address_longitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_longitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')';
          }
          else if ($maximum_longitude > 180) {
            $overflow = abs($maximum_longitude - 180);
            $between_a = [180, $minimum_longitude];
            sort($between_a);
            $between_b = [-180, (-180 + $overflow)];
            sort($between_b);
            $where[] = '(`address_longitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_longitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')';
          }
          else {
            $between_a = [$minimum_longitude, $maximum_longitude];
            sort($between_a);
            $where[] = '`address_longitude` between ' . $between_a[0] . ' and ' . $between_a[1];
          }
      }
    } else {
      // Fill these in for query performance.
      $where[] = '`address_longitude` between -180 and 180';
      $where[] = '`address_latitude` between -90 and 90';
    }

    // Should match their position in the thermostat profile exactly.
    $metric_codes = [
      'property' => [
        'age',
        'square_feet'
      ],
      'runtime_per_degree_day' => [
        'heat_1',
        'heat_2',
        'auxiliary_heat_1',
        'auxiliary_heat_2',
        'cool_1',
        'cool_2'
      ],
      'setpoint' => [
        'heat',
        'cool'
      ],
      'setback' => [
        'heat',
        'cool'
      ],
      'balance_point' => [
        'heat_1',
        'heat_2',
        'resist'
      ]
    ];

    // Set all of the metric intervals. If Celsius add a bit of precision.
    $intervals = [];

    $intervals['property'] = [
      'age' => 1,
      'square_feet' => 500
    ];

    $intervals['runtime_per_degree_day'] = [
      'heat_1' => 1,
      'heat_2' => 1,
      'auxiliary_heat_1' => 1,
      'auxiliary_heat_2' => 1,
      'cool_1' => 1,
      'cool_2' => 1
    ];

    $intervals['setpoint'] = [
        'heat' => 0.5,
        'cool' => 0.5
    ];

    if($thermostat['temperature_unit'] === '°F') {
      $intervals['setback'] = [
        'heat' => 1,
        'cool' => 1
      ];

      $intervals['balance_point'] = [
        'heat_1' => 1,
        'heat_2' => 1,
        'resist' => 1
      ];
    } else {
      $intervals['setback'] = [
        'heat' => 0.5,
        'cool' => 0.5
      ];

      $intervals['balance_point'] = [
        'heat_1' => 0.5,
        'heat_2' => 0.5,
        'resist' => 0.5
      ];
    }

    $get_metric_template = function() {
      return [
        'values' => [],
        'histogram' => [],
        'standard_deviation' => null,
        'median' => null,
        'precision' => null
      ];
    };

    $metrics = [];
    foreach($metric_codes as $parent_metric_name => $parent_metric) {
      $metrics[$parent_metric_name] = [];
      foreach($parent_metric as $child_metric_name) {
        $metrics[$parent_metric_name][$child_metric_name] = $get_metric_template();
        $metrics[$parent_metric_name][$child_metric_name]['interval'] = $intervals[$parent_metric_name][$child_metric_name];
      }
    }

    $memory_limit = 16; // mb
    $memory_per_thermostat = 0.0054; // mb

    $limit_start = 0;
    $limit_count = (int) round($memory_limit / $memory_per_thermostat);

    /**
     * Selecting lots of rows can eventually run PHP out of memory, so chunk
     * this up into several queries to avoid that.
     */
    do {
      $result = $this->database->query(
        'select thermostat_id, profile from thermostat where ' . implode(' and ', $where) . ' limit ' . $limit_start . ',' . $limit_count . ''
      );

      // Get all the scores from the other thermostats
      while($other_thermostat = $result->fetch_assoc()) {
        $other_thermostat['profile'] = $other_thermostat['profile'] === null ? null : json_decode($other_thermostat['profile'], true);
        // Only use profiles with at least a year of data
        // Only use profiles generated in the past year
        if(
          $other_thermostat['profile']['metadata']['duration'] !== null &&
          $other_thermostat['profile']['metadata']['duration'] >= 365 &&
          strtotime($other_thermostat['profile']['metadata']['generated_at']) > strtotime('-1 year') &&
          $other_thermostat['thermostat_id'] !== $thermostat_id
        ) {
          foreach($metric_codes as $parent_metric_name => $parent_metric) {
            foreach($parent_metric as $child_metric_name) {
              if(
                isset($thermostat['profile'][$parent_metric_name]) === true &&
                isset($thermostat['profile'][$parent_metric_name][$child_metric_name]) === true &&
                $thermostat['profile'][$parent_metric_name][$child_metric_name] !== null &&
                isset($other_thermostat['profile'][$parent_metric_name]) === true &&
                isset($other_thermostat['profile'][$parent_metric_name][$child_metric_name]) === true &&
                $other_thermostat['profile'][$parent_metric_name][$child_metric_name] !== null
              ) {
                $interval = $intervals[$parent_metric_name][$child_metric_name];
                $data = round($other_thermostat['profile'][$parent_metric_name][$child_metric_name] / $interval) * $interval;

                $precision = strlen(substr(strrchr($interval, "."), 1));
                $data = number_format($data, $precision, '.', '');

                $metrics[$parent_metric_name][$child_metric_name]['values'][] = $data;
              }
            }
          }
        }
      }

      $limit_start += $limit_count;
    } while ($result->num_rows === $limit_count);

    // Cleanup. Set the standard deviation, median, and remove the temporary
    // values and any metrics that have no data.
    foreach($metric_codes as $parent_metric_name => $parent_metric) {
      foreach($parent_metric as $child_metric_name) {
        $data = $this->remove_outliers($metrics[$parent_metric_name][$child_metric_name]['values']);
        if(count($data['values']) > 0) {
          $metrics[$parent_metric_name][$child_metric_name]['histogram'] = $data['histogram'];
          $metrics[$parent_metric_name][$child_metric_name]['standard_deviation'] = array_standard_deviation($data['values']);
          $metrics[$parent_metric_name][$child_metric_name]['median'] = floatval(array_median($data['values']));
          unset($metrics[$parent_metric_name][$child_metric_name]['values']);
        } else {
          $metrics[$parent_metric_name][$child_metric_name] = null;
        }
      }
    }

    return $metrics;
  }

  /**
   * Remove outliers more than 2 standard deviations away from the mean. This
   * is an effective way to keep the scales meaningul for normal data.
   *
   * @param array $array Input array
   *
   * @return array Input array minus outliers.
   */
  private function remove_outliers($array) {
    $mean = array_mean($array);
    $standard_deviation = array_standard_deviation($array);

    $min = $mean - ($standard_deviation * 2);
    $max = $mean + ($standard_deviation * 2);

    $values = [];
    $histogram = [];
    foreach($array as $value) {
      if($value >= $min && $value <= $max) {
        $values[] = $value;

        $value_string = strval($value);
        if(isset($histogram[$value_string]) === false) {
          $histogram[$value_string] = 0;
        }
        $histogram[$value_string]++;
      }
    }

    return [
      'values' => $values,
      'histogram' => $histogram
    ];
  }
}
