"""GeoNetwork resource validator."""

import collections
import pathlib
import re
import sys
import zipfile

import requests
from requests.exceptions import HTTPError

from .cache import CACHE

# Endpoints
IDS = "https://ids.osuna.univ-nantes.fr/geonetwork/srv"
API = f"{IDS}/api"

# Custom data classes
Date = collections.namedtuple("Date", "date,type")
Contributor = collections.namedtuple("Contributor", "name,affiliation,role")
Keyword = collections.namedtuple("Keyword", "name,thesaurus")
Bbox = collections.namedtuple(
    "Bbox",
    "eastBoundLongitude,northBoundLatitude,southBoundLatitude,westBoundLongitude",
)

PROVIDER_OSUNA = [
    "Observatoire des Sciences de l'Univers Nantes Atlantique (Osuna)",
    "Observatoire des Sciences de l'Univers Nantes Atlantique (OSUNA)",
    "Observatoire des Sciences de l'Univers Nantes Atlantique",
    "Osuna",
    "OSUNA",
]


# Custom decorator
def property_catcher(func):
    def wrapper(self):
        try:
            return func(self)
        except KeyError as err:
            raise ValueError(f"Missing {err} key for '{func.__name__}'")
        except TypeError:
            raise ValueError(f"Empty value for '{func.__name__}'")

    return property(wrapper)


class GeoNetworkResource:
    """GeoNetwork generic resource."""

    def __init__(self, uuid):
        self._uuid = uuid
        self._data = get_record(uuid)

    @property_catcher
    def uuid(self):
        uuid = _text(self._data["gmd:fileIdentifier"])

        if not is_uuid(uuid):
            raise ValueError(f"UUID invalid format: '{uuid}'")

        if uuid != self._uuid:
            raise ValueError(f"UUID mismatch: '{uuid}' vs. '{self._uuid}'")

        return uuid

    @property
    def url(self):
        return f"{IDS}/resources/records/{self.uuid}"

    @property_catcher
    def lang(self):
        if language := self._data.get("gmd:language"):
            lang = _text(language.get("gmd:LanguageCode", language)).lower()

        elif language := self._data_id.get("gmd:language"):
            lang = _text(language["gmd:LanguageCode"]).lower()
        else:
            raise ValueError("Missing 'lang' parameter")

        if lang not in ["fre", "eng"]:
            raise ValueError(
                f"Language '{lang}' invalid (should be either 'fre' or 'eng')"
            )

        return lang[:2]

    @property
    def _data_id(self):
        return self._data["gmd:identificationInfo"]["gmd:MD_DataIdentification"]

    @property
    def _citation(self):
        return self._data_id["gmd:citation"]["gmd:CI_Citation"]

    @property_catcher
    def title(self):
        return _text(self._citation["gmd:title"])

    @property_catcher
    def description(self):
        return _text(self._data_id["gmd:abstract"])

    @property_catcher
    def subjects(self):
        return [
            Keyword(subject, self._thesaurus(keywords))
            for keywords in _list(self._data_id["gmd:descriptiveKeywords"])
            for keyword in _list(keywords["gmd:MD_Keywords"]["gmd:keyword"])
            if (subject := _text(keyword)) and subject
        ]

    @staticmethod
    def _thesaurus(keywords):
        if "gmd:thesaurusName" in keywords["gmd:MD_Keywords"]:
            return _text(
                keywords["gmd:MD_Keywords"]["gmd:thesaurusName"]["gmd:CI_Citation"][
                    "gmd:title"
                ]
            )

    @property_catcher
    def contributors(self):
        contributors = [
            Contributor(
                _text(author["gmd:individualName"]).title(),
                _text(author["gmd:organisationName"]),
                _text(author["gmd:role"]["gmd:CI_RoleCode"]),
            )
            for point_of_contact in _list(self._data_id["gmd:pointOfContact"])
            if (author := point_of_contact["gmd:CI_ResponsibleParty"])
        ]

        for contributor in contributors:
            if not contributor.role:
                raise ValueError(
                    f"Contributor `{contributor.name}` does not have a ROLE"
                )

        return contributors

    @property
    def _dist(self):
        return self._data["gmd:distributionInfo"]["gmd:MD_Distribution"]

    @property_catcher
    def providers(self):
        providers = [
            Contributor(
                None,
                _text(provider["gmd:organisationName"]),
                _text(provider["gmd:role"]["gmd:CI_RoleCode"]),
            )
            for distributor in _list(self._dist["gmd:distributor"])
            if (
                provider := distributor["gmd:MD_Distributor"]["gmd:distributorContact"][
                    "gmd:CI_ResponsibleParty"
                ]
            )
        ]

        for provider in providers:
            if not provider.role:
                raise ValueError(
                    f"Provider: `{provider.affiliation}` does not have a ROLE"
                )

        for provider in providers:
            if provider.role == "resourceProvider":
                if provider.affiliation in PROVIDER_OSUNA:
                    return providers

        raise ValueError(
            "'Observatoire des Sciences de l'Univers Nantes Atlantique (Osuna)' should be listed as a 'resource provider'."
        )

    @property
    def dates(self):
        return [
            Date(
                _date(date["gmd:date"]),
                _text(date["gmd:dateType"]["gmd:CI_DateTypeCode"]),
            )
            for date in _list(self._citation["gmd:date"]["gmd:CI_Date"])
        ]

    @property_catcher
    def publication_date(self):
        """Both creation and publication dates are accepted."""
        for date in self.dates:
            if date.type in ["publication", "creation"]:
                return date.date

        raise ValueError("Missing 'publication or creation date'")

    @property
    def publication_year(self):
        return int(self.publication_date[:4])

    @property_catcher
    def status(self):
        return _text(self._data_id["gmd:status"]["gmd:MD_ProgressCode"])

    @property_catcher
    def update(self):
        update = _text(
            self._data_id["gmd:resourceMaintenance"]["gmd:MD_MaintenanceInformation"][
                "gmd:maintenanceAndUpdateFrequency"
            ]["gmd:MD_MaintenanceFrequencyCode"]
        )

        if self.status == "onGoing" and update == "notPlanned":
            raise ValueError(
                f"Incompatible update maintenance frequency: '{self.status}' vs '{update}'"
            )

        return update

    @property
    def _extents(self):
        return [
            extent["gmd:EX_Extent"] for extent in _list(self._data_id["gmd:extent"])
        ]

    @property_catcher
    def temporal_coverages(self):
        periods = [
            period
            for extent in self._extents
            for dt in _list(extent.get("gmd:temporalElement"))
            if (period := _period(dt))
        ]

        if not periods:
            raise ValueError("Missing temporal coverage(s)")

        _, last = periods[0]
        for start, end in periods[1:]:
            if is_overlap(last, start):
                raise ValueError(f"Period overlap: '{last}' > '{start}'")
            last = end

        return periods

    @property
    def t_start(self):
        return self.temporal_coverages[0][0]

    @property
    def t_end(self):
        return self.temporal_coverages[-1][-1]

    @property_catcher
    def spatial_coverages(self):
        areas = [
            _bbox(area)
            for extent in self._extents
            if "gmd:geographicElement" in extent
            for area in _list(extent["gmd:geographicElement"])
        ]

        if not areas:
            raise ValueError("Missing spatial coverage(s)")

        return areas

    @property_catcher
    def thumbnail(self):
        return _text(
            self._data_id["gmd:graphicOverview"]["gmd:MD_BrowseGraphic"]["gmd:fileName"]
        )

    @property
    def _attachments(self):
        return _list(
            self._dist["gmd:transferOptions"]["gmd:MD_DigitalTransferOptions"][
                "gmd:onLine"
            ]
        )

    @property
    def urls(self):
        urls = [
            file["gmd:CI_OnlineResource"]["gmd:linkage"]["gmd:URL"]
            for file in self._attachments
            if "http--download" in _text(file["gmd:CI_OnlineResource"]["gmd:protocol"])
        ]

        if not urls:
            raise ValueError("Missing downloadable data")
        return urls

    @property_catcher
    def files(self):
        files = [pathlib.Path(url) for url in self.urls]

        for file in files:
            if not is_filename(file.name):
                raise ValueError(
                    f"Invalid filename '{file.name}'. "
                    "Only ascii letters, digits, '_' and '-' are accepted."
                )
        return files

    @property_catcher
    def formats(self):
        formats = [
            _text(fmt["gmd:MD_Format"]["gmd:name"]).lower()
            for fmt in _list(self._dist["gmd:distributionFormat"])
            if fmt
        ]

        if not formats:
            raise ValueError("Missing file(s) format(s)")

        try:
            files = self.files
        except Exception:
            raise ValueError("File format can not be checked")

        for file in files:
            if (ext := file.suffix.lower()[1:]) not in formats:
                raise ValueError(
                    f"File format '{ext}' is not listed in distribution formats: {formats}"
                )

        return formats

    @property
    def data_integrity(self):
        try:
            urls = self.urls
            t_start = self.t_start
            t_end = self.t_end
        except Exception:
            raise ValueError("Data integrity can not be checked")

        for url in urls:
            fname = CACHE / self.uuid / pathlib.Path(url).name
            fname.parent.mkdir(parents=True, exist_ok=True)

            if not fname.exists() or _size(url) != _size(fname):
                try:
                    _download(url, fname)
                except HTTPError:
                    raise ValueError(f"Online resource not available ('{url}')")

            if fname.suffix == ".zip":
                files = _unzip(fname)
                if len(files) == 1:
                    fname = files[0]

            if fname.suffix == ".csv":
                try:
                    start, end = _csv_coverage(fname)
                except UnicodeDecodeError:
                    raise ValueError(
                        f"Tabular data '{fname.name}' is not encoded in UTF-8"
                    )

                if t_start != start:
                    raise ValueError(
                        "Temporal 'start time' is not consistent with data 'start time'"
                    )
                if t_end != end:
                    raise ValueError(
                        "Temporal 'end time' is not consistent with data 'end time'"
                    )

        return True

    @property
    def _constraints(self):
        return _list(self._data_id["gmd:resourceConstraints"])

    @property_catcher
    def rights(self):
        txts = [
            _text(legal["gmd:useLimitation"])
            for constraint in self._constraints
            if (legal := constraint.get("gmd:MD_LegalConstraints"))
        ]

        if not txts:
            raise ValueError("Missing legal constraints")

        if len(txts) > 1:
            raise ValueError("Only one license can be used")

        txt = txts.pop()

        if lic := is_open(txt):
            return lic.group().lower().replace(" ", "-")

        raise ValueError(
            "An open license (CC or Etalab) should be used in 'Use limitation'"
        )


DOI_PREFIX = "10.18465"


def doi(uuid):
    return f"https://doi.org/{DOI_PREFIX}/{uuid}"


def is_published(uuid):
    return requests.head(doi(uuid)).ok


def validate(uuid: str, verbose=True):
    """Validate geonetwork resource."""
    print(f"⚙️ Checking: '{uuid}'")

    if is_published(uuid):
        print(f"  🌍 DOI published: {doi(uuid)}")
        return False

    try:
        resource = GeoNetworkResource(uuid)
    except HTTPError as err:
        print(f"  ❌ ERROR: {err}")
        return False

    REQUIRED = [
        "uuid",
        "lang",
        "title",
        "description",
        "subjects",
        "contributors",
        "providers",
        "publication_date",
        "status",
        "update",
        "temporal_coverages",
        "spatial_coverages",
        "thumbnail",
        "files",
        "formats",
        "data_integrity",
        "rights",
    ]

    valid = True
    for attr in REQUIRED:
        try:
            getattr(resource, attr)
            if verbose:
                print(f"  ✅ {attr.title().replace('_', ' ')}")

        except ValueError as err:
            print(f"  ❌ ERROR: {err}")
            valid = False

    return valid


def get_record(uuid: str) -> dict:
    """Get resource record."""
    resp = requests.get(
        API + f"/records/{uuid}", headers={"Accept": "application/json"}
    )

    # Parse JSON response
    json = resp.json()

    if resp.ok:
        return json

    raise HTTPError(json["message"])


def get_all_uuid(fname: str = "uuid.csv") -> list:
    """Get all resources uuid from a file."""
    return [line.split(",")[0] for line in pathlib.Path(fname).read_text().splitlines()]


# Regex validators
def is_uuid(value):
    return re.match(
        r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", value
    )


def is_filename(fname):
    return re.match(
        r"^[a-zA-Z0-9_-]+$",
        pathlib.Path(fname).stem,
    )


def is_date(value):
    return re.match(r"^[12][09][0-9]{2}-[0-9]{2}-[0-9]{2}$", str(value))


def is_overlap(t_1, t_2):
    return int(t_1[:10].replace("-", "")) <= int(t_2[:10].replace("-", ""))


def is_open(txt):
    return re.search(
        r"|".join(
            [
                r"(CC0)",
                r"(CC[\s-]BY[\s-]NC[\s-]ND)",
                r"(CC[\s-]BY[\s-]NC[\s-]SA)",
                r"(CC[\s-]BY[\s-]ND)",
                r"(CC[\s-]BY[\s-]NC)",
                r"(CC[\s-]BY[\s-]SA)",
                r"(CC[\s-]BY)",
                r"(Licence Ouverte 2\.0)",
            ]
        ),
        txt,
        flags=re.IGNORECASE,
    )


# Misc
def _list(elements):
    if isinstance(elements, list):
        return [el for el in elements if el]
    if elements is None:
        return []
    return [elements]


def _text(prop):
    if prop:
        if val := prop.get("gco:CharacterString"):
            return val["#text"]

        if val := prop.get("gmx:Anchor"):
            return val["#text"]

        if val := prop.get("@codeListValue"):
            return val

    return ""


def _float(prop):
    return float(prop["gco:Decimal"]["#text"])


def _date(prop):
    date = prop["gco:DateTime" if "gco:DateTime" in prop else "gco:Date"]["#text"][:10]

    if is_date(date):
        return date

    raise ValueError(f"Date invalid format: '{date}'")


def _period(prop):
    extent = prop["gmd:EX_TemporalExtent"]["gmd:extent"]

    if "gml:TimePeriod" not in extent:
        return None

    period = extent["gml:TimePeriod"]
    start, end = period["gml:beginPosition"], period["gml:endPosition"]

    if not start:
        raise ValueError("Period 'start time' is missing or null")

    if not is_date(start):
        raise ValueError(f"Period 'start time' invalid: '{start}'")

    if not end:
        raise ValueError("Period 'end time' is missing or null")

    if not is_date(end):
        raise ValueError(f"Period 'end time' invalid: '{end}'")

    if not is_overlap(start, end):
        raise ValueError(f"Period 'start time' > 'end time': '{start}' > '{end}'")

    return start, end


def _bbox(prop):
    bbox = prop["gmd:EX_GeographicBoundingBox"]

    return Bbox(
        _float(bbox["gmd:eastBoundLongitude"]),
        _float(bbox["gmd:northBoundLatitude"]),
        _float(bbox["gmd:southBoundLatitude"]),
        _float(bbox["gmd:westBoundLongitude"]),
    )


def _size(file):
    if str(file).startswith("http"):
        return int(requests.head(file).headers["Content-Length"])
    return pathlib.Path(file).stat().st_size


def _download(url, fname):
    resp = requests.get(url)

    if resp.ok:
        fname.write_bytes(resp.content)
        return fname

    raise HTTPError("Resource not available")


def _unzip(fname):
    parent = fname.parent
    with zipfile.ZipFile(fname) as fzip:
        files = fzip.namelist()
        for f in files:
            if not (parent / f).exists():
                fzip.extract(f, path=parent)

    return [parent / f for f in files]


def _csv_coverage(fname):
    data = fname.read_text().splitlines()
    return [
        f"{yyyy}-{mm}-{dd}"
        for i in [1, -1]
        for dd, mm, yyyy in re.findall(r"^(\d{2})/(\d{2})/(\d{4})", data[i])
    ]
