import { csrfToken } from '@/lib/csrf';
import agentRoutes from '@/routes/agent';

export type ChartSpec = {
    type: 'line' | 'bar' | 'area' | 'pie';
    title: string;
    labels: string[];
    series: Array<{ name: string; data: number[] }>;
    meta?: Record<string, unknown>;
};

export type TableSpec = {
    title: string;
    columns: string[];
    rows: Array<Array<string | number>>;
    meta?: Record<string, unknown>;
};

export type HtmlSpec = {
    title?: string | null;
    html: string;
};

export type Artifact =
    | { kind: 'chart'; spec: ChartSpec }
    | { kind: 'table'; spec: TableSpec }
    | { kind: 'html'; spec: HtmlSpec };

export type ChatMessage = {
    id: string;
    role: 'user' | 'assistant' | string;
    content: string;
    artifacts?: Artifact[];
    /** @deprecated Use artifacts instead */
    charts?: ChartSpec[];
    failed?: boolean;
};

const CHART_TYPES = new Set<ChartSpec['type']>(['line', 'bar', 'area', 'pie']);
const MAX_CHART_LABELS = 100;
const MAX_CHART_SERIES = 8;

function coerceNumber(value: unknown): number | null {
    if (typeof value === 'number' && Number.isFinite(value)) {
        return value;
    }

    if (typeof value === 'string' && value.trim() !== '') {
        const parsed = Number(value);

        return Number.isFinite(parsed) ? parsed : null;
    }

    return null;
}

function downsampleChart(
    labels: string[],
    series: ChartSpec['series'],
    maxPoints: number,
): { labels: string[]; series: ChartSpec['series'] } {
    if (labels.length <= maxPoints) {
        return { labels, series };
    }

    const bucketSize = Math.ceil(labels.length / maxPoints);
    const newLabels: string[] = [];
    const newSeries = series.map((item) => ({
        name: item.name,
        data: [] as number[],
    }));

    for (let start = 0; start < labels.length; start += bucketSize) {
        const end = Math.min(start + bucketSize, labels.length);
        const bucketLabels = labels.slice(start, end);
        newLabels.push(
            bucketLabels.length === 1
                ? bucketLabels[0]
                : `${bucketLabels[0]} – ${bucketLabels[bucketLabels.length - 1]}`,
        );

        series.forEach((item, seriesIndex) => {
            const slice = item.data.slice(start, end);
            const average =
                slice.length > 0
                    ? slice.reduce((sum, value) => sum + value, 0) /
                      slice.length
                    : 0;
            newSeries[seriesIndex].data.push(Math.round(average * 100) / 100);
        });
    }

    return { labels: newLabels, series: newSeries };
}

export function normalizeChartSpec(payload: unknown): ChartSpec | null {
    if (typeof payload !== 'object' || payload === null) {
        return null;
    }

    const chart = payload as Record<string, unknown>;
    const type = chart.type;
    const title = chart.title;
    const labels = chart.labels;
    const series = chart.series;

    if (
        typeof type !== 'string' ||
        !CHART_TYPES.has(type as ChartSpec['type']) ||
        typeof title !== 'string' ||
        title.trim() === '' ||
        !Array.isArray(labels) ||
        !Array.isArray(series)
    ) {
        return null;
    }

    const normalizedLabels = labels.filter(
        (label): label is string =>
            typeof label === 'string' && label.trim() !== '',
    );

    if (normalizedLabels.length === 0) {
        return null;
    }

    const normalizedSeries = series
        .map((item) => {
            if (typeof item !== 'object' || item === null) {
                return null;
            }

            const name = (item as { name?: unknown }).name;
            const data = (item as { data?: unknown }).data;

            if (
                typeof name !== 'string' ||
                name.trim() === '' ||
                name === 'label' ||
                !Array.isArray(data)
            ) {
                return null;
            }

            const normalizedData = data.map(
                (value) => coerceNumber(value) ?? 0,
            );

            if (normalizedData.length !== normalizedLabels.length) {
                return null;
            }

            if (!normalizedData.every((value) => Number.isFinite(value))) {
                return null;
            }

            return {
                name: name.trim(),
                data: normalizedData,
            };
        })
        .filter((item): item is ChartSpec['series'][number] => item !== null);

    if (
        normalizedSeries.length === 0 ||
        normalizedSeries.length > MAX_CHART_SERIES
    ) {
        return null;
    }

    const downsampled =
        normalizedLabels.length > MAX_CHART_LABELS
            ? downsampleChart(
                  normalizedLabels,
                  normalizedSeries,
                  MAX_CHART_LABELS,
              )
            : { labels: normalizedLabels, series: normalizedSeries };

    return {
        type: type as ChartSpec['type'],
        title: title.trim(),
        labels: downsampled.labels,
        series: downsampled.series,
        meta:
            typeof chart.meta === 'object' && chart.meta !== null
                ? (chart.meta as Record<string, unknown>)
                : undefined,
    };
}

export function normalizeTableSpec(payload: unknown): TableSpec | null {
    if (typeof payload !== 'object' || payload === null) {
        return null;
    }

    const table = payload as Record<string, unknown>;
    const title = table.title;
    const columns = table.columns;
    const rows = table.rows;

    if (
        typeof title !== 'string' ||
        title.trim() === '' ||
        !Array.isArray(columns) ||
        !Array.isArray(rows)
    ) {
        return null;
    }

    const normalizedColumns = columns.filter(
        (column): column is string =>
            typeof column === 'string' && column.trim() !== '',
    );

    if (normalizedColumns.length === 0 || normalizedColumns.length > 20) {
        return null;
    }

    const normalizedRows = rows
        .slice(0, 200)
        .map((row) => {
            if (!Array.isArray(row)) {
                return null;
            }

            const cells = row.slice(0, normalizedColumns.length);

            return normalizedColumns.map((_, index) => {
                const cell = cells[index];

                if (typeof cell === 'number' && Number.isFinite(cell)) {
                    return Math.round(cell * 100) / 100;
                }

                if (typeof cell === 'boolean') {
                    return cell ? 'Yes' : 'No';
                }

                if (cell === null || cell === undefined) {
                    return '';
                }

                return String(cell);
            });
        })
        .filter((row): row is Array<string | number> => row !== null);

    if (normalizedRows.length === 0) {
        return null;
    }

    return {
        title: title.trim(),
        columns: normalizedColumns,
        rows: normalizedRows,
        meta:
            typeof table.meta === 'object' && table.meta !== null
                ? (table.meta as Record<string, unknown>)
                : undefined,
    };
}

export function normalizeHtmlSpec(payload: unknown): HtmlSpec | null {
    if (typeof payload !== 'object' || payload === null) {
        return null;
    }

    const htmlSpec = payload as Record<string, unknown>;
    const html = htmlSpec.html;

    if (typeof html !== 'string' || html.trim() === '') {
        return null;
    }

    const title = htmlSpec.title;

    return {
        title:
            typeof title === 'string' && title.trim() !== ''
                ? title.trim()
                : null,
        html: html.trim(),
    };
}

/**
 * Two artifacts are considered duplicates when they have the same kind and
 * deep-equal spec — this happens when the model calls a builder tool
 * (BuildChartSpec/BuildTableSpec) and then re-presents the same data via
 * PresentArtifact, which would otherwise render the same chart/table twice.
 */
function artifactsEqual(a: Artifact, b: Artifact): boolean {
    if (a.kind !== b.kind) {
        return false;
    }

    return JSON.stringify(a.spec) === JSON.stringify(b.spec);
}

export function dedupeArtifacts(artifacts: Artifact[]): Artifact[] {
    const deduped: Artifact[] = [];

    for (const artifact of artifacts) {
        if (!deduped.some((existing) => artifactsEqual(existing, artifact))) {
            deduped.push(artifact);
        }
    }

    return deduped;
}

function artifactFromRecord(record: Record<string, unknown>): Artifact[] {
    const artifacts: Artifact[] = [];

    if (record.chart && typeof record.chart === 'object') {
        const chart = normalizeChartSpec(record.chart);

        if (chart) {
            artifacts.push({ kind: 'chart', spec: chart });
        }
    }

    if (record.table && typeof record.table === 'object') {
        const table = normalizeTableSpec(record.table);

        if (table) {
            artifacts.push({ kind: 'table', spec: table });
        }
    }

    if (record.html && typeof record.html === 'object') {
        const html = normalizeHtmlSpec(record.html);

        if (html) {
            artifacts.push({ kind: 'html', spec: html });
        }
    }

    if (Array.isArray(record.charts)) {
        for (const chart of record.charts) {
            const normalized = normalizeChartSpec(chart);

            if (normalized) {
                artifacts.push({ kind: 'chart', spec: normalized });
            }
        }
    }

    if (Array.isArray(record.tables)) {
        for (const table of record.tables) {
            const normalized = normalizeTableSpec(table);

            if (normalized) {
                artifacts.push({ kind: 'table', spec: normalized });
            }
        }
    }

    if (Array.isArray(record.artifacts)) {
        for (const artifact of record.artifacts) {
            artifacts.push(...extractArtifacts(artifact));
        }
    }

    return artifacts;
}

export function extractArtifacts(payload: unknown): Artifact[] {
    if (payload == null) {
        return [];
    }

    if (typeof payload === 'string') {
        try {
            return extractArtifacts(JSON.parse(payload));
        } catch {
            return [];
        }
    }

    if (typeof payload !== 'object' || payload === null) {
        return [];
    }

    const record = payload as Record<string, unknown>;

    if ('result' in record) {
        return extractArtifacts(record.result);
    }

    if ('error' in record && typeof record.error === 'string') {
        return [];
    }

    return artifactFromRecord(record);
}

export function extractCharts(payload: unknown): ChartSpec[] {
    return extractArtifacts(payload)
        .filter(
            (artifact): artifact is { kind: 'chart'; spec: ChartSpec } =>
                artifact.kind === 'chart',
        )
        .map((artifact) => artifact.spec);
}

export async function streamAgentMessage(options: {
    message: string;
    conversationId?: string | null;
    onText: (delta: string, full: string) => void;
    onArtifacts: (artifacts: Artifact[]) => void;
    /** @deprecated Use onArtifacts */
    onCharts?: (charts: ChartSpec[]) => void;
    onError?: (message: string) => void;
    /** Called when the backend created a new conversation for this message. */
    onConversationId?: (conversationId: string) => void;
    signal?: AbortSignal;
}): Promise<void> {
    const url = options.conversationId
        ? agentRoutes.conversations.messages.store.url(options.conversationId)
        : agentRoutes.messages.store.url();

    const response = await fetch(url, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            Accept: 'text/event-stream',
            'X-XSRF-TOKEN': csrfToken(),
            'X-Requested-With': 'XMLHttpRequest',
        },
        credentials: 'same-origin',
        body: JSON.stringify({
            message: options.message,
            conversation_id: options.conversationId ?? undefined,
        }),
        signal: options.signal,
    });

    if (!response.ok || !response.body) {
        const text = await response.text();
        let message = text;

        try {
            const payload = JSON.parse(text) as {
                message?: string;
                errors?: Record<string, string[]>;
            };
            message =
                payload.message ??
                Object.values(payload.errors ?? {})[0]?.[0] ??
                text;
        } catch {
            // The response may be plain text.
        }

        throw new Error(message || `Request failed (${response.status})`);
    }

    const createdConversationId = response.headers.get('X-Conversation-Id');

    if (createdConversationId) {
        options.onConversationId?.(createdConversationId);
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    let full = '';
    let textStarted = false;
    const artifacts: Artifact[] = [];

    const emitArtifacts = () => {
        options.onArtifacts([...artifacts]);
        options.onCharts?.(
            artifacts
                .filter(
                    (
                        artifact,
                    ): artifact is { kind: 'chart'; spec: ChartSpec } =>
                        artifact.kind === 'chart',
                )
                .map((artifact) => artifact.spec),
        );
    };

    // Artifacts from tool calls arrive before the model's text response (tool
    // calls happen first in the agent loop). Buffer them and reveal them once
    // the model starts composing its answer so the chart/table/html and the
    // text appear together, instead of the artifact popping in on its own.
    const addArtifacts = (found: Artifact[]) => {
        const before = artifacts.length;
        artifacts.push(...found);
        const deduped = dedupeArtifacts(artifacts);
        artifacts.length = 0;
        artifacts.push(...deduped);

        if (artifacts.length !== before && textStarted) {
            emitArtifacts();
        }
    };

    while (true) {
        const { done, value } = await reader.read();

        if (done) {
            break;
        }

        buffer += decoder.decode(value, { stream: true });
        const chunks = buffer.split('\n\n');
        buffer = chunks.pop() ?? '';

        for (const chunk of chunks) {
            const lines = chunk.split('\n');

            for (const line of lines) {
                if (!line.startsWith('data: ')) {
                    continue;
                }

                const data = line.slice(6).trim();

                if (data === '[DONE]') {
                    continue;
                }

                try {
                    const event = JSON.parse(data) as {
                        type?: string;
                        delta?: string;
                        output?: unknown;
                        errorText?: string;
                    };

                    if (
                        (event.type === 'text-delta' ||
                            event.type === 'text-delta-start' ||
                            event.type === 'text') &&
                        typeof event.delta === 'string'
                    ) {
                        const shouldRevealArtifacts =
                            !textStarted && artifacts.length > 0;
                        textStarted = true;
                        full += event.delta;
                        options.onText(event.delta, full);

                        if (shouldRevealArtifacts) {
                            emitArtifacts();
                        }
                    }

                    if (event.type === 'tool-output-available') {
                        const found = extractArtifacts(event.output);

                        if (found.length > 0) {
                            addArtifacts(found);
                        }
                    }

                    if (event.type === 'tool-output-error') {
                        options.onError?.(
                            typeof event.errorText === 'string'
                                ? event.errorText
                                : typeof event.output === 'string'
                                  ? event.output
                                  : 'A financial data tool failed while preparing this response.',
                        );
                    }
                } catch {
                    options.onError?.(
                        'The agent returned an invalid streaming event. Please try again.',
                    );
                }
            }
        }
    }

    // Safety net: a turn that produced artifacts but no text (or whose text
    // arrived before the artifacts finished streaming) still needs a final flush.
    if (artifacts.length > 0) {
        emitArtifacts();
    }
}
