"""
Generate CMOR CVs table

Also generates the files in the split view.
"""

import datetime as dt
import itertools
import json
import re
import warnings
from functools import cache, partial
from pathlib import Path
from typing import Annotated, Any, TypeAlias

import esgvoc.api as ev_api
import requests
import typer
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

CMOR_MAX_STRING_LENGTH = 1023
"""
Maximum string length supported by CMOR

See https://github.com/WCRP-CMIP/cmip7-cmor-tables/issues/112
"""


def check_within_cmor_max_string_length(obj: Any, loc: str = "") -> None:
    """
    Recursively check that every string in `obj` is within `CMOR_MAX_STRING_LENGTH`

    This covers plain strings, dictionary keys and list/tuple entries.
    """
    if isinstance(obj, str):
        if len(obj) > CMOR_MAX_STRING_LENGTH:
            msg = (
                f"String at {loc or '<root>'} has length {len(obj)}, "
                f"which exceeds {CMOR_MAX_STRING_LENGTH=}. Value: {obj!r}"
            )
            raise ValueError(msg)

    elif isinstance(obj, dict):
        for k, v in obj.items():
            if isinstance(k, str) and len(k) > CMOR_MAX_STRING_LENGTH:
                msg = (
                    f"Dictionary key at {loc or '<root>'} has length {len(k)}, "
                    f"which exceeds {CMOR_MAX_STRING_LENGTH=}. Key: {k!r}"
                )
                raise ValueError(msg)

            check_within_cmor_max_string_length(v, f"{loc}.{k}" if loc else str(k))

    elif isinstance(obj, (list, tuple)):
        for i, v in enumerate(obj):
            check_within_cmor_max_string_length(v, f"{loc}[{i}]")


def cut_to_length(
    text: str, length: int = CMOR_MAX_STRING_LENGTH, parts_delimiter: str | None = ". "
) -> str:
    """
    Cut a given string to a specific length

    The `parts_delimiter` ensures that the string is only cut at specific boundaries.
    The default tries to keep full sentences together.
    If not supplied, we simply cut the string to the given length,
    irrespective of whether that cuts sentences in half (for example).
    """
    if len(text) <= length:
        # Already short enough, do nothing
        return text

    if parts_delimiter is None:
        return text[:length]

    parts_l = []
    suffix = parts_delimiter.strip()
    total_length = 0
    for part in text.split(parts_delimiter):
        to_add = len(part)
        if parts_l:
            # Include the delimiter which will get inserted too
            to_add += len(parts_delimiter)

        if total_length + to_add + len(suffix) > length:
            break

        parts_l.append(part)
        total_length += to_add

    if not parts_l:
        msg = f"Could not cut the input to {length=} with {parts_delimiter=}. Input={text!r}"
        raise ValueError(msg)

    res = parts_delimiter.join(parts_l)
    res = f"{res}{suffix}"

    return res


AllowedDict: TypeAlias = dict[str, Any]
"""
Dictionary (key-value pairs). The keys define the allowed values for the given attribute

The values can be anything,
they generally provide extra information about the meaning of the keys.
"""

RegularExpressionValidators: TypeAlias = list[str]
"""
List of values which are assumed to be regular expressions

Attribute values provided by teams are then validated
against these regular expressions.
"""


class CMORDRSDefinition(BaseModel):
    """
    CMOR data reference syntax (DRS) definition
    """

    directory_path_example: str
    """
    Example of a directory path that follows this DRS
    """

    directory_path_template: str
    """
    Template to use for generating directory paths
    """

    filename_example: str
    """
    Example of a filename path that follows this DRS
    """

    filename_template: str
    """
    Template to use for generating filename paths
    """


class CMORExperimentDefinition(BaseModel):
    """
    CMOR experiment definition
    """

    activity_id: list[str]
    """
    Activity ID to which this experiment belongs
    """

    # required_model_components: RegularExpressionValidators
    # """
    # Required model components to run this experiment
    # """
    #
    # additional_allowed_model_components: RegularExpressionValidators
    # """
    # Additional model components that can be included when running this experiment
    # """

    description: str
    """
    Experiment description
    """

    experiment: str
    """
    Experiment description (same as description)
    """

    # TODO: check if we should switch to timestamps
    start_year: int | None
    """Start year of the experiment"""

    end_year: int | None
    """End year of the experiment"""

    min_number_yrs_per_sim: int | None
    """Minimum number of years of simulation required"""

    experiment_id: str
    """
    Experiment ID
    """

    # # Not a thing anymore, hence remove
    # host_collection: str
    # """
    # Host collection of this experiment
    # """

    parent_activity_id: list[str]
    """Activity ID for the parent of this experiment"""

    parent_experiment_id: list[str]
    """Experiment ID for the parent of this experiment"""

    tier: int
    """
    Tier i.e. priority of this experiment

    Lower is higher priority i.e. 1 is the highest priority
    """


class CMORFrequencyDefinition(BaseModel):
    """
    CMOR frequency definition
    """

    approx_interval: float
    """
    Approximate interval in days
    """

    approx_interval_warning: float = Field(ge=0, le=1)
    """
    Threshold for raising warnings about the consistency between data and `approx_interval`

    If the absolute difference between the actual reporting interval
    and the data's time interval (i.e. frequency)
    is greater than `approx_interval_warning * approx_interval`,
    then a warning will be raised by CMOR.
    For example, for an approximate interval of 25 days and `approx_interval_warning=0.5`,
    an interval less than 12.5 days or greater than 37.5 days will raise a warning.
    """

    approx_interval_error: float = Field(ge=0, le=1)
    """
    Threshold for raising errors about the consistency between data and `approx_interval`

    If the absolute difference between the actual reporting interval
    and the data's time interval (i.e. frequency)
    is greater than `approx_interval_error * approx_interval`,
    then an error will be raised by CMOR.
    For example, for an approximate interval of 24 days and `approx_interval_error=0.75`,
    an interval less than 6.0 days or greater than 42.0 days will raise an error.
    """

    description: str
    """
    Description
    """


class CMORSpecificLicenseDefinition(BaseModel):
    """
    CMOR-style specific license definition
    """

    license_type: str
    """
    Type of the license
    """

    license_url: HttpUrl
    """
    URL that describes the license
    """


class CMORLicenseDefinition(BaseModel):
    """
    CMOR license definition
    """

    license_id: dict[str, CMORSpecificLicenseDefinition]
    """
    Supported licenses
    """

    # (rightfully) not in esgvoc
    license_template: str
    """
    Template for writing license strings
    """


class CMORModelComponentDefintion(BaseModel):
    """
    CMOR model component definition
    """

    description: str
    """Description"""

    native_nominal_resolution: str
    """Native nominal resolution of this component"""


class CMORSourceDefinition(BaseModel):
    """
    CMOR source definition

    The meaning of 'source' is a bit fuzzy across projects,
    but for CMIP phases it refers to the model which provided the simulation.
    """

    source: str
    """
    Source information

    Combination of source name and information about each model component
    """

    source_id: str
    """
    Source ID for `self`
    """


def convert_none_value_to_empty_string(v: Any) -> Any:
    return v if v is not None else ""


def remove_none_values_from_dict(inv: dict[str, Any]) -> dict[str, Any]:
    res = {}
    for k, v in inv.items():
        if isinstance(v, list):
            res[k] = [convert_none_value_to_empty_string(vv) for vv in v]

        elif isinstance(v, dict):
            res[k] = remove_none_values_from_dict(v)

        else:
            res[k] = convert_none_value_to_empty_string(v)

    return res


class CMORCVsTable(BaseModel):
    """
    Representation of the JSON table required by CMOR for CVs

    Note a potential source of confusion:
    this table actually defines the CVs for CMOR,
    as well as CMOR's behaviour.
    For example, the behaviour of CMOR varies
    depending on whether each value is a single value,
    a dict or a list.
    So this schema/data model will not be general,
    it instead varies based on the specific requirements of the project.

    CMOR also takes in variable tables,
    as well as a user input table.
    This model doesn't consider those tables
    or their interactions with this table at the moment.
    """

    model_config = ConfigDict(extra="forbid")

    DRS: CMORDRSDefinition
    """
    CMOR definition of the data reference syntax
    """

    # Note; not a required global attribute hence dropped
    # archive_id: AllowedDict
    # """
    # Allowed values of `archive_id`
    # """

    # Conventions added in following https://github.com/PCMDI/cmor/issues/937
    Conventions: RegularExpressionValidators
    """
    Allowed values of `Conventions`
    """

    activity_id: AllowedDict
    """
    Allowed values of `activity_id`
    """

    area_label: AllowedDict
    """
    Allowed values of `area_label`
    """

    branding_suffix: str
    """
    Template for branding suffix
    """

    creation_date: RegularExpressionValidators
    """
    Allowed patterns for `creation_date`
    """

    data_specs_version: str
    """
    Allowed value of `data_specs_version`
    """

    drs_specs: str
    """
    Allowed value of `drs_specs`
    """

    experiment_id: dict[str, CMORExperimentDefinition]
    """
    CMOR-style experiment definitions
    """

    forcing_index: RegularExpressionValidators
    """
    Allowed patterns for `forcing_index`
    """

    frequency: AllowedDict
    """
    Allowed values of `frequency`
    """

    grid_label: AllowedDict
    """
    Allowed values of `grid_label`
    """

    horizontal_label: AllowedDict
    """
    Allowed values of `horizontal_label`
    """

    initialization_index: RegularExpressionValidators
    """
    Allowed patterns for `initialization_index`
    """

    institution_id: AllowedDict
    """
    Allowed values of `institution_id`
    """

    license: CMORLicenseDefinition
    """
    CMOR-style license definition
    """

    mip_era: str
    """
    Allowed value of `mip_era`
    """

    nominal_resolution: RegularExpressionValidators
    """
    Allowed values of `nominal_resolution`
    """

    physics_index: RegularExpressionValidators
    """
    Allowed patterns for `physics_index`
    """

    product: AllowedDict
    """
    Allowed values of `product`
    """

    realization_index: RegularExpressionValidators
    """
    Allowed patterns for `realization_index`
    """

    realm: AllowedDict
    """
    Allowed values of `realm`
    """

    region: AllowedDict
    """
    Allowed values of `region`
    """

    required_global_attributes: list[str]
    """
    Required global attributes
    """

    source_id: dict[str, CMORSourceDefinition]
    """
    CMOR-style source definitions
    """

    temporal_label: AllowedDict
    """
    Allowed values of `temporal_label`
    """

    tracking_id: RegularExpressionValidators
    """
    Allowed patterns for `tracking_id`
    """

    tracking_prefix: str
    """
    Tracking prefix to use when writing data
    """

    variant_label: RegularExpressionValidators
    """
    Allowed patterns for `variant_label`
    """

    vertical_label: AllowedDict
    """
    Allowed values of `vertical_label`
    """

    @model_validator(mode="after")
    def validate_string_lengths(self) -> "CMORCVsTable":
        """
        Validate that every string used `CMOR_MAX_STRING_LENGTH`

        This walks the full (serialised) table, so it covers all strings,
        all `AllowedDict` keys and all `RegularExpressionValidators` entries,
        including those in nested sub-objects.
        """
        check_within_cmor_max_string_length(self.model_dump(mode="json"))

        return self

    def to_cvs_json(
        self, top_level_key: str = "CV"
    ) -> dict[str, dict[str, str, AllowedDict, RegularExpressionValidators]]:
        md = self.model_dump(mode="json")

        # # Unclear why this is done for some keys and not others,
        # # which makes reasoning hard.
        # to_hyphenise = list(md["drs"].keys())
        # for k in to_hyphenise:
        #     md["drs"][k.replace("_", "-")] = md["drs"].pop(k)
        #
        # md["experiment_id"] = {k: v.to_json() for k, v in self.experiment_id.experiments.items()}
        # # More fun
        # md["DRS"] = md.pop("drs")

        md_no_none = remove_none_values_from_dict(md)

        cvs_json = {top_level_key: md_no_none}

        return cvs_json


def get_project_attribute_property(
    attribute_value: str,
    attribute_to_match: str,
    ev_project: ev_api.project_specs.ProjectSpecs,
    exclude_parent_matches: bool = True,
    exclude: tuple[str, ...] = (
        "external_variables",
        "institution",
    ),
) -> ev_api.project_specs.AttributeProperty:
    for ev_attribute_property in ev_project.attr_specs:
        ev_field_name = ev_attribute_property.attr_field_name
        if (
            exclude_parent_matches
            and ev_field_name is not None
            and ("parent" in ev_field_name)
        ):
            continue

        if exclude and ev_field_name is not None and ev_field_name in exclude:
            continue

        if getattr(ev_attribute_property, attribute_to_match) == attribute_value:
            break

    else:
        msg = (
            f"Nothing in attr_specs had {attribute_to_match} equal to {attribute_value}"
        )
        raise KeyError(msg)

    return ev_attribute_property


def get_allowed_dict_for_attribute(
    attribute_name: str, ev_project: ev_api.project_specs.ProjectSpecs
) -> AllowedDict:
    ev_attribute_property = get_project_attribute_property(
        attribute_value=attribute_name,
        attribute_to_match="attr_field_name",
        ev_project=ev_project,
    )
    attribute_instances = ev_api.get_all_terms_in_collection(
        ev_project.project_id, ev_attribute_property.source_collection
    )

    res = {v.drs_name: v.description for v in attribute_instances}

    return res


def convert_python_regex_to_cmor_regex(inv: str) -> list[str]:
    # Not ideal that we have to do this ourselves,
    # but I can't see another way
    # (it doesn't make sense to use posix regex in the CV JSON
    # because then esgvoc's Python API won't work)

    if "|" in inv:
        or_sections = re.findall(r"\([^|(]*\|[^)]*\)", inv)
        if not or_sections:
            raise AssertionError(inv)

        substitution_components = []
        for or_section in or_sections:
            tmp = []
            for subs in (v.strip("()") for v in or_section.split("|")):
                tmp.append((or_section, subs))

            substitution_components.append(tmp)

        to_substitute = []
        for substitution_set in itertools.product(*substitution_components):
            filled = inv
            for old, new in substitution_set:
                filled = filled.replace(old, new)

            to_substitute.append(filled)

    else:
        to_substitute = [inv]

    res = []
    for start in to_substitute:
        # Get rid of Python style capturing groups.
        # Super brittle, might break if there are brackets inside the caught exptmpsion.
        # We'll have to fix as we find problems, regex is annoyingly complicated.
        tmp = re.sub(r"\(\?P\<[^>]*\>([^)]*)\)", r"\1", start)

        # Other things we seem to have to change
        tmp = tmp.replace("{", r"\{")
        tmp = tmp.replace("}", r"\}")
        tmp = tmp.replace("(", r"\(")
        tmp = tmp.replace(")", r"\)")
        tmp = tmp.replace(r"\d", "[[:digit:]]")
        tmp = tmp.replace("+", r"\{1,\}")
        tmp = tmp.replace("?", r"\{0,\}")

        res.append(tmp)

    return res


def get_regular_expression_validator_for_attribute(
    attribute_property: ev_api.project_specs.AttributeProperty,
    ev_project: ev_api.project_specs.ProjectSpecs,
) -> RegularExpressionValidators:
    attribute_instances = ev_api.get_all_terms_in_collection(
        ev_project.project_id, attribute_property.source_collection
    )
    res = []
    for v in attribute_instances:
        res.extend(convert_python_regex_to_cmor_regex(v.regex))

    return res


def get_template_for_composite_attribute(
    attribute_name: str, ev_project: ev_api.project_specs.ProjectSpecs
) -> str:
    ev_attribute_property = get_project_attribute_property(
        attribute_value=attribute_name,
        attribute_to_match="attr_field_name",
        ev_project=ev_project,
    )
    terms = ev_api.get_all_terms_in_collection(
        ev_project.project_id, ev_attribute_property.source_collection
    )
    if len(terms) > 1:
        raise AssertionError(terms)

    term = terms[0]

    parts_l = []
    for v in term.parts:
        va = get_project_attribute_property(v.type, "source_collection", ev_project)
        parts_l.append(f"<{va.attr_field_name}>")

    if term.separator != "-":
        msg = f"CMOR only supports '-' as a separator, received {term.separator=} for {term=}"
        raise NotImplementedError(msg)

    res = "".join(parts_l)

    return res


def get_single_allowed_value_for_attribute(
    attribute_name: str, ev_project: ev_api.project_specs.ProjectSpecs
) -> str:
    ev_attribute_property = get_project_attribute_property(
        attribute_value=attribute_name,
        attribute_to_match="attr_field_name",
        ev_project=ev_project,
    )
    terms = ev_api.get_all_terms_in_collection(
        ev_project.project_id, ev_attribute_property.source_collection
    )
    if len(terms) > 1:
        raise AssertionError(terms)

    term = terms[0]

    res = term.drs_name

    return res


def get_cmor_license_definition(
    source_collection: str, ev_project: ev_api.project_specs.ProjectSpecs
) -> CMORLicenseDefinition:
    terms = ev_api.get_all_terms_in_collection(ev_project.project_id, source_collection)

    license_ids_d = {
        v.drs_name: CMORSpecificLicenseDefinition(
            license_type=v.description,
            license_url=v.url,
        )
        for v in terms
    }

    res = CMORLicenseDefinition(
        license_id=license_ids_d,
        license_template=(
            "<license_id>; CMIP7 data produced by <institution_id> "
            "is licensed under a <license_type> License (<license_url>). "
            "Consult https://wcrp-cmip.github.io/cmip7-guidance/docs/CMIP7/Guidance_for_users/#2-terms-of-use-and-citations-requirements "  # noqa: E501
            "for terms of use governing CMIP7 output, "
            "including citation requirements and proper acknowledgment. "
            "The data producers and data providers make no warranty, "
            "either express or implied, including, but not limited to, "
            "warranties of merchantability and fitness for a particular purpose. "
            "All liabilities arising from the supply of the information "
            "(including any liability arising in negligence) "
            "are excluded to the fullest extent permitted by law."
        ),
    )

    return res


def get_approx_interval(interval: float, units: str) -> float:
    try:
        import pint

        ur = pint.get_application_registry()
    except ImportError as exc:
        msg = "Missing optional dependency `pint`, please install"
        raise ImportError(msg) from exc

    if units == "month":
        # Special case, month is 30 days
        res = interval * 30.0
    else:
        res = ur.Quantity(interval, units).to("day").m

    return res


def get_cmor_experiment_id_definitions(
    source_collection: str, ev_project: ev_api.project_specs.ProjectSpecs
) -> dict[str, CMORExperimentDefinition]:
    terms = ev_api.get_all_terms_in_collection(ev_project.project_id, source_collection)

    get_term = partial(ev_api.get_term_in_project, ev_project.project_id)
    res = {}
    for v in terms:
        if isinstance(v.start_timestamp, dt.datetime):
            start_year = v.start_timestamp.year
        elif not v.start_timestamp:
            start_year = v.start_timestamp
        else:
            msg = (
                f"`start_timestamp` not correctly serialised for `{v.drs_name}`. "
                f"{type(v.start_timestamp)=}. "
                "Issue in the underlying CVs?"
            )
            raise TypeError(msg)

        if isinstance(v.end_timestamp, dt.datetime):
            end_year = v.end_timestamp.year
        elif not v.end_timestamp:
            end_year = v.end_timestamp
        else:
            msg = (
                f"`end_timestamp` not correctly serialised for `{v.drs_name}`. "
                f"{type(v.end_timestamp)=}. "
                "Issue in the underlying CVs?"
            )
            raise TypeError(msg)

        description_cut = cut_to_length(v.description)

        res[v.drs_name] = CMORExperimentDefinition(
            activity_id=[get_term(v.activity).drs_name],
            # required_model_components=[vv.drs_name for vv in v.required_model_components],
            # additional_allowed_model_components=[vv.drs_name for vv in v.additional_allowed_model_components],
            description=description_cut,
            experiment=description_cut,
            start_year=start_year,
            end_year=end_year,
            min_number_yrs_per_sim=v.min_number_yrs_per_sim,
            experiment_id=v.drs_name,
            parent_activity_id=[v.parent_activity.drs_name]
            if v.parent_activity
            else [],
            parent_experiment_id=[v.parent_experiment.drs_name]
            if v.parent_experiment
            else [],
            tier=v.tier,
        )

    return res


def get_cmor_nominal_resolution_defintions(
    source_collection: str, ev_project: ev_api.project_specs.ProjectSpecs
) -> list[str]:
    try:
        import pint

        ur = pint.get_application_registry()
    except ImportError as exc:
        msg = "Missing optional dependency `pint`, please install"
        raise ImportError(msg) from exc

    terms = ev_api.get_all_terms_in_collection(ev_project.project_id, source_collection)
    res = []
    for t in terms:
        value_f = float(t.value)
        size_km = ur.Quantity(value_f, t.unit).to("km").m
        if int(size_km) == size_km:
            allowed = f"{size_km:.0f} km"
        else:
            allowed = f"{size_km:.1f} km"

        res.append(allowed)

    return sorted(res)


EMD_MODEL_BASE_URL = (
    "https://raw.githubusercontent.com/WCRP-CMIP/"
    "Essential-Model-Documentation/src-data/model"
)
"""
Base URL for the model files in the Essential Model Documentation (EMD)

See https://github.com/WCRP-CMIP/Essential-Model-Documentation/tree/src-data/model
"""


@cache
def get_emd_session() -> requests.Session:
    """
    Get a [`requests.Session`][] configured to retry EMD requests

    We hit the EMD (see [`EMD_MODEL_BASE_URL`][]) once per source ID,
    which can trip GitHub's rate limits and return HTTP 429 (too many requests).
    The session retries on 429 and 5xx responses using an exponential backoff,
    and respects the `Retry-After` header when the server provides one.

    The session is cached so connections are reused across calls.
    """
    retry = Retry(
        total=5,
        backoff_factor=1.0,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=("GET",),
        respect_retry_after_header=True,
    )
    adapter = HTTPAdapter(max_retries=retry)

    session = requests.Session()
    session.mount("https://", adapter)
    session.mount("http://", adapter)

    return session


def is_emd_grid_part(part: str) -> bool:
    """
    Is `part` a grid part of an EMD model component?

    A grid part is one of:

    - a horizontal ("h") or vertical ("v") token followed by one or more digits,
      e.g. "h125" or "v40"
    - "no-horizontal" or "no-vertical", used when a component has no such grid
    """
    if part in ("no-horizontal", "no-vertical"):
        return True

    return len(part) > 1 and part[0] in ("h", "v") and part[1:].isdigit()


def get_source_suffix_from_emd(drs_name: str) -> str:
    """
    Get the ``source`` suffix for a given source ID from the Essential Model Documentation

    We read the relevant model file directly from the EMD
    (see [`EMD_MODEL_BASE_URL`][]).
    This is used as a fallback for the cases where esgvoc doesn't
    (yet) expose the model components for the source.

    The EMD file name is the lower-cased source ID (`drs_name`).
    We read the model components from that file,
    then parse each component into a "<realm>: <model name>" pair
    before joining all the resulting pairs with "; ".

    Each component is of the form "<realm>_<model name>_<grid parts>",
    where the realm can itself contain underscores or hyphens
    (e.g. "land_surface" or "land-surface").
    The grid parts are a horizontal and/or vertical token
    (see [`is_emd_grid_part`][]), e.g. "h125", "v40", "no-horizontal" or
    "no-vertical". There may be more than one or, for some components, only one
    (e.g. "sea-ice_fesim-2-7_h114_no-vertical" has a horizontal part
    and an explicit "no-vertical").
    We therefore strip off any trailing grid parts,
    then treat the last of the remaining parts as the model name
    and re-join the rest to recover the realm.
    Any "_" in the realm is then replaced with "-" for the output.

    As a robustness check, we check that each hyphenated realm
    is in the file's "dynamic_components".
    A warning (rather than an error) is raised if a realm can't be found there.
    """
    session = get_emd_session()

    # The EMD file names are the lower-cased source ID
    url = f"{EMD_MODEL_BASE_URL}/{drs_name.lower()}.json"
    response = session.get(url, timeout=30)
    response.raise_for_status()
    model_info = response.json()

    # The key holding the model components has been called both
    # "model_components" and "component_configs" across EMD files,
    # so support both.
    for key in ("model_components", "component_configs"):
        if key in model_info:
            model_components = model_info[key]
            break
    else:
        msg = (
            f"Could not find model components for {drs_name!r} at {url}. "
            f"Available keys: {sorted(model_info)}"
        )
        raise KeyError(msg)

    # Used to sanity check the parsed realms
    dynamic_components = model_info.get("dynamic_components", [])

    source_l = []
    for component in model_components:
        parts = component.split("_")

        # Strip off any trailing grid parts (e.g. "h125", "v40").
        # There may be one or more of these, so we can't assume a fixed number.
        # We always keep at least two parts (the realm and the model name).
        while len(parts) > 2 and is_emd_grid_part(parts[-1]):
            parts.pop()

        # The last remaining part is the model name,
        # the rest (re-joined) is the realm, which can itself contain underscores.
        model_name = parts[-1]
        realm = "_".join(parts[:-1])

        # Sanity check: the realm (with "_" replaced by "-")
        # should be one of the file's `dynamic_components`.
        realm_hyphenated = realm.replace("_", "-")
        if realm_hyphenated not in dynamic_components:
            warnings.warn(
                f"Realm {realm_hyphenated!r} (parsed from component {component!r} "
                f"for source {drs_name!r}) is not in the model's "
                f"dynamic_components {dynamic_components!r}"
            )

        source_l.append(f"{realm_hyphenated}: {model_name}")

    source_suffix = "; ".join(source_l)

    return source_suffix


def get_cmor_source_id_definitions(
    source_collection: str, ev_project: ev_api.project_specs.ProjectSpecs
) -> dict[str, CMORSourceDefinition]:
    terms = ev_api.get_all_terms_in_collection(ev_project.project_id, source_collection)

    res = {}
    for term in terms:
        source_l = []
        for mc in term.model_components:
            try:
                source_l.append(f"{mc.component}: {mc.name}")
            except AttributeError:
                # Workaround while EMD models are broken
                source_l.append(f"{mc['component']}: {mc['name']}")

        # Prefer the information from esgvoc,
        # but fall back to reading the model components directly from the EMD
        # if esgvoc doesn't (yet) expose them.
        if source_l:
            source_suffix = "; ".join(source_l)
        else:
            source_suffix = get_source_suffix_from_emd(term.drs_name)

        source = f"{term.drs_name}: {source_suffix}"

        res[term.drs_name] = CMORSourceDefinition(
            label=term.name,
            # Not sure what is meant to be in here with the current model
            label_extended=f"{term.name} ({term.release_year})",
            source=source,
            source_id=term.drs_name,
        )

    return res


def get_cmor_frequency_definitions(
    source_collection: str, ev_project: ev_api.project_specs.ProjectSpecs
) -> dict[str, CMORFrequencyDefinition]:
    terms = ev_api.get_all_terms_in_collection(ev_project.project_id, source_collection)

    res = {
        v.drs_name: CMORFrequencyDefinition(
            description=v.description,
            approx_interval=get_approx_interval(v.interval, units=v.units),
            # Sensible defaults
            approx_interval_warning=0.1,
            approx_interval_error=0.2,
        )
        if v.interval
        # I'm still not convinced that it wouldn't be simpler to use the same schema for all types
        else "fixed (time invariant) field"
        for v in terms
    }

    return res


def get_cmor_drs_definition(
    ev_project: ev_api.project_specs.ProjectSpecs,
) -> CMORDRSDefinition:
    # Hard-code examples to avoid spurious changes
    # (this obviously isn't a general solution)

    drs_specs_example = ev_api.get_term_in_collection(
        ev_project.project_id, "drs_specs", "mip-drs7"
    )
    mip_era_example = ev_api.get_term_in_collection(
        ev_project.project_id, "mip_era", "cmip7"
    )
    activity_example = ev_api.get_term_in_collection(
        ev_project.project_id, "activity", "cmip"
    )
    # Collection still moving around
    for collection in ("organisation", "institution"):
        organisation_example = ev_api.get_term_in_collection(
            ev_project.project_id, collection, "cnrm-cerfacs"
        )
        if organisation_example is not None:
            break
    else:
        raise AssertionError

    source_example = ev_api.get_term_in_collection(
        ev_project.project_id, "source", "cnrm_esm2_1e"
    )
    experiment_example = ev_api.get_term_in_collection(
        ev_project.project_id, "experiment", "1pctco2"
    )
    variant_label_example = "r1i1p1f1"
    region_example = ev_api.get_term_in_collection(
        ev_project.project_id, "region", "global"
    )
    frequency_example = "mon"
    variable_example = ev_api.get_term_in_collection(
        ev_project.project_id, "variable", "rsus"
    )
    branding_suffix_example = "tavg-h2m-hxy-u"
    grid_example = ev_api.get_term_in_collection(
        ev_project.project_id, "grid_label", "g101"
    )
    directory_path_example = "20251104"
    time_range_example = "185001-202112"

    directory_path_template_l = []
    directory_path_example_l = []
    for part in ev_project.drs_specs["directory"].parts:
        # print(f"Processing {part=}")
        if not part.is_required:
            raise NotImplementedError

        if part.source_collection == "directory_date":
            # Maybe should be using catalogue specs rather than attr specs?
            # Hard-coded CMOR weirdness
            directory_path_template_l.append("<version>")

        elif part.source_collection == "branding_suffix":
            # Branding suffix is a bit special so hard-code.
            # In short, the DRS specs tell you how to validate
            # (so, if you know the branded variable,
            # you know what the branding suffix has to be).
            # However, here I just want to know what the components
            # of branding suffix are so I can write the CMOR table.
            # This is different, hence we can't use the project specs.
            directory_path_template_l.append("<branding_suffix>")

        elif part.source_collection in ("variable", "experiment"):
            # We can undo this if an update comes in esgvoc which allows us to map
            # DRS parts to attribute fields
            directory_path_template_l.append(f"<{part.source_collection}_id>")

        else:
            project_attribute_property = get_project_attribute_property(
                attribute_value=part.source_collection,
                attribute_to_match="source_collection",
                ev_project=ev_project,
            )
            directory_path_template_l.append(
                f"<{project_attribute_property.attr_field_name}>"
            )

        if part.source_collection == "drs_specs":
            directory_path_example_l.append(drs_specs_example.drs_name)
        elif part.source_collection == "mip_era":
            directory_path_example_l.append(mip_era_example.drs_name)
        elif part.source_collection == "activity":
            directory_path_example_l.append(activity_example.drs_name)
        # Name still moving around, see https://github.com/WCRP-CMIP/CMIP7-CVs/pull/368
        elif part.source_collection in ("organisation", "institution"):
            directory_path_example_l.append(organisation_example.drs_name)
        elif part.source_collection == "source":
            directory_path_example_l.append(source_example.drs_name)
        elif part.source_collection == "experiment":
            directory_path_example_l.append(experiment_example.drs_name)
        elif part.source_collection == "region":
            directory_path_example_l.append(region_example.drs_name)
        elif part.source_collection == "frequency":
            directory_path_example_l.append(frequency_example)
        elif part.source_collection == "variable":
            directory_path_example_l.append(variable_example.drs_name)
        elif part.source_collection == "branding_suffix":
            directory_path_example_l.append(branding_suffix_example)
        elif part.source_collection == "grid_label":
            directory_path_example_l.append(grid_example.drs_name)
        elif part.source_collection == "variant_label":
            directory_path_example_l.append(variant_label_example)
        elif part.source_collection == "directory_date":
            directory_path_example_l.append(directory_path_example)
        else:
            msg = f"Examples should be hard-coded: {part=}"
            raise AssertionError(msg)

        # print(f"Finished {part=}")

    # CMOR hard-codes "/" as a separator
    # and doesn't want the separator in the template.
    directory_path_template = "".join(directory_path_template_l)
    directory_path_example = ev_project.drs_specs["directory"].separator.join(
        directory_path_example_l
    )

    filename_template_l = []
    filename_example_l = []
    for i, part in enumerate(ev_project.drs_specs["file_name"].parts):
        if i > 0:
            prefix = ev_project.drs_specs["file_name"].separator

        else:
            prefix = ""

        if part.source_collection == "time_range":
            # Maybe should be using catalogue specs rather than attr specs?
            # Hard-coded CMOR weirdness
            cmor_placeholder = "timeRange"

        elif part.source_collection == "branding_suffix":
            # Branding suffix is a bit special so hard-code.
            # In short, the DRS specs tell you how to validate
            # (so, if you know the branded variable,
            # you know what the branding suffix has to be).
            # However, here I just want to know what the components
            # of branding suffix are so I can write the CMOR table.
            # This is different, hence we can't use the project specs.
            cmor_placeholder = "branding_suffix"

        elif part.source_collection in ("variable", "experiment"):
            # We can undo this if an update comes in esgvoc which allows us to map
            # DRS parts to attribute fields
            cmor_placeholder = f"{part.source_collection}_id"

        else:
            project_attribute_property = get_project_attribute_property(
                attribute_value=part.source_collection,
                attribute_to_match="source_collection",
                ev_project=ev_project,
            )
            cmor_placeholder = project_attribute_property.attr_field_name

        if part.source_collection == "variable":
            example_value = variable_example.drs_name
        elif part.source_collection == "branding_suffix":
            example_value = branding_suffix_example
        elif part.source_collection == "frequency":
            example_value = frequency_example
        elif part.source_collection == "region":
            example_value = region_example.drs_name
        elif part.source_collection == "grid_label":
            example_value = grid_example.drs_name
        elif part.source_collection == "source":
            example_value = source_example.drs_name
        elif part.source_collection == "experiment":
            example_value = experiment_example.drs_name
        elif part.source_collection == "variant_label":
            example_value = variant_label_example
        elif part.source_collection == "time_range":
            example_value = time_range_example
        else:
            msg = f"Examples should be hard-coded: {part=}"
            raise AssertionError(msg)

        # CMOR hard-codes "_" as a separator
        # and doesn't want the separator in the template.
        filename_template_prefix = ""
        if part.source_collection == "time_range":
            # Don't put time range in the CMOR template as CMOR doesn't support it anymore
            # Details: https://github.com/WCRP-CMIP/CMIP7-CVs/pull/336#discussion_r2731049844
            pass
        elif part.is_required:
            filename_template_l.append(
                f"{filename_template_prefix}<{cmor_placeholder}>"
            )
        else:
            filename_template_l.append(
                f"[{filename_template_prefix}<{cmor_placeholder}>]"
            )

        filename_example_l.append(f"{prefix}{example_value}")

    filename_template_excl_ext = "".join(filename_template_l)
    # Current CMOR versions don't need/want the extension for whatever eason
    filename_template = f"{filename_template_excl_ext}"
    filename_example_excl_ext = "".join(filename_example_l)
    filename_example = f"{filename_example_excl_ext}.nc"

    res = CMORDRSDefinition(
        directory_path_example=directory_path_example,
        directory_path_template=directory_path_template,
        filename_example=filename_example,
        filename_template=filename_template,
    )

    return res


def generate_cvs_table_esgvoc(project: str) -> CMORCVsTable:
    """
    Generate CVs table from information available via esgvoc
    """
    ev_project = ev_api.projects.get_project(project)

    init_kwargs = {"required_global_attributes": []}
    for attr_property in ev_project.attr_specs:
        # Use source_collection as fallback when attr_field_name is None
        if attr_property.attr_field_name is None:
            attr_property.attr_field_name = attr_property.source_collection
        if attr_property.is_required:
            init_kwargs["required_global_attributes"].append(
                attr_property.attr_field_name
            )

        # Logic: https://github.com/WCRP-CMIP/CMIP7-CVs/issues/271#issuecomment-3286291815
        # Conventions added back in following https://github.com/PCMDI/cmor/issues/937
        if attr_property.attr_field_name in [
            # Fields not needed in CMOR tables
            "cmip6_compound_name",
            # Fields not needed in the CMOR CV table
            # (appear in variable tables instead)
            "branded_variable",
            "external_variables",
            "variable_id",
            # Fields not needed in the CMOR CV table
            # (user provided and validated in other ways)
            "parent_activity_id",
            "parent_experiment_id",
            "parent_mip_era",
            "parent_source_id",
            "parent_time_units",
            "parent_variant_label",
            # Fields not controlled by CVs
            "branch_time_in_child",
            "branch_time_in_parent",
            # Non-validated fields
            "experiment",
            "institution",
            "license",
            "source",
            "references",
            "title",
            "variant_info",
        ]:
            # Not handled in CMOR tables
            continue

        elif attr_property.attr_field_name in [
            "data_specs_version",
            "drs_specs",
            "mip_era",
        ]:
            value = get_single_allowed_value_for_attribute(
                attr_property.attr_field_name, ev_project
            )

            kwarg = attr_property.attr_field_name

        elif attr_property.attr_field_name == "license_id":
            value = get_cmor_license_definition(
                attr_property.source_collection, ev_project
            )
            kwarg = "license"

        elif attr_property.attr_field_name == "frequency":
            value = get_cmor_frequency_definitions(
                attr_property.source_collection, ev_project
            )
            kwarg = attr_property.attr_field_name

        elif attr_property.attr_field_name == "experiment_id":
            value = get_cmor_experiment_id_definitions(
                attr_property.source_collection, ev_project
            )
            kwarg = attr_property.attr_field_name

        elif attr_property.attr_field_name == "nominal_resolution":
            kwarg = attr_property.attr_field_name
            value = get_cmor_nominal_resolution_defintions(
                attr_property.attr_field_name, ev_project
            )

        elif attr_property.attr_field_name == "source_id":
            value = get_cmor_source_id_definitions(
                attr_property.source_collection, ev_project
            )
            kwarg = attr_property.attr_field_name

        elif attr_property.attr_field_name in ("activity_id",):
            # Hard-code for now
            # TODO: figure out how to unpack typing.Annotated
            kwarg = attr_property.attr_field_name
            value = get_allowed_dict_for_attribute(
                attr_property.attr_field_name, ev_project
            )

        elif attr_property.attr_field_name == "Conventions":
            # As requested in: https://github.com/WCRP-CMIP/cmip7-cmor-tables/issues/78
            kwarg = attr_property.attr_field_name
            value = list(
                get_allowed_dict_for_attribute(
                    attr_property.attr_field_name, ev_project
                ).keys()
            )

        elif attr_property.attr_field_name == "grid_label":
            # Not sure why this is a necessary exception
            kwarg = attr_property.attr_field_name
            attribute_instances = ev_api.get_all_terms_in_collection(
                ev_project.project_id, "grid_label"
            )
            value = {v.drs_name: v.description for v in attribute_instances}

        elif attr_property.attr_field_name == "branding_suffix":
            # Branding suffix is a bit special so hard-code.
            # In short, the DRS specs tell you how to validate
            # (so, if you know the branded variable,
            # you know what the branding suffix has to be).
            # However, here I just want to know what the components
            # of branding suffix are so I can write the CMOR table.
            # This is different, hence we can't use the project specs.
            kwarg = attr_property.attr_field_name

            terms = ev_api.get_all_terms_in_collection(
                ev_project.project_id, "branding_suffix"
            )
            if len(terms) > 1:
                raise AssertionError(terms)

            term = terms[0]

            parts_l = []
            for v in term.parts:
                va = get_project_attribute_property(
                    v.type, "source_collection", ev_project
                )
                if va.attr_field_name is None:
                    parts_l.append(f"<{va.source_collection}>")

                else:
                    parts_l.append(f"<{va.attr_field_name}>")

            if term.separator != "-":
                msg = f"CMOR only supports '-' as a separator, received {term.separator=} for {term=}"
                raise NotImplementedError(msg)

            value = "".join(parts_l)

        else:
            kwarg = attr_property.attr_field_name

            DD_name = ev_api.get_data_descriptor_from_collection_in_project(
                project, attr_property.source_collection
            )
            from esgvoc.api.data_descriptors import DATA_DESCRIPTOR_CLASS_MAPPING

            pydantic_class = DATA_DESCRIPTOR_CLASS_MAPPING[DD_name]
            if issubclass(
                pydantic_class,
                ev_api.data_descriptors.data_descriptor.PlainTermDataDescriptor,
            ):
                value = get_allowed_dict_for_attribute(
                    attr_property.attr_field_name, ev_project
                )

            elif issubclass(
                pydantic_class,
                ev_api.data_descriptors.data_descriptor.PatternTermDataDescriptor,
            ):
                value = get_regular_expression_validator_for_attribute(
                    attr_property, ev_project
                )

            elif issubclass(
                pydantic_class,
                ev_api.data_descriptors.data_descriptor.CompositeTermDataDescriptor,
            ):
                value = get_template_for_composite_attribute(
                    attr_property.attr_field_name, ev_project
                )

            else:
                raise NotImplementedError(pydantic_class)

        init_kwargs[kwarg] = value

    init_kwargs["DRS"] = get_cmor_drs_definition(ev_project)
    # Hard-code rather than retrieving from esgvoc because this is a purely CMOR thing.
    # (esgvoc is used for validating that the written values are correct,
    # not generating them in the first place,
    # so esgvoc only needs to know the regexp to use to validate `tracking_id`,
    # it doesn't need the tracking prefix.)
    tracking_prefix = "hdl:21.14107"

    # Of course, we include a quick check to make sure that they're not obviously inconsistent.
    if len(init_kwargs["tracking_id"]) != 1:
        raise AssertionError
    tracking_id_esgvoc = init_kwargs["tracking_id"][0]

    tracking_prefix_regex_escaped = f"^{re.escape(tracking_prefix)}"
    if not tracking_id_esgvoc.startswith(tracking_prefix_regex_escaped):
        msg = (
            f"{tracking_prefix=} and {tracking_prefix_regex_escaped=} "
            "but esgvoc says that tracking_id should match "
            f"{tracking_id_esgvoc}"
        )
        raise AssertionError(msg)

    init_kwargs["tracking_prefix"] = tracking_prefix

    cmor_cvs_table = CMORCVsTable(**init_kwargs)

    return cmor_cvs_table


def add_non_esgvoc_info(cvs_table: CMORCVsTable) -> CMORCVsTable:
    res = cvs_table.model_copy(deep=True)

    updated_frequencies = {}
    for k, v in cvs_table.frequency.items():
        if not isinstance(v, CMORFrequencyDefinition):
            updated_frequencies[k] = v
            continue

        if k == "subhr":
            uf = CMORFrequencyDefinition(
                description=v.description,
                approx_interval=v.approx_interval,
                approx_interval_warning=0.5,
                approx_interval_error=0.9,
            )

        else:
            uf = CMORFrequencyDefinition(
                description=v.description,
                approx_interval=v.approx_interval,
                approx_interval_warning=v.approx_interval_warning,
                approx_interval_error=v.approx_interval_error,
            )

        updated_frequencies[k] = uf

    res.frequency = updated_frequencies

    return res


def _list_sort(obj: Any):
    """
    Walk a dictionary, sorting any lists that are encountered
    """
    for k, v in obj.items():
        if isinstance(v, dict):
            _list_sort(v)
        elif isinstance(v, list):
            obj[k] = sorted(v)


app = typer.Typer()


@app.command()
def cmor_export_cvs_table(
    out_path: Annotated[
        Path | None,
        typer.Option(
            help="Path in which to write the output. If not provided, the result is printed instead.",
            dir_okay=False,
            file_okay=True,
        ),
    ] = None,
    out_path_split_view: Annotated[
        Path | None,
        typer.Option(
            help="Path in which to write the CVs information, split into one file per CV. If not provided, the split view is not created.",
            dir_okay=True,
            file_okay=False,
        ),
    ] = None,
) -> None:
    """
    Export CVs table in the format required by CMOR
    """
    json_dump_settings = dict(indent=4, sort_keys=True)

    # Hard-code as that is all we support
    project = "cmip7"

    cvs_table_esgvoc = generate_cvs_table_esgvoc(project=project)
    cvs_table = add_non_esgvoc_info(cvs_table_esgvoc)
    cvs_table_json = cvs_table.to_cvs_json()

    # Sort before writing to disk or displaying to ensure stability of ordering
    _list_sort(cvs_table_json)

    if out_path:
        with open(out_path, "w") as fh:
            json.dump(cvs_table_json, fh, **json_dump_settings)
            fh.write("\n")

    else:
        print(json.dumps(cvs_table_json, **json_dump_settings))

    if out_path_split_view:
        out_path_split_view.mkdir(exist_ok=True)
        for key, value in cvs_table_json["CV"].items():
            out_path = out_path_split_view / f"{key}.json"
            with open(out_path, "w") as fh:
                json.dump(value, fh, **json_dump_settings)


if __name__ == "__main__":
    app()
