#!/usr/bin/env python3

from http import HTTPStatus

import click
import click_log
import glob
import json
import logging
import re
import requests
from pathlib import Path
from pprint import pprint
from tempfile import TemporaryFile, NamedTemporaryFile
from typing import List, Dict
from zipfile import ZipFile, ZIP_DEFLATED

from requests.sessions import Session

from .ftmap import FTMap
from .metadata import get_md_metas
from . import __version__ as md2ft_version

logger = logging.getLogger("fluidtopics.markdown")
click_log.basic_config(logger)

CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
RE_H1_MD = re.compile("^# (.*)$")


class MD2FTError(Exception):
    pass


def prepare_ft_session(
        portal_base_url: str,
        user: str,
        passwd: str) -> Session:
    "Prepare an FT session to call API"
    ft_session = requests.Session()
    login_url = portal_base_url + "/api/authentication/login"
    rep = ft_session.post(login_url, json={"login": user, "password": passwd})
    if not rep.ok:
        raise MD2FTError(f"Cannot log in to FT portal {login_url} (user={user}, password=XXX): {rep.content}")
    return ft_session


def prune_empty_dir(content: Dict[str, dict]) -> Dict[str, dict]:
    """Remove empty dir (i.e. content node with child) which doesn't have any non-empty child"""
    # end of recursion 1: node is empty
    if not content:
        return {}

    to_delete = []
    for k, node in content.items():
        if "childs" in node:
            new_childs = prune_empty_dir(node["childs"])
            # node is a directory without content and no child
            if "ft:originID" in node["value"] and not new_childs:
                to_delete.append(k)
    for k in to_delete:
        del content[k]
    return content


def dir_content(curdir: Path, relto: Path, skip: List[Path], meta_include_list: List[str],
                implicit_meta: bool = False, dev_mode: bool = False) -> Dict[str, dict]:
    """Recursively Collect the directory content starting at curdir
    """
    content = {}
    # Recursively loop over the curdir + sort dir using filename
    for file in sorted(curdir.iterdir(), key=lambda f: f.name):
        if file.relative_to(relto) in skip:
            logger.debug(f"Skipping {file.as_posix()} as told.")
            continue
        relf_posix = file.relative_to(relto).as_posix()
        f_posix = file.as_posix()
        if file.is_dir():
            if file.name.startswith(".") or file.name.startswith("__"):
                continue
            dirdesc = file / "README.md"
            if dirdesc.exists():
                dirmetas, with_content = get_md_metas(dirdesc)  # We handle default title for directory differently...
                dirtitle = dirmetas.get("ft:title", file.name)
                logger.debug(f"Found directory description in {dirdesc}")
                skip.append(dirdesc.relative_to(relto))
                if meta_include_list:
                    audience = dirmetas.get("audience", None)
                    if audience is None:
                        logger.warning(
                            f"Skipping {file.relative_to(relto).as_posix()} no audience given as meta while include_list = {meta_include_list}")
                        continue
                    elif audience not in meta_include_list:
                        logger.warning(
                            f"Skipping {file.relative_to(relto).as_posix()} as audience={audience} not in include_list = {meta_include_list}")
                        continue
                # If README with metas + content => create href
                if with_content:
                    content[relf_posix] = {
                        "value": {"href": dirdesc.relative_to(relto).as_posix(), "ft:title": dirtitle, "metas": dirmetas},
                        "childs": dir_content(file, relto, skip, meta_include_list, implicit_meta, dev_mode)
                    }
                # if README with metas only => create ft:originID (ToC entry only)
                else:
                    content[relf_posix] = {
                        "value": {"ft:originID": f_posix, "ft:title": dirtitle, "metas": dirmetas},
                        "childs": dir_content(file, relto, skip, meta_include_list, implicit_meta, dev_mode)
                    }
            # if no README => create ft:originID from dir title (ToC entry only)
            else:
                if not implicit_meta:
                    raise MD2FTError(f"No README.md in directory {file} while in explicit mode, add one with an 'ft:title' meta.")
                else:
                    # provide default value for directory description in implicit mode
                    content[relf_posix] = {
                        "value": {"ft:originID": f_posix, "ft:title": file.name, "metas": {}},
                        "childs": dir_content(file, relto, skip, meta_include_list, implicit_meta, dev_mode)
                    }

        elif file.is_file() and file.match("*.md"):
            metas, _ = get_md_metas(file, implicit_meta)
            audience = metas.get("audience", None)
            if meta_include_list:
                if audience is None:
                    logger.warning(
                        f"Skipping {file.relative_to(relto).as_posix()} no audience given as meta while include_list = {meta_include_list}")
                    continue
                elif audience not in meta_include_list:
                    logger.warning(
                        f"Skipping {file.relative_to(relto).as_posix()} as audience={audience} not in include_list = {meta_include_list}")
                    continue
            if metas.get("ft:title", None) is not None:
                content[relf_posix] = {"value": {"href": relf_posix, "ft:title": metas["ft:title"], "metas": metas}}
            else:
                if dev_mode:
                    logger.warning(f"Skipping {file.as_posix()} no ft:title given as meta.")
                else:
                    raise MD2FTError(f"no meta ft:title or # title found in {file.as_posix()}")
    return content


def _create_zip_file(output, pinput, image_dir, ftmap_file, collected_files):
    with ZipFile(output, mode='w', compression=ZIP_DEFLATED) as archive:
        archive.writestr("generated.ftmap", data=ftmap_file.read())
        # NB: we need to use glob.glob and not pathlib.Path.rglob
        #     because of: https://bugs.python.org/issue33428
        #     see also: https://stackoverflow.com/questions/46529760/getting-glob-to-follow-symlinks-in-python
        for fn in glob.glob(f"{str(pinput)}/**/*", recursive=True):
            f = Path(fn)
            if f.match('*.ftmap'):
                continue
            if f.is_dir() or ((pinput / Path(image_dir)) in f.parents):
                archive.write(f, arcname=f.relative_to(pinput))
            elif f.is_file() and f.suffix.lower() in ['.png', '.jpg', '.jpeg', '.gif', '.svg']:
                logger.debug(f"f={f}, arcname={f.relative_to(pinput)}")
                archive.write(f, arcname=f.relative_to(pinput))
            elif f.relative_to(pinput) in collected_files:
                # get MD content with stripped-out metadata
                _, md_content = get_md_metas(f)
                archive.writestr(str(f.relative_to(pinput)), md_content)
            else:
                logger.debug(f"{f.relative_to(pinput)} not collected in archive.")


def do_collect(
            input: str, output: str,
            included_audiences: List[str],
            implicit_meta: bool,
            dev_mode: bool = False
            ):
    """Collect markdown files and create FTML archive with a book FT Map
    built from the metadata found in the markdown files (unless implicit_meta is True)
    The created archive may then be uploaded to FT portal using `upload`.
    """
    pinput = Path(input)
    root_readme = list(pinput.glob("README.md"))
    metas = None
    # We always collect metas from root README is there is some
    if root_readme:
        root_readme = root_readme[0]
        metas, _ = get_md_metas(root_readme)
    if implicit_meta:
        logger.debug("Implicit meta MODE")
        implicit_metas_values = {
            # if implicit meta is set to True, build the title from pinput dir name
            "media_dir": "images",
            "ft:title": f"{pinput.name}",
            "ft:originID": input,
            "Category": "Technical Notes",
            "audience": "internal"
        }
        # Replace provided metas value if any
        if metas:
            implicit_metas_values.update(metas)
        metas = implicit_metas_values

    if metas is None and not implicit_meta:
        raise MD2FTError(f"No README.md found in {pinput}")

    logger.debug(f"root metas = {metas}")
    # Get media_dir value or use default one (images/)
    image_dir = metas.get("media_dir", "images")
    skipped = [Path(image_dir)]
    if root_readme and not implicit_meta:
        skipped.append(root_readme.relative_to(pinput))
    # Recursively collect md files beginning at pinput
    collected = dir_content(pinput, pinput, skipped, meta_include_list=included_audiences, implicit_meta=implicit_meta, dev_mode=dev_mode)
    # Clean up empty dirs (i.e. which do not contain md files)
    collected = prune_empty_dir(collected)
    logger.debug(f"collected= {collected}")
    if "ft:title" not in metas:
        raise MD2FTError(f"No ft:title in {root_readme}.")
    if "ft:originID" not in metas:
        raise MD2FTError(f"No ft:originID in {root_readme}.")
    # Init FTMAP with mandatory prop: ft:title, ft:originID
    ftmap = FTMap(title=metas["ft:title"],
                  origin_id=metas["ft:originID"],
                  editorial_type=metas.get("ft:editorialType", "book"),
                  lang=metas.get("ft:lang", "en-US"))
    if "metas" in metas:
        ft_metas = json.loads(metas["metas"].replace("'", "\""))
        ft_metas = {k: v["value"] for k, v in ft_metas.items()}
        logger.warning("Using '[_metadata_:metas]:-' for metadata is deprecated")
        logger.warning("Use '[_metadata_:key]:- \"value\"' for each metadata")
    else:
        ft_metas = metas
    logger.debug(f"FT metas = {ft_metas}")
    ftmap.add_metas(ftmap.root, ft_metas)
    # Build FTMAP ToC with collected items + return files list to be zipped
    collected_files = ftmap.populate_toc(ftmap.get_toptoc(), None, collected.values())
    logger.info(f"Collected {len(collected_files)} markdown files in FTMap.")
    logger.debug(f"FTMap collected_files= {collected_files}")
    # Create the output zip file
    with TemporaryFile() as ftmap_file:
        ftmap.write(ftmap_file)
        ftmap_file.seek(0)
        _create_zip_file(output, pinput, image_dir, ftmap_file, collected_files)


def do_publish(
        zip: Path, portal_base_url: str,
        user: str, passwd: str,
        source_id: str,
        dry_run: bool):
    """Upload and publish an FTML archive to an FT portal.
    """
    if not dry_run:
        ft_session = prepare_ft_session(portal_base_url, user, passwd)
        # Verify that the server is reachable and has FTML source exists
        list_sources_url = f"{portal_base_url}/api/admin/khub/sources"
        rep = ft_session.get(list_sources_url)
        if rep.status_code in [HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN]:
            raise MD2FTError(f"Server rejected credentials as Unauthorized ({rep.status_code}).")
        elif rep.status_code == HTTPStatus.NOT_FOUND:
            raise MD2FTError(f'The instance id or the API in "{list_sources_url}" is not found ({rep.status_code}).')
        elif not rep.ok:
            raise MD2FTError(rep.text)

    # Upload FTML zip archive
    if type(zip) == str:
        zipfile = {'file': ("md2ft_" + Path(zip).name, Path(zip).open('rb'))}
    else:
        zipfile = {'file': ("md2ft_" + Path(zip.name).name, zip)}
    post_url = f"{portal_base_url}/api/admin/khub/sources/{source_id}/upload"

    if not dry_run:
        rep = ft_session.post(post_url, files=zipfile)
        if rep.status_code == HTTPStatus.OK:
            answer = rep.json()
            logger.info(f"{zipfile['file'][0]} file published on {portal_base_url}.")
            pprint(answer)
        else:
            raise MD2FTError(rep.text)
    else:
        logger.info(f"DRY RUN: {zipfile['file'][0]} file NOT published on {portal_base_url}.")


def _get_meta_values(
        key: str,
        metadatas: List[dict],
        first_value_only: bool):
    "Extract a meta FT API json"
    meta = next((keys for keys in metadatas if keys["key"] == key), None)
    if meta is not None:
        if first_value_only:
            return meta["values"][0]
        else:
            return meta["values"]
    else:
        if first_value_only:
            return None
        else:
            return []


def do_unpublish(
        portal_base_url: str,
        user: str,
        passwd: str,
        source_id: str,
        dry_run: bool,
        do_it: bool,
        select: str):
    """
        Delete selected publications by :
        - lastEdtion : the old version of the publications (older than the last publish)
        - title : delete the publication with the corresponding title
    """
    if not dry_run:
        # Prepare a FT session
        ft_session = prepare_ft_session(portal_base_url, user, passwd)

        # Get all the maps from md2ft source
        list_maps = f"{portal_base_url}/api/admin/khub/publications?ft:sourceId={source_id}"
        r = ft_session.get(list_maps)
        if r.status_code == HTTPStatus.UNAUTHORIZED:
            logger.fatal(f"Server rejected credentials as Unauthorized ({r.status_code}).")
        if r.status_code == HTTPStatus.NOT_FOUND:
            logger.fatal(f'The instance id or the API in "{list_maps}" is not found ({r.status_code}).')
        r.raise_for_status()
        list_maps_json = r.json()

        # Exist if no publication or source does not exist
        if list_maps_json["count"] == 0:
            logger.info(f"No publications in this source_id or the source: {source_id} doesn't exist")
            return

        # Prepare documents dictionary for the specified source id
        documents = dict()
        for map in list_maps_json["items"]:
            documents[map["id"]] = map["metadata"]

        selection = select.split("=")
        metaname = selection[0]
        if len(selection) > 1:
            metavalue = selection[1]
        else:
            metavalue = None

        logger.debug(f"Selecting documents with metaname={metaname} and metavalue={metavalue}...")
        if metaname == "ft:lastEdition":
            # Get the maxDate (ie the last date of publish)
            latestDates = [_get_meta_values(metaname, metas, True) for metas in documents.values()]
            logger.debug(f"latest dates are: {latestDates}")
            latestDate = max(latestDates)
            logger.debug(f"latest date is: {latestDate}")
            # Remove the newest publication to have a dict of publication to delete
            selected_documents = \
                {
                    key: metas for key, metas in documents.items()
                    if _get_meta_values(metaname, metas, True) != latestDate
                }
        else:
            # Filter the map to only delete documents with given metavalue for metaname
            selected_documents = \
                {key: metas for key, metas in documents.items() if metavalue in _get_meta_values(metaname, metas, False)}

        if selected_documents:
            # Iter on each publication to delete
            for id_to_delete, metas in selected_documents.items():
                delete_map = f"{portal_base_url}/api/admin/khub/maps/{id_to_delete}"
                # Delete the publication if enable
                if do_it:
                    r = ft_session.delete(delete_map)

                    if r.status_code == HTTPStatus.OK:
                        answer = r.json()
                        logger.info(f" ID MAP {id_to_delete} deleted on {portal_base_url}.")
                        pprint(answer)
                    r.raise_for_status()
                else:
                    logger.info(f"Would delete document id={id_to_delete} (whose metas={metas}) on {portal_base_url}.")
            if not do_it:
                logger.info("If you want to delete the previous publications do add --do-it parameter")
        else:
            logger.info(f"No publications selected with <<{select}>> from source_id : {source_id} on portal {portal_base_url}")
    else:
        logger.info(f"DRY RUN: No publications deleted from source_id : {source_id} on portal : {portal_base_url}.")


@click.group(context_settings=CONTEXT_SETTINGS)
@click.version_option(md2ft_version)
@click_log.simple_verbosity_option(logger, default="INFO", show_default=True)
def cli():
    pass


collect_opts = [
    click.argument(
        "input",
        nargs=1,
        required=True,
        metavar="<documentation_root_dir>",
        type=click.Path(file_okay=False, dir_okay=True, readable=True),
    ),
    click.option(
        "--include", "included_audiences",
        metavar="<audience tag>",
        multiple=True,
        required=False,
        help="Only the Markdown file(s) whose 'audience' metadata value is listed here will be collected.",
    ),
    click.option(
        "--implicit-meta",
        is_flag=True,
        default=False,
        show_default=True,
        help="Do not require metadata in Markdown file.",
    ),
    click.option(
        "--dev-mode",
        is_flag=True,
        default=False,
        show_default=True,
        help="Ignore files without a meta ft:title or a # first section title.",
    ),
]

publish_opts = [
    click.argument(
        "portal_base_url",
        metavar="<FT_portal_base_url>",
        required=True,
        type=click.STRING
    ),
    click.option(
        "-u", "--user",
        type=click.STRING,
        default="root@fluidtopics.com",
        show_default=True,
        metavar="<user>",
        help="The FT user"
    ),
    click.option(
        "-p", "--passwd",
        type=click.STRING,
        default="change_it",
        show_default=True,
        metavar="<password>",
        help="The FT password"
    ),
    click.option(
        "-s", "--source-id",
        type=click.STRING,
        default="ftml",
        show_default=True,
        metavar="<source_id>",
        help="The FT source ID"
    ),
    click.option(
        "--dry-run",
        is_flag=True,
        default=False,
        show_default=True,
        help="Do all steps besides actual push to FT portal.",
    ),
]


def add_options(options):
    def _add_options(func):
        for option in reversed(options):
            func = option(func)
        return func
    return _add_options


@cli.command("collect")
@add_options(collect_opts)
@click.option(
    "-o", "--output", "output",
    metavar="<archive_file>",
    type=click.Path(file_okay=True, dir_okay=False, writable=True),
    help="output path of the FTML archive to be created",
    show_default=True,
    default="ftml_content.zip")
def collect(input: str, output: str,
            included_audiences: List[str],
            implicit_meta: bool,
            dev_mode: bool):
    """Collect documentation and create an FTML archive based on the metadata found in
       the Markdown files (unless implicit_meta is set as True).
       The archive may then be published to a Fluid Topics portal using the `publish` command.
    """
    do_collect(input, output, included_audiences, implicit_meta, dev_mode)
    logger.info(f"Written FTML archive in: {output}.")
    logger.info(f"You can push the archive to FT portal with:")
    logger.info(f"  md2ft publish {output} <ft_portal_url>")


@cli.command("collect_and_publish")
@add_options(collect_opts)
@add_options(publish_opts)
def collect_and_publish(input: str,
                        included_audiences: List[str],
                        implicit_meta: bool,
                        dev_mode: bool,
                        portal_base_url: str,
                        user: str,
                        passwd: str,
                        source_id: str,
                        dry_run: bool):
    """Collect upload and publish documentation to an FT portal.
    """
    with NamedTemporaryFile(mode="r+b", suffix=".zip") as tempzip:
        logger.debug(f"tempzip={tempzip.name}")
        do_collect(input, tempzip, included_audiences, implicit_meta)
        # rewind the file for next read
        tempzip.seek(0)
        do_publish(tempzip, portal_base_url, user, passwd, source_id, dry_run)


@cli.command("publish")
@click.argument(
    "zip",
    required=True,
    type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True)
)
@add_options(publish_opts)
def publish(zip: Path, portal_base_url: str,
            user: str,
            passwd: str,
            source_id: str,
            dry_run: bool):
    """Upload and publish an FTML archive to an FT portal.
    """
    do_publish(zip, portal_base_url, user, passwd, source_id, dry_run)


@cli.command("unpublish")
@add_options(publish_opts)
@click.option(
    "--do-it",
    is_flag=True,
    default=False,
    show_default=True,
    help="Confirm the unpublish of all documents, otherwise it just show them",
)
@click.option(
    "--select",
    metavar="<metaname>[=<metavalue>]",
    default="ft:lastEdition",
    show_default=True,
    help="""Unpublish selected documents:

    - ft:lastEdition : Delete all publications older than the latest ones from an FT portal (from last md2ft publish)\n
    - metaname=<metavalue> : Delete publications with the given <metavalue> for <metaname>
    """,
)
def delete_book(
        portal_base_url: str,
        user: str,
        passwd: str,
        source_id: str,
        dry_run: bool,
        do_it: bool,
        select: str):
    """ Delete all publications older than the latest ones from an FT portal (from last md2ft publish) 
    """
    do_unpublish(portal_base_url, user, passwd, source_id, dry_run, do_it, select)


if __name__ == '__main__':
    cli()
