# encoding: utf-8

'''
These dictize functions generally take a domain object (such as Package) and
convert it to a dictionary, including related objects (e.g. for Package it
includes PackageTags, PackageExtras, PackageGroup etc).

The basic recipe is to call:

    dictized = ckan.lib.dictization.table_dictize(domain_object)

which builds the dictionary by iterating over the table columns.
'''
from __future__ import annotations

import copy
from typing import (
    Any, Callable, Iterable,
    Optional, Union, cast, overload
)
from typing_extensions import Literal

from urllib.parse import urlsplit

from sqlalchemy.sql.schema import Table
from sqlalchemy.sql.selectable import Select

from sqlalchemy.sql import select, false

import ckan.logic as logic
import ckan.plugins as plugins
import ckan.lib.helpers as h
import ckan.lib.dictization as d
import ckan.authz as authz
import ckan.lib.search as search
import ckan.lib.munge as munge
import ckan.model as model
from ckan.types import Context
from ckan.common import config

## package save

def group_list_dictize(
        obj_list: Union[Iterable[model.Group],
                        Iterable[tuple[model.Group, str]]],
        context: Context,
        sort_key: Callable[..., Any]=lambda x: h.strxfrm(x['display_name']), reverse: bool=False,
        with_package_counts: bool=True,
        with_member_counts: bool=True,
        include_groups: bool=False,
        include_extras: bool=False) -> list[dict[str, Any]]:

    group_dictize_context: Context = context.copy()
    # Set options to avoid any SOLR queries for each group, which would
    # slow things further.
    group_dictize_options: dict[str, Any] = {
            'packages_field': 'dataset_count' if with_package_counts else None,
            # don't allow packages_field='datasets' as it is too slow
            'include_groups': include_groups,
            'include_extras': include_extras,
            'include_users': False,  # too slow - don't allow
            }
    if with_package_counts and 'dataset_counts' not in group_dictize_context:
        # 'dataset_counts' will already be in the context in the case that
        # group_list_dictize recurses via group_dictize (groups in groups)
        group_dictize_context['dataset_counts'] = get_group_dataset_counts()
    if context.get('with_capacity'):
        group_list = [
            group_dictize(
                group, group_dictize_context,
                capacity=capacity, include_member_count=with_member_counts, **group_dictize_options)
            for group, capacity
            in cast("list[tuple[model.Group, str]]", obj_list)]
    else:
        group_list = [
            group_dictize(group, group_dictize_context, include_member_count=with_member_counts,
                          **group_dictize_options)
            for group in cast("list[model.Group]", obj_list)]

    return sorted(group_list, key=sort_key, reverse=reverse)

def resource_list_dictize(
        res_list: list[model.Resource],
        context: Context) -> list[dict[str, Any]]:

    active = context.get('active', True)
    result_list = []
    for res in res_list:
        resource_dict = resource_dictize(res, context)
        if active and res.state != 'active':
            continue

        result_list.append(resource_dict)

    return sorted(result_list, key=lambda x: x["position"])

def extras_dict_dictize(
        extras_dict: dict[str, Any],
        context: Context) -> list[dict[str, Any]]:
    result_list = []
    for extra in extras_dict.values():
        dictized = d.table_dictize(extra, context)
        if not extra.state == 'active':
            continue
        result_list.append(dictized)

    return sorted(result_list, key=lambda x: x["key"])

def extras_list_dictize(
        extras_list: list[Any], context: Context) -> list[dict[str, Any]]:
    result_list = []
    active = context.get('active', True)
    for extra in extras_list:
        dictized = d.table_dictize(extra, context)
        if active and extra.state != 'active':
            continue
        result_list.append(dictized)

    return sorted(result_list, key=lambda x: x["key"])


def resource_dictize(res: model.Resource, context: Context) -> dict[str, Any]:
    resource = d.table_dictize(res, context)
    extras = resource.pop("extras", None)
    if extras:
        resource.update(extras)
    # some urls do not have the protocol this adds http:// to these
    url = resource['url']
    ## for_edit is only called at the times when the dataset is to be edited
    ## in the frontend. Without for_edit the whole qualified url is returned.
    if resource.get('url_type') == 'upload' and not context.get('for_edit'):
        url = url.rsplit('/')[-1]
        cleaned_name = munge.munge_filename(url)
        pkg = model.Package.get(resource['package_id'])
        package_type = u'dataset'
        if pkg:
            package_type = pkg.type or u'dataset'
        resource['url'] = h.url_for('{}_resource.download'.format(
                                        package_type),
                                    id=resource['package_id'],
                                    resource_id=res.id,
                                    filename=cleaned_name,
                                    qualified=True)
    elif resource['url'] and not urlsplit(url).scheme \
         and not context.get('for_edit'):
        resource['url'] = u'http://' + url.lstrip('/')
    return resource


def _execute(q: Select[Any], table: Table, context: Context) -> Any:
    '''
    Takes an SqlAlchemy query (q) that is (at its base) a Select on an
    object table (table), and it returns the object.

    Analogous with _execute_with_revision, so takes the same params, even
    though it doesn't need the table.
    '''
    session = model.Session
    result: Any = session.execute(q)
    return result


def package_dictize(
    pkg: model.Package, context: Context, include_plugin_data: bool = False
    ) -> dict[str, Any]:
    '''
    Given a Package object, returns an equivalent dictionary.
    '''
    assert not (context.get('revision_id') or
                context.get('revision_date')), \
        'Revision functionality has been removed'
    execute = _execute
    # package
    if not pkg:
        raise logic.NotFound
    result_dict = d.table_dictize(pkg, context)
    # strip whitespace from title
    if result_dict.get('title'):
        result_dict['title'] = result_dict['title'].strip()
    # plugin_data
    plugin_data = result_dict.pop('plugin_data', None)
    if include_plugin_data:
        result_dict['plugin_data'] = copy.deepcopy(
            plugin_data) if plugin_data else plugin_data

    # resources
    res = model.resource_table
    q = select(res).where(res.c["package_id"] == pkg.id)
    result = execute(q, res, context)
    result_dict["resources"] = resource_list_dictize(result, context)
    result_dict['num_resources'] = len(result_dict.get('resources', []))

    # tags
    tag = model.tag_table
    pkg_tag = model.package_tag_table
    q = select(tag, pkg_tag.c["state"]).join(
        pkg_tag, tag.c["id"] == pkg_tag.c["tag_id"]
    ).where(pkg_tag.c["package_id"] == pkg.id)
    result = execute(q, pkg_tag, context)
    result_dict["tags"] = d.obj_list_dictize(result, context,
                                             lambda x: x["name"])
    result_dict['num_tags'] = len(result_dict.get('tags', []))

    # Add display_names to tags. At first a tag's display_name is just the
    # same as its name, but the display_name might get changed later (e.g.
    # translated into another language by the multilingual extension).
    for tag_dict in result_dict['tags']:
        assert 'display_name' not in tag_dict
        tag_dict['display_name'] = tag_dict['name']

    # return extras in old compatible format
    result_dict["extras"] = [
        {'key': k, 'value': v}
        for k, v in (result_dict["extras"] or {}).items()
    ]

    # groups
    member = model.member_table
    group = model.group_table
    q = select(group, member.c["capacity"]).join(
        member, group.c["id"] == member.c["group_id"]
    ).where(
        member.c["table_id"] == pkg.id,
        member.c["state"] == 'active',
        group.c["is_organization"] == false()
    )

    result = execute(q, member, context)
    context['with_capacity'] = False
    # no package counts as cannot fetch from search index at the same
    # time as indexing to it.
    # tags, extras and sub-groups are not included for speed
    result_dict["groups"] = group_list_dictize(result, context,
                                               with_package_counts=False)

    # owning organization
    group = model.group_table
    q = select(group).where(
        group.c["id"] == pkg.owner_org
    ).where(group.c["state"] == 'active')
    result = execute(q, group, context)
    organizations = d.obj_list_dictize(result, context)
    if organizations:
        result_dict["organization"] = organizations[0]
        # compatibility with old organization output format
        # FIXME? extras returned and schema applied here would be really
        # useful for translations
        del result_dict["organization"]["extras"]
    else:
        result_dict["organization"] = None

    # relations
    rel = model.package_relationship_table
    q = select(
        rel
    ).where(rel.c["subject_package_id"] == pkg.id)
    result = execute(q, rel, context)
    result_dict["relationships_as_subject"] = \
        d.obj_list_dictize(result, context)
    q = select(
        rel
    ).where(rel.c["object_package_id"] == pkg.id)
    result = execute(q, rel, context)
    result_dict["relationships_as_object"] = \
        d.obj_list_dictize(result, context)

    # Extra properties from the domain object

    # isopen
    result_dict['isopen'] = pkg.isopen()

    # type
    # if null assign the default value to make searching easier
    result_dict['type'] = pkg.type or u'dataset'

    # license
    if pkg.license and pkg.license.url:
        result_dict['license_url'] = pkg.license.url
        result_dict['license_title'] = pkg.license.title.split('::')[-1]
    elif pkg.license:
        result_dict['license_title'] = pkg.license.title
    else:
        result_dict['license_title'] = pkg.license_id

    # creation and modification date
    result_dict['metadata_modified'] = pkg.metadata_modified.isoformat()
    result_dict['metadata_created'] = pkg.metadata_created.isoformat() \
        if pkg.metadata_created else None

    return result_dict


@overload
def _get_members(context: Context, group: model.Group,
                 member_type: Literal['users'],
                 ) -> list[tuple[model.User, str]]:
    ...


@overload
def _get_members(context: Context, group: model.Group,
                 member_type: Literal['groups'],
                 ) -> list[tuple[model.Group, str]]:
    ...


@overload
def _get_members(context: Context, group: model.Group,
                 member_type: Literal['tags'],
                 ) -> list[tuple[model.Tag, str]]:
    ...


def _get_members(context: Context, group: model.Group,
                 member_type: str) -> list[tuple[Any, str]]:

    Entity = getattr(model, member_type[:-1].capitalize())
    q = model.Session.query(
        Entity, model.Member.capacity).\
        join(model.Member, model.Member.table_id == Entity.id).\
        filter(model.Member.group_id == group.id).\
        filter(model.Member.state == 'active').\
        filter(model.Member.table_name == member_type[:-1])
    if member_type == 'packages':
        q = q.filter(Entity.private == false())
    if 'limits' in context and member_type in context['limits']:
        limit: int = context['limits'][member_type]
        return q.limit(limit).all()
    return q.all()


def get_group_dataset_counts() -> dict[str, Any]:
    '''For all public groups, return their dataset counts, as a SOLR facet'''
    query = search.PackageSearchQuery()
    q: dict[str, Any] = {'q': '', 'fq': 'dataset_type:dataset',
         'fl': 'groups', 'facet.field': ['groups', 'owner_org'],
         'facet.limit': -1, 'rows': 1}
    query.run(q)
    return query.facets


def group_dictize(group: model.Group, context: Context,
                  include_groups: bool=True,
                  include_users: bool=True,
                  include_extras: bool=True,
                  include_member_count: bool=False,
                  packages_field: Optional[str]='datasets',
                  **kw: Any) -> dict[str, Any]:
    '''
    Turns a Group object and related into a dictionary. The related objects
    like tags are included unless you specify it in the params.

    :param packages_field: determines the format of the `packages` field - can
    be `datasets`, `dataset_count` or None.
    '''
    assert packages_field in ('datasets', 'dataset_count', None)

    result_dict = d.table_dictize(group, context)
    result_dict.update(kw)

    result_dict['display_name'] = group.title or group.name

    # return extras in old compatible format
    if include_extras:
        result_dict['extras'] = [
            {'key': k, 'value': v}
            for k, v in (result_dict['extras'] or {}).items()
        ]
    else:
        del result_dict['extras']

    context['with_capacity'] = True

    if packages_field:
        def get_packages_for_this_group(group_: model.Group,
                                        just_the_count: bool = False):
            # Ask SOLR for the list of packages for this org/group
            q: dict[str, Any] = {
                'facet': 'false',
                'rows': 0,
            }

            if group_.is_organization:
                q['fq'] = '+owner_org:"{0}"'.format(group_.id)
            else:
                q['fq'] = '+groups:"{0}"'.format(group_.name)

            if group_.is_organization:
                is_group_member = (context.get('user') and
                    authz.has_user_permission_for_group_or_org(
                        group_.id, context.get('user'), 'read'))
                if is_group_member:
                    q['include_private'] = True
                else:
                    if config.get('ckan.auth.allow_dataset_collaborators'):
                        q['include_private'] = True

            if not just_the_count:
                # package_search limits 'rows' anyway, so this is only if you
                # want even fewer
                try:
                    packages_limit = context['limits']['packages']
                except KeyError:
                    del q['rows']  # leave it to package_search to limit it
                else:
                    q['rows'] = packages_limit

            search_context = cast(
                Context, dict((k, v) for (k, v) in context.items()
                              if k != 'schema'))
            search_results = logic.get_action('package_search')(
                search_context, q)
            return search_results['count'], search_results['results']

        if packages_field == 'datasets':
            package_count, packages = get_packages_for_this_group(group)
            result_dict['packages'] = packages
        else:
            dataset_counts = context.get('dataset_counts', None)

            if dataset_counts is None:
                package_count, packages = get_packages_for_this_group(
                    group, just_the_count=True)
            else:
                # Use the pre-calculated package_counts passed in.
                facets = dataset_counts
                if group.is_organization:
                    package_count = facets['owner_org'].get(group.id, 0)
                else:
                    package_count = facets['groups'].get(group.name, 0)

        result_dict['package_count'] = package_count

    if include_groups:
        # these sub-groups won't have tags or extras for speed
        result_dict['groups'] = group_list_dictize(
            _get_members(context, group, 'groups'),
            context, include_groups=True)

    if include_users:
        result_dict['users'] = user_list_dictize(
            _get_members(context, group, 'users'),
            context)

    if include_member_count:
        result_dict['member_count'] = len(_get_members(context, group, 'users'))

    context['with_capacity'] = False

    if context.get('for_view'):
        if result_dict['is_organization']:
            plugin = plugins.IOrganizationController
        else:
            plugin = plugins.IGroupController
        for item in plugins.PluginImplementations(plugin):
            result_dict = item.before_view(result_dict)

    image_url = result_dict.get('image_url')
    result_dict['image_display_url'] = image_url
    if image_url and not image_url.startswith('http'):
        #munge here should not have an effect only doing it in case
        #of potential vulnerability of dodgy api input
        image_url = munge.munge_filename_legacy(image_url)
        result_dict['image_display_url'] = h.url_for_static(
            'uploads/group/%s' % result_dict.get('image_url'),
            qualified=True
        )
    return result_dict

def tag_list_dictize(
        tag_list: Union[Iterable[model.Tag], Iterable[tuple[model.Tag, str]]],
        context: Context) -> list[dict[str, Any]]:

    result_list: list[dict[str, Any]] = []
    for tag in tag_list:
        if context.get('with_capacity'):
            assert not isinstance(tag, model.Tag)
            tag, capacity = tag
            dictized = d.table_dictize(tag, context, capacity=capacity)
        else:
            dictized = d.table_dictize(tag, context)

        # Add display_names to tag dicts. At first a tag's display_name is just
        # the same as its name, but the display_name might get changed later
        # (e.g.  translated into another language by the multilingual
        # extension).
        assert 'display_name' not in dictized
        dictized['display_name'] = dictized['name']

        if context.get('for_view'):
            for item in plugins.PluginImplementations(
                    plugins.ITagController):
                dictized = item.before_view(dictized)

        result_list.append(dictized)

    return result_list

def tag_dictize(tag: model.Tag, context: Context,
                include_datasets: bool=True) -> dict[str, Any]:
    tag_dict = d.table_dictize(tag, context)
    package_dicts = []
    if include_datasets:
        query = search.PackageSearchQuery()

        tag_query = u'+capacity:public '
        vocab_id = tag_dict.get('vocabulary_id')

        if vocab_id:
            vocab = model.Vocabulary.get(vocab_id)
            assert vocab
            tag_query += u'+vocab_{0}:"{1}"'.format(vocab.name, tag.name)
        else:
            tag_query += u'+tags:"{0}"'.format(tag.name)

        q: dict[str, Any] = {
            'q': tag_query, 'fl': 'data_dict', 'wt': 'json', 'rows': 1000}

        package_dicts = [h.json.loads(result['data_dict'])
                         for result in query.run(q)['results']]

    # Add display_names to tags. At first a tag's display_name is just the
    # same as its name, but the display_name might get changed later (e.g.
    # translated into another language by the multilingual extension).
    assert 'display_name' not in tag_dict
    tag_dict['display_name'] = tag_dict['name']

    if context.get('for_view'):
        for item in plugins.PluginImplementations(plugins.ITagController):
            tag_dict = item.before_view(tag_dict)

        if include_datasets:
            tag_dict['packages'] = []
            for package_dict in package_dicts:
                for item in plugins.PluginImplementations(plugins.IPackageController):
                    package_dict = item.before_dataset_view(package_dict)
                tag_dict['packages'].append(package_dict)
    else:
        if include_datasets:
            tag_dict['packages'] = package_dicts

    return tag_dict

def user_list_dictize(
        obj_list: Union[list[model.User], list[tuple[model.User, str]]],
        context: Context,
        sort_key: Callable[[Any], Any] = lambda x: h.strxfrm(x['name']),
        reverse: bool=False) -> list[dict[str, Any]]:

    result_list = []

    for obj in obj_list:
        user_dict = user_dictize(obj, context)
        user_dict.pop('reset_key', None)
        user_dict.pop('apikey', None)
        user_dict.pop('email', None)
        result_list.append(user_dict)
    return sorted(result_list, key=sort_key, reverse=reverse)

def member_dictize(member: Union[model.Member, model.PackageMember],
                   context: Context) -> dict[str, Any]:
    return d.table_dictize(member, context)

def user_dictize(
        user: Union[model.User, tuple[model.User, str]], context: Context,
        include_password_hash: bool=False,
        include_plugin_extras: bool=False) -> dict[str, Any]:

    if context.get('with_capacity'):
        # Fix type: "User" is not iterable
        user, capacity = user  #type: ignore
        result_dict = d.table_dictize(user, context, capacity=capacity)
    else:
        result_dict = d.table_dictize(user, context)

    assert isinstance(user, model.User)
    password_hash = result_dict.pop('password')
    del result_dict['reset_key']

    result_dict['display_name'] = user.display_name
    result_dict['email_hash'] = user.email_hash
    result_dict['number_created_packages'] = user.number_created_packages(
        include_private_and_draft=context.get(
            'count_private_and_draft_datasets', False))

    requester = context.get('user')

    result_dict.pop('reset_key', None)
    apikey = result_dict.pop('apikey', None)
    email = result_dict.pop('email', None)
    plugin_extras = result_dict.pop('plugin_extras', None)

    if context.get('keep_email', False):
        result_dict['email'] = email

    if context.get('keep_apikey', False):
        result_dict['apikey'] = apikey

    if requester == user.name:
        result_dict['apikey'] = apikey
        result_dict['email'] = email

    if authz.is_sysadmin(requester) or context.get("ignore_auth") is True:
        result_dict['apikey'] = apikey
        result_dict['email'] = email

        if include_password_hash:
            result_dict['password_hash'] = password_hash

        if include_plugin_extras:
            result_dict['plugin_extras'] = copy.deepcopy(
                plugin_extras) if plugin_extras else plugin_extras


    image_url = result_dict.get('image_url')
    result_dict['image_display_url'] = image_url
    if image_url and not image_url.startswith('http'):
        # munge here should not have any effect, only doing it in case
        # of potential vulnerability of dodgy api input.
        image_url = munge.munge_filename_legacy(image_url)
        result_dict['image_display_url'] = h.url_for_static(
            'uploads/user/%s' % result_dict.get('image_url'),
            qualified=True
        )

    return result_dict

def task_status_dictize(
        task_status: model.TaskStatus, context: Context) -> dict[str, Any]:
    return d.table_dictize(task_status, context)


def vocabulary_dictize(vocabulary: model.Vocabulary, context: Context,
                       include_datasets: bool=False) -> dict[str, Any]:
    vocabulary_dict = d.table_dictize(vocabulary, context)
    assert 'tags' not in vocabulary_dict

    vocabulary_dict['tags'] = [tag_dictize(tag, context, include_datasets)
                               for tag in vocabulary.tags]
    return vocabulary_dict

def vocabulary_list_dictize(vocabulary_list: list[model.Vocabulary],
                            context: Context) -> list[dict[str, Any]]:
    return [vocabulary_dictize(vocabulary, context)
            for vocabulary in vocabulary_list]

def user_following_user_dictize(follower: model.UserFollowingUser,
                                context: Context) -> dict[str, Any]:
    return d.table_dictize(follower, context)

def user_following_dataset_dictize(follower: model.UserFollowingDataset,
                                   context: Context) -> dict[str, Any]:
    return d.table_dictize(follower, context)

def user_following_group_dictize(follower: model.UserFollowingGroup,
                                 context: Context) -> dict[str, Any]:
    return d.table_dictize(follower, context)

def resource_view_dictize(resource_view: model.ResourceView,
                          context: Context) -> dict[str, Any]:
    dictized = d.table_dictize(resource_view, context)
    dictized.pop('order')
    config = dictized.pop('config', {})
    dictized.update(config)
    resource = model.Resource.get(resource_view.resource_id)
    assert resource
    package_id = resource.package_id
    dictized['package_id'] = package_id
    return dictized

def resource_view_list_dictize(resource_views: list[model.ResourceView],
                               context: Context) -> list[dict[str, Any]]:
    resource_view_dicts: list[dict[str, Any]] = []
    for view in resource_views:
        resource_view_dicts.append(resource_view_dictize(view, context))
    return resource_view_dicts


def api_token_dictize(api_token: model.ApiToken,
                      context: Context) -> dict[str, Any]:
    include_plugin_extras = context.get(u'include_plugin_extras', False)
    result_dict = d.table_dictize(api_token, context)
    plugin_extras = result_dict.pop(u'plugin_extras', None)
    if include_plugin_extras:
        result_dict[u'plugin_extras'] = copy.deepcopy(
            plugin_extras) if plugin_extras else plugin_extras
    return result_dict


def api_token_list_dictize(tokens: Iterable[model.ApiToken],
                           context: Context) -> list[dict[str, Any]]:
    token_dicts: list[dict[str, Any]] = []
    for token in tokens:
        token_dicts.append(api_token_dictize(token, context))
    return token_dicts
