function [G,GR] = grouptransform(T,groupVars,method,varargin)
%GROUPTRANSFORM Transformations by group.
%   G = GROUPTRANSFORM(T,GROUPVARS,METHOD) or 
%   G = GROUPTRANSFORM(X,GROUPVARS,METHOD) groups all columns in table or 
%   timetable T or in matrix X according to the unique combinations of 
%   grouping variables in GROUPVARS and applies the METHOD to each group.
%   For table T, G is a table or timetable with the computations specified
%   by METHOD replacing the data of the non-grouping variables. METHOD is 
%   applied group-wise to all non-grouping variables in T. GROUPVARS must
%   be a table variable name, a cell array of table variable names, a
%   vector of table variable indices, a logical vector whose elements each 
%   correspond to a table variable, a function handle that takes the 
%   table variables as input and returns a logical scalar (such as 
%   @isnumeric), or a table vartype subscript.
%   For matrix X, G is a matrix containing the results of applying the
%   METHOD to X by group. GROUPVARS must have the same number of rows as X
%   and must be a column vector, multiple column vectors stored as a
%   matrix, or multiple column vectors stored in a cell array.
%   GROUPVARS can also be [] to indicate no grouping. 
%   METHOD can be a function handle or one of the following:
% 
%        'zscore'       - normalizes by centering the data to have mean 0 
%                         and scaling to have standard deviation 1
% 
%        'norm'         - normalizes by scaling the data to unit length 
%                         using the vector 2-norm
% 
%        'meancenter'   - normalizes by centering the data to have mean 0
% 
%        'rescale'      - normalizes by rescaling the range of the data to 
%                         the interval [0,1] 
% 
%        'meanfill'     - fill missing values with the mean of the group
% 
%        'linearfill'   - fill missing values using linear interpolation of 
%                         non-missing entries in the group
% 
%   G = GROUPTRANSFORM(T,GROUPVARS,GROUPBINS,METHOD) or
%   [G,GR] = GROUPTRANSFORM(X,GROUPVARS,GROUPBINS,METHOD) specifies the 
%   discretization for GROUPVARS to be done prior to grouping. For table T,
%   the discretized grouping variables are returned as variables in the 
%   output table. For matrix X, the discretized grouping variables are 
%   returned in GR. If GROUPBINS is one of the options from the list below, 
%   then that discretization is applied to every grouping variable. 
%   Otherwise, GROUPBINS must be a cell array with one element for each 
%   grouping variable. Elements of GROUPBINS can be one of the following:
%   
%      - (default) 'none' indicating no discretization
%      - a list of bin edges specified as a numeric vector
%      - number of bins specified as an integer scalar
%      - time duration specified as a scalar of type duration or 
%        calendarDuration indicating bin widths for datetime or duration 
%        grouping variables
%      - time bins for datetime grouping variables specified as one of the 
%        following: 'second', 'minute', 'hour', 'day', 'week', 'month', 
%        'quarter', 'year', 'decade', 'century', 'secondofminute', 
%        'minuteofhour', 'hourofday', 'dayname','dayofweek', 'dayofmonth', 
%        'dayofyear', 'weekofmonth', 'weekofyear', 'monthofyear', 
%        'monthname', or 'quarterofyear'
%      - time bins for duration grouping variables specified as one of the 
%        following: 'second', 'minute', 'hour', 'day', or 'year'
% 
%   G = GROUPTRANSFORM(T,...,DATAVARS) applies the method only to the data 
%   in the table variables specified by DATAVARS. The default is all 
%   non-grouping variables in T. DATAVARS must be a table variable name, a 
%   cell array of table variable names, a vector of table variable indices,
%   a logical vector whose elements each correspond to a table variable, a 
%   function handle that takes the table variables as input and returns a 
%   logical scalar, or a table vartype subscript.
% 
%   G = GROUPTRANSFORM(...,'IncludedEdge',LR) specifies which edge is 
%   included for each bin in the discretization. This N-V pair can only be 
%   used when GROUPBINS is specified. LR must be one of the following:
%        'left'     - (default) all bins include the left bin edge, except  
%                   for the last bin which includes both edges.
%        'right'    - all bins include the right bin edge, except for the 
%                   first bin which includes both edges.
% 
%   G = GROUPTRANSFORM(...,'ReplaceValues',TF) specifies how the 
%   transformed data is returned. TF must be one of the following:
%        true       - (default) replace data variables with transformed 
%                   data
%        false      - appends the transformed data as additional matrix
%                   columns or table variables to the input data
%
%   [G,GR] = GROUPTRANSFORM(X,...) also returns the groups GR for any of
%   the previous matrix syntaxes.
%
%   Examples:
%
%      % Normalize concentration data by dose
%      Time = seconds([1;1;1;2;2;2;5;5;5;9;9;9]);
%      Dose = [1;2;3;1;2;3;1;2;3;1;2;3];
%      Concentration = [14.2;28.1;11.5;13.7;16.1;11;10;10;9.5;6;2;7.5];
%      T = timetable(Time,Dose,Concentration);
%      G = grouptransform(T,'Dose','zscore','Concentration')
%
%      % Fill missing temperature and pressure data by hour
%      t1 = datetime('2015-12-18 08:00:00');
%      t2 = datetime('2015-12-18 10:59:00');
%      Time = (t1:minutes(20):t2).';
%      Temp = [37.3;37.9;38.2;39.1;NaN;40.0;42.3;43.1;NaN];
%      Pressure = [30.1;30.2;30.1;30.03;NaN;30.1;29.9;29.8;29.7];
%      T = timetable(Time,Temp,Pressure);
%      G = grouptransform(T,'Time','hour','linearfill')
%
%      % Compute the difference between each pressure measurement and the
%      % hourly mean, i.e. center pressure mean to 0 by hour
%      [G,GR] = grouptransform(Pressure,Time,'hour','meancenter')
%
%   See also GROUPSUMMARY, GROUPFILTER, GROUPCOUNTS, FINDGROUPS, SPLITAPPLY, DISCRETIZE.

%   Copyright 2018-2019 The MathWorks, Inc. 

narginchk(3,Inf);

% Compute matrix/table switch flag
tableFlag = isa(T,'tabular');

if tableFlag
    % Error if asking for more than 1 output for table
    nargoutchk(0,1);
end

% Parse grouping variables
[groupingData,groupVars] = matlab.internal.math.parseGroupVars(groupVars,tableFlag,'grouptransform',T);

% Initial processing depends on table vs matrix
if tableFlag
    % Create labels for groupVars in output table
    gvLabels = groupVars;
    
    % Set default values for data variables
    varNamesT = T.Properties.VariableNames;
    dataVars = varNamesT;
else
    % To avoid errors create numbered labels
    tmp = num2cell(1:numel(groupingData));
    gvLabels = cellfun(@num2str,tmp,'UniformOutput',false);
    dataVars = T;
end

% Set rest of default values
inclEdge = 'left';
replaceDataVars = true;

% Parse groupbins
gbProvided = false;
dvNotProvided = true;
indStart = 1;
if matlab.internal.math.isgroupbins(method,'grouptransform')
    [groupBins,scalarExpandBins] = matlab.internal.math.parsegroupbins(method,numel(gvLabels),'grouptransform');
    gbProvided = true;
    if isempty(varargin)
        error(message('MATLAB:grouptransform:MethodNotProvided'));
    else
        method = varargin{indStart};
        indStart = indStart + 1;
    end
end
% Parse method
[method,methodPrefix] = parseMethod(method);
if nargin > 2+indStart
    if tableFlag
        %Parse data variables
        if (isnumeric(varargin{indStart}) || islogical(varargin{indStart}) || ...
                ((ischar(varargin{indStart}) || isstring(varargin{indStart})) && ...
                ~any(matlab.internal.math.checkInputName(varargin{indStart},{'IncludedEdge','ReplaceValues'},1))) || ...
                isa(varargin{indStart},'function_handle') || iscell(varargin{indStart}) || ...
                rem(nargin-(indStart+1),2) == 0)

            dataVars = matlab.internal.math.checkDataVariables(T, varargin{indStart}, 'grouptransform','Data');
            if ~iscell(dataVars)
                dataVars = varNamesT(dataVars);
            end
            dataVars = unique(dataVars,'stable');
            dvNotProvided = false;
            indStart = indStart + 1;
        end
    end
    
    % Parse name-value pairs
    if rem(nargin-(indStart),2) == 0
        for j = indStart:2:length(varargin)
            name = varargin{j};
            if (~(ischar(name) && isrow(name)) && ~(isstring(name) && isscalar(name))) ...
                    || (isstring(name) && strlength(name) == 0)
                error(message('MATLAB:grouptransform:ParseFlags'));
            elseif matlab.internal.math.checkInputName(name,{'IncludedEdge'},1)
                if ~gbProvided
                    error(message('MATLAB:grouptransform:IncludedEdgeNoGroupBins'));
                end
                inclEdge = varargin{j+1};
            elseif matlab.internal.math.checkInputName(name,{'ReplaceValues'},1)
                replaceDataVars = varargin{j+1};
                matlab.internal.datatypes.validateLogical(replaceDataVars,'ReplaceValues');
            else
                error(message('MATLAB:grouptransform:ParseFlags'));
            end
        end
    else
        error(message('MATLAB:grouptransform:KeyWithoutValue'));
    end
end

% Keep track of which groupVars have a group bin
gbForGV = false(size(groupVars));
if gbProvided
    % Discretize grouping variables and remove repeated pairs of grouping
    % variables and group bins
    [groupingData,groupVars,gvLabels,gbForGV] = matlab.internal.math.discgroupvar(groupingData,...
        groupVars,gvLabels,groupBins,inclEdge,scalarExpandBins,'grouptransform',tableFlag);
else
    if tableFlag
        % Remove repeated grouping variables
        [groupVars,ridx] = unique(groupVars,'stable');
        groupingData = groupingData(ridx);
        gvLabels = gvLabels(ridx);
        gbForGV = gbForGV(ridx);
    end
end

% Compute grouping index and data
[gvIdx,~,~,gcount] = matlab.internal.math.mgrp2idx(groupingData,size(T,1),true,false);

% Extract data variables
[dvData,dataLabels,numDataVars] = matlab.internal.math.extractDataVars(T,groupVars,dataVars,tableFlag,dvNotProvided);

% Compute group tranformations
gStats = cell(1,numDataVars);
gStatsLabel = cell(1,numDataVars);
idx = ~isnan(gvIdx);
gvIdx = gvIdx(idx);
[gvIdxSorted, gvIdxSortedIdx] = sort(gvIdx);
newIdx(gvIdxSortedIdx) = (1:numel(gvIdxSorted))';
newIdx = newIdx(:);
for ii = 1:numDataVars
    try
        x = dvData{ii};
        ind = repmat({':'},ndims(x)-1,1);
        f = method;
        if isempty(x)
            g = @(y) {f(x(y,ind{:}))};
        else
            x = x(idx,ind{:});
            x = x(gvIdxSortedIdx,ind{:});
            g = @(y) {f(x(y,ind{:}))};
        end
        
        % If not grouping by anything then apply method directly
        if isempty(gvIdx)
            % Need to special case mean fill of an empty since mean of an
            % empty will return NaN
            if isempty(x) && strcmp(methodPrefix,'meanfill')
                d = {x};
            else
                d = {f(x)};
            end
        else
            % Otherwise group with accumarray
            d = accumarray(gvIdxSorted, (1:numel(gvIdxSorted)).',[],g);
        end

        % Empties might not be returned from accummarray in a cell array.
        % We currently don't hit this line of code, but keeping it for good
        % measure.
        if ~iscell(d)
            d = {d};
        end
        % Check to make sure method returned appropriate size and do the
        % expansion if first dimension is a scalar.
        d = vertcat(d{:,1});
        if ~tableFlag
            if length(size(d)) ~= 2 || ~(size(d,2) == 0 || size(d,2) == 1)
                error(message('MATLAB:grouptransform:MethodOutputInvalidSize'));
            end
        end
        if (size(d,1) ~= size(x,1)) && ~(isscalar(d) && size(x,1) == 0)
            if size(d,1) == max(gvIdx)
                dext = cell(size(d,1),1);
                for iter = 1:size(d,1)
                    dext(iter,1) = {repmat(d(iter,ind{:}),[gcount(iter),ones(1,ndims(x)-1)])};
                end
                d = vertcat(dext{:,1});
            else
                error(message('MATLAB:grouptransform:MethodOutputInvalidSize'));
            end
        end
        if isempty(d)
            gStats{ii} = d;
        else
            gStats{ii} = d(newIdx,ind{:});
        end
        gStatsLabel{ii} = [methodPrefix '_' dataLabels{ii}];
    catch ME
        if (strcmp(ME.identifier,'MATLAB:grouptransform:MethodOutputInvalidSize'))
            rethrow(ME);
        else
            % Single error being thrown for all other possible problems above
            if tableFlag
                error(message('MATLAB:grouptransform:ApplyDataVarsError',dataLabels{ii}));
            else
                error(message('MATLAB:grouptransform:ApplyDataVecsError',dataLabels{ii}));
            end
        end
    end
end

% Prepare discretized groupingData
if (gbProvided && tableFlag) || nargout > 1
    if tableFlag
        groupingData = groupingData(gbForGV);
        gvLabels = gvLabels(gbForGV);
    else
        % Set groups
        numGroupVars = numel(groupingData);
        if numGroupVars == 1
            GR = groupingData{1};
        elseif numGroupVars == 0
            emptyfun = str2func([class(groupVars) '.empty']);
            % groupVars must be 0x0 or mx0 here because for 0xn numGroupVars is n
            GR = emptyfun(size(T,1),0);
            if issparse(groupVars)
                GR = sparse(GR);
            end
        else
            GR = groupingData;
        end
    end
end

if replaceDataVars % 'ReplaceValues' true
    if tableFlag
        G = T;
        if gbProvided
            % Make sure all labels are unique
            uniqueLabels = matlab.lang.makeUniqueStrings([varNamesT,gvLabels],string(T.Properties.DimensionNames),namelengthmax);
            gdataT = table.init(groupingData,height(T),{},numel(groupingData),uniqueLabels(numel(varNamesT)+1:end));
            G = [G,gdataT];
        end
        for jj = 1:numDataVars
            G.(dataLabels{jj}) = gStats{:,jj};
        end
    else
        % Try to extract G from Data
        try
            G = [gStats{:}];
        catch ME
            % Error if data cannot be concatenated
            error(message('MATLAB:grouptransform:StatsCattable'));
        end
    end
else % 'ReplaceValues' false
    if tableFlag
        if gbProvided
            % Make sure all labels are unique
            uniqueLabels = matlab.lang.makeUniqueStrings([varNamesT,gvLabels,gStatsLabel],string(T.Properties.DimensionNames),namelengthmax);
            vars = [groupingData,gStats];
            G = table.init(vars,height(T),{},numel(vars),uniqueLabels(numel(varNamesT)+1:end));
        else
            % Make sure all labels are unique
            uniqueLabels = matlab.lang.makeUniqueStrings([varNamesT,gStatsLabel],string(T.Properties.DimensionNames),namelengthmax);
            G = table.init(gStats,height(T),{},numel(gStats),uniqueLabels(numel(varNamesT)+1:end));
        end
        if isequal(T, timetable.empty)
            G = T;
        else
            G = [T,G];
        end
    else
        % Try to extract G from Data
        try
            G = [T,gStats{:}];
        catch ME
            % Error if results cannot be concatenated
            error(message('MATLAB:grouptransform:StatsCattable'));
        end
    end
end

%--------------------------------------------------------------------------
function [method,methodPrefix] = parseMethod(method)
%PARSEMETHOD Assembles method into a function handles

if ischar(method)
    method = string(method);
end
if numel(method) > 1
    error(message('MATLAB:grouptransform:InvalidMethodOption'));
end

if isstring(method) && ~any(startsWith({'zscore', 'norm', 'meancenter', 'rescale', 'meanfill', ...
        'linearfill'}, char(method{1}),'IgnoreCase',true))
    error(message('MATLAB:grouptransform:InvalidMethodOption'));
end

% Change each option to a function handle and set names appropriately
if isstring(method)
    if strncmpi(method,'zscore',1)
        method = @(x) normalize(x,1);
        methodPrefix = 'zscore';
    elseif strncmpi(method,'norm',3)
        method = @(x) normalize(x,1,'norm',2);
        methodPrefix = 'norm';
    elseif strncmpi(method,'meancenter',5)
        method = @(x) normalize(x,1,'center','mean');
        methodPrefix = 'meancenter';
    elseif strncmpi(method,'rescale',1)
        method = @(x) normalize(x,1,'range',[0,1]);
        methodPrefix = 'rescale';
    elseif strncmpi(method,'meanfill',5)
        method = @(x) fillmissing(x,'constant',mean(x,1,'omitnan'),1);
        methodPrefix = 'meanfill';
    elseif strncmpi(method,'linearfill',1)
        method = @(x) fillmissing(x,'linear',1);
        methodPrefix = 'linearfill';
    else
        error(message('MATLAB:grouptransform:InvalidMethodOption'));
    end
else
    if ~isa(method,'function_handle')
        error(message('MATLAB:grouptransform:InvalidMethodOption'));
    end
    methodPrefix = 'fun';
end
