import { FastifyInstance } from "fastify"
import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"

import { pool } from "../../db"
import { s3 } from "./s3"
import { secrets } from "./secrets"
import { decrypt, encrypt } from "./crypt"

type TableRows = Record<string, Record<string, any>[]>
type TableMetadata = {
    columns: string[]
    jsonColumns: Set<string>
    generatedColumns: Set<string>
}

export type TenantFullExport = {
    format: "fedeo.tenant-full-export"
    version: 1
    exportedAt: string
    tenantId: number
    tables: TableRows
    files: {
        id: string
        path: string
        name: string | null
        mimeType: string | null
        size: number | null
        contentBase64: string | null
        missing?: boolean
        error?: string
    }[]
}

type ImportResult = {
    tenantId: number
    tables: { table: string; rows: number }[]
    files: { restored: number; skipped: number }
}

type ImportOptions = {
    targetTenantId?: number | null
}

const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
    iban: "__plainIban",
    bic: "__plainBic",
    bankName: "__plainBankName",
}

const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`

const tableColumns = async (client: any) => {
    const result = await client.query(`
        select table_name, column_name, data_type, is_generated
        from information_schema.columns
        where table_schema = 'public'
        order by table_name, ordinal_position
    `)

    const columnsByTable = new Map<string, TableMetadata>()
    for (const row of result.rows) {
        const metadata = columnsByTable.get(row.table_name) || {
            columns: [],
            jsonColumns: new Set<string>(),
            generatedColumns: new Set<string>(),
        }

        metadata.columns.push(row.column_name)
        if (row.data_type === "json" || row.data_type === "jsonb") {
            metadata.jsonColumns.add(row.column_name)
        }
        if (row.is_generated === "ALWAYS") {
            metadata.generatedColumns.add(row.column_name)
        }

        columnsByTable.set(row.table_name, metadata)
    }

    return columnsByTable
}

const loadRows = async (client: any, table: string, whereSql: string, params: any[] = []) => {
    const result = await client.query(`select * from ${quoteIdent(table)} where ${whereSql}`, params)

    return result.rows
}

const collectIds = (rows: Record<string, any>[], column: string) => {
    return Array.from(new Set(rows.map((row) => row[column]).filter(Boolean)))
}

const addRows = (tables: TableRows, table: string, rows: Record<string, any>[]) => {
    if (!rows.length) {
        if (!tables[table]) tables[table] = []
        return
    }

    const existingRows = tables[table] || []
    const existingKeys = new Set(existingRows.map((row) => JSON.stringify(row)))
    for (const row of rows) {
        const key = JSON.stringify(row)
        if (!existingKeys.has(key)) {
            existingRows.push(row)
            existingKeys.add(key)
        }
    }
    tables[table] = existingRows
}

const decryptEntityBankAccountsForExport = (rows: Record<string, any>[] = []) => {
    return rows.map((row) => {
        const nextRow = { ...row }

        try {
            nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban] = row.iban_encrypted ? decrypt(row.iban_encrypted) : null
            nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic] = row.bic_encrypted ? decrypt(row.bic_encrypted) : null
            nextRow[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName] = row.bank_name_encrypted ? decrypt(row.bank_name_encrypted) : null
        } catch (err: any) {
            throw new Error(`Bankverbindung ${row.id || ""} konnte für den Export nicht entschlüsselt werden: ${err?.message || err}`)
        }

        return nextRow
    })
}

const isMissingObjectError = (err: any) =>
    err?.Code === "NoSuchKey" ||
    err?.name === "NoSuchKey" ||
    err?.$metadata?.httpStatusCode === 404

const loadObjectAsBase64 = async (path: string) => {
    try {
        const { Body } = await s3.send(new GetObjectCommand({
            Bucket: secrets.S3_BUCKET,
            Key: path,
        }))

        const chunks: Buffer[] = []
        for await (const chunk of Body as any) {
            chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
        }

        return {
            contentBase64: Buffer.concat(chunks).toString("base64"),
            missing: false,
            error: null,
        }
    } catch (err: any) {
        if (!isMissingObjectError(err)) throw err

        return {
            contentBase64: null,
            missing: true,
            error: err?.Code || err?.name || "NoSuchKey",
        }
    }
}

export const buildTenantFullExport = async (server: FastifyInstance, tenantId: number): Promise<TenantFullExport> => {
    const client = await pool.connect()

    try {
        const columnsByTable = await tableColumns(client)
        const tables: TableRows = {}

        const tenantRows = await loadRows(client, "tenants", "id = $1", [tenantId])
        if (!tenantRows.length) throw new Error("Tenant nicht gefunden")

        addRows(tables, "tenants", tenantRows)

        for (const [table, metadata] of columnsByTable.entries()) {
            if (table === "tenants") continue
            const { columns } = metadata

            const tenantColumn = columns.includes("tenant")
                ? "tenant"
                : columns.includes("tenant_id")
                    ? "tenant_id"
                    : null

            if (!tenantColumn) continue

            const rows = await loadRows(client, table, `${quoteIdent(tenantColumn)} = $1`, [tenantId])
            addRows(tables, table, rows)
        }

        const profileIds = collectIds(tables.auth_profiles || [], "id")
        const userIds = Array.from(new Set([
            ...collectIds(tables.auth_tenant_users || [], "user_id"),
            ...collectIds(tables.auth_profiles || [], "user_id"),
            ...collectIds(tables.auth_user_roles || [], "user_id"),
        ]))
        const roleIds = Array.from(new Set([
            ...collectIds(tables.auth_roles || [], "id"),
            ...collectIds(tables.auth_user_roles || [], "role_id"),
        ]))

        if (userIds.length) {
            addRows(tables, "auth_users", await loadRows(client, "auth_users", "id = any($1::uuid[])", [userIds]))
        }

        if (roleIds.length) {
            addRows(tables, "auth_roles", await loadRows(client, "auth_roles", "id = any($1::uuid[])", [roleIds]))
            addRows(tables, "auth_role_permissions", await loadRows(client, "auth_role_permissions", "role_id = any($1::uuid[])", [roleIds]))
        }

        if (profileIds.length) {
            addRows(tables, "auth_profile_branches", await loadRows(client, "auth_profile_branches", "profile_id = any($1::uuid[])", [profileIds]))
            addRows(tables, "auth_profile_teams", await loadRows(client, "auth_profile_teams", "profile_id = any($1::uuid[])", [profileIds]))
        }

        if (tables.entitybankaccounts?.length) {
            tables.entitybankaccounts = decryptEntityBankAccountsForExport(tables.entitybankaccounts)
        }

        const fileRows = tables.files || []
        const files = []

        for (const file of fileRows) {
            if (!file.path) continue

            const object = await loadObjectAsBase64(file.path)
            if (object.missing) {
                server.log.warn({
                    fileId: file.id,
                    path: file.path,
                    bucket: secrets.S3_BUCKET,
                    error: object.error,
                }, "Tenant full export skipped missing S3 object")
            }

            files.push({
                id: file.id,
                path: file.path,
                name: file.name || null,
                mimeType: file.mimeType || null,
                size: file.size || null,
                contentBase64: object.contentBase64,
                missing: object.missing || undefined,
                error: object.error || undefined,
            })
        }

        return {
            format: "fedeo.tenant-full-export",
            version: 1,
            exportedAt: new Date().toISOString(),
            tenantId,
            tables,
            files,
        }
    } finally {
        client.release()
    }
}

const restoreFiles = async (exportData: TenantFullExport) => {
    let restored = 0
    let skipped = 0

    for (const file of exportData.files || []) {
        if (!file.path || !file.contentBase64) {
            skipped += 1
            continue
        }

        const body = Buffer.from(file.contentBase64, "base64")
        await s3.send(new PutObjectCommand({
            Bucket: secrets.S3_BUCKET,
            Key: file.path,
            Body: body,
            ContentType: file.mimeType || "application/octet-stream",
            ContentLength: body.length,
        }))
        restored += 1
    }

    return { restored, skipped }
}

const remapTenantScopedExport = (
    exportData: TenantFullExport,
    targetTenantId?: number | null
): TenantFullExport => {
    if (!targetTenantId || targetTenantId === exportData.tenantId) return exportData

    const sourceTenantId = exportData.tenantId
    const sourcePathPrefix = `${sourceTenantId}/`
    const targetPathPrefix = `${targetTenantId}/`
    const tables: TableRows = {}

    for (const [table, rows] of Object.entries(exportData.tables || {})) {
        tables[table] = rows.map((row) => {
            const nextRow = { ...row }

            if (table === "tenants" && nextRow.id === sourceTenantId) {
                nextRow.id = targetTenantId
            }

            if (nextRow.tenant === sourceTenantId) {
                nextRow.tenant = targetTenantId
            }

            if (nextRow.tenant_id === sourceTenantId) {
                nextRow.tenant_id = targetTenantId
            }

            if (table === "files" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) {
                nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
            }

            return nextRow
        })
    }

    return {
        ...exportData,
        tenantId: targetTenantId,
        tables,
        files: (exportData.files || []).map((file) => ({
            ...file,
            path: file.path?.startsWith(sourcePathPrefix)
                ? `${targetPathPrefix}${file.path.slice(sourcePathPrefix.length)}`
                : file.path,
        })),
    }
}

const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) => {
    const rows = exportData.tables.entitybankaccounts || []

    for (const row of rows) {
        const plainIban = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban]
        const plainBic = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic]
        const plainBankName = row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName]

        if (typeof plainIban === "string" && typeof plainBic === "string" && typeof plainBankName === "string") {
            row.iban_encrypted = encrypt(plainIban)
            row.bic_encrypted = encrypt(plainBic)
            row.bank_name_encrypted = encrypt(plainBankName)
        }

        delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.iban]
        delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bic]
        delete row[ENTITY_BANKACCOUNT_PLAIN_FIELDS.bankName]
    }
}

const prepareColumnValue = (value: any, isJsonColumn: boolean) => {
    if (!isJsonColumn || value === null || typeof value === "undefined") return value
    if (typeof value === "string") return value

    return JSON.stringify(value)
}

const insertRows = async (client: any, table: string, rows: Record<string, any>[], metadata: TableMetadata) => {
    if (!rows.length) return 0

    let inserted = 0
    const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column)))

    for (const row of rows) {
        const rowColumns = Object.keys(row).filter((column) => availableColumns.has(column))
        if (!rowColumns.length) continue

        const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ")
        const values = rowColumns.map((column) => prepareColumnValue(row[column], metadata.jsonColumns.has(column)))

        await client.query(
            `insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) on conflict do nothing`,
            values
        )
        inserted += 1
    }

    return inserted
}

const insertIdentityRowsWithRemap = async (
    client: any,
    table: string,
    rows: Record<string, any>[],
    metadata: TableMetadata
) => {
    if (!rows.length) return { count: 0, idMap: new Map<number, number>() }

    let count = 0
    const idMap = new Map<number, number>()
    const availableColumns = new Set(metadata.columns.filter((column) => !metadata.generatedColumns.has(column)))

    for (const row of rows) {
        const sourceId = Number(row.id)
        if (!sourceId) continue

        const existing = await client.query(`select * from ${quoteIdent(table)} where ${quoteIdent("id")} = $1 limit 1`, [sourceId])
        const existingTenant = existing.rows[0]?.tenant ?? existing.rows[0]?.tenant_id
        const rowTenant = row.tenant ?? row.tenant_id
        const shouldPreserveId = existing.rows.length === 0
        const shouldReuseExisting = existing.rows.length > 0 && rowTenant && existingTenant === rowTenant
        const rowForInsert = shouldPreserveId ? row : { ...row, id: undefined }
        const rowColumns = Object
            .keys(rowForInsert)
            .filter((column) => availableColumns.has(column) && typeof rowForInsert[column] !== "undefined")

        if (shouldReuseExisting) {
            idMap.set(sourceId, sourceId)
            continue
        }

        if (!rowColumns.length) continue

        const placeholders = rowColumns.map((_, index) => `$${index + 1}`).join(", ")
        const values = rowColumns.map((column) => prepareColumnValue(rowForInsert[column], metadata.jsonColumns.has(column)))
        const inserted = await client.query(
            `insert into ${quoteIdent(table)} (${rowColumns.map(quoteIdent).join(", ")}) values (${placeholders}) returning ${quoteIdent("id")}`,
            values
        )
        const targetId = Number(inserted.rows[0]?.id)
        if (targetId) {
            idMap.set(sourceId, targetId)
        }
        count += 1
    }

    return { count, idMap }
}

const remapTableColumn = (rows: Record<string, any>[] = [], column: string, idMap: Map<number, number>) => {
    if (!idMap.size) return

    for (const row of rows) {
        const currentValue = Number(row[column])
        const mappedValue = idMap.get(currentValue)
        if (mappedValue) row[column] = mappedValue
    }
}

const refreshSequences = async (client: any, columnsByTable: Map<string, TableMetadata>) => {
    for (const [table, metadata] of columnsByTable.entries()) {
        const { columns } = metadata
        if (!columns.includes("id")) continue

        const sequenceResult = await client.query("select pg_get_serial_sequence($1, $2) as sequence_name", [`public.${table}`, "id"])
        const sequenceName = sequenceResult.rows[0]?.sequence_name
        if (!sequenceName) continue

        await client.query(`
            select setval(
                $1::regclass,
                greatest(coalesce((select max(id) from ${quoteIdent(table)}), 1), 1),
                true
            )
        `, [sequenceName])
    }
}

export const importTenantFullExport = async (
    server: FastifyInstance,
    rawExportData: TenantFullExport,
    options: ImportOptions = {}
): Promise<ImportResult> => {
    if (rawExportData?.format !== "fedeo.tenant-full-export" || rawExportData.version !== 1) {
        throw new Error("Ungültiges FEDEO Mandantenexport-Format")
    }

    const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
    encryptEntityBankAccountRowsForImport(exportData)
    const client = await pool.connect()
    const importOrder = [
        "tenants",
        "auth_users",
        "auth_roles",
        "auth_role_permissions",
        "auth_tenant_users",
        "auth_profiles",
        "auth_user_roles",
        "auth_profile_branches",
        "auth_profile_teams",
    ]

    try {
        const columnsByTable = await tableColumns(client)
        const tableNames = [
            ...importOrder,
            ...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(),
        ].filter((table, index, all) => all.indexOf(table) === index)

        await client.query("begin")
        await client.query("set local session_replication_role = replica")
        const files = await restoreFiles(exportData)

        const importedTables: { table: string; rows: number }[] = []
        const bankaccountMetadata = columnsByTable.get("bankaccounts")
        if (bankaccountMetadata) {
            const result = await insertIdentityRowsWithRemap(client, "bankaccounts", exportData.tables.bankaccounts || [], bankaccountMetadata)
            remapTableColumn(exportData.tables.bankstatements, "account", result.idMap)
            importedTables.push({ table: "bankaccounts", rows: result.count })
        }

        const bankstatementMetadata = columnsByTable.get("bankstatements")
        if (bankstatementMetadata) {
            const result = await insertIdentityRowsWithRemap(client, "bankstatements", exportData.tables.bankstatements || [], bankstatementMetadata)
            remapTableColumn(exportData.tables.statementallocations, "bs_id", result.idMap)
            remapTableColumn(exportData.tables.historyitems, "bankstatement", result.idMap)
            importedTables.push({ table: "bankstatements", rows: result.count })
        }

        for (const table of tableNames) {
            if (["bankaccounts", "bankstatements"].includes(table)) continue

            const rows = exportData.tables[table] || []
            const metadata = columnsByTable.get(table)
            if (!metadata) continue

            const count = await insertRows(client, table, rows, metadata)
            importedTables.push({ table, rows: count })
        }

        await refreshSequences(client, columnsByTable)
        await client.query("commit")

        return {
            tenantId: exportData.tenantId,
            tables: importedTables,
            files,
        }
    } catch (err) {
        await client.query("rollback")
        throw err
    } finally {
        client.release()
    }
}
