const EDGES = ['left', 'right', 'top', 'bottom'];

function normalizeRect(rect) {
  const source = rect || {};
  return {
    x: Number(source.x || 0),
    y: Number(source.y || 0),
    width: Math.max(1, Number(source.width || 0)),
    height: Math.max(1, Number(source.height || 0)),
  };
}

function rectRight(rect) {
  return rect.x + rect.width;
}

function rectBottom(rect) {
  return rect.y + rect.height;
}

function rectCenterX(rect) {
  return rect.x + (rect.width / 2);
}

function rectCenterY(rect) {
  return rect.y + (rect.height / 2);
}

function edgeOpposite(edge) {
  return {
    left: 'right',
    right: 'left',
    top: 'bottom',
    bottom: 'top',
  }[edge] || null;
}

function axisGap(startA, endA, startB, endB) {
  if (endA < startB) {
    return startB - endA;
  }
  if (endB < startA) {
    return startA - endB;
  }
  return 0;
}

function snapRectToEdge(targetRect, sourceRect, edge, preserveCrossAxis) {
  const target = normalizeRect(targetRect);
  const source = normalizeRect(sourceRect);

  if (edge === 'left') {
    target.x = source.x - target.width;
    if (!preserveCrossAxis) {
      target.y = source.y + ((source.height - target.height) / 2);
    }
  } else if (edge === 'right') {
    target.x = rectRight(source);
    if (!preserveCrossAxis) {
      target.y = source.y + ((source.height - target.height) / 2);
    }
  } else if (edge === 'top') {
    if (!preserveCrossAxis) {
      target.x = source.x + ((source.width - target.width) / 2);
    }
    target.y = source.y - target.height;
  } else if (edge === 'bottom') {
    if (!preserveCrossAxis) {
      target.x = source.x + ((source.width - target.width) / 2);
    }
    target.y = rectBottom(source);
  }

  return target;
}

function adjacentRect(sourceRect, targetRect, edge, crossOffset) {
  const source = normalizeRect(sourceRect);
  const target = normalizeRect(targetRect);
  const offset = Number.isFinite(Number(crossOffset)) ? Number(crossOffset) : 0;
  const adjacent = snapRectToEdge(target, source, edge, false);
  if (edge === 'left' || edge === 'right') {
    adjacent.y += offset;
  } else {
    adjacent.x += offset;
  }
  return adjacent;
}

function crossAxisSelectionScore(rect, edge, point) {
  const bounds = normalizeRect(rect);
  const coordinate = edge === 'left' || edge === 'right'
    ? Number(point && point.y)
    : Number(point && point.x);
  const start = edge === 'left' || edge === 'right' ? bounds.y : bounds.x;
  const end = edge === 'left' || edge === 'right' ? rectBottom(bounds) : rectRight(bounds);
  const center = start + ((end - start) / 2);
  if (coordinate >= start && coordinate < end) {
    return {
      contains: true,
      outsideDistance: 0,
      centerDistance: Math.abs(coordinate - center),
    };
  }
  return {
    contains: false,
    outsideDistance: coordinate < start ? start - coordinate : coordinate - end,
    centerDistance: Math.abs(coordinate - center),
  };
}

function selectEdgeCandidate(candidates, edge, point) {
  return (candidates || []).reduce((best, candidate, index) => {
    const score = crossAxisSelectionScore(candidate.projectedRect, edge, point);
    const ranked = { candidate, score, index };
    const sameContainment = best && score.contains === best.score.contains;
    const closerOutside = sameContainment &&
      score.outsideDistance < best.score.outsideDistance;
    const closerCenter = sameContainment &&
      score.outsideDistance === best.score.outsideDistance &&
      score.centerDistance < best.score.centerDistance;
    if (!best || (score.contains && !best.score.contains) || closerOutside || closerCenter) {
      return ranked;
    }
    return best;
  }, null)?.candidate || null;
}

function crossAxisOffset(targetRect, sourceRect, edge) {
  const target = normalizeRect(targetRect);
  const source = normalizeRect(sourceRect);
  if (edge === 'left' || edge === 'right') {
    return rectCenterY(target) - rectCenterY(source);
  }
  return rectCenterX(target) - rectCenterX(source);
}

function edgeDistance(targetRect, sourceRect, edge) {
  if (edge === 'left') {
    return {
      boundary: Math.min(
        Math.abs(rectRight(targetRect) - sourceRect.x),
        Math.abs(rectCenterX(targetRect) - sourceRect.x)
      ),
      crossAxis: axisGap(
        targetRect.y,
        rectBottom(targetRect),
        sourceRect.y,
        rectBottom(sourceRect)
      ),
    };
  }
  if (edge === 'right') {
    return {
      boundary: Math.min(
        Math.abs(targetRect.x - rectRight(sourceRect)),
        Math.abs(rectCenterX(targetRect) - rectRight(sourceRect))
      ),
      crossAxis: axisGap(
        targetRect.y,
        rectBottom(targetRect),
        sourceRect.y,
        rectBottom(sourceRect)
      ),
    };
  }
  if (edge === 'top') {
    return {
      boundary: Math.min(
        Math.abs(rectBottom(targetRect) - sourceRect.y),
        Math.abs(rectCenterY(targetRect) - sourceRect.y)
      ),
      crossAxis: axisGap(
        targetRect.x,
        rectRight(targetRect),
        sourceRect.x,
        rectRight(sourceRect)
      ),
    };
  }
  return {
    boundary: Math.min(
      Math.abs(targetRect.y - rectBottom(sourceRect)),
      Math.abs(rectCenterY(targetRect) - rectBottom(sourceRect))
    ),
    crossAxis: axisGap(
      targetRect.x,
      rectRight(targetRect),
      sourceRect.x,
      rectRight(sourceRect)
    ),
  };
}

function findSnapCandidate(targetRect, sources, snapDistance, preserveCrossAxis) {
  const target = normalizeRect(targetRect);
  const maxDistance = Math.max(0, Number(snapDistance || 0));
  let best = null;

  (sources || []).forEach(source => {
    const sourceRect = normalizeRect(source.rect);
    EDGES.forEach(edge => {
      const distance = edgeDistance(target, sourceRect, edge);
      if (distance.boundary > maxDistance || distance.crossAxis > maxDistance) {
        return;
      }

      const score = distance.boundary + (distance.crossAxis * 2);
      if (!best || score < best.score) {
        best = {
          edge,
          sourceDisplayId: String(source.displayId),
          distance: distance.boundary,
          score,
          rect: snapRectToEdge(target, sourceRect, edge, preserveCrossAxis),
        };
      }
    });
  });

  return best;
}

function memberUnion(members) {
  const normalized = (members || []).map(member => normalizeRect(member.rect));
  if (!normalized.length) {
    return normalizeRect();
  }
  const minX = Math.min.apply(null, normalized.map(rect => rect.x));
  const minY = Math.min.apply(null, normalized.map(rect => rect.y));
  const maxX = Math.max.apply(null, normalized.map(rectRight));
  const maxY = Math.max.apply(null, normalized.map(rectBottom));
  return {
    x: minX,
    y: minY,
    width: Math.max(1, maxX - minX),
    height: Math.max(1, maxY - minY),
  };
}

function boundaryValue(rect, edge) {
  if (edge === 'left') {
    return rectRight(rect);
  }
  if (edge === 'right') {
    return -rect.x;
  }
  if (edge === 'top') {
    return rectBottom(rect);
  }
  return -rect.y;
}

function crossAxisDistance(rect, edge, referenceRect) {
  if (!referenceRect) {
    return 0;
  }
  if (edge === 'left' || edge === 'right') {
    return Math.abs(rectCenterY(rect) - rectCenterY(referenceRect));
  }
  return Math.abs(rectCenterX(rect) - rectCenterX(referenceRect));
}

function boundaryMemberForEdge(members, edge, referenceRect) {
  const best = (members || []).reduce((current, member) => {
    const rect = normalizeRect(member.rect);
    const value = boundaryValue(rect, edge);
    const crossDistance = crossAxisDistance(rect, edge, referenceRect);
    if (!current ||
        value > current.value ||
        (value === current.value && crossDistance < current.crossDistance)) {
      return { member, value, crossDistance };
    }
    return current;
  }, null);
  return best ? best.member : null;
}

function findGroupSnapCandidate(movingMembers, sourceGroups, snapDistance) {
  const movingRect = memberUnion(movingMembers);
  const sources = (sourceGroups || []).map(group => ({
    displayId: group.groupId,
    rect: memberUnion(group.members),
  }));
  const candidate = findSnapCandidate(movingRect, sources, snapDistance, true);
  if (!candidate) {
    return null;
  }

  const sourceGroup = sourceGroups.find(group =>
    String(group.groupId) === candidate.sourceDisplayId);
  if (!sourceGroup) {
    return null;
  }

  const sourceRect = memberUnion(sourceGroup.members);
  const movingMember = boundaryMemberForEdge(
    movingMembers,
    candidate.edge,
    sourceRect
  );
  const sourceMember = boundaryMemberForEdge(
    sourceGroup.members,
    edgeOpposite(candidate.edge),
    movingRect
  );
  if (!movingMember || !sourceMember) {
    return null;
  }

  return Object.assign({}, candidate, {
    sourceGroupId: String(sourceGroup.groupId),
    movingMemberId: String(movingMember.id),
    sourceMemberId: String(sourceMember.id),
    crossOffset: crossAxisOffset(candidate.rect, sourceRect, candidate.edge),
    deltaX: candidate.rect.x - movingRect.x,
    deltaY: candidate.rect.y - movingRect.y,
  });
}

function layoutHostId(layoutId) {
  return String(layoutId || '').split('::')[0];
}

function latestLayoutForHost(layout, hostId) {
  let latest = null;
  Object.keys(layout || {}).forEach((layoutId, index) => {
    if (layoutHostId(layoutId) !== String(hostId)) {
      return;
    }
    const entry = layout[layoutId];
    if (!entry || !(typeof entry === 'string' ? entry : entry.edge)) {
      return;
    }
    const updatedAt = Number((typeof entry === 'string' ? 0 : entry.updatedAt) || 0);
    if (!latest ||
        updatedAt > latest.updatedAt ||
        (updatedAt === latest.updatedAt && index > latest.index)) {
      latest = { layoutId, entry, updatedAt, index };
    }
  });
  return latest && {
    layoutId: latest.layoutId,
    entry: latest.entry,
  };
}

module.exports = {
  adjacentRect,
  boundaryMemberForEdge,
  crossAxisOffset,
  edgeOpposite,
  findGroupSnapCandidate,
  findSnapCandidate,
  latestLayoutForHost,
  memberUnion,
  selectEdgeCandidate,
  snapRectToEdge,
};
