#!/usr/bin/env python3
"""Generate CMake presets documentation (reST) and JSON schema.

The documentation and JSON schema for CMake presets are both generated from
Help/manual/presets/schema.yaml, which describes the schema in a way that
allows both documentation forms to be combined in a single file, and also makes
it much easier to manage version revisions (compared to editing the JSON schema
'by hand').

All input and output files are expected to be in known locations relative to
this script.

Usage: python3 regenerate-presets.py
"""

import json
import re
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Self

import yaml

LATEST = 1
TYPES = {}
REFS = {}

PRESETS_REL = 'Help/manual/presets'
SCHEMA_YAML_FILENAME = 'schema.yaml'
SCHEMA_JSON_FILENAME = 'schema.json'

WORKSPACE = Path(__file__).parent.parent.parent.absolute()
SCRIPT_REL = Path(__file__).relative_to(WORKSPACE)
PRESETS = WORKSPACE / PRESETS_REL
DIAGNOSTICS = WORKSPACE / 'Source/cmDiagnostics.h'

RST_BANNER = f"""\
.. This file was generated by {SCRIPT_REL.as_posix()}
   from {PRESETS_REL}/{SCHEMA_YAML_FILENAME}. Do not edit.
"""

DIAGNOSTIC_TABLE_MACRO = 'CM_FOR_EACH_DIAGNOSTIC_TABLE'
CONFIGURE_PRESET_PROPERTIES_PATH = (
    'properties', 'configurePresets', 'items', 'properties'
)
WARNING_DESCRIPTION = (
    'An optional boolean. '
    'Equivalent to passing -W{c} or -Wno-{c} on the command line. '
    'This may not be set to false if errors.{p} is set to true.'
)
WARNING_SPHINX_DESCRIPTION = """
An optional boolean. Equivalent to passing :option:`-W{c} <cmake -W>` or
:option:`-Wno-{c} <cmake -Wno->` on the command line.
This may not be set to ``false`` if ``errors.{p}`` is set to ``true``.
""".strip()
ERROR_DESCRIPTION = (
    'An optional boolean. '
    'Equivalent to passing -Werror={c} or -Wno-error={c} on the command line. '
    'This may not be set to true if warnings.{p} is set to false.'
)
ERROR_SPHINX_DESCRIPTION = """
An optional boolean. Equivalent to passing :cmake-option:`-Werror={c}` or
:cmake-option:`-Wno-error={c}` on the command line.
This may not be set to ``true`` if ``warnings.{p}`` is set to ``false``.
""".strip()


# =============================================================================
@dataclass
class Diagnostic:
    presetName: str
    cliName: str
    since: int

    # -------------------------------------------------------------------------
    def format(self, template: str) -> str:
        return template.format(c=self.cliName, p=self.presetName)


# =============================================================================
class Value:

    # -------------------------------------------------------------------------
    def __init__(self, data: list[dict[str, Any]]):
        self.data = data
        self.spans = None

    # -------------------------------------------------------------------------
    @property
    def objects(self) -> dict[str, 'Object']:
        return {}

    # -------------------------------------------------------------------------
    def updateSpans(self, since: int, until: int) -> None:
        self.spans = [(since, until)]

    # -------------------------------------------------------------------------
    def updateRefs(self, objects: dict[str, 'Object']) -> None:
        pass

    # -------------------------------------------------------------------------
    def defs(self) -> dict[str, Any]:
        return {}

    # -------------------------------------------------------------------------
    def schema(self, version: int) -> dict[str, Any]:
        return deepcopy(self.data)


# =============================================================================
class Object(Value):

    # -------------------------------------------------------------------------
    def __init__(self, data: dict[str, Any], name: str,
                 label: str | None = None):
        # Prevent data sharing from modifying unrelated parts of the schema as
        # we pick apart our section.
        data = deepcopy(data)

        self.name = data.pop('id', name)
        self.label = label

        self.properties = {}
        for n, p in data.pop('properties').items():
            pl = '.'.join([label, n]) if label is not None else n
            self.properties[n] = Property(p, n, pl)
        if len(self.properties):
            self.properties[CommentProperty.name] = CommentProperty()

        ap = data.pop('additionalProperties', None)
        if ap is not None:
            self.additionalProperties = buildValue(ap, self.name, self.label)
        else:
            self.additionalProperties = None

        super().__init__(data)

    # -------------------------------------------------------------------------
    @property
    def objects(self) -> dict[str, Self]:
        out = {self.name: self}
        for p in self.properties.values():
            out.update(p.objects)
        if self.additionalProperties is not None:
            out.update(self.additionalProperties.objects)
        return out

    # -------------------------------------------------------------------------
    def updateSpans(self, since: int, until: int) -> None:
        spans = [[(since, until)]]
        for p in self.properties.values():
            p.updateSpans(since, until)
            spans.append(p.spans)
        if self.additionalProperties is not None:
            self.additionalProperties.updateSpans(since, until)
            spans.append(self.additionalProperties.spans)
        self.spans = combineSpans(*spans)

    # -------------------------------------------------------------------------
    def updateRefs(self, objects: dict[str, 'Object']) -> None:
        for p in self.properties.values():
            p.updateRefs(objects)
        if self.additionalProperties is not None:
            self.additionalProperties.updateRefs(objects)

    # -------------------------------------------------------------------------
    def doc(self, since: int, until: int) -> str | None:
        since = max(since, self.spans[0][0])
        until = min(until, self.spans[-1][1])

        out = []
        for _, p in self.properties.items():
            content = p.doc(since, until)
            if not content:
                continue
            out.append(content)

        return '\n\n'.join(out) if len(out) else None

    # -------------------------------------------------------------------------
    def defs(self, exclude: set[str] = {}) -> dict[str, Any]:
        out = {}
        for n, p in self.properties.items():
            if n in exclude:
                continue
            for s in p.spans:
                r = f'{p.label}@{spanRef(*s)}'
                out[r] = p.schema(s[0])
            out.update(p.defs())
        if self.additionalProperties is not None:
            out.update(self.additionalProperties.defs())
        return out

    # -------------------------------------------------------------------------
    def schema(self, version: int, inline: set[str] = {}) -> dict[str, Any]:
        out = deepcopy(self.data)

        props = {}
        required = []
        for n, p in self.properties.items():
            if n in inline:
                props[n] = p.schema(version)
            else:
                r = p.ref(version)
                if r is not None:
                    props[n] = r
            if p.required:
                required.append(n)

        out['properties'] = props
        if len(required):
            out['required'] = required

        if self.additionalProperties is not None:
            ap = self.additionalProperties.schema(version)
            out['additionalProperties'] = ap
        return out


# =============================================================================
class Ref(Value):

    # -------------------------------------------------------------------------
    def __init__(self, data: list[dict[str, Any]]):
        self.target = data.pop('target')
        self.targetSpans = None
        super().__init__(data)

    # -------------------------------------------------------------------------
    def updateSpans(self, since: int, until: int) -> None:
        if self.spans is None:
            super().updateSpans(since, until)
        self.spans = trimSpans(self.spans, since, until)

    # -------------------------------------------------------------------------
    def updateRefs(self, objects: dict[str, 'Object']) -> None:
        global REFS

        t = objects.get(self.target)
        if t is not None:
            since = self.spans[0][0]
            until = self.spans[-1][1]
            self.spans = trimSpans(t.spans, since, until)
            self.targetSpans = t.spans

        rs = REFS.get(self.target, (since, until))
        REFS[self.target] = (min(since, rs[0]), max(until, rs[1]))

    # -------------------------------------------------------------------------
    def schema(self, version: int) -> dict[str, Any]:
        ts = trimSpans(self.targetSpans, *REFS[self.target])
        s = findSpan(ts, version)
        if s is not None:
            return {
                '$ref': f'#/definitions/{self.target}@{spanRef(*s)}',
            }
        return {}


# =============================================================================
class Array(Value):

    # -------------------------------------------------------------------------
    def __init__(self, data: dict[str, Any], name: str, label: str):
        item = data.pop('items')
        self.value = buildValue(item, name, label)

        super().__init__(data)

    # -------------------------------------------------------------------------
    @property
    def objects(self) -> dict[str, Object]:
        return self.value.objects

    # -------------------------------------------------------------------------
    def updateSpans(self, since: int, until: int) -> None:
        self.value.updateSpans(since, until)
        self.spans = self.value.spans

    # -------------------------------------------------------------------------
    def updateRefs(self, objects: dict[str, 'Object']) -> None:
        self.value.updateRefs(objects)

    # -------------------------------------------------------------------------
    def defs(self) -> dict[str, Any]:
        return self.value.defs()

    # -------------------------------------------------------------------------
    def schema(self, version: int) -> dict[str, Any]:
        out = deepcopy(self.data)
        out['items'] = self.value.schema(version)
        return out


# =============================================================================
class Union(Value):

    # -------------------------------------------------------------------------
    def __init__(self, data: list[dict[str, Any]], name: str, label: str):
        self.items = []
        for i, d in enumerate(data):
            self.items.append(buildValue(d, name, self.itemLabel(label, i)))

        super().__init__({})

    # -------------------------------------------------------------------------
    def itemLabel(self, label: str, index: int) -> str:
        return label

    # -------------------------------------------------------------------------
    @property
    def objects(self) -> dict[str, Object]:
        out = {}
        for v in self.items:
            out.update(v.objects)
        return out

    # -------------------------------------------------------------------------
    def updateSpans(self, since: int, until: int) -> None:
        spans = [[(since, until)]]
        for i in self.items:
            i.updateSpans(since, until)
            spans.append(i.spans)
        self.spans = combineSpans(*spans)

    # -------------------------------------------------------------------------
    def updateRefs(self, objects: dict[str, 'Object']) -> None:
        for i in self.items:
            i.updateRefs(objects)

    # -------------------------------------------------------------------------
    def defs(self) -> dict[str, Any]:
        out = {}
        for v in self.items:
            out.update(v.defs())
        return out

    # -------------------------------------------------------------------------
    def schema(self, version: int) -> dict[str, Any]:
        out = []
        for i in self.items:
            out.append(i.schema(version))
        return {'anyOf': out}


# =============================================================================
class Variant(Union):

    # -------------------------------------------------------------------------
    def __init__(self, data: list[dict[str, Any]], name: str, label: str):
        self.identifier = data.pop('id', None)

        if self.identifier is not None:
            name = self.identifier
            label = self.identifier

        super().__init__(data.pop('items'), name, label)

    # -------------------------------------------------------------------------
    def itemLabel(self, label: str, index: int) -> str:
        return f'{label}[{index}]'

    # -------------------------------------------------------------------------
    @property
    def objects(self) -> dict[str, Object]:
        out = super().objects
        if self.identifier is not None:
            out[self.identifier] = self
        return out

    # -------------------------------------------------------------------------
    def doc(self, since: int, until: int) -> None:
        return None

    # -------------------------------------------------------------------------
    def defs(self) -> dict[str, Any]:
        out = super().defs()

        if self.identifier is not None:
            for s in self.spans:
                n = f'{self.identifier}@{spanRef(*s)}'
                out[n] = super().schema(s[0])

        return out

    # -------------------------------------------------------------------------
    def schema(self, version: int) -> dict[str, Any]:
        if self.identifier is not None:
            s = findSpan(self.spans, version)
            if s is not None:
                return {
                    '$ref': f'#/definitions/{self.identifier}@{spanRef(*s)}',
                }
            return {}

        return super().schema(version)


# =============================================================================
class Property:

    # -------------------------------------------------------------------------
    def __init__(self, data: dict[str, Any], name: str, label: str):
        self.name = name
        self.label = label
        self.since = data.pop('since', 1)
        self.until = data.pop('until', LATEST + 1)
        self.required = data.pop('required', False)
        self.content = data.pop('sphinxDescription', data.get('description'))

        note = data.pop('sphinxNote', None)
        if note is not None:
            self.content += f'\n\n.. note::\n\n{indent(note)}'

        self._revisions = []
        for rev in data.pop('revisions', []):
            rd = deepcopy(data)
            rd.update(rev)
            self._revisions.append(Property(rd, self.name, self.label))

        if len(self._revisions):
            self.since = max(self.since,
                             min([r.since for r in self._revisions]))
            self.until = min(self.until,
                             max([r.until for r in self._revisions]))
            self.value = None
        else:
            self.value = buildValue(data, self.name, self.label)

        self.spans = None

    # -------------------------------------------------------------------------
    @property
    def objects(self) -> dict[str, Object]:
        if len(self._revisions):
            out = {}
            for r in self._revisions:
                out.update(r.objects)
            return out

        return self.value.objects

    # -------------------------------------------------------------------------
    def updateSpans(self, since: int, until: int) -> None:
        since = max(since, self.since)
        until = min(until, self.until)

        if len(self._revisions):
            spans = [[(since, until)]]
            for r in self._revisions:
                r.updateSpans(since, until)
                spans.append(r.spans)
            self.spans = combineSpans(*spans)
        else:
            self.value.updateSpans(since, until)
            self.spans = self.value.spans

    # -------------------------------------------------------------------------
    def updateRefs(self, objects: dict[str, 'Object']) -> None:
        if len(self._revisions):
            for r in self._revisions:
                r.updateRefs(objects)
        else:
            self.value.updateRefs(objects)

    # -------------------------------------------------------------------------
    def ref(self, version: int) -> dict[str, str] | None:
        s = findSpan(self.spans, version)
        if s is not None:
            return {
                '$ref': f'#/definitions/{self.label}@{spanRef(*s)}',
            }
        return None

    # -------------------------------------------------------------------------
    def doc(self, since: int, until: int) -> str | None:
        if self.content is None:
            return None

        out = f'.. _`CMakePresets.{self.label}`:\n\n``{self.name}``\n'
        if self.since > since:
            out += f'  .. presets-versionadded:: {self.since}\n\n'
        if self.until < until:
            out += f'  .. presets-versionremoved:: {self.until}\n\n'
        out += indent(self.content)

        return out

    # -------------------------------------------------------------------------
    def defs(self) -> dict[str, Any]:
        if len(self._revisions):
            out = {}
            for r in self._revisions:
                out.update(r.defs())
            return out

        return self.value.defs()

    # -------------------------------------------------------------------------
    def schema(self, version: int) -> dict[str, Any]:
        for r in self._revisions:
            if version >= r.since and version < r.until:
                return r.value.schema(version)
        return self.value.schema(version)


# =============================================================================
class CommentProperty(Property):
    name: str = '$comment'
    since: int = 10

    # -------------------------------------------------------------------------
    def __init__(self):
        data = {
            'since': type(self).since,
            'id': 'comment',
            'type': 'variant',
            'items': [
                {
                    'type': 'string',
                    'description': 'A single-line comment.',
                },
                {
                    'type': 'array',
                    'description': 'A multi-line comment.',
                    'minItems': 1,
                    'items': {
                        'type': 'string',
                        'description': 'One line of the multi-line comment.',
                    }
                }
            ],
        }
        super().__init__(data, type(self).name, type(self).name)

    # -------------------------------------------------------------------------
    def doc(self, since: int, until: int) -> None:
        return None


# -----------------------------------------------------------------------------
def buildValue(
    data: Any, name: str, label: str | None = None
) -> Variant | Object | Array | Ref | Value:
    if type(data) is dict:
        if 'anyOf' in data:
            return Union(data.pop('anyOf'), name, label)
        elif data['type'] == 'variant':
            return Variant(data, name, label)
        elif data['type'] == 'array':
            return Array(data, name, label)
        elif data['type'] == 'object':
            return Object(data, name, label)
        elif data['type'] == 'ref':
            return Ref(data)

    return Value(data)


# -----------------------------------------------------------------------------
def extractDivisions(spans: list[tuple[int, int]]) -> set[int]:
    return {n for s in spans for n in s}


# -----------------------------------------------------------------------------
def trimSpans(spans: list[tuple[int, int]],
              since: int, until: int) -> list[tuple[int, int]]:
    divisions = extractDivisions(spans)

    last = since
    out = []

    for d in sorted(divisions):
        if d > since:
            out.append((last, min(d, until)))
            last = min(d, until)
        if d >= until:
            break

    return out


# -----------------------------------------------------------------------------
def combineSpans(*spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
    divisions = set()
    for s in spans:
        divisions.update(extractDivisions(s))

    divisions = sorted(divisions)
    last = divisions.pop(0)
    out = []

    for d in divisions:
        out.append((last, d))
        last = d

    return out


# -----------------------------------------------------------------------------
def findSpan(spans: list[tuple[int, int]],
             version: int) -> tuple[int, int] | None:
    for s in spans:
        if version >= s[0] and version < s[1]:
            return s

    return None


# -----------------------------------------------------------------------------
def spanRef(since: int, until: int) -> str:
    if since + 1 < until:
        if until > LATEST:
            return f'v{since}..'
        else:
            return f'v{since}..v{until - 1}'
    return f'v{since}'


# -----------------------------------------------------------------------------
def indent(text: str):
    return '\n'.join([('  ' + line).rstrip() for line in text.split('\n')])


# -----------------------------------------------------------------------------
def trimDescriptions(data: Any):
    if type(data) is dict:
        for k, v in data.items():
            if type(v) is str and k in {'description', 'sphinxDescription'}:
                data[k] = v.strip()
            elif type(v) is dict:
                data[k] = trimDescriptions(v)
            elif type(v) is list:
                data[k] = [trimDescriptions(item) for item in v]

    return data


# -----------------------------------------------------------------------------
def getPath(data: Any, path: tuple[str]) -> Any:
    if len(path) == 0:
        return data
    return getPath(data[path[0]], path[1:])


# -----------------------------------------------------------------------------
def diagnosticPresetName(symbol: str) -> str:
    sep = False
    out = ''
    for c in symbol[4:]:
        if c == '_':
            sep = True
        elif sep:
            out += c
            sep = False
        else:
            out += c.lower()
    return out


# -----------------------------------------------------------------------------
def buildDiagnosticsSchema(
    diagnostics: list[Diagnostic],
    descriptionTemplate: str,
    sphinxDescriptionTemplate: str,
) -> dict[str, dict[str, Any]]:
    out = {}

    for d in diagnostics:
        out[d.presetName] = {
            'since': d.since,
            'type': 'boolean',
            'description': d.format(descriptionTemplate),
            'sphinxDescription': d.format(sphinxDescriptionTemplate),
        }

    return out


# -----------------------------------------------------------------------------
def mergeDiagnostics(
    generated: dict[str, dict[str, Any]],
    extra: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
    # Get unsorted names.
    tail = {}
    for k in list(extra.keys()):
        s = extra[k].pop('sort', True)
        if not s:
            tail[k] = extra.pop(k)

    # Combine inputs and sort.
    out = {}
    combined = deepcopy(generated)
    combined.update(extra)
    for k in sorted(combined.keys()):
        out[k] = combined[k]

    # Add unsorted items and return result.
    out.update(tail)
    return out


# -----------------------------------------------------------------------------
def readDiagnostics(path: Path | str) -> list[Diagnostic]:
    content = ''

    # Extract diagnostics table from header.
    with open(path, 'r') as f:
        extracting = False
        for line in f:
            if DIAGNOSTIC_TABLE_MACRO in line:
                extracting = True
                continue

            if extracting:
                content += line.replace('\\\n', ' ')
                if not line.strip().endswith('\\'):
                    break

    out = []
    while True:
        m = re.match(r'\s*SELECT[(]([^)]+)[)]', content)
        if m is None:
            break

        args = [a.strip() for a in m.group(1).split(',')]
        p = diagnosticPresetName(args[3])
        c = args[3][4:].lower().replace('_', '-')
        v = int(args[4])
        if v > 1:
            out.append(Diagnostic(p, c, v))

        content = content[m.span()[1]:]

    return out


# -----------------------------------------------------------------------------
def main():
    # Read the schema definition.
    with open(PRESETS / SCHEMA_YAML_FILENAME, 'r') as f:
        schema = yaml.safe_load(f)

    # Extract the current (latest) version.
    global LATEST

    del schema['definitions']
    LATEST = schema.pop('version')

    # Remove whitespace around descriptions.
    schema = trimDescriptions(schema)

    # Read diagnostics and update schema.
    diagnostics = readDiagnostics(DIAGNOSTICS)
    configureSchema = getPath(schema, CONFIGURE_PRESET_PROPERTIES_PATH)
    configureSchema['warnings']['properties'] = mergeDiagnostics(
        buildDiagnosticsSchema(diagnostics, WARNING_DESCRIPTION,
                               WARNING_SPHINX_DESCRIPTION),
        configureSchema['warnings']['properties'])
    configureSchema['errors']['properties'] = mergeDiagnostics(
        buildDiagnosticsSchema(diagnostics, ERROR_DESCRIPTION,
                               ERROR_SPHINX_DESCRIPTION),
        configureSchema['errors']['properties'])

    # Extract global type definitions.
    global TYPES, REFS

    for t in schema.pop('types', []):
        name = t['id']
        value = buildValue(t, name)
        value.updateSpans(1, LATEST + 1)
        TYPES[name] = value

    for t in TYPES.values():
        t.updateRefs(TYPES)

    # Reset the spans for references, as these currently contain only spans for
    # circular references of global types, which are likely broader than the
    # actual usage spans of those types. We'll recalculate the correct spans
    # when we update refs from the root object.
    REFS = {}

    # Parse the root object. This will also recursively parse other objects.
    root = Object(schema, 'root')
    root.updateSpans(1, LATEST + 1)

    # Resolve references.
    objects = root.objects

    types = objects
    types.update(TYPES)

    root.updateRefs(types)

    for name, value in TYPES.items():
        span = REFS.get(name)
        if span is not None:
            value.updateSpans(*span)

    root.updateSpans(1, LATEST + 1)

    # Extract Sphinx documentation for each object's properties.
    for n, o in objects.items():
        doc = o.doc(1, LATEST + 1)
        if doc is not None:
            print(f'- Generating reST documentation for {n} properties')
            with open(PRESETS / f'{o.name}-properties.rst', 'w') as f:
                print(RST_BANNER, file=f)
                print(doc, file=f)

    # Generate schema for each version.
    versions = []
    for v in range(1, LATEST + 1):
        vs = root.schema(v, inline={'version'})
        vs['properties']['version']['const'] = v
        versions.append(vs)

    # Add definitions and write the schema.
    types = root.defs(exclude={'version'})
    for t in TYPES.values():
        types.update(t.defs())

    schema = {
        '$schema': 'http://json-schema.org/draft/2020-12/schema#',
        'type': 'object',
        'oneOf': versions,
        'required': ['version'],
        'definitions': types
    }

    with open(PRESETS / SCHEMA_JSON_FILENAME, 'w') as f:
        print(json.dumps(schema, indent=2), file=f)


# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

main()
