/**
 * @fileoverview Open Charge Map service — HTTP client over the OCM `/v3/poi` endpoint with retry,
 * session-level result caching, and response normalization. Search uses `verbose=false`; single-
 * station detail uses `verbose=true` so `GeneralComments`/`UsageCost`/`NumberOfPoints`/`MediaItems`
 * are populated. Non-OK responses are translated to contract reasons (`auth_failed` for 401/403,
 * `upstream_unavailable` otherwise) so calling tools surface a stable `data.reason`.
 * @module services/openchargemap/openchargemap-service
 */
import { createHash } from 'node:crypto';
import { JsonRpcErrorCode, serviceUnavailable, unauthorized } from '@cyanheads/mcp-ts-core/errors';
import { fetchWithTimeout, requestContextService, withRetry, } from '@cyanheads/mcp-ts-core/utils';
import { getReferenceDataService } from '../../services/reference-data/reference-data-service.js';
/** Result-cache TTL in seconds — station status drifts over minutes, not seconds. */
const CACHE_TTL_SECONDS = 600;
/** Map the OCM integer distance-unit enum to a string. */
function distanceUnitLabel(raw) {
    if (raw === 1)
        return 'KM';
    if (raw === 2)
        return 'Miles';
    return;
}
/** Serialize an array filter to a comma-joined string, or a scalar as-is. */
function joinFilter(value) {
    if (value === undefined)
        return;
    return Array.isArray(value) ? value.join(',') : String(value);
}
/**
 * Hash a composed cache key to a storage-safe token. The framework's key validator allows only
 * `[a-zA-Z0-9_.\-/]` — no colons — so segments join with hyphens (the hash is hex, also safe).
 */
function cacheKey(prefix, payload) {
    const hash = createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 32);
    return `ocm-${prefix}-${hash}`;
}
export class OpenChargeMapService {
    serverConfig;
    constructor(serverConfig) {
        this.serverConfig = serverConfig;
    }
    /**
     * Search POIs by radius or bounding box with optional filters (`verbose=false`).
     * Returns normalized stations ordered by distance. Cached per param set in `ctx.state`.
     */
    async searchPois(params, ctx) {
        const key = cacheKey('search', params);
        const cached = await ctx.state.get(key);
        if (cached) {
            ctx.log.debug('OCM search cache hit', { key });
            return cached;
        }
        const url = this.buildSearchUrl(params);
        const raw = await this.fetchPois(url, 'searchPois', ctx);
        const stations = raw.map((poi) => this.normalizeStation(poi));
        await ctx.state.set(key, stations, { ttl: CACHE_TTL_SECONDS });
        return stations;
    }
    /**
     * Fetch a single station's full record by numeric OCM ID (`verbose=true`).
     * Returns `null` when OCM responds HTTP 200 with `[]` (no such station) — the caller throws
     * `not_found`. `includeComments` embeds `UserComments[]`.
     */
    async getPoi(id, options, ctx) {
        const key = cacheKey('poi', { id, includeComments: options.includeComments });
        const cached = await ctx.state.get(key);
        if (cached === 'EMPTY')
            return null;
        if (cached) {
            ctx.log.debug('OCM detail cache hit', { key, id });
            return cached;
        }
        const url = this.buildDetailUrl(id, options.includeComments);
        const raw = await this.fetchPois(url, 'getPoi', ctx);
        const [first] = raw;
        if (!first) {
            await ctx.state.set(key, 'EMPTY', { ttl: CACHE_TTL_SECONDS });
            return null;
        }
        const station = this.normalizeStationDetail(first, options.includeComments);
        await ctx.state.set(key, station, { ttl: CACHE_TTL_SECONDS });
        return station;
    }
    // --- URL construction ---
    buildSearchUrl(params) {
        const qs = new URLSearchParams({
            output: 'json',
            compact: 'false',
            verbose: 'false',
            maxresults: String(params.maxresults),
        });
        if (params.boundingbox) {
            const { sw_lat, sw_lng, ne_lat, ne_lng } = params.boundingbox;
            qs.set('boundingbox', `(${sw_lat},${sw_lng}),(${ne_lat},${ne_lng})`);
        }
        else if (params.latitude !== undefined && params.longitude !== undefined) {
            qs.set('latitude', String(params.latitude));
            qs.set('longitude', String(params.longitude));
            qs.set('distance', String(params.distance ?? 25));
            qs.set('distanceunit', params.distanceUnit ?? 'KM');
        }
        if (params.countrycode)
            qs.set('countrycode', params.countrycode.toUpperCase());
        if (params.minpowerkw !== undefined)
            qs.set('minpowerkw', String(params.minpowerkw));
        if (params.minchargepoints !== undefined)
            qs.set('minnumberofpoints', String(params.minchargepoints));
        const conn = joinFilter(params.connectiontypeid);
        if (conn)
            qs.set('connectiontypeid', conn);
        const op = joinFilter(params.operatorid);
        if (op)
            qs.set('operatorid', op);
        const usage = joinFilter(params.usagetypeid);
        if (usage)
            qs.set('usagetypeid', usage);
        const level = joinFilter(params.levelid);
        if (level)
            qs.set('levelid', level);
        const status = joinFilter(params.statustypeid);
        if (status)
            qs.set('statustypeid', status);
        return `${this.serverConfig.baseUrl}/poi?${qs.toString()}`;
    }
    buildDetailUrl(id, includeComments) {
        const qs = new URLSearchParams({
            output: 'json',
            compact: 'false',
            verbose: 'true',
            chargepointid: String(id),
        });
        if (includeComments)
            qs.set('includecomments', 'true');
        return `${this.serverConfig.baseUrl}/poi?${qs.toString()}`;
    }
    // --- fetch + retry + error translation ---
    /** Fetch and parse a POI array, with retry over the full pipeline and status→reason mapping. */
    fetchPois(url, operation, ctx) {
        const reqCtx = requestContextService.createRequestContext({
            operation,
            parentContext: { requestId: ctx.requestId, traceId: ctx.traceId, tenantId: ctx.tenantId },
        });
        return withRetry(async () => {
            let response;
            try {
                response = await fetchWithTimeout(url, 15_000, reqCtx, {
                    headers: { 'X-API-Key': this.serverConfig.apiKey },
                    signal: ctx.signal,
                });
            }
            catch (error) {
                throw this.translateFetchError(error, ctx);
            }
            const data = (await response.json());
            if (!Array.isArray(data)) {
                throw serviceUnavailable('Open Charge Map returned an unexpected (non-array) response.', {
                    reason: 'upstream_unavailable',
                    ...ctx.recoveryFor('upstream_unavailable'),
                });
            }
            return data;
        }, { operation, context: reqCtx, baseDelayMs: 1500, signal: ctx.signal });
    }
    /**
     * `fetchWithTimeout` throws a classified `McpError` on non-OK. Re-tag auth failures (401/403,
     * which OCM returns for a missing/invalid key) as `auth_failed`/Unauthorized (non-retryable);
     * everything else as `upstream_unavailable`/ServiceUnavailable (retryable) so the retry predicate
     * and the tool contracts align.
     */
    translateFetchError(error, ctx) {
        const code = error.code;
        if (code === JsonRpcErrorCode.Unauthorized || code === JsonRpcErrorCode.Forbidden) {
            return unauthorized('Open Charge Map rejected the API key (HTTP 401/403).', {
                reason: 'auth_failed',
                retryable: false,
                ...ctx.recoveryFor('auth_failed'),
            });
        }
        return serviceUnavailable(error instanceof Error ? error.message : 'Open Charge Map request failed.', {
            reason: 'upstream_unavailable',
            retryable: true,
            ...ctx.recoveryFor('upstream_unavailable'),
        }, { cause: error });
    }
    // --- normalization ---
    normalizeStation(poi) {
        const addr = poi.AddressInfo ?? {};
        const usage = poi.UsageType ?? undefined;
        const status = poi.StatusType ?? undefined;
        const ref = getReferenceDataService();
        const distUnit = distanceUnitLabel(addr.DistanceUnit);
        const station = {
            id: poi.ID ?? 0,
            uuid: poi.UUID ?? '',
            title: addr.Title?.trim() || `Station ${poi.ID ?? ''}`.trim(),
            address: {
                latitude: addr.Latitude ?? 0,
                longitude: addr.Longitude ?? 0,
                ...(addr.AddressLine1 ? { line1: addr.AddressLine1 } : {}),
                ...(addr.Town ? { town: addr.Town } : {}),
                ...(addr.StateOrProvince ? { stateOrProvince: addr.StateOrProvince } : {}),
                ...(addr.Postcode ? { postcode: addr.Postcode } : {}),
                ...(addr.Country?.Title ? { country: addr.Country.Title } : {}),
                ...(addr.Country?.ISOCode ? { countryCode: addr.Country.ISOCode } : {}),
                ...(addr.AccessComments ? { accessComments: addr.AccessComments } : {}),
            },
            connections: (poi.Connections ?? []).map((c) => this.normalizeConnection(c)),
            ...(typeof addr.Distance === 'number' ? { distance: addr.Distance } : {}),
            ...(distUnit ? { distanceUnit: distUnit } : {}),
            ...(poi.OperatorInfo?.Title ? { operator: poi.OperatorInfo.Title } : {}),
            ...(typeof poi.OperatorID === 'number' ? { operatorId: poi.OperatorID } : {}),
            ...(usage?.Title ? { usageType: usage.Title } : {}),
            ...(typeof usage?.IsPayAtLocation === 'boolean'
                ? { isPayAtLocation: usage.IsPayAtLocation }
                : {}),
            ...(typeof usage?.IsMembershipRequired === 'boolean'
                ? { isMembershipRequired: usage.IsMembershipRequired }
                : {}),
            ...(typeof usage?.IsAccessKeyRequired === 'boolean'
                ? { isAccessKeyRequired: usage.IsAccessKeyRequired }
                : {}),
            ...(typeof poi.NumberOfPoints === 'number' ? { numberOfPoints: poi.NumberOfPoints } : {}),
            ...(status?.Title ? { status: status.Title } : {}),
            // IsOperational is ABSENT for Unknown (ID 0) — only set when the key is actually present.
            ...(typeof status?.IsOperational === 'boolean'
                ? { isOperational: status.IsOperational }
                : {}),
            ...(poi.DateLastVerified !== undefined ? { dateLastVerified: poi.DateLastVerified } : {}),
            ...(typeof poi.IsRecentlyVerified === 'boolean'
                ? { isRecentlyVerified: poi.IsRecentlyVerified }
                : {}),
            ...(poi.DataProvider?.Title ? { dataProvider: poi.DataProvider.Title } : {}),
        };
        // Backfill operator/usage/status titles from reference data when OCM left them off but gave an ID.
        if (!station.operator && typeof poi.OperatorID === 'number') {
            const label = ref.labelFor('operators', poi.OperatorID);
            if (label)
                station.operator = label;
        }
        return station;
    }
    normalizeStationDetail(poi, includeComments) {
        const base = this.normalizeStation(poi);
        const detail = {
            ...base,
            ...(poi.GeneralComments?.trim() ? { generalComments: poi.GeneralComments.trim() } : {}),
            ...(poi.UsageCost?.trim() ? { usageCost: poi.UsageCost.trim() } : {}),
            ...(poi.DataProvider?.WebsiteURL ? { dataProviderUrl: poi.DataProvider.WebsiteURL } : {}),
            ...(poi.DateLastStatusUpdate !== undefined
                ? { dateLastStatusUpdate: poi.DateLastStatusUpdate }
                : {}),
            ...(poi.SubmissionStatus?.Title ? { submissionStatus: poi.SubmissionStatus.Title } : {}),
        };
        const media = (poi.MediaItems ?? [])
            .filter((m) => Boolean(m?.ItemURL))
            .map((m) => ({ url: m.ItemURL, ...(m.Comment ? { comment: m.Comment } : {}) }));
        if (media.length > 0)
            detail.media = media;
        if (includeComments) {
            detail.comments = this.normalizeComments(poi.UserComments ?? []);
        }
        return detail;
    }
    normalizeConnection(c) {
        return {
            ...(typeof c.ConnectionTypeID === 'number' ? { connectionTypeId: c.ConnectionTypeID } : {}),
            ...(c.ConnectionType?.Title ? { connectionType: c.ConnectionType.Title } : {}),
            ...(c.Level?.Title ? { level: c.Level.Title } : {}),
            ...(typeof c.LevelID === 'number' ? { levelId: c.LevelID } : {}),
            ...(c.PowerKW !== undefined ? { powerKW: c.PowerKW } : {}),
            ...(c.CurrentType?.Title ? { currentType: c.CurrentType.Title } : {}),
            ...(c.Amps !== undefined ? { amps: c.Amps } : {}),
            ...(c.Voltage !== undefined ? { voltage: c.Voltage } : {}),
            ...(c.Quantity !== undefined ? { quantity: c.Quantity } : {}),
        };
    }
    /** Normalize and sort comments newest-first. */
    normalizeComments(raw) {
        return raw
            .map((c) => ({
            ...(c.UserName ? { user: c.UserName } : {}),
            ...(c.CommentType?.Title ? { commentType: c.CommentType.Title } : {}),
            ...(c.Comment ? { comment: c.Comment } : {}),
            ...(c.Rating !== undefined ? { rating: c.Rating } : {}),
            ...(c.DateCreated ? { dateCreated: c.DateCreated } : {}),
        }))
            .sort((a, b) => {
            const ta = a.dateCreated ? Date.parse(a.dateCreated) : 0;
            const tb = b.dateCreated ? Date.parse(b.dateCreated) : 0;
            return tb - ta;
        });
    }
}
// --- Init/accessor pattern ---
let _service;
/** Construct the OCM service. Registered in `createApp()`'s `setup()`. */
export function initOpenChargeMapService(serverConfig) {
    _service = new OpenChargeMapService(serverConfig);
}
/** Accessor for the initialized OCM service. */
export function getOpenChargeMapService() {
    if (!_service) {
        throw new Error('OpenChargeMapService not initialized — call initOpenChargeMapService() in setup()');
    }
    return _service;
}
/** Reset the singleton (test isolation only). */
export function resetOpenChargeMapService() {
    _service = undefined;
}
//# sourceMappingURL=openchargemap-service.js.map