Изменение формата объявлений на новый

Материал из Raskrutka
Перейти к: навигация, поиск
// @license Copyright 2016, Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

/**
 * @name ETA Transition Helper.
 *
 * @overview An AdWords Script that exports standard text ads from an account
 *           to a Spreadsheet and then imports newly created expanded text ads
 *           back to the AdWords account.
 *
 *           see https://developers.google.com/adwords/scripts/docs/solutions/mccapp-eta-transition-helper
 *           for more details.
 *
 * @author AdWords Scripts Team [adwords-scripts@googlegroups.com]
 *
 * @version 1.0
 *
 * @changelog
 * - version 1.0
 *   - Released initial version.
 */
//////////////////////////////////////////////////////////////////////////
//////////////////////////// CONFIGURATION ///////////////////////////////
//////////////////////////////////////////////////////////////////////////
var CONFIG = {
  // Select by CIDs (For MCC only).
  //selectByAccountIds: ['123-456-7890', '098-765-4321'],

  // Select by sub-mcc (For MCC only).
  //selectBySubMccId: '123-456-7890',

  // Recipient email address for script notifications.
  email: 'mcc@raskrutka.com.ua',

  // Set DEBUG mode. In this mode, the script will print messages to the log
  // output screen.
  debug: true,

  spreadsheet: {
    // ID of template spreadsheet.
    templateId: '1C66jjF57dZ5Me8vbUDnXTRXTNekHIVEwEQF8PnVD9Ks',

    // Name of the spreadsheet where all ads are exported and later imported to
    // upload ETAs.
    targetName: 'ETA Transition Helper v1.0',

    // Stores a reference to Sheet.
    // Do not set any values for `sheet`, this is dynamically filled when
    // initialized.
    sheet: null,

    // The name of the sheet inside the spreadsheet.
    sheetName: 'main',

    // The first row where the content starts.
    firstContentRow: 4,

    // The row index, in spreadsheet, where header is located.
    headerRow: 3,

    // Column to check and determine if row is not empty.
    nonEmptyColumnCheck: 'customerName',

    // Holds mappings between column names and their spreadsheet index.
    // Do not set any values for `columnNamesToIndices`, this is dynamically
    // filled when initialized.
    columnNamesToIndices: {},

    // Default status for each new ETA.
    defaultStatus: 'paused',

    // Column mapping.
    columns: [
              // User and Ad parent (Adgroup and Campaign) information.
              'customerId',
              'customerName',
              'campaignId',
              'campaignName',
              'adGroupId',
              'adGroupName',

              // STA related attributes (Read Only).
              'staId',
              'headline',
              'description1',
              'description2',
              'staApprovalStatus',
              'displayUrl',

              // STA related attributes (Editable).
              'staStatus',

              // STA performance metrics (Read Only).
              'impressions',
              'clicks',
              'ctr',

              // Shared attributes between both ETA and STA (Editable).
              'finalUrl',
              'mobileFinalUrl',
              'trackingTemplate',
              'customParameters',
              'labels',

              // ETA related attributes (Editable).
              'headline1',
              'charactersRemainingH1',
              'headline2',
              'charactersRemainingH2',
              'description',
              'charactersRemainingDesc',
              'path1',
              'path2',
              'etaStatus',

              // ETA related attributes (Read Only).
              'etaApprovalStatus',
              'etaCreated',
              'etaId',

              'readyToUpload',

              'errorMessage'],

    // Map between report fields and spreadsheet columns.
    // This mapping is used when exporting report data
    // to the spreadsheet. Will skip fields with `null` values.
    reportFieldMap: {
      customerId: 'accountId',
      customerName: 'accountName',
      campaignId: 'campaignId',
      campaignName: 'campaignName',
      adGroupId: 'adGroupId',
      adGroupName: 'adGroupName',

      staId: 'id',
      headline: 'headline',
      description1: 'description1',
      description2: 'description2',
      staApprovalStatus: 'creativeApprovalStatus',
      displayUrl: 'displayUrl',
      staStatus: 'status',

      impressions: 'impressions',
      clicks: 'clicks',
      ctr: 'ctr',

      finalUrl: 'creativeFinalUrls',
      mobileFinalUrl: 'creativeFinalMobileUrls',
      trackingTemplate: 'creativeTrackingUrlTemplate',
      customParameters: 'creativeUrlCustomParameters',
      labels: 'labels',

      headline1: 'headlinePart1',
      charactersRemainingH1: null,
      headline2: 'headlinePart2',
      charactersRemainingH2: null,
      description: 'description',
      charactersRemainingDesc: null,
      path1: 'path1',
      path2: 'path2',
      etaStatus: null,

      etaApprovalStatus: null,
      etaCreated: null,
      etaId: null,

      readyToUpload: null,

      errorMessage: null
    },

    // Columns with formulas to avoid overriding.
    // Will copy these fields when appending new rows in the spreadsheet.
    columnsWithFormulas: ['charactersRemainingH1',
                          'charactersRemainingH2',
                          'charactersRemainingDesc',
                          'etaCreated']
  },

  // The total number of ads we would like to handle.
  numOfAds: 800,

  // The total number of accounts we would like to handle (MCC only).
  numOfAccounts: 50,

  // The duration used to download the Ad Performance Report.
  duration: 'LAST_30_DAYS',

  // The API version to use when downloading the Ad Performance Report.
  apiVersion: 'v201607',

  // When sorting ads by performance, use the values listed here as a criteria
  // when comparing different performance values. For example, if ad A has
  // 100 impressions and ad B has 90 impressions, ad A should be more important
  // for us - unless impressionsThreshold is equal or higher to 10, then we'll
  // consider both ads with the same magnitude of impressions and therefore
  // compare their CTR.
  performance: {
    impressionsThreshold: 10,
    ctrThreshold: 0.1
  },

  // Fields to select from Ad Performance Report and export to selected
  // spreadsheet.
  reportFields: ['CampaignId', 'CampaignName', 'AdGroupId', 'AdGroupName', 'Id',
                 'Headline', 'Description1', 'Description2',
                 'CreativeApprovalStatus', 'DisplayUrl',

                 'Status',

                 'Impressions', 'Clicks', 'Ctr',

                 'CreativeFinalUrls', 'CreativeFinalMobileUrls',
                 'CreativeTrackingUrlTemplate', 'CreativeUrlCustomParameters',
                 'Labels',

                 'HeadlinePart1', 'HeadlinePart2', 'Description',
                 'Path1', 'Path2'
  ],

  // The default label to apply to Ads if no value is present in the label
  // column.
  defaultLabelName: 'eta-upgrade',

  // Cache key for storing changes done to platform.
  changeCacheKey: 'platform_changes',

  // Time the value will remain in the cache, in seconds.
  cacheExpiration: 21600,

  // The start date to filter ETA reports by.
  etaReportStartDate: '20160801'
};

// Email template types.
var SPREADSHEET_CREATED = 1;

// Preview mode indicator.
var IS_PREVIEW = AdWordsApp.getExecutionInfo().isPreview();

//////////////////////////////////////////////////////////////////////////
///////////////////////////////// MAIN ///////////////////////////////////
//////////////////////////////////////////////////////////////////////////

// Declaration required for non-MCC accounts.
var MccApp = MccApp || undefined;

/**
 * Main application entry.
 *
 * This will run first.
 */
function main() {
  initConfig();

  if (!validateSpreadsheet(CONFIG.spreadsheet)) {
    Logger.log('Terminating execution due to malformed spreadsheet format.');
    return;
  }

  var errorCount;
  if (MccApp) {
    print('Exporting STAs from MCC');
    printExportResults(exportSTAMCC());

    print('Processing spreadsheet');
    errorCount = syncSpreadsheetMCC();
    print('Processing complete');
  } else {
    print('Exporting STAs from account');
    printExportResults(exportSTA(CONFIG.numOfAds));

    print('Processing spreadsheet');
    errorCount = syncSpreadsheet();
    print('Processing complete');
  }

  if (errorCount > 0) {
    throw 'Script runtime error. An error occured, please check the logs.';
  }
}


/**
 * Initializes dynamic properties of CONFIG.
 */
function initConfig() {
  var sheet = getCachedSheet(CONFIG.spreadsheet, CONFIG.email);
  var spreadsheet = sheet.getParent();
  if (!canEditSpreadsheet(spreadsheet)) {
    throw 'User account does not have permission to edit the spreadsheet. ' +
          'Please ensure the permission is set to `Can Edit` instead of ' +
          '`Can Comment` or `Can View`.';
  }

  if (!sheet) {
    throw 'Could not find a sheet named "' + CONFIG.spreadsheet.sheetName +
        '" in the spreadsheet. Please fix your spreadsheet.';
  }

  CONFIG.spreadsheet.sheet = sheet;

  CONFIG.spreadsheet.columns.forEach(function(columnName, index) {
    CONFIG.spreadsheet.columnNamesToIndices[columnName] = index;
  });

  // Set default email address to the spreadsheet's owner email address.
  if (isEmptyString(CONFIG.email)) {
    CONFIG.email = getFileOwnerEmail(spreadsheet);
  }

  // Notify which email address is used.
  print('* This script will send notifications to: ' + CONFIG.email);
}

/**
 * Checks the number of accounts to process and warns the user if the number
 * is above the recommended threshold.
 *
 * @param {ManagedAccountIterator} accountIterator The account iterator of the
 *   accounts that will be used.
 */
function processAccountLimit(accountIterator) {
  var accountsToProcess = accountIterator.totalNumEntities();
  print('Accounts to process: ' + accountsToProcess);
  if (accountsToProcess > CONFIG.numOfAccounts) {
    print('WARNING: Number of accounts is above the recommended threshold: ' +
        CONFIG.numOfAccounts);
    print('Please use "selectByAccountIds" or "selectBySubMccId" to select');
    print('which accounts to use from the CONFIG');
  }
}

/**
 * Get the accountIterator from the MCC using the selectors specified in the
 * config.
 *
 * @return {ManagedAccountIterator} The iterator for the selected accounts.
 * @throws {string}
 */
function getAccountIteratorFromMCC() {
  var selectBySubMccId = CONFIG.selectBySubMccId || null;
  var selectByAccountIds = CONFIG.selectByAccountIds || null;

  // Build account iterator.
  var accIterBuild = MccApp.accounts();

  // Restrict to sub-mcc.
  if (selectBySubMccId) {
    if (!isString(selectBySubMccId)) {
      throw 'selectBySubMccId: is not a string';
    }
    accIterBuild = accIterBuild.withCondition("ManagerCustomerId = '" +
        validateCid(selectBySubMccId) + "'");
  }

  // Restrict to list of account IDs.
  if (selectByAccountIds) {
    if (!Array.isArray(selectByAccountIds)) {
      throw 'selectByAccountIds: is not an Array';
    }
    for (var i = 0; i < selectByAccountIds.length; i++) {
      validateCid(selectByAccountIds[i]);
    }
    accIterBuild = accIterBuild.withIds(selectByAccountIds);
  }

  return accIterBuild.get();
}

/**
 * From the Ad Performance Report, copy all enabled and
 * not disapproved Text Ads to the configured spreadsheet.
 *
 * This function operates only for MCC accounts.
 *
 * The function will iterate over each client sub-account exporting in turn.
 * @return {{exportedCount: number,
 *           remainingRowCount: number,
 *           accountsProcessed: number}}
 *           An object containing statistics from the STA export.
 *             exportedCount     The number of exported Ads during exportSTA.
 *             remainingRowCount The number of remaining rows available in the
 *                               spreadsheet.
 *             accountsProcessed The number of accounts processed in the export.
 */
function exportSTAMCC() {
  // Store the current MCC account.
  var mccAccount = AdWordsApp.currentAccount();

  // Count the number of available STAs to export.
  var maxAdsToExport = CONFIG.numOfAds;

  var resultObject = {
    exportedCount: 0,
    remainingRowCount: maxAdsToExport,
    accountsProcessed: 0
  };

  // Get account iterator from MCC config.
  var accountIterator;
  try {
    accountIterator = getAccountIteratorFromMCC();
  }
  catch (err) {
    print(err);
    return resultObject;
  }
  if (accountIterator) {
    processAccountLimit(accountIterator);
    while (accountIterator.hasNext()) {
      var account = accountIterator.next();

      // Set the account as the client account as the active account.
      MccApp.select(account);
      print('Account: ' + account.getName() +
                 ' (' + account.getCustomerId() + ')');

      // Execute the export.
      var exportResult = exportSTA(maxAdsToExport);

      resultObject.exportedCount += exportResult.exportedCount;
      resultObject.remainingRowCount = exportResult.remainingRowCount;
      resultObject.accountsProcessed++;

      if (resultObject.remainingRowCount <= 0) {
        print('Maximum number of Ads reached, skipping export');
        break;
      }
    }
  }

  MccApp.select(mccAccount);

  return resultObject;
}


/**
 * From the Ad Performance Report, copy all enabled and
 * not disapproved Text Ads to the configured spreadsheet.
 *
 * @param {number} maxAdsToExport The maximum number of ads to export.
 *
 * @return {{exportedCount: number,
 *           remainingRowCount: number}}
 *           An object containing statistics from the STA export.
 *             exportedCount     The number of exported Ads during exportSTA.
 *             remainingRowCount The number of remaining rows available in the
 *                               spreadsheet.
 */
function exportSTA(maxAdsToExport) {
  if (isEmpty(maxAdsToExport) || isNaN(maxAdsToExport)) {
    throw 'Failed to export STAs to the spreadsheet. Please specify a ' +
          'valid integer number in CONFIG.numOfAds.';
  }

  // Download Ad Performance Report.
  // Get the top most performing ads.
  var report = AdWordsApp.report(
      'SELECT ' + CONFIG.reportFields.join(',') + ' ' +
      'FROM     AD_PERFORMANCE_REPORT ' +
      'WHERE    AdType = "TEXT_AD" ' +
      '         AND AdGroupStatus = "ENABLED" ' +
      '         AND CampaignStatus = "ENABLED" ' +
      '         AND Status = "ENABLED" ' +
      '         AND CreativeApprovalStatus != "DISAPPROVED" ' +
      'DURING   ' + CONFIG.duration, {
        apiVersion: CONFIG.apiVersion
      });

  var resultObject = {
    exportedCount: 0,
    remainingRowCount: maxAdsToExport,
    accountsProcessed: 1
  };

  var mostPerformingAds = getMostPerformingAds(report, CONFIG);
  if (mostPerformingAds !== null) {
    // Open spreadsheet.
    var sheet = getCachedSheet(CONFIG.spreadsheet, CONFIG.email);

    var rowObject = getContentRows(sheet,
                                    CONFIG.spreadsheet.firstContentRow,
                                    CONFIG.spreadsheet.nonEmptyColumnCheck,
                                    CONFIG.spreadsheet.columnNamesToIndices,
                                    false);
    var nonEmptyRows = rowObject.rows;

    // If sheet is not empty, only export ads not present in sheet.
    if (nonEmptyRows.length > 0) {
      // Switch `mostPerformingAds` to an id=>Ad object structure.
      var mostPerformingAdsObject =
          createIndexableObjectFromKeys(mostPerformingAds, ['id']);

      // For any ad present in the spreadsheet, set value to null.
      nonEmptyRows.forEach(function(row) {
        var staId = row.getNumber('staId');
        if (staId in mostPerformingAdsObject) {
          mostPerformingAdsObject[staId] = null;
        }

        // Subtract the number of empty ETAs from the total ads available
        // to export. This will preserve a bounded number of ETAs in the
        // spreadsheet.
        var etaId = row.getString('etaId');
        if (isEmptyString(etaId)) {
          maxAdsToExport--;
          resultObject.remainingRowCount--;
        }
      });

      // Keep ads that are not null (not present in spreadsheet).
      var reportsNotPresentInSheet = [];
      Object.keys(mostPerformingAdsObject).forEach(function(adId) {
        var ad = mostPerformingAdsObject[adId];
        if (mostPerformingAdsObject.hasOwnProperty(adId) && !isEmpty(ad)) {
          reportsNotPresentInSheet.push(ad);
        }
      });

      mostPerformingAds = reportsNotPresentInSheet;
    }

    // Traverse all ads and export them to the spreadsheet.
    maxAdsToExport = Math.min(Math.max(maxAdsToExport, 0),
                              mostPerformingAds.length);
    for (var i = 0; i < maxAdsToExport; i++) {
      mostPerformingAds[i].export(sheet, CONFIG.spreadsheet);
    }

    resultObject.exportedCount = maxAdsToExport;

    // Update the number of remaining rows to export.
    resultObject.remainingRowCount -= maxAdsToExport;

    // Print the number of ads exported.
    print(resultObject.exportedCount + ' ads exported to ' +
          CONFIG.spreadsheet.sheet.getParent().getUrl());
  }

  return resultObject;
}

/**
 * Print the result of the exportSTA function from the exportResults object.
 *
 * @param {{exportedCount: number,
 *          accountsProcessed: number}}
 *          exportResults An object containing statistics from the STA export.
 *             exportedCount     The number of exported Ads during exportSTA.
 *             accountsProcessed The number of accounts processed in the export.
 */
function printExportResults(exportResults) {
  print('Export Results:\n' +
        '  Total accounts processed: ' + exportResults.accountsProcessed +
        '\n' +
        '  Total STAs exported:      ' + exportResults.exportedCount + '\n');
}

/**
 * Sync content from and to the exported spreadsheet.
 *
 * This function operates only for MCC accounts.
 *
 * This function will iterate over each sub-account calling syncSpreadsheet for
 * the account CID
 *
 * @return {number} Returns the error count during the sync.
 *                  For example: update ad status, approval reason
 *                  and create ETAs that are flagged as ready for upload.
 */
function syncSpreadsheetMCC() {
  var errorCount = 0;

  // Store the current MCC account
  var mccAccount = AdWordsApp.currentAccount();

  // Get account iterator from MCC config.
  var accountIterator;
  try {
    accountIterator = getAccountIteratorFromMCC();
  }
  catch (err) {
    errorCount += 1;
  }
  if (accountIterator) {
    processAccountLimit(accountIterator);
    while (accountIterator.hasNext()) {
      var account = accountIterator.next();

      // Set the account as the client account as the active account
      MccApp.select(account);
      print('Account: ' + account.getName() +
            ' (' + account.getCustomerId() + ')');

      errorCount += syncSpreadsheet(account.getCustomerId());
    }
  }

  MccApp.select(mccAccount);

  return errorCount;
}


/**
 * Sync content from and to the exported spreadsheet.
 *
 * @param {string|null|undefined} customerId sync for the customer Id provided.
 *                                If null or undefined, then sync all rows.
 *
 * @return {number} Returns the error count during the sync.
 *                  For example: update ad status, approval reason
 *                  and create ETAs that are flagged as ready for upload.
 */
function syncSpreadsheet(customerId) {
  var errorCount = 0;

  var sheet = getCachedSheet(CONFIG.spreadsheet, CONFIG.email);

  // Combine Ad performing reports with rows in spreadsheet.
  var spreadsheetRowsAndReport;
  try {
    spreadsheetRowsAndReport =
      getReportWithSpreadsheetRows(customerId ? [customerId] : []);
  } catch(err) {
    Logger.log('Failed to retrieve report rows: ' + err);
    spreadsheetRowsAndReport = [];
    errorCount++;
  }

  // Keep track of changes made to AdWords platform.
  var allChanges = [];

  spreadsheetRowsAndReport.forEach(function(spreadsheetRowAndReport) {
    // `startOfIterationErrorCount` will keep track of the error count at the
    // start of this iteration.
    var startOfIterationErrorCount = errorCount;
    var spreadsheetRow = spreadsheetRowAndReport.row;

    var sta = spreadsheetRowAndReport.sta;
    var eta = spreadsheetRowAndReport.eta;

    // Keep track of changes made to STA.
    var staChanges = new AdChange(spreadsheetRow.getNumber('staId'),
                                  spreadsheetRow.getNumber('adGroupId'));
    // Keep track of changes made to ETA (etaId may be empty here).
    var etaChanges = new AdChange(spreadsheetRow.getNumber('etaId'),
                                  spreadsheetRow.getNumber('adGroupId'));
    // Save changes for this iteration.
    allChanges.push({
      sta: staChanges.getChangeStruct(),
      eta: [etaChanges.getChangeStruct()]
    });

    // If STA is null (report was not retrieved), then retrieve it.
    // STA is only null when it's not returned in `getMostPerformingAds` for
    // this specific row.
    if (isEmpty(sta)) {
      sta = getAdFromRow(spreadsheetRow, 'STA');
      if (isEmpty(sta)) {
        errorCount += 1;
        try {
          spreadsheetRow.markAsError('Error retrieving STA with Id ' +
              spreadsheetRow.getNumber('staId'));
        } catch (err) {
          spreadsheetRow.markAsError('Error retrieving STA with missing Id, ' +
              'row : ' + spreadsheetRow.getRowIndex());
        }
      }
    }

    errorCount += syncETA(eta, spreadsheetRow, etaChanges);
    errorCount += syncSTA(sta, spreadsheetRow, staChanges);
  });

  allChanges.map(function(change) {
    function _printChanges(type, id, changes) {
      function _print(field) {
        if (!field) {
          return;
        }

        var prefix = type + ' (' + id + '): ';
        if (isEmptyString(id)) {
          prefix = type + ' ';
        }

        print(prefix + field.fieldName + ' changed from "' +
              field.oldValue + '" to "' + field.newValue + '"');
      }

      if (Array.isArray(changes)) {
        changes.map(_print);
      } else {
        _print(changes);
      }
    }

    if (change.sta) {
      _printChanges('Standard text ad', change.sta.adId, change.sta.changes);
    }

    if (change.eta) {
      change.eta.map(function(eta) {
        if (eta.created) {
          print('Expanded text ad (' + eta.adId + '): created');
          _printChanges('+', null, eta.changes);
        } else {
          _printChanges('+ Expanded text ad', eta.adId, eta.changes);
        }
      });
    }
  });

  return errorCount;
}


/**
 * Retrieves labels that will be used to apply on ETA or STA.
 *
 * @param {SpreadsheetRow} spreadsheetRow A row in spreadsheet.
 * @param {string} defaultLabel
 *
 * @return {Array<string>} An array of label names.
 */
function getLabelsToApply(spreadsheetRow, defaultLabel) {
  var labelNames = spreadsheetRow.getArray('labels');

  if (isEmptyString(labelNames)) {
    labelNames = [defaultLabel];
  } else if (labelNames.indexOf(defaultLabel) === -1) {
    labelNames.push(defaultLabel);
  }

  return labelNames;
}


/**
 * Syncs STA in AdWords platform with STA in spreadsheet.
 * Currently only sync labels and status, only if ETA
 * has been created.
 *
 * @param {Ad} sta Ad object to apply changes to.
 * @param {SpreadsheetRow} spreadsheetRow A row in spreadsheet.
 * @param {AdChange} staChanges An object used for tracking changes
 *                              made in AdWords platform.
 *
 * @return {number} A count of errors encountered.
 */
function syncSTA(sta, spreadsheetRow, staChanges) {

  var errorCount = 0;
  // Get labels to apply to STAs and newly created ETAs.
  var labelNames = getLabelsToApply(spreadsheetRow, CONFIG.defaultLabelName);

  // If STA object and ETA Id is present.
  if (!isEmpty(sta) &&
      (!isEmptyString(spreadsheetRow.getString('etaId')) &&
      spreadsheetRow.getNumber('etaId') !== 0)) {

    // Determine if STA status will need to be updated.
    // If the setStatus operation is successful, track it.
    var oldStatus = sta.getStatus();
    var spreadsheetSTAStatus = spreadsheetRow.getString('staStatus');
    var hasNewStatus = (oldStatus !== spreadsheetSTAStatus);
    if (hasNewStatus && sta.syncStatus(spreadsheetSTAStatus)) {
      staChanges.trackChange('staStatus', oldStatus, spreadsheetSTAStatus);
    } else if (hasNewStatus) {
      errorCount += 1;
      try {
        spreadsheetRow.markAsError('Error syncing status for STA with id ' +
            spreadsheetRow.getNumber('staId'));
      } catch (err) {
        spreadsheetRow.markAsError('Error syncing status for STA with ' +
            'missing Id, row : ' + spreadsheetRow.getRowIndex());
      }
    }

    if (spreadsheetSTAStatus !== Ad.statuses.DISABLED &&
        sta.getStatus() !== Ad.statuses.DISABLED) {
      // Sync label(s) on STA.
      var currentLabels = sta.getLabels();
      // Applies labels, set in spreadsheet, on to STA.
      if (sta.syncLabels(labelNames)) {
        staChanges.trackChange('labels', currentLabels, labelNames);
      } else {
        errorCount += 1;
        try {
          spreadsheetRow.markAsError('Error applying labels ' + labelNames +
              ' on STA with Id ' + spreadsheetRow.getNumber('staId'));
        } catch (err) {
          spreadsheetRow.markAsError('Error applying labels on STA with ' +
              'missing Id, row : ' + spreadsheetRow.getRowIndex());
        }
      }
    }
  }

  return errorCount;
}


/**
 * Syncs ETA in AdWords platform with STA in spreadsheet.
 * Currently creates ETA if ready, sync labels, status, approval reason.
 *
 * @param {Ad} eta Ad object to apply changes to.
 * @param {SpreadsheetRow} spreadsheetRow A row in spreadsheet.
 * @param {AdChange} etaChanges An object used for tracking changes
 *                              made in AdWords platform.
 *
 * @return {number} A count of errors encountered.
 */
function syncETA(eta, spreadsheetRow, etaChanges) {

  var errorCount = 0;
  // Get labels to apply to STAs and newly created ETAs.
  var labelNames = getLabelsToApply(spreadsheetRow, CONFIG.defaultLabelName);

  // If ready to upload and ETA ID is not set.
  if (spreadsheetRow.getString('readyToUpload').trim().toLowerCase() ===
      'yes' && (isEmptyString(spreadsheetRow.getNumber('etaId')) ||
      spreadsheetRow.getNumber('etaId') === 0)) {

    // Create a new ETA.
    var result = createETA(spreadsheetRow);
    eta = result.ad;

    // Only apply ETA changes, if ETA object is present.
    if (isEmpty(eta)) {
      errorCount += 1;
      if (result.errors.length > 0) {
        spreadsheetRow.markAsError(result.errors);
      } else {
        spreadsheetRow.markAsError('Error creating new ETA for STA with Id ' +
                                   spreadsheetRow.getNumber('staId'));
      }
    } else {
      etaChanges.trackCreate(eta.getId(),
                             spreadsheetRow.getNumber('adGroupId'));

      if (!IS_PREVIEW) {
        // Update spreadsheet with ETA values.
        spreadsheetRow.set('etaId', eta.getId());
        // If ETA status is not defined set it to paused.
        if (isEmptyString(spreadsheetRow.getString('etaStatus'))) {
          spreadsheetRow.set('etaStatus', 'paused');
        }
      }

      // Applies labels, set in spreadsheet, on to ETA.
      if (eta.syncLabels(labelNames)) {
        etaChanges.trackChange('labels', '', labelNames);
      } else {
        errorCount += 1;
        spreadsheetRow.markAsError('Error applying labels ' + labelNames +
            ' on ETA with Id ' + eta.getId());
      }

      var spreadsheetETAStatus = spreadsheetRow.getString('etaStatus');
      if (eta.syncStatus(spreadsheetETAStatus)) {
        etaChanges.trackChange('status', '', spreadsheetETAStatus);
      } else {
        errorCount += 1;
        spreadsheetRow.markAsError('Error syncing status for ETA with Id ' +
            eta.getId());
      }
    }
  } else if (!isEmptyString(spreadsheetRow.getNumber('etaId')) &&
             spreadsheetRow.getNumber('etaId') !== 0) {

    // If ETA is null, retrieve it.
    // ETA is only null when it's not returned in `getETAReports` for this
    // specific row.
    if (isEmpty(eta)) {
      eta = getAdFromRow(spreadsheetRow, 'ETA');
    }

    if (isEmpty(eta)) {
      errorCount += 1;
      try {
        spreadsheetRow.markAsError('Error retrieving ETA with Id ' +
                                   spreadsheetRow.getNumber('etaId'));
      }
      catch (err) {
        spreadsheetRow.markAsError('Error retrieving ETA with missing Id, ' +
                                   'row :  ' + spreadsheetRow.getRowIndex());
      }
    // Only apply ETA changes, if ETA object is present.
    } else {
      // Set latest approval status of ETA in spreadsheet.
      var etaApprovalStatus = eta.getApprovalStatus();
      if (!isEmptyString(etaApprovalStatus)) {
        spreadsheetRow.set('etaApprovalStatus', etaApprovalStatus);
      }

      var currentStatus = eta.getStatus();
      var spreadsheetETAStatus = spreadsheetRow.getString('etaStatus');
      if (eta.syncStatus(spreadsheetETAStatus)) {
        etaChanges.trackChange('status', currentStatus, spreadsheetETAStatus);
      }

      // If ETA has not been disabled.
      if (spreadsheetETAStatus !== Ad.statuses.DISABLED) {
        // Sync label(s) on ETA.
        var currentLabels = eta.getLabels();
        // Applies labels, set in spreadsheet, on to ETA.
        if (eta.syncLabels(labelNames)) {
          etaChanges.trackChange('labels', currentLabels, labelNames);
        } else {
          errorCount += 1;
          spreadsheetRow.markAsError('Error applying labels ' + labelNames +
              ' on ETA with Id ' + eta.getId());
        }
      }
    }
  }

  return errorCount;
}

//////////////////////////////////////////////////////////////////////////
////////////////////////////////// AD ////////////////////////////////////
//////////////////////////////////////////////////////////////////////////

// Depends on the following global functions:
// - createLabel
// - isEmpty
// - isEmptyString
// - print



/**
 * Represents an Ad.
 *
 * @param {Object} row A report row from which to parse an Ad. This object
 *                     is expected to have properties included in `rowFields`.
 * @param {Array<string>} rowFields An array of fields to select from `row`.
 * @param {AdWordsApp.Ad} adWordsAppAd An Ad object.
 * @param {AdWordsApp.Account} account Current AW account.
 *
 * @constructor
 */
function Ad(row, rowFields, adWordsAppAd, account) {
  this.row = {
    accountId: account.getCustomerId(),
    accountName: account.getName()
  };

  // Stores AdWordsApp.Ad.
  this.ad = null;
  if (!isEmpty(adWordsAppAd)) {
    this.ad = adWordsAppAd;
  }

  if (!isEmpty(row) && !isEmpty(rowFields) && Array.isArray(rowFields)) {
    // Copy all selected fields.
    var self = this;
    rowFields.forEach(function(field) {
      // Lowercase the first character.
      var normalizedFieldName = field.charAt(0).toLowerCase() +
          field.substring(1, field.length);

      if (normalizedFieldName === 'status' ||
          normalizedFieldName === 'creativeApprovalStatus') {
        self.row[normalizedFieldName] = row[field].trim().toLowerCase();
      } else {
        self.row[normalizedFieldName] = row[field];
      }

      // Assign `id` to self, for efficient access.
      if (normalizedFieldName === 'id') {
        self.id = row[field];
      }
    });
  }
}


/**
 * Supported statuses.
 * @enum {string}
 */
Ad.statuses = {
  ENABLED: 'enabled',
  PAUSED: 'paused',
  DISABLED: 'disabled'
};


/**
 * Returns true if this instance has an AdWordsApp.Ad object.
 *
 * @return {boolean}
 */
Ad.prototype.hasAdWordsAppAd = function() {
  return (!isEmpty(this.ad));
};


/**
 * Returns an Ad's id.
 *
 * @return {number}
 * @throws {string}
 */
Ad.prototype.getId = function() {
  // If AdWordsApp.Ad object is present.
  if (this.hasAdWordsAppAd()) {
    return this.ad.getId();
  // Otherwise, dealing with Ad Report object.
  } else if (!isEmptyString(this.row.id)) {
    return this.row.id;
  } else {
    throw 'Invalid ad object.';
  }
};


/**
 * Returns an Ad's labels.
 *
 * @return {Array<string>} An array of label names.
 * @throws {string}
 */
Ad.prototype.getLabels = function() {
  var labelNames = [];

  // If AdWordsApp.Ad object is present.
  if (this.hasAdWordsAppAd()) {
    if (this.ad.getId() > 0) {
      var labelIterator = this.ad.labels().get();

      while (labelIterator.hasNext()) {
        var label = labelIterator.next();
        labelNames.push(label.getName());
      }
    }
  // Dealing with an Ad report.
  } else if (this.row['labels'] !== undefined &&
             typeof this.row.labels === 'string') {
    // If no labels are present.
    if (isEmpty(this.row.labels) || this.row.labels === '--') {
      return [];
    } else {
      try {
        labelNames = JSON.parse(this.row.labels);
      } catch (err) {
        throw 'Invalid JSON string stored in Ad.labels';
      }
    }
    // Dealing with an unknown object.
  } else {
    throw 'Invalid ad object';
  }

  return labelNames;
};


/**
 * Determines which labels to remove and which labels to add.
 *
 * @param {Array<string>} newLabelNames An array of new label names to apply.
 *
 * @return {{add: Array<string>,
 *           remove: Array<string>,
 *           hasDiff: boolean}} An object with new labels to apply under an
 *                              `add` attribute, labels to remove under a
 *                              `remove` attribute, and a `hasDiff` attribute
 *                              used to indicate if any differences were found.
 *
 * @private
 */
Ad.prototype.diffLabels_ = function(newLabelNames) {
  // `newLabelNames` must be an array.
  if (!Array.isArray(newLabelNames)) {
    throw 'Incorrect `newLabelNames` parameter.';
  }

  var diff = {
    add: [],
    remove: [],
    hasDiff: false
  };

  var currentLabelNames = this.getLabels();

  newLabelNames.forEach(function(newLabelName) {
    newLabelName = newLabelName.trim();
    // If new label is not present in current labels, append to `add`.
    if (currentLabelNames.indexOf(newLabelName) === -1) {
      diff.add.push(newLabelName);
      diff.hasDiff = true;
    }
    return;
  });

  currentLabelNames.forEach(function(currentLabelName) {
    currentLabelName = currentLabelName.trim();
    // If current label is not present in new labels, append to `remove`.
    if (newLabelNames.indexOf(currentLabelName) === -1) {
      diff.remove.push(currentLabelName);
      diff.hasDiff = true;
    }
    return;
  });

  return diff;
};


/**
 * Adds labels on Ad based on `labelNamesDiff` values.
 *
 * @param {Array<string>} newLabels Contains an array of label names to add.
 *
 * @return {boolean} Representing whether all labels were successfully applied.
 */
Ad.prototype.syncLabels = function(newLabels) {
  // If newLabels is empty, nothing to add.
  if (isEmpty(newLabels) || newLabels.length <= 0) {
    return true;
  }

  var allApplied = true;
  var labelNamesDiff = this.diffLabels_(newLabels);

  // If differences are present, make sure we have AdWordsApp.Ad object.
  if (labelNamesDiff.hasDiff && !this.hasAdWordsAppAd()) {
    var success = this.getAd();
    if (!success) {
      return false;
    }
  }

  var self = this;
  labelNamesDiff.add.forEach(function(labelName) {
    // Create label, if it doesn't exist.
    var success = createLabel(labelName);

    if (success) {
      try {
        if (self.ad.getId() > 0) {
          self.ad.applyLabel(labelName);
        }
      } catch (err) {
        print('Failed to apply ' + labelName + ' to Ad Id: ' + self.getId(),
            [err]);
        allApplied = false;
      }
    } else {
      print('Failed to create ' + labelName + ' and apply to Ad Id: ' +
            self.getId());
      allApplied = false;
    }
  });

  return allApplied;
};


/**
 * Returns status of Ad.
 *
 * @return {string}
 */
Ad.prototype.getStatus = function() {
  var adStatus = '';

  // If we have AdWordsApp.Ad object.
  if (this.hasAdWordsAppAd()) {
    if (this.ad.getId() > 0) {
      if (this.ad.isEnabled()) {
        adStatus = Ad.statuses.ENABLED;
      } else if (this.ad.isPaused()) {
        adStatus = Ad.statuses.PAUSED;
      }
    }
  // Otherwise, dealing with Ad Report object.
  } else if (!isEmptyString(this.row.status)) {
    adStatus = this.row.status;
  } else {
    throw 'Invalid ad object.';
  }

  return adStatus;
};


/**
 * Returns approval status of Ad.
 *
 * @return {?string}
 */
Ad.prototype.getApprovalStatus = function() {
  var approvalStatus = null;

  // If dealing with an AdWordsApp.Ad object.
  if (this.hasAdWordsAppAd()) {
    if (this.ad.getId() > 0) {
      approvalStatus = this.ad.getApprovalStatus();
    }
  // Otherwise, dealing with Ad Report object.
  } else {
    approvalStatus = this.row.creativeApprovalStatus;
  }

  if (isEmptyString(approvalStatus)) {
    return null;
  }

  return approvalStatus.trim().toLowerCase();
};


/**
 * Syncs the status of an Ad in spreadsheet with AdWordsApp.Ad.
 *
 * @param {Ad.statuses} status Possible choices are `enabled`, `paused`, and
 *                             `disabled`.
 *
 * @return {boolean} A boolean indicating if operation was successfull.
 */
Ad.prototype.syncStatus = function(status) {
  if (isEmptyString(status)) {
    throw 'Incorrect status parameter.';
  }

  var normalizedStatus = status.trim().toLowerCase();

  // If status does not differ, no changes need to be made.
  if (this.getStatus() === normalizedStatus) {
    return true;
  // Otherwise, change will have to be made, therefore
  // make sure AdWordsApp.Ad is present.
  } else if (!this.hasAdWordsAppAd()) {
    var success = this.getAd();
    if (!success) {
      return false;
    }
  }

  try {
    if (this.ad.getId() < 0) {
      // Negative ad ID indicate running in preview mode.
      return true;
    }

    switch (normalizedStatus) {
      case Ad.statuses.ENABLED:
        this.ad.enable();
        return true;
      case Ad.statuses.PAUSED:
        this.ad.pause();
        return true;
      // For no value, set to pause.
      case '':
        this.ad.pause();
        return true;
      default:
        return false;
    }
  } catch (err) {
    return false;
  }
};


/**
 * Retrieves and sets an Ad object to this instance.
 *
 * @return {boolean} Whether the ad was found or not.
 */
Ad.prototype.getAd = function() {
  if (isEmptyString(this.row.adGroupId) || isEmptyString(this.row.id)) {
    throw 'Invalid Ad instance.';
  }

  var adIdAndAdgroupId = [[this.row.adGroupId, this.row.id]];

  var adSelector = AdWordsApp.ads()
                   .withIds(adIdAndAdgroupId);

  var adIterator = adSelector.get();

  // There is at most 1 Ad.
  if (adIterator.hasNext()) {
    this.ad = adIterator.next();
    return true;
  } else {
    return false;
  }
};


/**
 * Export ad to selected Google sheet.
 *
 * @param {Sheet} sheet Output sheet.
 * @param {{columns: Array<string>,
 *          reportFieldMap: Object,
 *          columnNamesToIndices: Object,
 *          nonEmptyColumnCheck: string
 *        }} sheetConfig Configuration for selected sheet.
 */
Ad.prototype.export = function(sheet, sheetConfig) {
  if (isEmpty(sheetConfig) ||
      isEmpty(sheetConfig.columns) ||
      isEmpty(sheetConfig.reportFieldMap) ||
      isEmpty(sheetConfig.columnNamesToIndices) ||
      isEmpty(sheetConfig.nonEmptyColumnCheck)) {
    throw 'Failed exporting ad ' + this.id + '. Must provide valid sheet ' +
          'configuration when exporting an Ad to a spreadsheet';
  }

  var fields = [];
  var self = this;

  // Check if exporting to the first content row in the spreadsheet.
  var lastRow = sheet.getLastRow();
  var colIndex =
      sheetConfig.columnNamesToIndices[sheetConfig.nonEmptyColumnCheck] + 1;

  var isFirstRow = (lastRow === sheetConfig.firstContentRow) &&
      isEmptyString(sheet.getRange(lastRow, colIndex).getValue());

  // Parse all columns.
  sheetConfig.columns.forEach(function(key) {
    var field;
    // Detect whether this field is a formula and copy the formula from
    // previous cell if necessary.
    if (!isFirstRow && !isEmpty(sheetConfig.columnsWithFormulas) &&
        sheetConfig.columnsWithFormulas.indexOf(key) !== -1) {
      // Copy formula.
      var formulaIndex = sheetConfig.columns.indexOf(key);
      if (formulaIndex !== -1) {
        // Add 1 to index because spreadsheet begins at index 1.
        var formulaPreviousCell = sheet.getRange(lastRow, formulaIndex + 1);

        // Return the formula from one cell above this row.
        field = formulaPreviousCell.getFormulaR1C1();
      }
    }

    if (isEmptyString(field)) {
    // Parse field by column name.
     field = self.parseField_(key, sheetConfig);
    }

    // Stage field.
    fields.push(field);
  });

  if (isFirstRow) {
    // Because we're "freezing" rows, we can't delete all other non-frozen
    // rows and so we're left with one empty row. Instead of adding a new
    // row, replace the values in the cells.
    var prevSectionIndex = 1;
    var values = [];

    // Copy fields in batches.
    fields.forEach(function(field) {
      if (!isEmptyString(field)) {
        // Batch fields together to avoid making many I/O operations.
        values.push(field);
      } else if (values.length > 0) {
        // Flush fields.
        var row = sheet.getRange(lastRow, prevSectionIndex, 1, values.length);
        row.setValues([values]);

        // Update section start index.
        prevSectionIndex += values.length + 1;

        // Reset batch.
        values = [];
      } else {
        // Skip empty field.
        prevSectionIndex++;
      }
    });
  } else {
    // Add a new row and copy all fields.
    sheet.insertRowAfter(lastRow);
    lastRow++;

    // Flush fields.
    var row = sheet.getRange(lastRow, 1, 1, fields.length);
    row.setValues([fields]);
  }
};

/**
 * Parse a field based on a column key.
 *
 * @param {string} key Column key.
 * @param {{reportFieldMap: Array<dict>,
 *          defaultStatus: string
 *          }} sheetConfig A configuration object that contains a mapping from
 *                         spreadsheet column names to report columns names,
 *                         and a default value for `etaStatus`.
 *
 * @return {string} A field based on the column key.
 *
 * @private
 */
Ad.prototype.parseField_ = function(key, sheetConfig) {
  var proxyKey = sheetConfig.reportFieldMap[key];
  if (proxyKey === undefined) {
    throw 'Failed exporting ad ' + this.id +
          '. Missing report field mapping for: ' + key + '.';
  }

  // Get the field value using the key from the spreadhseet-column name.
  var field = this.row[proxyKey];

  // Handle specific columns differently.
  switch (key) {
    case 'description':
      // Concatenate description 1 and description 2
      // in case description field is blank.
      if (isEmptyString(field)) {
        field = this.row.description1 +
                (isEmptyString(this.row.description2) ? '' :
                 '\n' + this.row.description2);
      }

      break;

    case 'headline1':
      // If headline part 1 is blank, use headline instead.
      if (isEmptyString(field)) {
        field = this.row.headline;
      }

      break;

    case 'etaStatus':
      if (isEmptyString(field)) {
        field = sheetConfig.defaultStatus;
      }

      break;

    case 'finalUrl':
    case 'mobileFinalUrl':
      // Support only single value for Final URL and
      // Mobile Final URL.
      try {
        field = JSON.parse(field);
      } catch (ignore) {
        // Value is most likely already a string, use it
        // directly. For example "--".
      }

      field = getFirstElementInArray(field);
      break;
  }

  if (isEmptyString(field) || field === '--') {
    // Replace all undefined fields because Range.setValues won't play well
    // with them.
    // Replace all '--' with empty strings to avoid exporting empty values
    // formatted like that.
    field = '';
  }

  return field;
};

//////////////////////////////////////////////////////////////////////////
//////////////////////////////// UTILS ///////////////////////////////////
//////////////////////////////////////////////////////////////////////////

/**
 * Get a unique file tag that be used to locate the file.
 *
 * @param {string} seed A seed string used to generate a unique tag.
 *
 * @return {string} A unique string associated with the current account and
 *                  the seed used.
 */
function getUniqueFileTag(seed) {
  // Use the account ID as an additional identifier.
  var currentAccount = AdWordsApp.currentAccount();
  return seed + '-' + currentAccount.getCustomerId();
}


/**
 * Compare two arrays. Nested objects within the array will be compared
 * by reference. Therefore, an identical objects but of two different
 * instances will be considered not equal.
 *
 * For example:
 * isArrayShallowEquals([1, {a: 1}], [1, {a: 1}]) // false.
 * var obj = {a: 1};
 * isArrayShallowEquals([1, obj], [1, obj]) // true.
 *
 * @param {Array} a Array A.
 * @param {Array} b Array B.
 *
 * @return {boolean} Whether both arrays are shallowly equal or not.
 */
function isArrayShallowEquals(a, b) {
  // Condition 1: one of the arrays is empty.
  if ((!a && b) || (!b && a)) {
    return false;
  }

  // Condition 2: both arrays are null or undefined.
  if (a == null && a == b) {
    return true;
  }

  // Condition 3: either one of the arguments is not an array.
  if (!Array.isArray(a) || !Array.isArray(b)) {
    return false;
  }

  // Condition 4: arrays of different length.
  if (a.length !== b.length) {
    return false;
  }

  // Condition 5: compare each element in the arrays.
  for (var i = 0; i < a.length; i++) {
    // Shallow compare.
    if (a[i] !== b[i]) {
      return false;
    }
  }

  return true;
}


/**
 * Get the first element in an array.
 *
 * @param {Array} arr The array from which to get the first element.
 *
 * @return {Object} The first element in the array or `null` if empty. If
 *                   `arr` is not an array, return it as is.
 */
function getFirstElementInArray(arr) {
  if (!arr || !Array.isArray(arr)) {
    return arr;
  }

  if (arr.length === 0) {
    return null;
  }

  return arr[0];
}


/**
 * Check if given string is empty.
 *
 * @param {string} str Empty string candidate.
 *
 * @return {boolean} True if string is empty, o/w false.
 */
function isEmptyString(str) {
  if (str == null) {
    return true;
  }

  if (isString(str)) {
    return str.trim() === '';
  }

  return false;
}


/**
 * Check if `obj` is null or undefined.
 *
 * @param {Object} obj
 *
 * @return {boolean}
 */
function isEmpty(obj) {
  return obj === null || obj === undefined;
}


/**
 * Check if `obj` is an object.
 *
 * @param {Object} obj
 *
 * @return {boolean}
 */
function isObject(obj) {
  // Check if obj exists and has Object string value
  if (!obj ||
      toString.call(obj) !== '[object Object]') {
    return false;
  }
  // Check is constructor is owned
  if (obj.constructor &&
      !hasOwnProperty.call(obj, 'constructor') &&
      !hasOwnProperty.call(obj.constructor.prototype, 'isPrototypeOf')) {
    return false;
  }
  // Empty if no properties OR
  // All properties are owned is last enum property is owned
  var key;
  for (key in obj) {}

  return key === undefined || hasOwnProperty.call(obj, key);
}


/**
 * Check if `obj` is a valid params object.
 *
 * @param {Object} obj
 * @param {Array} valueTypes A list of valid value types allowed as parameters
 * @param {boolean} emptyIsInvalid A boolean defining if an empty object is
 *                                 invalid
 *
 * @return {Object} An object with { success: <boolean>, message: <string> },
 *                   where message is the reason for failure if success is
 *                   false.
 */
function isValidParamsObject(obj, valueTypes, emptyIsInvalid) {
  var result = {
    success: false,
    message: ''
  };

  if (!emptyIsInvalid && (isEmpty(obj) || isEmptyString(obj))) {
    result.success = true;
    return result;
  }

  if (obj === null) {
    result.message = 'params is null';
    return result;
  }
  if (obj === undefined) {
    result.message = 'params is undefined';
    return result;
  }
  if (!isObject(obj)) {
    result.message = 'params is not an object';
    return result;
  }
  if (!valueTypes || !Array.isArray(valueTypes)) {
    result.message = 'valueTypes is not a valid array';
    return result;
  }
  var keyCount = 0;
  for (var key in obj) {
    if (obj.hasOwnProperty(key)) {
      var value = obj[key];
      if (valueTypes.indexOf(typeof value) === -1) {
        result.message = 'key `' + key + '` has an invalid type `' +
            typeof value + '`';
        return result;
      }
    }
    keyCount++;
  }
  if (emptyIsInvalid && keyCount === 0) {
    result.message = 'params is empty';
    return result;
  }
  result.success = true;
  return result;
}


/**
 * Print to logger if in DEBUG mode.
 *
 * @param {string} msg The message to print with Logger.
 * @param {?Array} arr An array of strings to print.
 */
function print(msg, arr) {
  if (CONFIG.debug) {
    if (arr) {
      Logger.log(msg + ' ' + arr.join(', '));
    } else {
      Logger.log(msg);
    }
  }
}


/**
 * Check if `str` is a string.
 *
 * @param {string} str Questionable string.
 *
 * @return {boolean} Whether `str` is indeed a string.
 */
function isString(str) {
  return typeof str === 'string' || str instanceof String;
}


/**
 * Compares the performance between two ads.
 *
 * @param {Ad} a Ad a.
 * @param {Ad} b Ad b.
 *
 * @return {number} +1 if Ad a has better performance, -1 if Ad b has better
 *         performance, and 0 if equal.
 */
function comparePerformance(a, b) {
  var impressionsDiff = a.row.impressions - b.row.impressions;
  var ctrDiff = parseFloat(a.row.ctr) - parseFloat(b.row.ctr);

  var absImpressionsDiff = Math.abs(impressionsDiff);
  var absCtrDiff = Math.abs(ctrDiff);

  /**
   * Helper function to compare impressions and CTR.
   *
   * @param {number} impressionDiff The difference between both impressions.
   * @param {number} ctrDiff The difference between both CTRs.
   *
   * @return {number} +1 if impressionDiff is > 0, or if impressionDiff === 0
   *                  and ctrDiff > 0. -1 if impressionDiff is < 0, or if
   *                  impressionDiff === 0 and ctrDiff < 0. Otherwise, returns
   *                  0.
   */
  function _compare(impressionDiff, ctrDiff) {
    // Choose the ad with the most impressions.
    if (impressionsDiff > 0) {
      return 1;
    } else if (impressionsDiff < 0) {
      return -1;
    } else {
      // Both ads have equal impressions, choose highest CTR.
      if (ctrDiff > 0) {
        return 1;
      } else if (ctrDiff < 0) {
        return -1;
      } else {
        return 0;
      }
    }
  }

  if (absImpressionsDiff < CONFIG.performance.impressionsThreshold) {
    // The difference in impressions is insignificant, gauge at CTR.
    if (absCtrDiff < CONFIG.performance.ctrThreshold) {
      // The difference in CTR is insignificant, choose the highest impresssion,
      // or the highest CTR in case impressions are equal.
      return _compare(impressionsDiff, ctrDiff);
    } else if (ctrDiff > 0) {
      // The difference in CTR is significant, therefore if it's positive it
      // should be in favor of *this* ad.
      return 1;
    } else {
      // The difference in CTR is significant, therefore if it's negative it
      // should be in favor of *compared* ad.
      return -1;
    }
  } else if (impressionsDiff > 0) {
    // The difference in impressions is significant, therefore if it's positive
    // it should be in favor of *this* ad.
    return 1;
  } else {
    // The difference in impressions is significant, therefore if it's negative
    // it should be in favor of *compared* ad.
    return -1;
  }
}


/**
 * Composite to reverse ad compare in order to have the most perfoming ad at
 * index 0.
 *
 * @param {function (number, number) : boolean} compareFunc The original compare
 *                                                          function.
 *
 * @return {function (number, number) : boolean} A reverse version of
 *                                               compareFunc.
 */
function reverseCompare(compareFunc) {
  return function(a, b) {
    return -compareFunc(a, b);
  };
}


/**
 * Traverse the report and keep the top most NUM_OF_ADS that
 * are considered most performing based on the configuration given.
 *
 * @param {AdWordsApp.Report} report The report from which we want to
 *                                   extrapolate the most performing ads.
 * @param {{reportFields: Array<string>,
 *          numOfAds: number}} config The configuration used to define which
 *                                    ads to consider most performing.
 *
 * @return {Array<Ad>} A sorted array of Ads by performance
 */
function getMostPerformingAds(report, config) {
  if (report === null) {
    throw 'Failed to find most performing ads: Report used for retrieving ' +
          'top performing ads is empty';
  }

  // Traverse all rows.
  var currentAccount = AdWordsApp.currentAccount();
  var result = [];
  var rows = report.rows();
  while (rows.hasNext()) {
    var ad = new Ad(rows.next(), config.reportFields, null, currentAccount);
    result.push(ad);
  }

  // Sort by performance.
  result.sort(reverseCompare(comparePerformance));
  return result;
}


/**
 * Retrieve ETA reports.
 *
 * @param {{etaReportStartDate: string,
 *          reportFields: Array<string>,
 *          apiVersion: string}} config The configuration used to select and
 *                                      parse ETA reports. `etaReportStartDate`
 *                                      should be formatted as YYYYMMDD.
 *
 * @return {Array<Ad>} A sorted array of ETAs.
 */
function getETAReports(config) {
  var report = AdWordsApp.report(
      'SELECT ' + config.reportFields.join(',') + ' ' +
      'FROM     AD_PERFORMANCE_REPORT ' +
      'WHERE    AdType = "EXPANDED_TEXT_AD" ' +
      'AND Date >= "' + config.etaReportStartDate + '"', {
        apiVersion: config.apiVersion
      });

  if (report === null) {
    return null;
  }

  // Traverse all rows.
  var currentAccount = AdWordsApp.currentAccount();
  var result = [];
  var rows = report.rows();
  while (rows.hasNext()) {
    var ad = new Ad(rows.next(), config.reportFields, null, currentAccount);
    result.push(ad);
  }

  return result;
}


/**
 * Attempt to open an existing spreadsheet with description containing
 * `config.templateId`. If not found, copy template spreadsheet with id
 * `config.templateId` and return its handle.
 *
 * @param {{templateId: string,
 *          targetName: string}} config A configuration object with meta-data
 *                                      about the target spreadsheet.
 * @param {?string} emailToNotify An email address to send out notification
 *                                when creating a new spreadsheet. If none is
 *                                given, will use the email address of the
 *                                owner of the spreadsheet.
 *
 * @return {Spreadsheet} Selected spreadsheet.
 */
function getSpreadsheet(config, emailToNotify) {
  if (isEmpty(config)) {
    throw 'Config is missing. Cannot get spreadsheet';
  }

  if (isEmptyString(config.templateId) || isEmptyString(config.targetName)) {
    throw 'Config must include both `templateId` and `targetName` fields.';
  }

  var spreadsheetFile;
  // Attempt to locate the spreadsheet.
  // Throws an exception when spreadsheet not found.
  var files = DriveApp.searchFiles('fullText contains "' +
                                   getUniqueFileTag(config.templateId) + '"');
  if (files.hasNext()) {
    spreadsheetFile = files.next();
    if (files.hasNext()) {
      print('WARNING: more than one file with \'' + config.templateId +
                 '\' detected.');
    }
  } else {
    // Create a copy of the template spreadsheet.
    spreadsheetFile = copyFile(config.templateId, config.targetName);
    if (spreadsheetFile) {
      // Notify via email.
      if (isEmptyString(emailToNotify)) {
        // Set the default email address to owner's email address.
        emailToNotify = getFileOwnerEmail(spreadsheetFile);
      }

      notify(emailToNotify, SPREADSHEET_CREATED, spreadsheetFile.getUrl());
    }
  }

  // Get spreadsheet by ID.
  var spreadsheet = null;
  try {
    spreadsheet = !!spreadsheetFile &&
      SpreadsheetApp.openById(spreadsheetFile.getId());
  } catch (err) {
    print(err);
  }

  if (!spreadsheet) {
    throw 'Spreadsheet is missing or unavailable. Please ensure the URL is ' +
          'correct and that you have permission to edit';
  } else {
    return spreadsheet;
  }
}


/**
 * Open spreadsheet with name `config.targetName` and select its sheet with
 * name `config.sheetName`.
 *
 * @param {{sheetName: string,
 *          templateId: string,
 *          targetName: string}} config Containing sheet meta-data.
 * @param {?string} emailToNotify An email address to send out notification
 *                                when creating a new spreadsheet. If none is
 *                                given, will use the email address of the
 *                                owner of the spreadsheet.
 *
 * @return {Sheet} Selected sheet.
 */
function getSheet(config, emailToNotify) {
  if (isEmptyString(config.sheetName)) {
    throw 'Config must include `sheetName` field. Please set to the name of ' +
          'sheet within the spreadsheet e.g. main';
  }

  var spreadsheet = getSpreadsheet(config, emailToNotify);
  var sheet = spreadsheet.getSheetByName(config.sheetName);
  if (!sheet) {
    throw 'Spreadsheet is missing a sheet named: ' + config.sheetName +
          '. Please ensure the sheet exists in the spreadsheet';
  }

  return sheet;
}


/**
 * Retrieves cached sheet.
 *
 * @param {{sheet: Sheet,
 *          sheetName: string,
 *          templateId: string,
 *          targetName: string}} config An object to retrieve cached sheet from.
 * @param {?string} emailToNotify An email address to send out notification
 *                                when creating a new spreadsheet. If none is
 *                                given, will use the email address of the
 *                                owner of the spreadsheet.
 *
 * @return {?Sheet} Selected sheet.
 */
function getCachedSheet(config, emailToNotify) {
  // If sheet already present.
  if (!isEmpty(config.sheet)) {
    return config.sheet;
  // Else, cache and return.
  } else {
    var sheet = getSheet(config, emailToNotify);
    config.sheet = sheet;
    return sheet;
  }
}


/**
 * Returns a boolean if the current user is able to edit the specified
 * spreadsheet.
 *
 * @param {Spreadsheet} spreadsheet The spreadsheet to detect edit permissions
 *                                  on.
 *
 * @return {boolean} Returns true if the current user can edit the spreadsheet
 *                   provided.
 */
function canEditSpreadsheet(spreadsheet) {
  // Validate the spreadsheet argument exists.
  if (isEmpty(spreadsheet)) {
    throw 'Argument provided to canEditSpreadsheet is not a valid spreadsheet';
  }
  // Return RANGE level protections.
  var protections = spreadsheet.getProtections(
        SpreadsheetApp.ProtectionType.RANGE);

  if (protections.length === 0) {
    // No protections at a spreadsheet level.
    return true;
  }
  for (var i = 0; i < protections.length; i++) {
    if (protections[i].canEdit()) {
      return true;
    }
  }
  // If no protections allow editing, not editable.
  return false;
}


/**
 * Wrap `func` so that it may receive default input.
 * ```
 * For example:
 * function add(a, b) {
 *   return a + b;
 * }
 *
 * var add1 = compose(add, 1);
 * add1(2); // 3
 *
 * @param {Function} func The function to wrap.
 *
 * @return {Function} The composed function. (for lack of better name.)
 */
function compose(func) {
  var prevArgs = Array.prototype.slice.call(arguments, 1);
  return function() {
    var newArgs = Array.prototype.slice.call(arguments);
    var args = prevArgs.concat(newArgs);
    return func.apply(this, args);
  };
}


/**
 * Validate the structure of the spreadsheet.
 *
 * @param {{templateId: string,
 *          targetName: string,
 *          columns: Array<string>,
 *          firstContentRow: number,
 *          columnMappingSpecialCases: Object
 *        }} config Spreadsheet configuration. `templateId` and `targetName`
 *                  are used to find the spreadsheet. `columns` are the
 *                  expected columns in the spreadsheet. `firstContentRow` is
 *                  the first row index after the header row.
 *                  `columnsMappingSpecialCases` is a dictionary (string:
 *                  string) for headers that require special handling.
 *
 * @return {boolean} Whether the spreadsheet is valid.
 */
function validateSpreadsheet(config) {
  /**
   * Remove whitespaces, any non-numeric value, and lower case given header.
   *
   * @param {Object} specialCases A dictionary (string: string) for
   *                              headers that require special handling.
   * @param {string} header The header to parse.
   *
   * @return {string} A normalized header.
   */
  function _parseHeader(specialCases, header) {
    // Remove whitespaces, non-alphanumeric characters and lowercase
    // all letters.
    var parsed = header.replace(/ |\?|\W/g, '').toLowerCase();

    // Handle special cases.
    if (!isEmpty(specialCases)) {
      var specialCase = specialCases[parsed];
      if (!isEmptyString(specialCase)) {
        parsed = specialCase;
      }
    }

    return parsed;
  }

  /**
   * Verify that the existing headers are equal to expected headers.
   *
   * @param {Array<string>} headers Existing headers.
   * @param {Array<string>} expectedHeaders The expected headers.
   *
   * @return {{pass: boolean, mapping: Object}} The verification result.
   *         `pass` indicates whether the validation passed. `mapping`
   *         (string: string) contains meta-data on failed mapping.
   */
  function _checkHeaders(headers, expectedHeaders) {
    var pass = true;
    var failureMapping = {};

    // Create a copy of expectedHeaders so we could manipulate it.
    expectedHeaders = expectedHeaders.slice();

    // Go over every header.
    headers.forEach(function(header) {
      var expectedHeader = expectedHeaders.shift();
      if (header !== expectedHeader) {
        pass = false;
        failureMapping[expectedHeader] = header;
      }
    });

    return {
      pass: pass,
      mapping: failureMapping
    };
  }

  /**
   * Validate a spreadsheet's column headers.
   *
   * @param {{templateId: string,
   *          targetName: string,
   *          columns: Array<string>,
   *          firstContentRow: number,
   *          columnMappingSpecialCases: {'string': string}
   *        }} config Spreadsheet configuration.
   *
   * @return {{pass: boolean, mapping: Object}} The verification result.
   *         `pass` indicates whether the validation passed. `mapping`
   *         (string: string) contains meta-data on failed mapping.
   */
  function _verifyColumns(config) {
    var sheet = getCachedSheet(config);
    var header = sheet.getRange(config.firstContentRow - 1, 1, 1,
                                config.columns.length);
    var headerValues = header.getValues()[0];

    var passObj = _checkHeaders(config.columns.map(compose(_parseHeader,
                                config.columnMappingSpecialCases)),
                                headerValues.map(compose(_parseHeader,
                                config.columnMappingSpecialCases)));
    return passObj;
  }

  // Verify columns.
  var passMain = _verifyColumns(config);
  if (!passMain.pass) {
    Logger.log('\nColumns mapping (sheet:script):\n' +
               JSON.stringify(passMain.mapping, null, 2));
  }

  return passMain.pass;
}


/**
 * Retrieves an Ad object from a SpreadsheetRow object.
 *
 * @param {SpreadsheetRow} row
 * @param {string} type The type of ad. Options are either 'STA' or 'ETA'.
 *
 * @return {?Ad} Returns an Ad if found, or null if no ad is found.
 */
function getAdFromRow(row, type) {
  var ad = null;
  if (type === 'ETA') {
    try {
      ad = getAd([row.get('adGroupId'), row.get('etaId')]);
    } catch (err) {
      Logger.log('Failed to get ETA due to: ' + err);
    }
  }
  else if (type === 'STA') {
    try {
      ad = getAd([row.get('adGroupId'), row.get('staId')]);
    } catch (err) {
      Logger.log('Failed to get STA due to: ' + err);
    }
  }
  else {
    throw 'Incorrect Ad type.';
  }

  return ad;
}


/**
 * Retrieves an Ad object.
 *
 * @param {Array<number>} adIdAndAdgroupId An array consisting of an
 *                                AdGroup Id and Ad Id. AdGroup Id and Ad Id
 *                                is the unique key for identifying an Ad.
 *
 * @return {?Ad} Returns an Ad if found, or null if no ad is found.
 */
function getAd(adIdAndAdgroupId) {
  if (!adIdAndAdgroupId || adIdAndAdgroupId.length < 2 ||
      isEmptyString(adIdAndAdgroupId[0]) ||
      isEmptyString(adIdAndAdgroupId[1])) {
    throw '`adIdAndAdgroupId` must include two values: adGroup ID, and Ad ID';
  }

  var adSelector = AdWordsApp.ads()
                   .withIds([adIdAndAdgroupId]);

  var adIterator = adSelector.get();

  // There is at most 1 Ad.
  if (adIterator.hasNext()) {
    var currentAccount = AdWordsApp.currentAccount();
    return new Ad(null, null, adIterator.next(), currentAccount);
  } else {
    return null;
  }
}



/**
 * An object representation of a row in a spreadsheet.
 *
 * @constructor
 *
 * @param {Array<Object>} values The values in this row.
 * @param {Range} allRange A range where this row is part of.
 * @param {number} rowOffset The row offset for this row within `allRange`.
 * @param {Object} columnNamesToIndices A mapping between a columnName
 *                 and an index.
 */
function SpreadsheetRow(values, allRange, rowOffset, columnNamesToIndices) {
  if (isEmpty(values) || isEmpty(allRange) || isNaN(rowOffset) ||
      isEmpty(columnNamesToIndices)) {
    throw 'Unable to retrieve row in spreadsheet because of incorrect ' +
        'parameters passed to the "SpreadsheetRow" function.';
  }

  // Because of the hard dependency in 'errorMessage', validate whether it
  // exists.
  if (!columnNamesToIndices.hasOwnProperty('errorMessage') ||
      isEmpty(columnNamesToIndices.errorMessage)) {
    throw '`errorMessage` is a required attribute for `columnNamesToIndices` ' +
        'in order to be able to properly identify where to write error ' +
        'messages in a spreadsheet row';
  }

  // The Range of the row in sheet.
  this.range_ = allRange.offset(rowOffset, 0, 1, allRange.getLastColumn());

  // The index of the row in sheet.
  this.rowIndex_ = this.range_.getRowIndex();

  // Storing a reference to this object and it will be used as read-only.
  this.columnNamesToIndices_ = columnNamesToIndices;

  this.hasErrors_ = false;
  // The values of the row in sheet.
  this.values_ = values;

  // Start fresh by removing any existing errors in this particular row.
  this.markAsResolved();
  // Check for proper values.
  this.validateValues_();
}


/**
 * Validates values in this row, if any improper values are detected
 * then display appropriate error message in error column.
 *
 * @private
 */
SpreadsheetRow.prototype.validateValues_ = function() {
  var staStatusValue = this.get('staStatus');

  if (!isSupportedStatus(staStatusValue)) {
    this.markAsError('Unsuported STA status with value of \'' +
                     staStatusValue + '\'.');
  }

  var etaStatusValue = this.get('etaStatus');

  if (!isSupportedStatus(etaStatusValue)) {
    this.markAsError('Unsuported ETA status with value of \'' +
                     etaStatusValue + '\'.');
  }
};


/**
 * Retrieve the column index of a column name.
 *
 * @param {string} columnName The name of the column in the spreadsheet we want
 *                            to retrieve index for.
 *
 * @return {number} The index of columnName.
 * @private
 */
SpreadsheetRow.prototype.getColumnIndex_ = function(columnName) {
  if (!(columnName in this.columnNamesToIndices_)) {
    throw 'Column "' + columnName + '" does not exist.';
  } else {
    return this.columnNamesToIndices_[columnName];
  }
};


/**
 * Sets the value of a given column for this row.
 *
 * @param {string} columnName
 * @param {string} value
 *
 */
SpreadsheetRow.prototype.set = function(columnName, value) {
  var columnIndex = this.getColumnIndex_(columnName);

  // Add 1, because SpreadSheetApp's Range is relative to 1.
  this.range_.getCell(1, columnIndex + 1).setValue(value);
  this.values_[columnIndex] = value;
};


/**
 * Gets the value at a given column for this row.
 *
 * @param {string} columnName
 *
 * @return {number|boolean|Date|string} The value stored at given column.
 */
SpreadsheetRow.prototype.get = function(columnName) {
  var columnIndex = this.getColumnIndex_(columnName);

  return this.values_[columnIndex];
};


/**
 * Parse value as a JSON object. Value must be either string or an array.
 * Also handles the case where a value may be a string with out
 * quotations.
 *
 * @param {string} columnName
 *
 * @return {Array}
 */
SpreadsheetRow.prototype.getArray = function(columnName) {
  var columnIndex = this.getColumnIndex_(columnName);

  var values = [];

  if (isEmptyString(this.values_[columnIndex])) {
    return values;
  }

  try {
    values = JSON.parse(this.values_[columnIndex]);
  } catch (err) {
    // Maybe a valid string with no quotations is present.
    var valueWithQuotes = '"' + this.values_[columnIndex] + '"';

    try {
      values = JSON.parse(valueWithQuotes);
    } catch (err) {
      throw 'Incorrect JSON value stored in `' + columnName + '` column.';
    }
  }

  // Must be an array or string.
  if (!Array.isArray(values) && (typeof values !== 'string')) {
    throw 'Incorrect array value stored in `' + columnName + '` column.';
  }

  // At this point it must be an array or string. If it's a string,
  // keep return type consistent by always returning an array.
  if (!Array.isArray(values)) {
    values = [values];
  }

  return values;
};


/**
 * Gets the number value at a given column for this row.
 *
 * @param {string} columnName
 *
 * @return {number} The number value stored at given column.
 * @throws {string}
 */
SpreadsheetRow.prototype.getNumber = function(columnName) {
  var value = this.get(columnName);

  if (isNaN(value)) {
    throw 'Value stored in "' + columnName + '" is not a valid number.';
  }

  return Number(value);
};


/**
 * Gets the string value at a given column for this row.
 *
 * @param {string} columnName
 *
 * @return {string} The string value stored at given column.
 */
SpreadsheetRow.prototype.getString = function(columnName) {
  var value = this.get(columnName);

  return value.toString();
};


/**
 * Retrieve the cell at a given column for this row.
 *
 * @param {string} columnName
 *
 * @return {Range}
 */
SpreadsheetRow.prototype.getCell = function(columnName) {
  var columnIndex = this.getColumnIndex_(columnName);
  return this.range_.getCell(1, columnIndex + 1);
};


/**
 * Returns true if row has been marked with an error.
 *
 * @return {boolean}
 */
SpreadsheetRow.prototype.hasErrors = function() {
  return this.hasErrors_;
};


/**
 * Highlights row signalling that an error has occured.
 *
 * @param {string|Array<string>} messages Messages to print and add to error
 *                                      column. Can be a single string or an
 *                                      array of strings.
 */
SpreadsheetRow.prototype.markAsError = function(messages) {
  if (isEmpty(messages) || isEmptyString(messages)) {
    throw 'Empty `messages` parameter provided. A non-empty message must be ' +
        'provided.';
  }

  this.range_.setBackground('red');

  if (Array.isArray(messages)) {
    messages = messages.join('\n- ');
  }

  messages = '- ' + messages + '\n';

  // Append to any content already present in `errorMessage` column.
  this.set('errorMessage', this.get('errorMessage') + messages);
  print(messages);
  this.hasErrors_ = true;
};


/**
 * Removes any previous highlights set on this row.
 */
SpreadsheetRow.prototype.markAsResolved = function() {
  this.range_.setBackground(null);
  this.set('errorMessage', '');
};


/**
 * Retrieve the index of this row in spreadsheet.
 *
 * @return {number}
 */
SpreadsheetRow.prototype.getRowIndex = function() {
  return this.rowIndex_;
};


/**
 * Determines whether the `status` parameter is equivalant to one of the
 * supported Ad statuses.
 *
 * @param {string} status
 *
 * @return {boolean}
 */
function isSupportedStatus(status) {
  return (status === Ad.statuses.ENABLED ||
      status === Ad.statuses.PAUSED);
}


/**
 * Makes an array of objects indexable by a certain attribute in it's objects.
 *
 * @param {Array<Object>} objects An array of objects from which index
 *                                keys will be retrieved from.
 * @param {Array<string>} keys The keys to set index as. If more than one
 *                             element, then keys will get concatenated.
 *
 * @return {Object}
 */
function createIndexableObjectFromKeys(objects, keys) {
  if (isEmpty(objects)) {
    return {};
  }

  var indexableObject = {};

  objects.forEach(function(object) {
    var newIndexArray = [];
    keys.forEach(function(key) {
      var indexValue = object[key];
      if (!isEmptyString(indexValue)) {
        newIndexArray.push(indexValue);
      }
    });

    if (newIndexArray.length > 0) {
      var newIndexString = newIndexArray.join('|');
      if (!isEmptyString(newIndexString)) {
        indexableObject[newIndexString] = object;
      }
    }
  });

  return indexableObject;
}


/**
 * Retrieves the last row checked when syncing spreadsheet and increments
 * value by 1.
 *
 * @param {Sheet} sheet
 * @param {string} key
 *
 * @return {Object} An object with a reference to cell and integer representing
 *                   the index of last row checked.
 */
function getLastRowCheck(sheet, key) {

  var cell = sheet.getRange(key);
  var lastRowChecked = cell.getValue();

  if (isNaN(lastRowChecked)) {
    return {
      cell: null,
      value: 0
    };
  }

  cell.setValue(lastRowChecked + 1);

  return {
    cell: cell,
    value: lastRowChecked
  };
}


/**
 * Retrieves all non-empty rows, starting from config.startRowIndex.
 *
 * @param {Sheet} sheet A sheet in the spreadsheet.
 * @param {number} firstContentRow The index which content is expected to start.
 * @param {number} nonEmptyColumnCheck The column index to check and determine
 *                                  if row is considered empty.
 * @param {Object} columnNamesToIndices A mapping between column indices and
 *                 column names.
 * @param {boolean} isValidOnly If true, invalid rows will be filtered out.
 * @param {Array<SpreadsheetRow>} rowCache The row cache to use for the rows
 *                                         output. Only valid if it matches the
 *                                         target output size.
 *
 * @return {{rows: Array<SpreadsheetRow>,
 *           maxRows: number,
 *           newRowCache: Array<SpreadsheetRow> }} Object containing:
 *                     rows Containing the main content of the sheet.
 *                     maxRows The maximum number of row values possible in
 *                             the range.
 *                     newRowCache A sparse array of all rows added + cached
 *                                 rows during this execution of getContentRows.
 */
function getContentRows(sheet, firstContentRow, nonEmptyColumnCheck,
                        columnNamesToIndices, isValidOnly, rowCache) {
  // Make sure parameters are valid.
  if (isNaN(firstContentRow) ||
      firstContentRow <= 0 ||
      isEmptyString(nonEmptyColumnCheck) ||
      isEmptyString(columnNamesToIndices) ||
      (!isEmpty(rowCache) && !Array.isArray(rowCache))) {
    throw 'Unable to retrieve spreadsheet\'s content because of incorrect ' +
        'parameters passed to the "getContentRows" function.';
  }

  // Retrieve all the range at once to avoid checking each row separately.
  var endRowIndex = sheet.getLastRow();
  var lastColumnIndex = sheet.getLastColumn();

  if (firstContentRow > endRowIndex) {
    // Spreadsheet is empty.
    return {
      rows: []
    };
  }

  var range = sheet.getRange(firstContentRow, 1,
                             endRowIndex - firstContentRow + 1,
                             lastColumnIndex);
  var values = range.getValues();
  var maxRows = range.getNumRows();

  if (rowCache &&
      rowCache.length !== maxRows) {
    throw 'Parameter rowCache passed to "getContentRows" function ' +
        'is incorrect size: ' +
        'rowCache.length: ' + rowCache.length + '!== ' +
        'maxRows: ' + maxRows;
  }

  var spreadsheetRows = [];
  var nonEmptyIndexCheck = columnNamesToIndices[nonEmptyColumnCheck];

  if (isEmpty(nonEmptyIndexCheck)) {
    throw 'Please make sure to supply a value for `nonEmptyColumnCheck` that ' +
          'defines whether a row is considered to be empty or not.';
  }

  var newRowCache = [];
  newRowCache.length = maxRows;
  for (var rowOffset = 0; rowOffset < maxRows; rowOffset++) {
    // Check against the nonEmptyColumnIndex, to make sure
    // this row contains values.
    if (!isEmptyString(values[rowOffset][nonEmptyIndexCheck])) {
      var row = null;
      if (rowCache) {
        row = rowCache[rowOffset];
      }

      if (!row) {
        row = new SpreadsheetRow(values[rowOffset], range, rowOffset,
                                     columnNamesToIndices);
      }

      if (!row) {
        throw 'Spreadsheet row was not correctly created for rowOffset: ' +
            rowOffset;
      }

      // Cache row, even if it contains errors.
      newRowCache[rowOffset] = row;
      if (isValidOnly && row.hasErrors()) {
        continue;
      }

      spreadsheetRows.push(row);
    }
  }

  return {
    rows: spreadsheetRows,
    newRowCache: newRowCache,
    maxRows: maxRows
  };
}


/**
 * Send an email notification.
 *
 * @param {string} to The email address to send the notification to.
 * @param {number} event The event type.
 * @param {Object} payload The payload used in the email template.
 *
 * @return {boolean} Success/failure.
 */
function notify(to, event, payload) {
  var template = getEmailTemplate(event, payload);
  if (template) {
    // Strip down HTML elements for devices that don't render HTML.
    var body = template.body.replace(/<b>|<\/b>|<i>|<\/i>/g, '')
          .replace(/<br\/>/g, '\n');
    try {
      MailApp.sendEmail(to, template.subject, body, {
        htmlBody: template.body
      });

      // Successfully sent email notification.
      return true;
    } catch (err) {
      print('\n******* ERROR: Failed to send email to: ' + to + '. ' +
            err + '*******\n');
    }
  }

  // Failed to send email notification.
  return false;
}


/**
 * Get email template by event type.
 *
 * @param {number} event The event type.
 * @param {Object} payload The payload used in the template.
 *
 * @return {Object} An object with `subject` and `body` attributes,
 *                  or `null` if not found.
 */
function getEmailTemplate(event, payload) {
  var template = null;
  switch (event) {
    case SPREADSHEET_CREATED:
      template = {
        subject: '[ETA Transition Helper] installed successfully',
        body: 'Hello,<br/><br/>' +
              'The ETA Transition Helper was installed successfully on your ' +
              'account \'' + AdWordsApp.currentAccount().getName() +
              '\'.<br/><br/>' +
              'To view the exported standard text ads and begin creating ' +
              'expanded text ads open <a href=\'' + payload + '\'>this ' +
              'spreadsheet</a>.<br/><br/>' +
              'Yours,<br/>' +
              'ETA Transition Helper'
      };

      break;
  }

  return template;
}


/**
 * Copies an existing file.
 *
 * @param {string} sourceId The ID of the existing file.
 * @param {string} outputName The output file name.
 *
 * @return {?File} A handler on the new file, or null if failed.
 */
function copyFile(sourceId, outputName) {
  var output = null;
  try {
    var source = DriveApp.getFileById(sourceId);
    var destination = DriveApp.getRootFolder();
    // Create the copy in the root folder.
    output = source.makeCopy(outputName, destination);
    output.setDescription('[' + getUniqueFileTag(sourceId) + '] ' + outputName);
    print('- Created a new file: ' + output.getUrl());
  } catch (e) {
    print('\n******* ERROR: Failed to copy file: ' + e + '*******');
  }

  return output;
}


/**
 * Get the email address of the user who owns this file.
 *
 * @param {File|Spreadsheet} file Selected file.
 *
 * @return {?string} The email address or `null` if no owner found.
 */
function getFileOwnerEmail(file) {
  var owner = file && file.getOwner();
  return owner && owner.getEmail();
}

/**
 * Validate the CID for an account in the format 123-456-7890.
 *
 * @param {string} cid The CID string to validate.
 *
 * @return {number} Return the CID in numerical value i.e. 1234567890
 * @throws {string}
 */
function validateCid(cid) {
  if (!cid) {
    throw 'Invalid CID: Empty or null : ' + cid;
  }
  if (!isString(cid)) {
    throw 'Invalid CID: Is not a string : ' + cid;
  }
  var cidArray = cid.split('-');
  if (cidArray.length !== 3) {
    throw 'Invalid CID: Incorrect separators : ' + cid;
  }
  if (cidArray[0].length !== 3) {
    throw 'Invalid CID: Incorrect part 1 : ' + cid;
  }
  if (cidArray[1].length !== 3) {
    throw 'Invalid CID: Incorrect part 2 : ' + cid;
  }
  if (cidArray[2].length !== 4) {
    throw 'Invalid CID: Incorrect part 3 : ' + cid;
  }
  var cidInt = parseInt(cidArray.join(''), 10);
  if (isNaN(cidInt)) {
    throw 'Invalid CID: isNaN : ' + cid;
  }
  return cidInt;
}

//////////////////////////////////////////////////////////////////////////
/////////////////////// SYNC SPREADSHEET HELPERS /////////////////////////
//////////////////////////////////////////////////////////////////////////


/**
 * Combines an array of Ads with their respective rows in the spreadsheet.
 *
 * @param {Array<string>} customerIds An array of customerIds to filter
 *                                    resulting Ads. Use empty array to not
 *                                    filter any Ads.
 *
 * @return {Array<Object>} Returns an array of objects, in which each object
 *                         has a reference to a given row in the spreadsheet,
 *                         and its respective report.
 */
function getReportWithSpreadsheetRows(customerIds) {
  if (!Array.isArray(customerIds)) {
    throw "customerIds is not a valid array";
  }

  // Open spreadsheet.
  var sheet = getCachedSheet(CONFIG.spreadsheet, CONFIG.email);

  var rowObject = getContentRows(sheet,
                                  CONFIG.spreadsheet.firstContentRow,
                                  CONFIG.spreadsheet.nonEmptyColumnCheck,
                                  CONFIG.spreadsheet.columnNamesToIndices,
                                  true,
                                  CONFIG.spreadsheet.rowCache);
  var nonEmptyValidRows = rowObject.rows;
  CONFIG.spreadsheet.rowCache = rowObject.newRowCache;

  // Get rows containing content.
  var report = AdWordsApp.report(
      'SELECT ' + CONFIG.reportFields.join(',') + ' ' +
      'FROM     AD_PERFORMANCE_REPORT ' +
      'WHERE    AdType = "TEXT_AD" ' +
      '         AND Status = "ENABLED" ' +
      '         AND CreativeApprovalStatus != "DISAPPROVED" ' +
      'DURING   ' + CONFIG.duration, {
        apiVersion: CONFIG.apiVersion
      });

  var mostPerformingAds = getMostPerformingAds(report, CONFIG);
  var performingETA = getETAReports(CONFIG);

  // Swith Ads to a [id]=>Ad object structure.
  mostPerformingAds = createIndexableObjectFromKeys(mostPerformingAds, ['id']);
  performingETA = createIndexableObjectFromKeys(performingETA, ['id']);

  var rowsAndAds = [];

  nonEmptyValidRows.forEach(function(row) {
   // Skip if this row does not belong to any of the customerIds passed.
    if (customerIds.length > 0 &&
        customerIds.indexOf(row.get('customerId')) === -1) {
      return;
    }

    var sta = mostPerformingAds[row.get('staId')];
    var eta = performingETA[row.get('etaId')];

    if (!sta) {
      sta = null;
    }

    if (!eta) {
      eta = null;
    }

    rowsAndAds.push({
      row: row,
      sta: sta,
      eta: eta
    });
  });

  return rowsAndAds;
}


/**
 * Splices `spreadsheetRowsAndReport` according to `lastRowCheckedIndex`
 *
 * @param {Array<Object>} spreadsheetRowsAndReport
 * @param {number} lastRowCheckedIndex The index of last row processed in
 *                                     previous run.
 * @param {number} headerIndex The index of the row containing header.
 */
function spliceFromLastRowChecked(spreadsheetRowsAndReport, lastRowCheckedIndex,
                                  headerIndex) {
  if (isNaN(lastRowCheckedIndex)) {
    lastRowCheckedIndex = 0;
  }
  // Get Index stored in lastRowCheckedCell relative to header.
  lastRowCheckedIndex = lastRowCheckedIndex - headerIndex;

  // If lastRowCheckedIndex is between 0 and number of rows in spreadsheet
  // (not inclusive) then we can ignore all rows up to lastRowCheckedIndex.
  if (lastRowCheckedIndex > 0 &&
      lastRowCheckedIndex < spreadsheetRowsAndReport.length) {
    spreadsheetRowsAndReport.splice(0, lastRowCheckedIndex);
  }
}


/**
 * Determines whether 'etaObj' has the necessary fields and values
 * set for ETA creation.
 *
 * @param {Object} etaObj An ETA object with relevant ETA attributes for ETA
 *                        creation.
 *
 * @return {Object} An Ad object with 'ad' and 'errors' attributes. If no errors
 *                  were encountered then 'ad' will contain an Ad object of
 *                  newly created Ad, and 'errors' will be an empty array.
 *                  Otherwise, if an error was encountered then 'error' will be
 *                  an array of strings and 'ad' will be null.
 */
function isValidForETACreation(etaObj) {
  var returnObject = {
    ad: null,
    errors: []
  };

  /**
   * REQUIRED:
   * - finalURL
   * - headline1
   * - headline2
   * - description
   */

  if (!etaObj.finalURLs || etaObj.finalURLs.length < 1) {
    returnObject.errors.push('Failed to create ETA: finalUrl is missing.' +
                             ' [Required]');
  }

  if (etaObj.finalURLs && etaObj.finalURLs.length > 1) {
    returnObject.errors.push('Failed to create ETA: finalUrl supports only a' +
                             ' single URL');
  }

  if (!etaObj.headline1) {
    returnObject.errors.push('Failed to create ETA: headline1 is missing.' +
                             ' [Required]');
  }

  if (!etaObj.headline2) {
    returnObject.errors.push('Failed to create ETA: headline2 is missing.' +
                             ' [Required]');
  }

  if (!etaObj.description) {
    returnObject.errors.push('Failed to create ETA: description is missing.' +
                             ' [Required]');
  }

  /**
  * OPTIONAL:
  * - path1
  *   - path2 (Only if path1)
  * - mobileFinalURL
  * - trackingTemplate
  * - customParameters
  */

  if (etaObj.path2 && !etaObj.path1) {
    returnObject.errors.push('Failed to create ETA: path1 is missing. Setting' +
                             ' path2 requires path1 to be set');
  }

  if (etaObj.mobileFinalURLs && etaObj.mobileFinalURLs.length > 1) {
    returnObject.errors.push('Failed to create ETA: mobileFinalURL supports' +
                             ' only a single URL');
  }

  if (!isValidParamsObject(etaObj.customParameters,
                           ['string'],
                           false).success) {
    returnObject.errors.push('Failed to create ETA: customParameters is not' +
                             ' a valid parameters object');
  }

  return returnObject;
}


/**
 * Parses a `spreadsheetRow` for appropriate ETA attributes.
 *
 * @param {SpreadSheetRow} spreadsheetRow A row in the spreadsheet.
 *
 * @return {Object} Returns an object containing the necessary fields
 *                  for creating an ETA.
 * @throws {string}
 */
function parseETA(spreadsheetRow) {

  var customParametersStr = spreadsheetRow.getString('customParameters');
  var customParameters = null;
  if (customParametersStr) {
    try {
      customParameters = JSON.parse(customParametersStr);
    }
    catch (err) {
      throw 'Invalid customParemeters value in spreadsheet.';
    }
  }


  return {
    campaignId: spreadsheetRow.getNumber('campaignId'),
    adGroupId: spreadsheetRow.getNumber('adGroupId'),
    finalURLs: spreadsheetRow.getArray('finalUrl'),
    headline1: spreadsheetRow.getString('headline1'),
    headline2: spreadsheetRow.getString('headline2'),
    description: spreadsheetRow.getString('description'),
    path1: spreadsheetRow.getString('path1'),
    path2: spreadsheetRow.getString('path2'),
    mobileFinalURLs: spreadsheetRow.getArray('mobileFinalUrl'),
    trackingTemplate: spreadsheetRow.getString('trackingTemplate'),
    customParameters: customParameters
  };
}


/**
 * Creates a new ETA.
 *
 * @param {SpreadSheetRow} spreadsheetRow A row in the spreadsheet.
 *
 * @return {Object} An Ad object with 'ad' and 'errors' attributes. If no errors
 *                  were encountered then 'ad' will contain an Ad object of
 *                  newly created Ad, and 'errors' will be an empty array.
 *                  Otherwise, if an error was encountered then 'error' will be
 *                  an array of strings and 'ad' will be null.
 */
function createETA(spreadsheetRow) {
  var etaObj;
  // Retrieve ETA fields from spreadsheet row.
  try {
    etaObj = parseETA(spreadsheetRow);
  }
  catch (err) {
    return {
      ad: null,
      errors: ['Failed to create ETA: ' + err.message]
    };
  }

  var returnObject = isValidForETACreation(etaObj);

  // Retrieve parent Campaign to check if campaign exists.
  var campaignIterator = AdWordsApp.campaigns()
      .withIds([etaObj.campaignId])
      .get();

  if (!campaignIterator.hasNext()) {
    returnObject.errors.push('Unable to create ETA because Campaign parent' +
                             ' with id ' + etaObj.campaignId + ' no longer' +
                             ' exists.');
  }

  // Retrieve parent AdGroup to add new Ad and check if AdGroup exists.
  var adGroupIterator = AdWordsApp.adGroups()
      .withIds([etaObj.adGroupId])
      .get();

  if (!adGroupIterator.hasNext()) {
    returnObject.errors.push('Unable to create ETA because AdGroup parent' +
                             ' with id ' + etaObj.adGroupId + ' no longer' +
                             ' exists.');
  }

  var adGroup = adGroupIterator.next();

  if (returnObject.errors.length === 0) {
    try {
      var adBuilder = adGroup.newAd().expandedTextAdBuilder()
          .withHeadlinePart1(etaObj.headline1)
          .withHeadlinePart2(etaObj.headline2)
          .withDescription(etaObj.description)
          .withFinalUrl(etaObj.finalURLs[0]);

      if (etaObj.path1) {
        adBuilder.withPath1(etaObj.path1);
        if (etaObj.path2) {
          adBuilder.withPath2(etaObj.path2);
        }
      }

      if (etaObj.mobileFinalURLs && etaObj.mobileFinalURLs[0]) {
        adBuilder.withMobileFinalUrl(etaObj.mobileFinalURLs[0]);
      }

      if (etaObj.trackingTemplate) {
        adBuilder.withTrackingTemplate(etaObj.trackingTemplate);
      }

      if (etaObj.customParameters) {
        adBuilder.withCustomParameters(etaObj.customParameters);
      }

      var result = adBuilder.build();
      if (!result.isSuccessful()) {
        // No ad was created.
        var errors = result.getErrors();

        var message = 'Failed to create ETA with errors:';
        for (var i = 0; i < errors.length; i++) {
          message += '\n' + errors[i];
        }

        returnObject.errors.push(message);
      } else {
        var currentAccount = AdWordsApp.currentAccount();
        returnObject.ad = new Ad(null, null, result.getResult(),
                                 currentAccount);
      }
    }
    catch (err) {
      var message = 'Failed to create ETA: ' + err.message;
      returnObject.errors.push(message);
    }
  }

  return returnObject;
}


/**
 * Creates a label if it doesn't exist.
 *
 * @param {string} labelName The name of the label to create.
 *
 * @return {boolean} True if label was successfully created, or already exist.
 */
function createLabel(labelName) {
  if (isEmptyString(labelName)) {
    return false;
  }

  var trimmedLabelName = labelName.trim();

  // There can only be one label with `labelName`.
  var labelIterator = AdWordsApp.labels()
      .withCondition('Name = "' + trimmedLabelName + '"')
      .get();

  // If labelIterator has no value, then create label.
  if (!labelIterator.hasNext()) {
    try {
      AdWordsApp.createLabel(trimmedLabelName);
      return true;
    } catch (err) {
      print('Failed to create ' + trimmedLabelName);
      return false;
    }
  }

  // Label exist, no need to create it.
  return true;
}



/**
 * Used for tracking changes to an Ad.
 *
 * @param {?number} adId An id of an Ad.
 * @param {?number} adGroupId An id of an AdGroup.
 *
 * @constructor
 */
function AdChange(adId, adGroupId) {
  this.structure = {
    adId: null,
    adGroupId: null
  };

  this.structure.adId = null;
  this.structure.adGroupId = null;

  if (!isEmpty(adId) && !isEmpty(adGroupId)) {
    this.structure.adId = adId;
    this.structure.adGroupId = adGroupId;
  }

  this.structure.changes = [];
}


/**
 * Append an object representing change, if changeFunction returns a truthful
 * value. This serves as a wrapper to all Ad change events such as changing
 * status, labels, etc.
 *
 * @param {string} fieldName The name field being changed.
 * @param {string} oldValue
 * @param {string} newValue
 */
AdChange.prototype.trackChange = function(fieldName, oldValue, newValue) {
  if (oldValue === newValue || isArrayShallowEquals(oldValue, newValue)) {
    return;
  }

  this.structure.changes.push({
    fieldName: fieldName,
    oldValue: oldValue,
    newValue: newValue
  });
};


/**
 * This serves as a wrapper for Ad create changes.
 *
 * @param {number} adId Id of newly created Ad.
 * @param {number} adGroupId Id of AdGroup parent of newly created Ad.
 */
AdChange.prototype.trackCreate = function(adId, adGroupId) {
  this.structure.created = true;
  this.structure.adId = adId;
  this.structure.adGroupId = adGroupId;
};


/**
 * Retrieves a reference to changes. Changes are represented by the internal
 * `structure` property.
 *
 * @return {Object}
 */
AdChange.prototype.getChangeStruct = function() {
  return this.structure;
};

/**
 * Reset all changes that were tracked.
 */
AdChange.prototype.resetChanges = function() {
  this.structure.changes = [];
  this.structure.created = false;
};