import { Injectable, Logger } from '@nestjs/common';
import { sql } from 'drizzle-orm';
import { getTableColumns, getTableName, is } from 'drizzle-orm';
import { PgTable } from 'drizzle-orm/pg-core';
import archiver, { ArchiverError } from 'archiver';
import { PassThrough } from 'stream';
import * as schema from '@/db/schema';
import { DbService, Tx } from '@/common/db/db.service';
import { AuditService } from '@/common/audit/audit.service';
import { NotFoundError } from '@/common/errors/domain-error';
import { tenants } from '@/db/schema';
import { eq } from 'drizzle-orm';

const PAGE = 2000;

/** Tables never exported: secrets and platform-internal state. */
const EXCLUDED = new Set(['refresh_tokens', 'api_keys', 'platform_users', 'plans', 'subscriptions', 'usage_records', 'outbox_events', 'email_outbox', 'webhook_deliveries']);

/**
 * Full tenant data export (GDPR / contract exit / migration): one NDJSON file
 * per tenant-scoped table inside a zip, plus a manifest with row counts.
 * Binary objects (documents, attachments) are referenced by storage key, not
 * embedded; copy them from the object store with the keys in the manifest.
 */
@Injectable()
export class TenantExportService {
  private readonly logger = new Logger(TenantExportService.name);

  constructor(
    private readonly db: DbService,
    private readonly audit: AuditService,
  ) {}

  /** Tables (name -> column names) that carry a tenant_id column. */
  tenantTables(): { name: string; columns: string[] }[] {
    const out: { name: string; columns: string[] }[] = [];
    for (const value of Object.values(schema)) {
      if (!is(value, PgTable)) continue;
      const name = getTableName(value);
      if (EXCLUDED.has(name)) continue;
      const cols = Object.values(getTableColumns(value)).map((c) => c.name);
      if (name === 'tenants' || cols.includes('tenant_id')) out.push({ name, columns: cols });
    }
    return out.sort((a, b) => a.name.localeCompare(b.name));
  }

  /** Streams a zip. The caller pipes `stream` to the HTTP response. */
  async exportTenant(tenantId: string): Promise<{ stream: NodeJS.ReadableStream; fileName: string }> {
    const tenant = await this.db.runBypass(async (tx) => {
      const [t] = await tx.select({ id: tenants.id, code: tenants.code, name: tenants.name }).from(tenants).where(eq(tenants.id, tenantId)).limit(1);
      if (!t) throw new NotFoundError('Tenant', tenantId);
      await this.audit.log({ action: 'tenant.export', entityType: 'tenant', entityId: tenantId, tenantId }, tx);
      return t;
    });

    const out = new PassThrough();
    const zip = archiver('zip', { zlib: { level: 6 } });
    zip.on('warning', (e: ArchiverError) => this.logger.warn(`zip warning: ${e.message}`));
    zip.on('error', (e: Error) => out.destroy(e));
    zip.pipe(out);

    // Build asynchronously; errors surface on the stream.
    void (async () => {
      const manifest: Record<string, unknown> = { tenant, exportedAt: new Date().toISOString(), format: 'ndjson', tables: {} as Record<string, number>, storageKeys: [] as string[] };
      try {
        for (const t of this.tenantTables()) {
          const rows = await this.readAll(tenantId, t.name);
          (manifest.tables as Record<string, number>)[t.name] = rows.length;
          if (t.name === 'documents' || t.name === 'attachments') {
            for (const r of rows) if (r.storage_key) (manifest.storageKeys as string[]).push(String(r.storage_key));
          }
          zip.append(rows.map((r) => JSON.stringify(redact(r))).join('\n'), { name: `data/${t.name}.ndjson` });
        }
        zip.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' });
        zip.append(README, { name: 'README.txt' });
        await zip.finalize();
      } catch (e) {
        this.logger.error(`Tenant export failed: ${(e as Error).message}`);
        zip.abort();
        out.destroy(e as Error);
      }
    })();

    return { stream: out, fileName: `tenant-${tenant.code}-${new Date().toISOString().slice(0, 10)}.zip` };
  }

  private async readAll(tenantId: string, table: string): Promise<Record<string, unknown>[]> {
    const all: Record<string, unknown>[] = [];
    for (let offset = 0; ; offset += PAGE) {
      const page = await this.db.runBypass(async (tx: Tx) => {
        const where = table === 'tenants' ? sql`id = ${tenantId}` : sql`tenant_id = ${tenantId}`;
        const res = await tx.execute(sql`select * from ${sql.identifier(table)} where ${where} order by 1 limit ${PAGE} offset ${offset}`);
        return res.rows as Record<string, unknown>[];
      });
      all.push(...page);
      if (page.length < PAGE) break;
    }
    return all;
  }
}

const SECRET_COLUMNS = new Set(['secret_encrypted', 'password_hash', 'key_hash', 'mfa_secret', 'token_hash']);
const SECRET_KEY = /(password|secret|token|api[_-]?key|private)/i;

/** Drops credential columns and any JSON config keys that look like secrets. */
function redact(row: Record<string, unknown>): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(row)) {
    if (SECRET_COLUMNS.has(k)) continue;
    out[k] = v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date) ? redactObject(v as Record<string, unknown>) : v;
  }
  return out;
}
function redactObject(o: Record<string, unknown>): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(o)) {
    if (SECRET_KEY.test(k)) { out[k] = '[redacted]'; continue; }
    out[k] = v && typeof v === 'object' && !Array.isArray(v) ? redactObject(v as Record<string, unknown>) : v;
  }
  return out;
}

const README = `WeStore WMS tenant export
=========================
data/<table>.ndjson : one JSON object per line, column names as in the database schema (snake_case).
manifest.json       : row counts per table and the object-storage keys of documents/attachments.

Secrets (API keys, refresh tokens, connector credentials) are never exported.
Binary files are not embedded; fetch them from the object store using manifest.storageKeys.
`;
