import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core";
import type { Transaction } from "../generated/kysely-tailordb";
import {
  EntityTypeNotRegisteredError,
  InvalidActorError,
  InvalidOperationTypeError,
  ScopeMismatchError,
  MissingEntityIdError,
  MissingEventIdError,
  ValidationErrorError,
} from "../lib/errors.generated";

const VALID_ACTOR_TYPES = ["USER", "SYSTEM", "SERVICE"];
const VALID_OPERATION_TYPES = ["CREATE", "UPDATE", "DELETE"];
const TRUNCATED_SUFFIX = "[truncated]";
const MAX_VALUE_LENGTH = 4000;
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

interface FileMetadata {
  fileId: string;
  filename: string;
  size: number;
}

interface ChangeField {
  fieldName: string;
  oldValue?: unknown;
  newValue?: unknown;
  fieldType?: "scalar" | "richtext" | "array" | "relation" | "file" | "binary" | "computed";
}

export interface LogAuditEventInput {
  eventId: string;
  actorType: string;
  actorId: string;
  entityType: string;
  entityId: string;
  operationType: string;
  companyId?: string;
  correlationId?: string;
  ipAddress?: string;
  userAgent?: string;
  sessionId?: string;
  requestId?: string;
  onBehalfOf?: string;
  changes: ChangeField[];
}

function maskValue(value: string): string {
  if (value.length <= 4) return "***";
  return "*".repeat(value.length - 4) + value.slice(-4);
}

async function hashValue(value: string): Promise<string> {
  const data = new TextEncoder().encode(value);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const hashArray = new Uint8Array(hashBuffer);
  return Array.from(hashArray, (b) => b.toString(16).padStart(2, "0")).join("");
}

function stripHtml(value: string): string {
  return value.replace(/<[^>]*>/g, "");
}

function normalizeFieldValue(value: unknown, fieldType?: string): string | null {
  if (value === null || value === undefined) return null;

  switch (fieldType) {
    case "binary":
    case "computed":
      return null;
    case "array":
      return typeof value === "string" ? value : JSON.stringify(value);
    case "relation":
      if (typeof value === "object" && value !== null) {
        const obj = value as Record<string, unknown>;
        if (obj.id !== undefined) {
          return typeof obj.id === "string" ? obj.id : JSON.stringify(obj.id);
        }
        return JSON.stringify(value);
      }
      return String(value as string | number);
    case "file": {
      if (typeof value === "string") return value;
      const meta = value as FileMetadata;
      return JSON.stringify({ fileId: meta.fileId, filename: meta.filename, size: meta.size });
    }
    case "richtext":
      return stripHtml(typeof value === "string" ? value : JSON.stringify(value));
    case "scalar":
    default:
      return typeof value === "object" ? JSON.stringify(value) : String(value as string | number);
  }
}

function truncateValue(value: string | null): string | null {
  if (value === null) return null;
  if (value.length > MAX_VALUE_LENGTH) {
    return value.substring(0, MAX_VALUE_LENGTH - TRUNCATED_SUFFIX.length) + TRUNCATED_SUFFIX;
  }
  return value;
}

async function applySensitivity(value: string | null, mode: string): Promise<string | null> {
  if (value === null) return null;

  let processed = value;

  switch (mode) {
    case "MASK":
      processed = maskValue(processed);
      break;
    case "HASH":
      processed = await hashValue(processed);
      break;
    case "EXCLUDE":
      return null;
    case "CAPTURE":
    default:
      break;
  }

  return truncateValue(processed);
}

/**
 * Function: logAuditEvent
 *
 * Creates a new audit entry and associated change detail records for the given
 * event. Performs idempotent duplicate detection, validates actor/entity/scope,
 * looks up the active policy, and applies sensitivity processing to field values.
 */
export async function run(db: Transaction, input: LogAuditEventInput, _ctx: CommandContext) {
  // 1. Validate eventId is a valid UUID
  if (!input.eventId || !UUID_REGEX.test(input.eventId)) {
    return err(new MissingEventIdError(String(input.eventId)));
  }

  // 2. Check duplicate eventId (idempotent)
  const existing = await db
    .selectFrom("AuditEntry")
    .selectAll()
    .where("eventId", "=", input.eventId)
    .executeTakeFirst();

  if (existing) {
    return ok({ auditEntry: existing, created: false });
  }

  // 3. Validate entityType registered
  const entity = await db
    .selectFrom("AuditableEntity")
    .selectAll()
    .where("entityName", "=", input.entityType)
    .executeTakeFirst();

  if (!entity) {
    return err(new EntityTypeNotRegisteredError(input.entityType));
  }

  // 4. Validate actorType and actorId
  if (!input.actorType || !VALID_ACTOR_TYPES.includes(input.actorType)) {
    return err(new InvalidActorError(String(input.actorType)));
  }
  if (!input.actorId) {
    return err(new InvalidActorError(String(input.actorId)));
  }

  // 5. Validate operationType
  if (!input.operationType || !VALID_OPERATION_TYPES.includes(input.operationType)) {
    return err(new InvalidOperationTypeError(String(input.operationType)));
  }

  // 6. Validate entityId non-empty
  if (!input.entityId) {
    return err(new MissingEntityIdError(String(input.entityId)));
  }

  // 7. Validate scope match
  if (entity.entityScope === "COMPANY_BOUND" && !input.companyId) {
    return err(new ScopeMismatchError(input.entityType));
  }
  if (entity.entityScope === "GLOBAL" && input.companyId) {
    return err(new ScopeMismatchError(input.entityType));
  }

  // 7b. Validate correlationId is UUID if provided
  if (input.correlationId && !UUID_REGEX.test(input.correlationId)) {
    return err(
      new ValidationErrorError(`correlationId must be a valid UUID: ${input.correlationId}`),
    );
  }

  // 8. Look up ACTIVE policy
  let policyQuery = db
    .selectFrom("AuditPolicy")
    .selectAll()
    .where("entityName", "=", input.entityType)
    .where("operationType", "=", input.operationType)
    .where("status", "=", "ACTIVE");

  if (input.companyId) {
    policyQuery = policyQuery.where("companyId", "=", input.companyId);
  } else {
    policyQuery = policyQuery.where("companyId", "is", null);
  }

  const policy = await policyQuery.executeTakeFirst();

  if (!policy) {
    return ok({ auditEntry: null, created: false });
  }

  // 9. Empty changes handling
  if (!input.changes || input.changes.length === 0) {
    // UPDATE with empty changes is a no-op (zero-delta save)
    if (input.operationType === "UPDATE") {
      return ok({ auditEntry: null, created: false });
    }
    // CREATE and DELETE require at least one change
    return err(
      new ValidationErrorError(`${input.operationType} operations require at least one change`),
    );
  }

  // 10. Get field rules for this policy
  const fieldRules = await db
    .selectFrom("PolicyFieldRule")
    .selectAll()
    .where("auditPolicyId", "=", policy.id)
    .execute();

  // Parse auditable fields (supports both legacy string[] and new {fieldName, fieldType?}[] formats)
  const rawFields = JSON.parse(entity.auditableFields) as
    | string[]
    | { fieldName: string; fieldType?: string }[];
  const auditableFieldNames = new Set<string>();
  const registeredFieldTypes = new Map<string, string>();
  for (const f of rawFields) {
    if (typeof f === "string") {
      auditableFieldNames.add(f);
    } else {
      auditableFieldNames.add(f.fieldName);
      if (f.fieldType) {
        registeredFieldTypes.set(f.fieldName, f.fieldType);
      }
    }
  }

  // Build field rule map
  const fieldRuleMap = new Map<string, string>();
  for (const rule of fieldRules) {
    fieldRuleMap.set(rule.fieldName, rule.sensitivityMode);
  }

  // Filter and process changes
  const hasExplicitRules = fieldRules.length > 0;
  const filteredChanges = (input.changes || []).filter((change) => {
    // Resolve fieldType: prefer registered type, fall back to input's fieldType
    const effectiveFieldType = registeredFieldTypes.get(change.fieldName) ?? change.fieldType;
    // Exclude binary and computed fields entirely
    if (effectiveFieldType === "binary" || effectiveFieldType === "computed") return false;
    // Drop fields not in entity's auditableFields
    if (!auditableFieldNames.has(change.fieldName)) return false;
    // If policy has explicit field rules, also drop fields not in policy's rules
    if (hasExplicitRules && !fieldRuleMap.has(change.fieldName)) return false;
    return true;
  });

  const processedChanges = (
    await Promise.all(
      filteredChanges.map(async (change) => {
        const mode = fieldRuleMap.get(change.fieldName) ?? "CAPTURE";
        // Resolve fieldType: prefer registered type, fall back to input's fieldType
        const effectiveFieldType = registeredFieldTypes.get(change.fieldName) ?? change.fieldType;
        // Normalize values based on field type before sensitivity processing
        const normalizedOld = normalizeFieldValue(change.oldValue, effectiveFieldType);
        const normalizedNew = normalizeFieldValue(change.newValue, effectiveFieldType);

        // Enforce stored invariants: CREATE → oldValue=null, DELETE → newValue=null
        const effectiveOld = input.operationType === "CREATE" ? null : normalizedOld;
        const effectiveNew = input.operationType === "DELETE" ? null : normalizedNew;

        return {
          fieldName: change.fieldName,
          _normalizedOld: effectiveOld,
          _normalizedNew: effectiveNew,
          oldValue: await applySensitivity(effectiveOld, mode),
          newValue: await applySensitivity(effectiveNew, mode),
        };
      }),
    )
  )
    .filter((change) => {
      // For UPDATE, drop fields where normalized old and new values are identical (no actual change)
      // Compare pre-sensitivity values so that EXCLUDE mode (both→null) still produces a record
      if (input.operationType === "UPDATE" && change._normalizedOld === change._normalizedNew)
        return false;
      return true;
    })
    .map(({ fieldName, oldValue, newValue }) => ({ fieldName, oldValue, newValue }));

  // 11. After filtering, if no processable changes remain:
  //     - UPDATE: no-op (zero-delta save)
  //     - CREATE/DELETE: must produce an entry per contract, so reject
  if (processedChanges.length === 0) {
    if (input.operationType === "UPDATE") {
      return ok({ auditEntry: null, created: false });
    }
    return err(
      new ValidationErrorError(
        `${input.operationType} operations require at least one auditable change after policy filtering`,
      ),
    );
  }

  // 12. Generate system timestamp
  const timestamp = new Date();

  // 12. Create AuditEntry
  const auditEntry = await db
    .insertInto("AuditEntry")
    .values({
      eventId: input.eventId,
      actorType: input.actorType,
      actorId: input.actorId,
      entityType: input.entityType,
      entityId: input.entityId,
      operationType: input.operationType,
      companyId: input.companyId ?? null,
      correlationId: input.correlationId ?? null,
      ipAddress: input.ipAddress ?? null,
      userAgent: input.userAgent ?? null,
      sessionId: input.sessionId ?? null,
      requestId: input.requestId ?? null,
      onBehalfOf: input.onBehalfOf ?? null,
      timestamp,
      createdAt: timestamp,
    })
    .returningAll()
    .executeTakeFirstOrThrow();

  // 13. Create ChangeDetail records (bulk insert)
  if (processedChanges.length > 0) {
    await db
      .insertInto("ChangeDetail")
      .values(
        processedChanges.map((change) => ({
          auditEntryId: auditEntry.id,
          fieldName: change.fieldName,
          oldValue: change.oldValue,
          newValue: change.newValue,
          createdAt: timestamp,
        })),
      )
      .execute();
  }

  // 14. Return ok
  return ok({ auditEntry, created: true });
}
