#!/usr/bin/env python3
#
#  formula.py
"""
Parse formulae into a Python object
"""
#
#  Copyright (c) 2020 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation; either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU Lesser General Public License for more details.
#
#  You should have received a copy of the GNU Lesser General Public License
#  along with this program; if not, write to the Free Software
#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#  MA 02110-1301, USA.
#
#  Based on ChemPy (https://github.com/bjodah/chempy)
#  |  Copyright (c) 2015-2018, Björn Dahlgren
#  |  All rights reserved.
#  |
#  |  Redistribution and use in source and binary forms, with or without modification,
#  |  are permitted provided that the following conditions are met:
#  |
#  |    Redistributions of source code must retain the above copyright notice, this
#  |    list of conditions and the following disclaimer.
#  |
#  |    Redistributions in binary form must reproduce the above copyright notice, this
#  |    list of conditions and the following disclaimer in the documentation and/or
#  |    other materials provided with the distribution.
#  |
#  |  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
#  |  ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
#  |  WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
#  |  DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
#  |  ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
#  |  (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
#  |  LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
#  |  ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
#  |  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
#  |  SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
#  Also based on Pyteomics (https://github.com/levitsky/pyteomics)
#  |  Copyright (c) 2011-2015, Anton Goloborodko & Lev Levitsky
#  |  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.
#  |
#  |  See also:
#  |  Goloborodko, A.A.; Levitsky, L.I.; Ivanov, M.V.; and Gorshkov, M.V. (2013)
#  |  "Pyteomics - a Python Framework for Exploratory Data Analysis and Rapid Software
#  |  Prototyping in Proteomics", Journal of The American Society for Mass Spectrometry,
#  |  24(2), 301–304. DOI: `10.1007/s13361-012-0516-6 <http://dx.doi.org/10.1007/s13361-012-0516-6>`_
#  |
#  |  Levitsky, L.I.; Klein, J.; Ivanov, M.V.; and Gorshkov, M.V. (2018)
#  |  "Pyteomics 4.0: five years of development of a Python proteomics framework",
#  |  Journal of Proteome Research.
#  |  DOI: `10.1021/acs.jproteome.8b00717 <http://dx.doi.org/10.1021/acs.jproteome.8b00717>`_
#
#  Also based on molmass (https://github.com/cgohlke/molmass)
#  |  Copyright (c) 1990-2020, Christoph Gohlke
#  |  All rights reserved.
#  |  Licensed under the BSD 3-Clause License
#  |  Redistribution and use in source and binary forms, with or without
#  |  modification, are permitted provided that the following conditions are met:
#  |
#  |  1. Redistributions of source code must retain the above copyright notice,
#  |     this list of conditions and the following disclaimer.
#  |
#  |  2. Redistributions in binary form must reproduce the above copyright notice,
#  |     this list of conditions and the following disclaimer in the documentation
#  |     and/or other materials provided with the distribution.
#  |
#  |  3. Neither the name of the copyright holder nor the names of its
#  |     contributors may be used to endorse or promote products derived from
#  |     this software without specific prior written permission.
#  |
#  |  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
#  |  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
#  |  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
#  |  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
#  |  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
#  |  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
#  |  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
#  |  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
#  |  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
#  |  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
#  |  POSSIBILITY OF SUCH DAMAGE.
#  |


# stdlib
import math
from collections import Counter, defaultdict
from itertools import combinations_with_replacement, product

#  3rd party
from mathematical.utils import gcd_array

# this package
from chemistry_tools.elements import ELEMENTS, isotope_data, D, T
from chemistry_tools.formulae.parser import string_to_composition
from ._parser_core import _make_isotope_string, _parse_isotope_string
from .iso_dist import IsotopeDistribution
from .utils import _hill_order, _split_isotope, GROUPS
from .composition import Composition


class Formula(defaultdict, Counter):
	"""
	A Formula object stores a chemical composition of a compound.
	It is based on :class:`python:dict`, with the symbols of chemical elements
	as keys and the values equal to the number of atoms of the corresponding
	element in the compound.
	"""
	# TODO: option to convert D and T to H[2] and H[3] ("heavy hydrogen")

	def __init__(self, composition=None, charge=0):
		"""
		:param composition: A :py:class:`Formula` object with the elemental composition of a substance,
			or a :class:`python:dict` representing the same.
			If ``None`` an empty object is created
		:type composition: :py:class:`Formula`, optional
		:param charge:
		:type charge: int, optional
		"""

		defaultdict.__init__(self, int)

		self.charge = 0

		if composition is not None:
			for isotope_string, num_atoms in composition.items():
				element_name, isotope_num = _split_isotope(isotope_string)

				# Remove explicitly undefined isotopes (e.g. X[0]).
				self[_make_isotope_string(element_name, isotope_num)] = num_atoms

			if isinstance(composition, Formula):
				if composition.charge:
					if charge and composition.charge:
						if charge != composition.charge:
							# No point raising error if the charges are the same
							raise ValueError(
									'Charge is specified in both the composition and with the '
									f'`charge` keyword argument. {charge}, {self.charge}')

					charge = composition.charge

		self._set_charge(charge)

	def _set_charge(self, charge: int):
		# Get charge
		if charge and self.charge:
			if charge != self.charge:
				# No point raising error if the charges are the same
				raise ValueError(
						'Charge is specified in both the formula and with the '
						f'`charge` keyword argument. {charge}, {self.charge}')
		elif charge:
			self.charge = charge

	@classmethod
	def from_string(cls, formula, charge=0):
		"""
		Create a new :class:`Formula` object by parsing a string

		.. note:: Isotopes cannot (currently) be parsed using this method

		:param formula: A string with a chemical formula
		:type formula:
		:param charge:
		:type charge: int, optional

		:rtype: :class:`Formula`
		# TODO: should throw error for unrecognised elements CGCGAATTCGCG
		"""

		_class = cls()

		formula = str(formula)
		formula = formula.strip().replace(' ', '')

		# Substitute abbreviations of common chemical groups
		for grp in reversed(sorted(GROUPS)):
			formula = formula.replace(grp, f'({GROUPS[grp]})')

		comp_and_charge = string_to_composition(formula)
		# print(comp_and_charge)

		if 0 in comp_and_charge:
			if charge:
				if comp_and_charge[0] != charge:
					raise ValueError("Cannot supply 'charge' when the formula already has a charge!")

			charge = comp_and_charge[0]

		for symbol, number in comp_and_charge.items():
			if number == 0:
				raise ValueError(f"Unrecognised formula: {formula}")
			# isotope = 0
			if symbol == 0:
				continue

			# isotope_re_1 = re.match(r"([A-z]+)(\[[0-9]*\])", symbol)
			# isotope_re_2 = re.findall(r"(\[[A-z]+)([0-9]*\])", symbol)
			#
			# if isotope_re_1:
			# 	elem, isotope = isotope_re_1[0]
			# 	isotope = re.findall(r"(\[)([0-9]+)(\])", isotope)[0][1]
			# 	if not isotope:
			# 		isotope = 0
			# elif isotope_re_2:
			# 	elem, isotope = isotope_re_2[0]
			# 	elem = elem.lstrip("[")
			# 	isotope = isotope.rstrip("]")
			# elif symbol not in ELEMENTS:
			# 	raise ValueError(f'Unknown chemical element with symbol {symbol}')
			# else:
			# 	elem = ELEMENTS[symbol].symbol
			elem, isotope = _split_isotope(symbol)

			iso_str = _make_isotope_string(elem, int(isotope) if isotope else 0)
			_class[iso_str] += int(number) if number else 1

		_class._set_charge(charge)
		return _class

	@classmethod
	def from_mass_fractions(cls, fractions, charge=0, maxcount=10, precision=1e-4):
		"""
		Create a new :class:`Formula` object from elemental mass fractions by parsing a string

		.. note:: Isotopes cannot (currently) be parsed using this method

		:param fractions: A dictionary of elements and mass fractions
		:type fractions: dict
		:param charge:
		:type charge: int, optional

		:rtype: :class:`Formula`

		Examples
		--------
		>>> Formula.from_mass_fractions({'H': 0.112, 'O': 0.888})
		'H2O'
		>>> Formula.from_mass_fractions({'D': 0.2, 'O': 0.8})
		'O[2H]2'
		>>> Formula.from_mass_fractions({'H': 8.97, 'C': 59.39, 'O': 31.64})
		'C5H9O2'
		>>> Formula.from_mass_fractions({'O': 0.26, '30Si': 0.74})
		'O2[30Si]3'

		"""

		# divide normalized fractions by element/isotope mass
		numbers = {}
		sumfractions = sum(fractions.values())
		for symbol, fraction in fractions.items():
			if symbol[0].isupper():
				try:
					mass = ELEMENTS[symbol].mass
				except KeyError as exc:
					raise ValueError(f"Unknown element '{symbol}'") from exc
			else:
				if symbol.startswith('['):
					symbol = symbol[1:-1]
				i = 0
				while symbol[i].isdigit():
					i += 1
				massnum = int(symbol[:i])
				symbol = symbol[i:]
				try:
					mass = ELEMENTS[symbol].isotopes[massnum].mass
				except KeyError as exc:
					raise ValueError(f"Unknown isotope '[{massnum}{symbol}]'") from exc
				symbol = f'[{massnum}{symbol}]'
			numbers[symbol] = fraction / (sumfractions * mass)

		# divide numbers by smallest number
		smallest = min(numbers.values())
		for symbol in numbers:
			numbers[symbol] /= smallest

		# find smallest factor that turns all numbers into integers
		precision *= len(numbers)
		best = 1e6
		factor = 1
		for i in range(1, maxcount):
			x = sum(abs((i * n) - round(i * n)) for n in numbers.values())
			if x < best:
				best = x
				factor = i
				if best < i * precision:
					break

		formula = []
		for symbol, number in sorted(numbers.items()):
			count = int(round(factor * number))
			if count > 1:
				formula.append(f'{symbol}{count}')
			else:
				formula.append(symbol)

		return cls.from_string(''.join(formula), charge=charge)

	@classmethod
	def from_kwargs(cls, *, charge=0, **kwargs):
		"""
		Create a new :class:`Formula` object from keyword arguments representing the elements in the compound

		:param charge:
		:type charge: int, optional

		:rtype: :class:`Formula`
		"""

		return cls(kwargs, charge=charge)

	@property
	def monoisotopic_mass(self):
		"""
		Calculate the monoisotopic mass of a :py:class:`Formula`.
		If any isotopes are already present in the formula, the mass of these will be preserved

		:return: mass
		:rtype: float
		"""

		# Calculate mass
		mass = 0.0

		for element, count in self.items():
			if element == "D":
				iso_mass = D.mass
			elif element == "T":
				iso_mass = T.mass
			else:
				symbol, isotope = _split_isotope(element)

				if isotope:
					iso_mass = ELEMENTS[symbol].isotopes[isotope].mass
				else:
					iso_mass = ELEMENTS[symbol].isotopes[ELEMENTS[symbol].nominalmass].mass

			mass += iso_mass * count

		return mass

	nominal_mass = exact_mass = monoisotopic_mass

	@property
	def mass(self):
		"""
		Calculate the average mass of a :py:class:`Formula`.

		Note that mass is not averaged for elements with specified isotopes.

		:return: mass
		:rtype: float
		"""

		# Calculate mass
		mass = 0.0

		for element, count in self.items():
			if element == "D":
				iso_mass = D.mass
			elif element == "T":
				iso_mass = T.mass
			else:
				symbol, isotope = _split_isotope(element)

				if isotope:
					iso_mass = ELEMENTS[symbol].isotopes[isotope].mass
				else:
					iso_mass = ELEMENTS[symbol].mass

			mass += iso_mass * count

		return mass

	average_mass = mass

	@property
	def mz(self):
		return self.get_mz()

	@property
	def average_mz(self):
		return self.get_mz(average=True)

	def get_mz(self, average=True, charge=None):
		"""
		Calculate the average mass:charge ratio (*m/z*) of a :py:class:`Formula`.

		:param average: If :py:const:`True` then the average *m/z* is calculated. Note that mass
			is not averaged for elements with specified isotopes. Default is
			:py:const:`True`.
		:type: average: bool, optional
		:param charge: The charge of the compound. If ``None`` then the existing charge of the Formula is used
		:type: charge: int, optional

		:return: mass
		:rtype: float
		"""

		if average:
			mass = self.average_mass
		else:
			mass = self.exact_mass

		# Calculate m/z
		if charge:
			mass /= charge
		elif self.charge:
			mass /= self.charge

		return mass

	def most_probable_isotopic_composition(self, elements_with_isotopes=None):
		"""
		Calculate the most probable isotopic composition of a molecule/ion.

		For each element, only two most abundant isotopes are considered.
		Any isotopes already in the Formula will be changed to the most abundant isotope

		:param elements_with_isotopes: A set of elements to be considered in isotopic distribution
			(by default, every element has an isotopic distribution).
		:type: elements_with_isotopes: container of str, optional
		:return: A tuple with the most probable isotopic composition and its
			relative abundance.
		:rtype: (Formula, float)
		"""

		# Removing isotopes from the composition.
		for isotope_string in self:
			element_name, isotope_num = _split_isotope(isotope_string)
			if isotope_num:
				self[element_name] += self.pop(isotope_string)

		isotopic_composition = Formula()

		for element_name in self:
			if not elements_with_isotopes or (element_name in elements_with_isotopes):
				# Take the two most abundant isotopes.
				first_iso, second_iso = sorted(
						[(i[0], i[1][1]) for i in isotope_data[element_name].items() if i[0]],
						key=lambda x: -x[1]
						)[:2]

				# Write the number of isotopes of the most abundant type.
				first_iso_str = _make_isotope_string(element_name, first_iso[0])
				isotopic_composition[first_iso_str] = int(math.ceil(self[element_name])) * first_iso[1]

				# Write the number of the second isotopes.
				second_iso_str = _make_isotope_string(element_name, second_iso[0])
				isotopic_composition[second_iso_str] = self[element_name] - isotopic_composition[first_iso_str]
			else:
				isotopic_composition[element_name] = self[element_name]

		return isotopic_composition, isotopic_composition.isotopic_composition_abundance

	@property
	def isotopic_composition_abundance(self):
		"""
		Calculate the relative abundance of the current isotopic composition
		of this molecule.

		:returns: The relative abundance of the current isotopic composition.
		:rtype: float
		"""

		isotopic_composition = defaultdict(dict)

		# Check if there are default and non-default isotopes of the same
		# element and rearrange the elements.
		for element in self:
			element_name, isotope_num = _split_isotope(element)

			# If there is already an entry for this element and either it
			# contains a default isotope or newly added isotope is default
			# then raise an exception.
			if (
					(element_name in isotopic_composition)
					and (isotope_num == 0 or 0 in isotopic_composition[element_name])
					):
				raise ValueError(
						'Please specify the isotopic states of all atoms of '
						f'{element_name} or do not specify them at all.'
						)
			else:
				isotopic_composition[element_name][isotope_num] = (
						self[element])

		# Calculate relative abundance.
		num1, num2, denom = 1, 1, 1

		for element_name, isotope_dict in isotopic_composition.items():
			num1 *= math.factorial(sum(isotope_dict.values()))
			for isotope_num, isotope_content in isotope_dict.items():
				denom *= math.factorial(isotope_content)
				if isotope_num:
					num2 *= (isotope_data[element_name][isotope_num][1] ** isotope_content)

		return num2 * (num1 / denom)

	def iter_isotopologues(
			self, report_abundance=False, elements_with_isotopes=None,
			isotope_threshold=5e-4, overall_threshold=0):
		"""
		Iterate over possible isotopic states of the molecule.

		The space of possible isotopic compositions is restrained by parameters
		``elements_with_isotopes``, ``isotope_threshold``, ``overall_threshold``.

		:param report_abundance: If :py:const:`True`, the output will contain 2-tuples: `(composition, abundance)`.
			Otherwise, only compositions are yielded. Default is :py:const:`False`.
		:type: report_abundance: bool, optional
		:param elements_with_isotopes: A set of elements to be considered in isotopic distribution
			(by default, every element has an isotopic distribution).
		:type: elements_with_isotopes: container of str, optional
		:param isotope_threshold: The threshold abundance of a specific isotope to be considered.
			Default is :py:const:`5e-4`.
		:type: isotope_threshold: float, optional
		:param overall_threshold: The threshold abundance of the calculated isotopic composition.
			Default is :py:const:`0`.
		:type: overall_threshold: float, optional

		:return: Iterator over possible isotopic compositions.
		:rtype: iterator
		"""

		dict_elem_isotopes = {}
		for element in self:
			if elements_with_isotopes is None or element in elements_with_isotopes:
				element_name, isotope_num = _split_isotope(element)
				isotopes = {k: v for k, v in isotope_data[element_name].items() if k != 0 and v[1] >= isotope_threshold}
				list_isotopes = [_make_isotope_string(element_name, k) for k in isotopes]
				dict_elem_isotopes[element] = list_isotopes
			else:
				dict_elem_isotopes[element] = [element]

		all_isotoplogues = []
		for element, list_isotopes in dict_elem_isotopes.items():
			n = self[element]
			list_comb_element_n = []
			for elementXn in combinations_with_replacement(list_isotopes, n):
				list_comb_element_n.append(elementXn)
			all_isotoplogues.append(list_comb_element_n)

		for isotopologue in product(*all_isotoplogues):
			flat_isotopologue = [atom for element in isotopologue for atom in element]
			ic = Formula(Counter(flat_isotopologue))
			if report_abundance or overall_threshold > 0.0:
				abundance = ic.isotopic_composition_abundance
				if abundance > overall_threshold:
					if report_abundance:
						yield ic, abundance
					else:
						yield ic
			else:
				yield ic

	def isotope_distribution(self):
		"""
		Returns a :class:`IsotopeDistribution` object representing the distribution of the
		isotopologues of the formula

		:rtype: :class:`IsotopeDistribution`
		"""

		return IsotopeDistribution(self)

	def copy(self):
		return self.__class__(self, charge=self.charge)

	def __reduce__(self):
		class_, args, state, list_iterator, dict_iterator = super(
				defaultdict, self).__reduce__()
		# Override the reduce of defaultdict so we do not provide the
		# `int` type as the first argument
		# which prevents from correctly unpickling the object
		args = ()
		return class_, args, state, list_iterator, dict_iterator

	def __missing__(self, key):
		# override default behavior: we don't want to add 0's to the dictionary
		return 0

	def __setitem__(self, key, value):
		if isinstance(value, float):
			value = int(round(value))
		elif not isinstance(value, int):
			raise TypeError(f'Only integers allowed as values in Formula, got {type(value).__name__}.')
		if value:  # reject 0's
			super(defaultdict, self).__setitem__(key, value)
		elif key in self:
			del self[key]

	def __add__(self, other):
		result = self.copy()
		for elem, count in other.items():
			result[elem] += count
		return result

	def __iadd__(self, other):
		for elem, count in other.items():
			self[elem] += count
		return self

	def __radd__(self, other):
		return self + other

	def __sub__(self, other):
		result = self.copy()
		for elem, count in other.items():
			result[elem] -= count
		return result

	def __isub__(self, other):
		for elem, count in other.items():
			self[elem] -= count
		return self

	def __rsub__(self, other):
		return (self - other) * (-1)

	def __mul__(self, other):
		if not isinstance(other, int):
			raise TypeError(f'Cannot multiply Formula by non-integer "{other}"')
		return type(self)({k: v * other for k, v in self.items()})

	def __imul__(self, other):
		if not isinstance(other, int):
			raise TypeError(f'Cannot multiply Formula by non-integer "{other}"')
		for elem in self:
			self[elem] *= other
		return self

	def __rmul__(self, other):
		return self * other

	def __eq__(self, other):
		if isinstance(other, (dict, Formula)):
			self_items = {i for i in self.items() if i[1]}
			other_items = {i for i in other.items() if i[1]}

			if isinstance(other, Formula):
				return self_items == other_items and self.charge == other.charge
			else:
				return self_items == other_items
		else:
			return NotImplemented

	def __str__(self):
		return f'{type(self).__name__}({", ".join(self._repr_elements())})'

	def _repr_elements(self):
		elements = [str(dict.__repr__(self))]

		if self.charge:
			elements.append(f"charge={self.charge}")

		return elements

	def __repr__(self):
		return str(self)

	def _repr_pretty_(self, p, cycle):
		if cycle:  # should never happen
			p.text('{} object with a cyclic reference'.format(type(self).__name__))
		p.text(str(self))

	@property
	def hill_formula(self):
		"""
		Returns formula in Hill notation

		Examples
		--------
		>>> Formula.from_string('BrC2H5').hill_formula
		'C2H5Br'
		>>> Formula.from_string('HBr').hill_formula
		'BrH'
		>>> Formula.from_string('[(CH3)3Si2]2NNa').hill_formula
		'C6H18NNaSi4'

		:rtype: str
		"""

		hill = []

		for symbol in _hill_order(self.elements):
			hill.append(symbol)
			count = self[symbol]
			if count > 1:
				hill.append(str(count))

		# alphabet = sorted(ELEMENTS.symbols)
		#
		# if "C" in self:
		# 	hill.append("C")
		# 	alphabet.remove("C")
		# 	count = self["C"]
		# 	if count > 1:
		# 		hill.append(str(count))
		# 	if "H" in self:
		# 		hill.append("H")
		# 		alphabet.remove("H")
		# 		count = self["H"]
		# 		if count > 1:
		# 			hill.append(str(count))
		#
		# for element in alphabet:
		# 	if element in self:
		# 		hill.append(element)
		# 		count = self[element]
		# 		if count > 1:
		# 			hill.append(str(count))
		#
		return ''.join(hill)

	@property
	def no_isotope_hill_formula(self):
		"""
		Returns formula in Hill notation, without any isotopes specified

		Examples
		--------
		>>> Formula.from_string('BrC2H5').no_isotope_hill_formula
		'C2H5Br'
		>>> Formula.from_string('HBr').no_isotope_hill_formula
		'BrH'
		>>> Formula.from_string('[(CH3)3Si2]2NNa').no_isotope_hill_formula
		'C6H18NNaSi4'

		:rtype: str
		"""

		hill = []

		ordered_symbols = dict()

		for symbol in _hill_order(self.elements):
			count = self[symbol]
			symbol, _ = _split_isotope(symbol)
			if symbol in ordered_symbols:
				ordered_symbols[symbol] += count
			else:
				ordered_symbols[symbol] = count

		for symbol, count in ordered_symbols.items():
			hill.append(symbol)
			if count > 1:
				hill.append(str(count))

		return ''.join(hill)

	@property
	def empirical_formula(self):
		"""
		Returns the empirical formula in Hill notation.

		The empirical formula has the simplest whole number ratio of atoms
		of each element present in formula.

		Examples
		--------
		>>> Formula.from_string('H2O').empirical
		'H2O'
		>>> Formula.from_string('S4').empirical
		'S'
		>>> Formula.from_string('C6H12O6').empirical
		'CH2O'

		:rtype: str
		"""

		hill = []

		divisor = gcd_array(list(self.values()))

		for symbol in _hill_order(self.elements):
			hill.append(symbol)
			count = self[symbol] // divisor
			if count > 1:
				hill.append(str(count))

		return ''.join(hill)

	@property
	def n_atoms(self):
		"""
		Return the number of atoms in the formula.

		Examples
		--------
		>>> Formula.from_string('CH3COOH').n_atoms
		8

		"""

		return sum(list(self.values()))

	@property
	def n_elements(self):
		"""
		Return the number of elements in the formula.

		Examples
		--------
		>>> Formula.from_string('CH3COOH').n_elements
		3

		"""

		return len(self)

	@property
	def elements(self):
		"""
		Returns a list of the element symbols in the formula.
		"""

		return list(self.keys())

	@property
	def composition(self):
		"""
		Returns a :class:`Composition` object representing the elemental composition of the Formula

		:return:
		:rtype:
		"""

		return Composition(self)
