import re
import os
import logging
import math
from itertools import groupby

log = logging.getLogger(__name__)


LENS_ENTRY_DEFAULT_RE = re.compile('^\{\s*(?P<lens_id>[0-9]+),\s*"(?P<lens_description>.*)"')

LENS_META_DEFAULT_RE = re.compile(
    (
        # anything at the start
        ".*?"
        # maybe min focal length and hyphen, surely max focal length e.g.: 24-70mm
        "(?:(?P<focal_length_min>[0-9]+)-)?(?P<focal_length_max>[0-9]+)mm"
        # anything in-between
        ".*?"
        # maybe short focal length max aperture and hyphen, surely at least single max aperture e.g.: f/4.5-5.6
        # short and tele indicate apertures at the short (focal_length_min) and tele (focal_length_max) position of the lens
        "(?:(?:f\/)|T|F)(?:(?P<aperture_max_short>[0-9]+(?:\.[0-9]+)?)-)?(?P<aperture_max_tele>[0-9]+(?:\.[0-9])?)"
        # check if there is a teleconverter pattern e.g. + 1.4x
        "(?:.*?\+.*?(?P<tc>[0-9.]+)x)?"
    )
)


def aperture_to_raw_exif(aperture):
    # see https://github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/Canon.pm#L9678
    """Transform aperture value to Canon maker note style hex format."""
    # for apertures < 1 the below is negative
    num = math.log(aperture) * 2 / math.log(2)

    # temporarily make the number positive
    if num < 0:
        num = -num
        sign = -1
    else:
        sign = 1

    val = int(num)
    frac = num - val

    if abs(frac - 0.33) < 0.05:
        frac = 0x0C
    elif abs(frac - 0.67) < 0.05:
        frac = 0x14
    else:
        frac = int(frac * 0x20 + 0.5)

    return sign * (val * 0x20 + frac)


def raw_exif_to_aperture(raw):
    """The inverse operation of aperture_to_raw_exif"""
    val = raw
    if val < 0:
        val = -val
        sign = -1
    else:
        sign = 1

    frac = val & 0x1F
    val -= frac
    # Convert 1/3 and 2/3 codes
    if frac == 0x0C:
        frac = 0x20 / 3
    elif frac == 0x14:
        frac = 0x40 / 3

    ev = sign * (val + frac) / 0x20
    return math.exp(ev * math.log(2) / 2)


def parse_lens_entry(text, pattern=LENS_ENTRY_DEFAULT_RE):
    """
    get the ID, and description from a lens entry field

    Expected input format:
    { 748, "Canon EF 100-400mm f/4.5-5.6L IS II USM + 1.4x" }
    We return a dict of:
    lens_id = 748
    lens_description = "Canon EF 100-400mm f/4.5-5.6L IS II USM + 1.4x"
    """
    result = pattern.match(text)
    return result.groups() if result else None


def extract_meta(text, pattern=LENS_META_DEFAULT_RE):
    """
    Extract metadata from lens description.

    Input expected in the form of e.g. "Canon EF 100-400mm f/4.5-5.6L IS II USM + 1.4x"
    We return a dict of:
    focal_length_min = 100
    focal_length_max = 400
    aperture_max_short = 4.5
    aperture_max_tele = 5.6
    tc = 1.4
    """
    result = pattern.match(text)

    if not result:
        # didn't match
        return None

    ret = result.groupdict()
    # set min to max value if we didn't get a range but a single value
    ret["focal_length_min"] = int(ret["focal_length_min"] or ret["focal_length_max"])
    ret["focal_length_max"] = int(ret["focal_length_max"])
    ret["aperture_max_short"] = float(ret["aperture_max_short"] or ret["aperture_max_tele"])
    ret["aperture_max_tele"] = float(ret["aperture_max_tele"])
    ret["tc"] = float(ret["tc"] or 1)
    return ret


def lens_is_match(l1, l2):
    """
    Test if lens l2 is compatible with lens l1

    This assumes we write l1's metadata and pick its 'aperture_max_short' value
    as the maximum aperture value to write into exif.
    Normally the canon maker note holds the max aperture of the lens at the focal length
    the picture was taken at. Thus for a f/4-6.3 lens, this value could be anywhere in that range.
    """
    # the problem is that the round trip transformation isn't exact
    # so we need to account for this here as well to not define a target
    # which isn't achievable for exiv2
    reconstructed_aperture = raw_exif_to_aperture(aperture_to_raw_exif(l1["aperture_max_short"] * l1["tc"]))
    return all(
        [
            l1["focal_length_min"] * l1["tc"] == l2["focal_length_min"] * l2["tc"],
            l1["focal_length_max"] * l1["tc"] == l2["focal_length_max"] * l2["tc"],
            (l2["aperture_max_short"] - 0.1) * l2["tc"]
            <= reconstructed_aperture
            <= (l2["aperture_max_tele"] + 0.1) * l2["tc"],
        ]
    )


def make_test_cases(lenses):
    """
    Creates a test case for each lens
    Main job of this function is to collect all ambiguous lenses and define a test target
    as the " *OR* " joined string of all ambiguous lens descriptions
    """
    test_cases = []
    for lens_id, group in groupby(lenses, lambda x: x["id"]):
        lens_group = list(group)
        test_cases += [
            {
                **lens["meta"],
                "id": lens["id"],
                "desc": lens["desc"],
                "target": " *OR* ".join([l["desc"] for l in lens_group if lens_is_match(lens["meta"], l["meta"])]),
            }
            for lens in lens_group
        ]
    return test_cases


def extract_lenses_from_cpp(filename, start_pattern):
    """
    Extract lens information from the lens descriptions array in a maker note cpp file
    filename: path to cpp file
    start_pattern: start_pattern == line.strip() should return True for
                   the starting line of the array containing the lenses.
    returns: a list of lens entries containing a tuple of the form:
             (lens ID, lens description, metadata dictionary)
             for content of metadata see extract_meta() function.
    """
    lenses = []
    with open(filename, "r") as f:
        in_lens_array = False

        for line in f.readlines():
            stripped = line.strip()

            if stripped == start_pattern:
                in_lens_array = True
                continue

            if stripped == "};":
                in_lens_array = False
                continue

            if in_lens_array:
                lens_entry = parse_lens_entry(stripped)
                if not lens_entry:
                    log.error(f"Failure parsing lens entry: {stripped}.")
                    continue

                if lens_entry[1] == "n/a":
                    continue

                meta = extract_meta(lens_entry[1])
                if not meta:
                    log.error(f"Failure extracting metadata from lens description:  {lens_entry[0]}: {lens_entry[1]}.")
                    continue

                lenses.append({"id": lens_entry[0], "desc": lens_entry[1], "meta": meta})
        return lenses
