"""
surplus: Plus Code to iOS-Shortcuts-like shareable text
-------------------------------------------------------
by mark <mark@joshwel.co> and contributors

This is free and unencumbered software released into the public domain.

Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.

In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

For more information, please refer to <http://unlicense.org/>
"""

from argparse import ArgumentParser
from collections import OrderedDict
from sys import stderr
from typing import Any, Callable, Final, Literal, NamedTuple

from geopy import Location  # type: ignore
from geopy.geocoders import Nominatim  # type: ignore
from pluscodes import PlusCode  # type: ignore
from pluscodes.openlocationcode import recoverNearest  # type: ignore
from pluscodes.validator import Validator  # type: ignore

VERSION: Final[tuple[int, int, int]] = (1, 1, 2)


class Localcode(NamedTuple):
    """
    typing.NamedTuple representing short Plus Code with locality

    code: str
        Plus Code - e.g.: "8QMF+FX"
    locality: str
        e.g.: "Singapore"
    """

    code: str
    locality: str

    def full_length(
        self, geocoder: Callable = Nominatim(user_agent="surplus").geocode
    ) -> tuple[bool, str]:
        """
        method that calculates full-length Plus Code using locality

        geocoder: typing.Callable = geopy.geocoders.Nominatim(user_agent="surplus").geocode
            place/locality to location function, accesses .longitude and .latitude if
            returned object is not None

        returns tuple[bool, str]
            (True, <str>) - conversion was successful, str is resultant Plus Code
            (False, <str>) - conversion failed, str is error message
        """
        location: Location | None = geocoder(self.locality)
        lat: float = 0.0
        lon: float = 0.0

        if location is None:
            return False, f"no coordinates found for '{self.locality}'"

        recv_pcode = recoverNearest(
            code=self.code,
            referenceLongitude=location.longitude,
            referenceLatitude=location.latitude,
        )

        return True, recv_pcode


class Latlong(NamedTuple):
    """
    typing.NamedTuple representing a pair of latitude and longitude coordinates

    lat: float
        latitudinal coordinate
    long: float
        longitudinal coordinate
    """

    lat: float
    long: float


def surplus(
    query: str | Localcode | Latlong,
    reverser: Callable = Nominatim(user_agent="surplus").reverse,
    debug: bool = False,
) -> tuple[bool, str]:
    """
    pluscode to shareable text conversion function

    query: str | surplus.Localcode | surplus.Latlong
        str - normal longcode (6PH58QMF+FX)
        surplus.Localcode - shortcode with locality (8QMF+FX Singapore)
        surplus.Latlong - latlong

    reverser: typing.Callable = geopy.geocoders.Nominatim(user_agent="surplus").reverser
        latlong to data function, accesses a dict from .raw attribute of return object
        function should be able to take a string with two floats and return a
        geopy.Location-like object (None checking is done)

            # code used by surplus
            location: dict[str, Any] = reverser(f"{lat}, {lon}").raw

        dict should be similar to nominatim raw dicts, see
        <https://nominatim.org/release-docs/latest/api/Output/#addressdetails>

    debug: bool = False
        prints lat, long and reverser response dict to stderr

    returns tuple[bool, str]
        (True, <str>)  - conversion was successful, str is resultant text
        (False, <str>) - conversion failed, str is error message
    """

    def _unique(l: list[str]) -> list[str]:
        """(internal function) returns a in-order unique list from list"""
        unique: OrderedDict = OrderedDict()
        for line in l:
            unique.update({line: None})
        return list(unique.keys())

    def _generate_text(address: dict[str, str], debug: bool = False) -> list[str]:
        """(internal function) separation of concern function for text generation"""

        text: list[str] = []
        seen_names: list[str] = []

        text.append(
            ("0\t" if debug else "")
            + ", ".join(
                seen_names := [
                    d
                    for d in _unique(
                        [
                            address.get(detail, "")
                            for detail in (
                                "emergency, historic, military, natural, landuse, place, "
                                "railway, man_made, aerialway, boundary, amenity, aeroway, "
                                "club, craft, leisure, office, mountain_pass, shop, "
                                "tourism, bridge, tunnel, waterway"
                            ).split(", ")
                        ]
                    )
                    if d != ""
                ]
            )
        )

        if address.get("building") != address.get("house_number"):
            seen_names += [address.get("building", "")]
            text.append(("1\t" if debug else "") + address.get("building", ""))

        seen_names += [address.get("highway", "")]
        text.append(("2\t" if debug else "") + address.get("highway", ""))

        seen_names += [address.get("house_name", "")]
        text.append(
            ("3\t" if debug else "")
            + (
                address.get("house_number", "")
                + (" " + address.get("house_name", "")).strip()
                + " "
                + address.get("road", "")
            ).strip()
        )

        if debug:
            stderr.write(f"debug: {seen_names=}\n")

        text.append("4\t" if debug else "")
        basket: list[str] = []
        for d in _unique(
            [
                address.get(detail, "")
                for detail in (
                    "residential, neighbourhood, allotments, quarter, "
                    "city_district, district, borough, suburb, subdivision, "
                    "municipality, city, town, village"
                ).split(", ")
            ]
        ):
            if all(
                _dvtm4 := [
                    d != "",
                    d not in address.get("road", ""),
                    d
                    not in [
                        address.get(detail, "")
                        for detail in (
                            "region, state, state_district, county, "
                            "state, country, continent"
                        ).split(", ")
                    ],
                    all(
                        _dvcm4 := [
                            True if (d not in sn) else False for sn in seen_names
                        ]
                    ),
                ]
            ):
                basket.append(d)

            if debug:
                stderr.write(f"debug: {d=}\t{_dvtm4=}\t{_dvcm4=}\n")

        text[-1] += ", ".join(basket)

        # text.append(
        #     ("4\t" if debug else "")
        #     + ", ".join(
        #         [
        #             d
        #             for d in _unique(
        #                 [
        #                     address.get(detail, "")
        #                     for detail in (
        #                         "residential, neighbourhood, allotments, quarter, "
        #                         "city_district, district, borough, suburb, subdivision, "
        #                         "municipality, city, town, village"
        #                     ).split(", ")
        #                 ]
        #             )
        #             if all(
        #                 _dvtm4 := [
        #                     d != "",
        #                     d not in address.get("road", ""),
        #                     d
        #                     not in [
        #                         address.get(detail, "")
        #                         for detail in (
        #                             "region, state, state_district, county, "
        #                             "state, country, continent"
        #                         ).split(", ")
        #                     ],
        #                     any(
        #                         _dvcm4 := [
        #                             True if (d not in sn) else False for sn in seen_names
        #                         ]
        #                     ),
        #                 ]
        #             )
        #         ]
        #     )
        # )

        text.append(("5\t" if debug else "") + address.get("postcode", ""))

        text.append(
            ("6\t" if debug else "")
            + ", ".join(
                [
                    d
                    for d in _unique(
                        [
                            address.get(detail, "")
                            for detail in (
                                "region, state, state_district, county, "
                                "state, country, continent"
                            ).split(", ")
                        ]
                    )
                    if d != ""
                ]
            )
        )

        return [d for d in _unique(text) if all([d != None, d != ""])]

    _latlong = handle_query(query=query, debug=debug)

    if _latlong[0] is False:
        assert isinstance(_latlong[1], str)
        return False, _latlong[1]

    assert isinstance(_latlong[1], Latlong)
    latlong = _latlong[1]

    try:
        _reversed: Location | None = reverser(f"{latlong.lat}, {latlong.long}")

        if _reversed is None:
            raise Exception(f"reverser function returned None")

        location: dict[str, Any] = _reversed.raw

    except Exception as reverr:
        return (
            False,
            f"error while reversing latlong ({Latlong}): {reverr.__class__.__name__} - {reverr}",
        )

    if debug:
        stderr.write(f"debug: {location=}\n")
        return True, "\n".join(
            _generate_text(address=location.get("address", {}), debug=debug)
            + _generate_text(address=location.get("address", {}))
        )

    return True, "\n".join(_generate_text(address=location.get("address", {})))


def parse_query(
    query: str, debug: bool = False
) -> tuple[Literal[True], str | Localcode | Latlong] | tuple[Literal[False], str]:
    """
    function that parses a string Plus Code, local code or latlong into a str,
    surplus.Localcode or surplus.Latlong respectively

    query: str
        string Plus Code, local code or latlong
    debug: bool = False
        prints query parsing information to stderr

    returns tuple[bool, str | Localcode | Latlong]
        (True, <str | Localcode | Latlong>)  - conversion was successful, second element is result
        (False, <str>) - conversion failed, str is error message
    """

    def _word_match(
        oquery: str, squery: list[str]
    ) -> tuple[Literal[True], str | Localcode | Latlong] | tuple[Literal[False], str]:
        """
        internal helper code reuse function

        looks through each 'word' and attempts to match to a Plus Code
        if found, remove from original query and strip of whitespace and commas
        use resulting stripped query as locality
        """

        pcode: str = ""

        for word in squery:
            if Validator().is_valid(word):
                pcode = word

                if Validator().is_full(word):
                    return True, word

        if pcode != "":  # found a pluscode
            locality = oquery.replace(pcode, "")
            locality = locality.strip().strip(",").strip()

            if debug:
                stderr.write(f"debug: {pcode=}, {locality=}\n")

            return True, Localcode(code=pcode, locality=locality)

        return False, "unable to find a pluscode/match to a format"

    squery = [word.strip(",").strip() for word in query.split()]

    if debug:
        stderr.write(f"debug: {squery=}\n")

    match squery:
        # attempt to match to conjoined latlong ('lat,long')
        case [a]:
            try:
                plat, plong = a.split(",")
                lat = float(plat)
                long = float(plong)

            except ValueError:
                return _word_match(oquery=query, squery=squery)

            else:
                return True, Latlong(lat=lat, long=long)

        # attempt to match to latlong ('lat, long')
        case [a, b]:
            try:
                lat = float(a)
                long = float(b)

            except ValueError:
                return _word_match(oquery=query, squery=squery)

            else:
                return True, Latlong(lat=lat, long=long)

        case _:
            return _word_match(oquery=query, squery=squery)


def handle_query(
    query: str | Localcode | Latlong, debug: bool = False
) -> tuple[Literal[True], Latlong] | tuple[Literal[False], str]:
    """
    function that gets returns a surplus.Latlong from a Plus Code string,
    surplus.Localcode or surplus.Latlong object.
    used after surplus.parse_query().

    query: str | Localcode | Latlong

    debug: bool = False

    returns tuple[bool, str | Latlong]
        (True, Latlong)  - conversion was successful, second element is latlong
        (False, <str>) - conversion failed, str is error message
    """
    lat: float = 0.0
    lon: float = 0.0

    if isinstance(query, Latlong):
        return True, query

    else:  # instances: str | Localcode
        str_pcode: str = ""

        if isinstance(query, Localcode):
            result = query.full_length()

            if not result[0]:
                return False, result[1]

            str_pcode = result[1]

        else:
            str_pcode = query

        try:
            pcode = PlusCode(str_pcode)

        except KeyError:
            return (
                False,
                "code given is not a full-length Plus Code (including area code), e.g.: 6PH58QMF+FX",
            )

        except Exception as pcderr:
            return (
                False,
                f"error while decoding Plus Code: {pcderr.__class__.__name__} - {pcderr}",
            )

        lat = pcode.area.center().lat
        lon = pcode.area.center().lon

        if debug:
            stderr.write(f"debug: {lat=}, {lon=}\n")

    return True, Latlong(lat=lat, long=lon)


def cli() -> None:
    parser = ArgumentParser(
        prog="surplus",
        description=__doc__[__doc__.find(":") + 2 : __doc__.find("\n", 1)],
    )
    parser.add_argument(
        "query",
        type=str,
        help="full-length Plus Code (6PH58QMF+FX), local code (8QMF+FX Singapore), or latlong (1.3336875, 103.7749375)",
        nargs="+",
    )
    parser.add_argument(
        "-d",
        "--debug",
        action="store_true",
        default=False,
        help="prints lat, long and reverser response dict to stderr",
    )
    args = parser.parse_args()

    stderr.write(
        f"surplus version {'.'.join([str(v) for v in VERSION])}"
        + (f", debug mode" if args.debug else "")
        + "\n"
    )

    if args.debug:
        stderr.write("debug: args.query='" + " ".join(args.query) + "'\n")

    query = parse_query(" ".join(args.query), debug=args.debug)
    if not query[0]:
        stderr.write(f"{query[-1]}\n")
        exit(1)

    result: tuple[bool, str] = surplus(query[-1], debug=args.debug)
    if not result[0]:
        stderr.write(f"{result[-1]}\n")
        exit(2)

    print(result[-1])


if __name__ == "__main__":
    cli()