import errno
import os

from inifile import IniFile

from lektor.constants import PRIMARY_ALT
from lektor.environment.expressions import Expression
from lektor.environment.expressions import FormatExpression
from lektor.i18n import generate_i18n_kvs
from lektor.i18n import get_i18n_block
from lektor.pagination import Pagination
from lektor.reporter import reporter
from lektor.types import builtin_types
from lektor.types.base import RawValue
from lektor.utils import bool_from_string
from lektor.utils import slugify


class ChildConfig:
    def __init__(
        self,
        *,
        enabled=None,
        slug_format=None,
        model=None,
        order_by=None,
        replaced_with=None,
        hidden=None,
    ):
        if enabled is None:
            enabled = True
        self.enabled = enabled
        self.slug_format = slug_format
        self.model = model
        self.order_by = order_by
        self.replaced_with = replaced_with
        self.hidden = hidden

    def to_json(self):
        return {
            "enabled": self.enabled,
            "slug_format": self.slug_format,
            "model": self.model,
            "order_by": self.order_by,
            "replaced_with": self.replaced_with,
            "hidden": self.hidden,
        }


class PaginationConfig:
    def __init__(
        self, env, *, enabled=None, per_page=None, url_suffix=None, items=None
    ):
        self.env = env
        if enabled is None:
            enabled = False
        self.enabled = enabled
        if per_page is None:
            per_page = 20
        elif not isinstance(per_page, int):
            raise TypeError(f"per_page must be an int or None, not {per_page!r}")
        if per_page <= 0:
            raise ValueError("per_page must be positive, not {per_page}")

        self.per_page = per_page
        if url_suffix is None:
            url_suffix = "page"
        self.url_suffix = url_suffix
        self.items = items
        self._items_tmpl = None

    def count_total_items(self, record):
        """Counts the number of items over all pages."""
        return self.get_pagination_query(record).count()

    def count_pages(self, record):
        """Returns the total number of pages for the children of a record."""
        total = self.count_total_items(record)
        npages = (total + self.per_page - 1) // self.per_page
        # Even when there are no children, we want at least one page
        return max(npages, 1)

    def slice_query_for_page(self, record, page):
        """Slices the query so it returns the children for a given page."""
        query = self.get_pagination_query(record)
        if not self.enabled or page is None:
            return query
        return query.limit(self.per_page).offset((page - 1) * self.per_page)

    @staticmethod
    def get_record_for_page(record, page_num):
        """Given a normal record this one returns the version specific
        for a page.
        """
        # If we already have the right version, return it.
        if record.page_num == page_num:
            return record

        # Check if we have a cached version
        pad = record.pad
        rv = pad.cache.get(record.path, record.alt, str(page_num))
        if rv is not Ellipsis:
            return rv

        # Make what we need out of what we have and put it into the cache.
        cls = record.__class__
        rv = cls(record.pad, record._data, page_num=page_num)
        pad.cache.remember(rv)
        return rv

    def match_pagination(self, record, url_path):
        """Matches the pagination from the URL path."""
        if not self.enabled:
            return None
        suffixes = self.url_suffix.strip("/").split("/")
        if url_path[: len(suffixes)] != suffixes:
            return None
        try:
            page_num = int(url_path[len(suffixes)])
        except (ValueError, IndexError):
            return None

        # It's important we do not allow "1" here as the first page is always
        # on the root.  Changing this would mean the URLs are incorrectly
        # generated if someone manually went to /page/1/.
        if page_num == 1 or len(url_path) != len(suffixes) + 1:
            return None

        # Page needs to have at least a single child.
        rv = self.get_record_for_page(record, page_num)
        if rv.pagination.items.first() is not None:
            return rv
        return None

    def get_pagination_controller(self, record):
        if not self.enabled:
            raise RuntimeError("Pagination is disabled")
        return Pagination(record, self)

    def get_pagination_query(self, record):
        items_expr = self.items
        if items_expr is None:
            return record.children
        if self._items_tmpl is None or self._items_tmpl[0] != items_expr:
            self._items_tmpl = (items_expr, Expression(self.env, items_expr))

        return self._items_tmpl[1].evaluate(record.pad, this=record)

    def to_json(self):
        return {
            "enabled": self.enabled,
            "per_page": self.per_page,
            "url_suffix": self.url_suffix,
            "items": self.items,
        }


class AttachmentConfig:
    def __init__(self, *, enabled=None, model=None, order_by=None, hidden=None):
        if enabled is None:
            enabled = True
        if hidden is None:
            hidden = False
        self.enabled = enabled
        self.model = model
        self.order_by = order_by
        self.hidden = hidden

    def to_json(self):
        return {
            "enabled": self.enabled,
            "model": self.model,
            "order_by": self.order_by,
            "hidden": self.hidden,
        }


class Field:
    def __init__(self, env, name, type=None, options=None):
        if type is None:
            type = env.types["string"]
        if options is None:
            options = {}
        self.options = options
        self.name = name
        label_i18n = get_i18n_block(options, "label")
        if not label_i18n:
            label_i18n = {"en": name.replace("_", " ").strip().capitalize()}
        self.label_i18n = label_i18n
        self.description_i18n = get_i18n_block(options, "description") or None
        self.default = options.get("default")
        self.type = type(env, options)

    @property
    def label(self):
        return self.label_i18n.get("en")

    def to_json(self, pad, record=None, alt=PRIMARY_ALT):
        return {
            "name": self.name,
            "label": self.label,
            "label_i18n": self.label_i18n,
            "hide_label": bool_from_string(
                self.options.get("hide_label"), default=False
            ),
            "description_i18n": self.description_i18n,
            "type": self.type.to_json(pad, record, alt),
            "default": self.default,
            "alts_enabled": bool_from_string(
                self.options.get("alts_enabled"), default=None
            ),
        }

    def deserialize_value(self, value, pad=None):
        raw_value = RawValue(self.name, value, field=self, pad=pad)
        return self.type.value_from_raw_with_default(raw_value)

    def serialize_value(self, value):
        return self.type.value_to_raw(value)

    def __repr__(self):
        return f"<{self.__class__.__name__} {self.name!r} type={self.type!r}>"


def _iter_all_fields(obj):
    for name in sorted(x for x in obj.field_map if x[:1] == "_"):
        yield obj.field_map[name]
    yield from obj.fields


class DataModel:
    def __init__(
        self,
        env,
        id,
        name_i18n,
        *,
        label_i18n=None,
        filename=None,
        hidden=None,
        protected=None,
        child_config=None,
        attachment_config=None,
        pagination_config=None,
        fields=None,
        primary_field=None,
        parent=None,
    ):
        self.env = env
        self.filename = filename
        self.id = id
        self.name_i18n = name_i18n
        self.label_i18n = label_i18n
        if hidden is None:
            hidden = False
        self.hidden = hidden
        if protected is None:
            protected = False
        self.protected = protected
        if child_config is None:
            child_config = ChildConfig()
        self.child_config = child_config
        if attachment_config is None:
            attachment_config = AttachmentConfig()
        self.attachment_config = attachment_config
        if pagination_config is None:
            pagination_config = PaginationConfig(env)
        self.pagination_config = pagination_config
        if fields is None:
            fields = []
        self.fields = fields
        if primary_field is None and fields:
            primary_field = fields[0].name
        self.primary_field = primary_field
        self.parent = parent

        # This is a mapping of the key names to the actual field which
        # also includes the system fields.  This is primarily used for
        # fast internal operations but also the admin.
        self.field_map = {x.name: x for x in fields}
        for key, (ty, opts) in system_fields.items():
            self.field_map[key] = Field(env, name=key, type=ty, options=opts)

        self._child_slug_tmpl = None
        self._child_replacements = None
        self._label_tmpls = {}

    @property
    def name(self):
        name = (self.name_i18n or {}).get("en")
        return name or self.id.title().replace("_", " ")

    @property
    def label(self):
        return (self.label_i18n or {}).get("en")

    def to_json(self, pad, record=None, alt=PRIMARY_ALT):
        """Describes the datamodel as JSON data."""
        return {
            "filename": self.filename,
            "alt": alt,
            "id": self.id,
            "name": self.name,
            "name_i18n": self.name_i18n,
            "primary_field": self.primary_field,
            "label": self.label,
            "label_i18n": self.label_i18n,
            "hidden": self.hidden,
            "protected": self.protected,
            "child_config": self.child_config.to_json(),
            "attachment_config": self.attachment_config.to_json(),
            "pagination_config": self.pagination_config.to_json(),
            "fields": [x.to_json(pad, record, alt) for x in _iter_all_fields(self)],
        }

    def format_record_label(self, record, lang="en"):
        """Returns the label for a given record."""
        label = self.label_i18n.get(lang)
        if label is None:
            return None

        tmpl = self._label_tmpls.get(lang)
        if tmpl is None:
            tmpl = (label, FormatExpression(self.env, label))
            self._label_tmpls[lang] = tmpl

        try:
            return tmpl[1].evaluate(record.pad, this=record)
        except Exception:
            # XXX: log
            return None

    def get_default_child_slug(self, pad, data):
        """Formats out the child slug."""
        slug_format = self.child_config.slug_format
        if slug_format is None:
            return data["_id"]

        if self._child_slug_tmpl is None or self._child_slug_tmpl[0] != slug_format:
            self._child_slug_tmpl = (
                slug_format,
                FormatExpression(self.env, slug_format),
            )

        try:
            return "_".join(
                self._child_slug_tmpl[1].evaluate(pad, this=data).strip().split()
            ).strip("/")
        except Exception as exc:
            reporter.report_generic(f"Failed to expand child slug_format: {exc}")
            return "temp-" + slugify(data["_id"])

    def get_default_template_name(self):
        return self.id + ".html"

    @property
    def has_own_children(self):
        return self.child_config.replaced_with is None and self.child_config.enabled

    @property
    def has_own_attachments(self):
        return self.attachment_config.enabled

    def get_child_replacements(self, record):
        """Returns the query that should be used as replacement for the
        actual children.
        """
        replaced_with = self.child_config.replaced_with
        if replaced_with is None:
            return None

        if (
            self._child_replacements is None
            or self._child_replacements[0] != replaced_with
        ):
            self._child_replacements = (
                replaced_with,
                Expression(self.env, replaced_with),
            )

        return self._child_replacements[1].evaluate(record.pad, this=record)

    def process_raw_data(self, raw_data, pad=None):
        rv = {}
        for field in self.field_map.values():
            value = raw_data.get(field.name)
            rv[field.name] = field.deserialize_value(value, pad=pad)
        rv["_model"] = self.id
        return rv

    def __repr__(self):
        return f"<{self.__class__.__name__} {self.id!r}>"


class FlowBlockModel:
    def __init__(
        self,
        env,
        id,
        name_i18n,
        *,
        filename=None,
        fields=None,
        order=None,
        button_label=None,
    ):
        self.env = env
        self.id = id
        self.name_i18n = name_i18n
        self.filename = filename
        if fields is None:
            fields = []
        self.fields = fields
        if order is None:
            order = 100
        self.order = order
        self.button_label = button_label

        self.field_map = {x.name: x for x in fields}
        self.field_map["_flowblock"] = Field(
            env, name="_flowblock", type=env.types["string"]
        )

    @property
    def name(self):
        return self.name_i18n.get("en") or self.id.title().replace("_", " ")

    def to_json(self, pad, record=None, alt=PRIMARY_ALT):
        return {
            "id": self.id,
            "name": self.name,
            "name_i18n": self.name_i18n,
            "filename": self.filename,
            "fields": [
                x.to_json(pad, record, alt)
                for x in _iter_all_fields(self)
                if x.name != "_flowblock"
            ],
            "order": self.order,
            "button_label": self.button_label,
        }

    def process_raw_data(self, raw_data, pad=None):
        rv = {}
        for field in self.field_map.values():
            value = raw_data.get(field.name)
            rv[field.name] = field.deserialize_value(value, pad=pad)
        rv["_flowblock"] = self.id
        return rv

    def __repr__(self):
        return f"<{self.__class__.__name__} {self.id!r}>"


def fielddata_from_ini(inifile):
    return [
        (
            sect.split(".", 1)[1],
            inifile.section_as_dict(sect),
        )
        for sect in inifile.sections()
        if sect.startswith("fields.")
    ]


def datamodel_data_from_ini(id, inifile):
    def _parse_order(value):
        value = (value or "").strip()
        if not value:
            return None
        return [x for x in [x.strip() for x in value.strip().split(",")] if x]

    return {
        "filename": inifile.filename,
        "id": id,
        "parent": inifile.get("model.inherits"),
        "name_i18n": get_i18n_block(inifile, "model.name"),
        "label_i18n": get_i18n_block(inifile, "model.label"),
        "primary_field": inifile.get("model.primary_field"),
        "hidden": inifile.get_bool("model.hidden", default=None),
        "protected": inifile.get_bool("model.protected", default=None),
        "child_config": {
            "enabled": inifile.get_bool("children.enabled", default=None),
            "slug_format": inifile.get("children.slug_format"),
            "model": inifile.get("children.model"),
            "order_by": _parse_order(inifile.get("children.order_by")),
            "replaced_with": inifile.get("children.replaced_with"),
            "hidden": inifile.get_bool("children.hidden", default=None),
        },
        "attachment_config": {
            "enabled": inifile.get_bool("attachments.enabled", default=None),
            "model": inifile.get("attachments.model"),
            "order_by": _parse_order(inifile.get("attachments.order_by")),
            "hidden": inifile.get_bool("attachments.hidden", default=None),
        },
        "pagination_config": {
            "enabled": inifile.get_bool("pagination.enabled", default=None),
            "per_page": inifile.get_int("pagination.per_page"),
            "url_suffix": inifile.get("pagination.url_suffix"),
            "items": inifile.get("pagination.items"),
        },
        "fields": fielddata_from_ini(inifile),
    }


def flowblock_data_from_ini(id, inifile):
    return {
        "filename": inifile.filename,
        "id": id,
        "name_i18n": get_i18n_block(inifile, "block.name"),
        "fields": fielddata_from_ini(inifile),
        "order": inifile.get_int("block.order"),
        "button_label": inifile.get("block.button_label"),
    }


def fields_from_data(env, data, parent_fields=None):
    fields = []
    known_fields = set()

    for name, options in data:
        ty = env.types[options.get("type", "string")]
        fields.append(Field(env=env, name=name, type=ty, options=options))
        known_fields.add(name)

    if parent_fields is not None:
        prepended_fields = [
            field for field in parent_fields if field.name not in known_fields
        ]
        fields = prepended_fields + fields

    return fields


def datamodel_from_data(env, model_data, parent=None):
    def get_value(key):
        path = key.split(".")
        node = model_data
        for item in path:
            node = node.get(item)
        if node is not None:
            return node
        if parent is not None:
            node = parent
            for item in path:
                node = getattr(node, item)
            return node
        return None

    fields = fields_from_data(
        env, model_data["fields"], parent and parent.fields or None
    )

    return DataModel(
        env,
        # data that never inherits
        filename=model_data["filename"],
        id=model_data["id"],
        parent=parent,
        name_i18n=model_data["name_i18n"],
        primary_field=model_data["primary_field"],
        # direct data that can inherit
        label_i18n=get_value("label_i18n"),
        hidden=get_value("hidden"),
        protected=get_value("protected"),
        child_config=ChildConfig(
            enabled=get_value("child_config.enabled"),
            slug_format=get_value("child_config.slug_format"),
            model=get_value("child_config.model"),
            order_by=get_value("child_config.order_by"),
            replaced_with=get_value("child_config.replaced_with"),
            hidden=get_value("child_config.hidden"),
        ),
        attachment_config=AttachmentConfig(
            enabled=get_value("attachment_config.enabled"),
            model=get_value("attachment_config.model"),
            order_by=get_value("attachment_config.order_by"),
            hidden=get_value("attachment_config.hidden"),
        ),
        pagination_config=PaginationConfig(
            env,
            enabled=get_value("pagination_config.enabled"),
            per_page=get_value("pagination_config.per_page"),
            url_suffix=get_value("pagination_config.url_suffix"),
            items=get_value("pagination_config.items"),
        ),
        fields=fields,
    )


def flowblock_from_data(env, block_data):
    return FlowBlockModel(
        env,
        filename=block_data["filename"],
        id=block_data["id"],
        name_i18n=block_data["name_i18n"],
        fields=fields_from_data(env, block_data["fields"]),
        order=block_data["order"],
        button_label=block_data["button_label"],
    )


def iter_inis(path):
    try:
        for filename in os.listdir(path):
            if not filename.endswith(".ini") or filename[:1] in "_.":
                continue
            fn = os.path.join(path, filename)
            if os.path.isfile(fn):
                base = filename[:-4]
                base = base.encode("utf-8").decode("ascii", "replace")
                inifile = IniFile(fn)
                yield base, inifile
    except OSError as e:
        if e.errno != errno.ENOENT:
            raise


def load_datamodels(env):
    """Loads the datamodels for a specific environment."""
    # Models will override previous loaded models with the same name
    # So models paths are loaded in reverse order
    paths = list(reversed(env.theme_paths)) + [env.root_path]
    paths = [os.path.join(p, "models") for p in paths]
    data = {}

    for path in paths:
        for model_id, inifile in iter_inis(path):
            data[model_id] = datamodel_data_from_ini(model_id, inifile)

    rv = {}

    def get_model(model_id):
        model = rv.get(model_id)
        if model is not None:
            return model
        if model_id in data:
            return create_model(model_id)
        return None

    def create_model(model_id):
        model_data = data.get(model_id)
        if model_data is None:
            raise RuntimeError(f"Model {model_id!r} not found")

        if model_data["parent"] is not None:
            parent = get_model(model_data["parent"])
        else:
            parent = None

        rv[model_id] = mod = datamodel_from_data(env, model_data, parent)
        return mod

    for model_id in data:
        get_model(model_id)

    rv["none"] = DataModel(env, "none", {"en": "None"}, hidden=True)

    return rv


def load_flowblocks(env):
    """Loads all the flow blocks for a specific environment."""
    # Flowblocks will override previous loaded flowblocks with the same name
    # So paths are loaded in reverse order
    paths = list(reversed(env.theme_paths)) + [env.root_path]
    paths = [os.path.join(p, "flowblocks") for p in paths]
    rv = {}

    for path in paths:
        for flowblock_id, inifile in iter_inis(path):
            rv[flowblock_id] = flowblock_from_data(
                env, flowblock_data_from_ini(flowblock_id, inifile)
            )

    return rv


system_fields = {}


def add_system_field(name, **opts):
    opts = dict(generate_i18n_kvs(**opts))
    ty = builtin_types[opts.pop("type")]
    system_fields[name] = (ty, opts)


# The full path of the record
add_system_field("_path", type="string")

# The local ID (within a folder) of the record
add_system_field("_id", type="string")

# The global ID (within a folder) of the record
add_system_field("_gid", type="string")

# The alt key that identifies this record
add_system_field("_alt", type="string")

# The alt key for the file that was actually referenced.
add_system_field("_source_alt", type="string")

# the model that defines the data of the record
add_system_field("_model", type="string")

# the template that should be used for rendering if not hidden
add_system_field(
    "_template",
    type="string",
    label_i18n="TEMPLATE",
    width="1/2",
    addon_label="[[code]]",
)

# the slug that should be used for this record.  This is added below the
# slug of the parent.
add_system_field("_slug", type="slug", label_i18n="URL_SLUG", width="1/2")

# This can be used to hide an individual record.
add_system_field(
    "_hidden",
    type="boolean",
    label_i18n="HIDE_PAGE",
    checkbox_label_i18n="HIDE_PAGE_EXPLANATION",
)

# This marks a page as undiscoverable.
add_system_field(
    "_discoverable",
    type="boolean",
    default="yes",
    label_i18n="PAGE_IS_DISCOVERABLE",
    checkbox_label_i18n="PAGE_IS_DISCOVERABLE_EXPLANATION",
)

# Useful fields for attachments.
add_system_field("_attachment_for", type="string")
add_system_field(
    "_attachment_type",
    type="string",
    label_i18n="ATTACHMENT_TYPE",
    addon_label="[[paperclip]]",
)
