# openapi_importer.py
#
# Copyright 2025 Pavel Baksy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later

import json
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any

try:
    import yaml as _yaml
    _YAML_AVAILABLE = True
except ImportError:
    _YAML_AVAILABLE = False

# JSON Schema type → representative default value
_TYPE_DEFAULTS: Dict[str, Any] = {
    'string':  '',
    'integer': 0,
    'number':  0.0,
    'boolean': False,
    'null':    None,
    'array':   [],
    'object':  {},
}

_FORMAT_DEFAULTS: Dict[str, Any] = {
    'date-time': '2024-01-01T00:00:00Z',
    'date':      '2024-01-01',
    'time':      '00:00:00',
    'email':     'user@example.com',
    'uuid':      '00000000-0000-0000-0000-000000000000',
    'uri':       'https://example.com',
    'hostname':  'example.com',
    'ipv4':      '0.0.0.0',
    'int32':     0,
    'int64':     0,
    'float':     0.0,
    'double':    0.0,
    'byte':      '',
    'binary':    '',
    'password':  '',
}

HTTP_METHODS = frozenset({'get', 'post', 'put', 'delete', 'patch', 'head', 'options'})


# ---------------------------------------------------------------------------
# Public data classes
# ---------------------------------------------------------------------------

@dataclass
class OpenApiOperation:
    name: str           # operationId or "METHOD /path"
    method: str         # uppercase: GET, POST, …
    path: str
    url: str
    headers: Dict[str, str]
    body: str           # JSON template or ''
    description: str    # summary / description


@dataclass
class OpenApiParseResult:
    api_title: str
    base_url: str
    operations: List[OpenApiOperation] = field(default_factory=list)
    error: Optional[str] = None


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------

def parse_openapi(content: str) -> OpenApiParseResult:
    """Parse OpenAPI 2.0 (Swagger) or 3.x from JSON or YAML."""
    data = _load(content)
    if isinstance(data, str):           # error message
        return OpenApiParseResult(api_title='', base_url='', error=data)

    if not isinstance(data, dict):
        return OpenApiParseResult(api_title='', base_url='',
                                  error='Not a valid OpenAPI document')

    if 'swagger' in data:
        return _parse_v2(data)
    elif 'openapi' in data:
        return _parse_v3(data)
    else:
        return OpenApiParseResult(
            api_title='', base_url='',
            error='Not a recognized OpenAPI document (missing "swagger" or "openapi" key)'
        )


def build_http_request(operation: OpenApiOperation):
    """Convert an OpenApiOperation into an HttpRequest."""
    from .models import HttpRequest

    syntax = 'JSON' if operation.body.lstrip().startswith('{') else 'RAW'
    return HttpRequest(
        method=operation.method,
        url=operation.url,
        headers=operation.headers,
        body=operation.body,
        syntax=syntax,
    )


# ---------------------------------------------------------------------------
# Loader (JSON + optional YAML)
# ---------------------------------------------------------------------------

def _load(content: str):
    """Return parsed dict or an error string."""
    content = content.strip()
    # Try JSON first
    try:
        return json.loads(content)
    except json.JSONDecodeError:
        pass

    # Fall back to YAML
    if _YAML_AVAILABLE:
        try:
            return _yaml.safe_load(content)
        except Exception as e:
            return f'Invalid YAML: {e}'

    # Looks like YAML but library not available
    if not content.startswith('{'):
        return ('YAML format detected but PyYAML is not available. '
                'Please export the spec as JSON.')
    return 'Invalid JSON'


# ---------------------------------------------------------------------------
# OpenAPI 2.0 (Swagger)
# ---------------------------------------------------------------------------

def _parse_v2(data: dict) -> OpenApiParseResult:
    info     = data.get('info', {})
    title    = info.get('title', 'API')

    schemes  = data.get('schemes', ['https'])
    scheme   = 'https' if 'https' in schemes else (schemes[0] if schemes else 'https')
    host     = data.get('host', 'localhost')
    base_path = data.get('basePath', '/').rstrip('/')
    base_url  = f'{scheme}://{host}{base_path}'

    definitions = data.get('definitions', {})
    paths       = data.get('paths', {})

    operations = []
    for path, path_item in paths.items():
        if not isinstance(path_item, dict):
            continue
        path_params = path_item.get('parameters', [])
        for method, op_data in path_item.items():
            if method not in HTTP_METHODS or not isinstance(op_data, dict):
                continue
            params = _resolve_params(path_params + op_data.get('parameters', []),
                                     data.get('parameters', {}))
            operations.append(_make_op_v2(
                method.upper(), path, base_url, op_data, params, definitions
            ))

    if not operations:
        return OpenApiParseResult(api_title=title, base_url=base_url,
                                  error='No operations found in this document')

    return OpenApiParseResult(api_title=title, base_url=base_url, operations=operations)


def _make_op_v2(method, path, base_url, op_data, params, definitions) -> OpenApiOperation:
    op_id       = op_data.get('operationId', '')
    description = op_data.get('summary') or op_data.get('description', '')
    name        = op_id or f'{method} {path}'

    url = base_url + path
    url = _append_required_query(url, params)

    headers: Dict[str, str] = {}
    body = ''

    if method in ('POST', 'PUT', 'PATCH', 'DELETE'):
        body_params = [p for p in params if p.get('in') == 'body']
        if body_params:
            consumes = op_data.get('consumes', ['application/json'])
            if any('json' in ct for ct in consumes):
                headers['Content-Type'] = 'application/json'
                schema = body_params[0].get('schema', {})
                body = _schema_to_json(schema, definitions)
            else:
                headers['Content-Type'] = consumes[0] if consumes else 'application/octet-stream'

        form_params = [p for p in params if p.get('in') == 'formData']
        if form_params and not body:
            headers['Content-Type'] = 'application/x-www-form-urlencoded'
            body = '&'.join(f"{p['name']}=" for p in form_params)

    produces = op_data.get('produces', ['application/json'])
    if any('json' in ct for ct in produces):
        headers.setdefault('Accept', 'application/json')

    return OpenApiOperation(
        name=name, method=method, path=path,
        url=url, headers=headers, body=body, description=description,
    )


# ---------------------------------------------------------------------------
# OpenAPI 3.x
# ---------------------------------------------------------------------------

def _parse_v3(data: dict) -> OpenApiParseResult:
    info  = data.get('info', {})
    title = info.get('title', 'API')

    servers  = data.get('servers') or [{}]
    base_url = servers[0].get('url', '').rstrip('/')

    components = data.get('components', {})
    schemas    = components.get('schemas', {})
    param_defs = components.get('parameters', {})
    paths      = data.get('paths', {})

    operations = []
    for path, path_item in paths.items():
        if not isinstance(path_item, dict):
            continue
        path_params = _resolve_params(path_item.get('parameters', []), param_defs)
        for method, op_data in path_item.items():
            if method not in HTTP_METHODS or not isinstance(op_data, dict):
                continue
            params = _resolve_params(path_params + op_data.get('parameters', []),
                                     param_defs)
            operations.append(_make_op_v3(
                method.upper(), path, base_url, op_data, params, schemas
            ))

    if not operations:
        return OpenApiParseResult(api_title=title, base_url=base_url,
                                  error='No operations found in this document')

    return OpenApiParseResult(api_title=title, base_url=base_url, operations=operations)


def _make_op_v3(method, path, base_url, op_data, params, schemas) -> OpenApiOperation:
    op_id       = op_data.get('operationId', '')
    description = op_data.get('summary') or op_data.get('description', '')
    name        = op_id or f'{method} {path}'

    url = base_url + path
    url = _append_required_query(url, params)

    headers: Dict[str, str] = {}
    body = ''

    req_body = op_data.get('requestBody', {})
    if req_body and method in ('POST', 'PUT', 'PATCH', 'DELETE'):
        content = req_body.get('content', {})
        # Prefer application/json
        json_ct = next(
            (ct for ct in content if 'json' in ct),
            None
        )
        if json_ct:
            headers['Content-Type'] = json_ct
            schema = content[json_ct].get('schema', {})
            body = _schema_to_json(schema, schemas)
        elif content:
            ct = next(iter(content))
            headers['Content-Type'] = ct
            if 'form' in ct:
                schema = content[ct].get('schema', {})
                props  = schema.get('properties', {})
                body   = '&'.join(f'{k}=' for k in props)

    headers.setdefault('Accept', 'application/json')

    return OpenApiOperation(
        name=name, method=method, path=path,
        url=url, headers=headers, body=body, description=description,
    )


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _resolve_params(params: list, param_defs: dict) -> list:
    """Resolve $ref entries in a parameter list."""
    resolved = []
    for p in params:
        if isinstance(p, dict) and '$ref' in p:
            ref_name = p['$ref'].split('/')[-1]
            p = param_defs.get(ref_name, p)
        resolved.append(p)
    return resolved


def _append_required_query(url: str, params: list) -> str:
    required = [p['name'] for p in params
                if p.get('in') == 'query' and p.get('required', False)]
    if required:
        url += '?' + '&'.join(f'{n}=' for n in required)
    return url


def _schema_to_json(schema: dict, defs: dict, depth: int = 0) -> str:
    """Return a pretty-printed JSON template string for the given schema."""
    try:
        value = _schema_value(schema, defs, depth)
        return json.dumps(value, indent=2, ensure_ascii=False)
    except Exception:
        return ''


def _schema_value(schema: dict, defs: dict, depth: int = 0) -> Any:
    """Recursively build a representative value from a JSON Schema."""
    if depth > 4:
        return {}

    # Resolve $ref
    if '$ref' in schema:
        ref_name = schema['$ref'].split('/')[-1]
        schema = defs.get(ref_name, {})
        if not schema:
            return {}

    # Combiners: allOf / anyOf / oneOf
    for combiner in ('allOf', 'anyOf', 'oneOf'):
        if combiner in schema:
            merged: Dict[str, Any] = {}
            for sub in schema[combiner]:
                if '$ref' in sub:
                    sub = defs.get(sub['$ref'].split('/')[-1], {})
                result = _schema_value(sub, defs, depth)
                if isinstance(result, dict):
                    merged.update(result)
            if merged:
                return merged
            # Non-object combiners: return first sub-value
            first = schema[combiner][0] if schema[combiner] else {}
            return _schema_value(first, defs, depth)

    type_ = schema.get('type', 'object' if 'properties' in schema else None)

    if type_ == 'object' or 'properties' in schema:
        result = {}
        required = schema.get('required', [])
        for prop, prop_schema in schema.get('properties', {}).items():
            result[prop] = _schema_value(prop_schema, defs, depth + 1)
        if not result and 'additionalProperties' in schema:
            add = schema['additionalProperties']
            if isinstance(add, dict):
                result['key'] = _schema_value(add, defs, depth + 1)
        return result

    if type_ == 'array':
        items = schema.get('items', {})
        return [_schema_value(items, defs, depth + 1)] if items else []

    if type_ == 'string':
        enum = schema.get('enum')
        if enum:
            return enum[0]
        fmt = schema.get('format', '')
        return _FORMAT_DEFAULTS.get(fmt, '')

    if type_ in ('integer', 'number'):
        return schema.get('minimum', 0)

    if type_ == 'boolean':
        return False

    if type_ == 'null':
        return None

    return _TYPE_DEFAULTS.get(type_, {})
