import type { FFetchOptions } from "@fetchkit/ffetch";
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { requestFromService, runLayerOrThrow, runLayerResult } from "../effect";
import { BuilderInvariantError, MetadataNotFoundError, SchemaValidationFailedError } from "../errors";
import { FMTable } from "../orm/table";
import { createDatabaseLayer, type FMODataLayer } from "../services";
import type { ExecutableBuilder, ExecutionContext, Metadata, Result } from "../types";
import { BatchBuilder } from "./batch-builder";
import { stripFmp12Extension } from "./database-name";
import { EntitySet } from "./entity-set";
import { SchemaManager } from "./schema-manager";
import { WebhookManager } from "./webhook-builder";

interface MetadataArgs {
  format?: "xml" | "json";
  /**
   * If provided, only the metadata for the specified table will be returned.
   * Requires FileMaker Server 22.0.4 or later.
   */
  tableName?: string;
  /**
   * If true, a reduced payload size will be returned by omitting certain annotations.
   */
  reduceAnnotations?: boolean;
}

export class Database<IncludeSpecialColumns extends boolean = false> {
  readonly schema: SchemaManager;
  readonly webhook: WebhookManager;
  private readonly databaseName: string;
  private readonly _normalizeDatabaseName: boolean;
  private readonly _useEntityIds: boolean;
  private readonly _includeSpecialColumns: IncludeSpecialColumns;
  /** @internal Database-scoped Effect Layer for dependency injection */
  readonly _layer: FMODataLayer;

  constructor(
    databaseName: string,
    context: ExecutionContext,
    config?: {
      /**
       * Whether to normalize the database name in requests.
       * Defaults to true.
       */
      normalizeDatabaseName?: boolean;
      /**
       * Whether to use entity IDs instead of field names in the actual requests to the server
       * Defaults to true if all occurrences use entity IDs, false otherwise
       * If set to false but some occurrences do not use entity IDs, an error will be thrown
       */
      useEntityIds?: boolean;
      /**
       * Whether to include special columns (ROWID and ROWMODID) in responses.
       * Note: Special columns are only included when there is no $select query.
       */
      includeSpecialColumns?: IncludeSpecialColumns;
    },
  ) {
    this.databaseName = databaseName;
    this._normalizeDatabaseName = config?.normalizeDatabaseName ?? true;
    this._useEntityIds = config?.useEntityIds ?? false;
    this._includeSpecialColumns = (config?.includeSpecialColumns ?? false) as IncludeSpecialColumns;

    // Create database-scoped layer from connection's base layer
    const baseLayer = context._getLayer?.();
    if (baseLayer) {
      this._layer = createDatabaseLayer(baseLayer, {
        databaseName: this.databaseName,
        normalizeDatabaseName: this._normalizeDatabaseName,
        useEntityIds: this._useEntityIds,
        includeSpecialColumns: this._includeSpecialColumns,
      });
    } else {
      throw new BuilderInvariantError(
        "Database",
        "ExecutionContext must implement _getLayer() for dependency injection",
      );
    }

    // Initialize schema and webhook managers with the database layer
    this.schema = new SchemaManager(this._layer);
    this.webhook = new WebhookManager(this._layer);
  }

  /**
   * @internal Used by adapter packages to access the database filename.
   */
  get _getDatabaseName(): string {
    return this.databaseName;
  }

  /**
   * @internal Used by EntitySet to access database configuration
   */
  get _getUseEntityIds(): boolean {
    return this._useEntityIds;
  }

  /**
   * @internal Used by EntitySet to access database configuration
   */
  get _getNormalizeDatabaseName(): boolean {
    return this._normalizeDatabaseName;
  }

  /**
   * @internal Used by EntitySet to access database configuration
   */
  get _getIncludeSpecialColumns(): IncludeSpecialColumns {
    return this._includeSpecialColumns;
  }

  /**
   * @internal Used by adapter packages for raw OData requests.
   * Makes requests through the Effect DI layer.
   */
  _makeRequest<T>(path: string, options?: RequestInit & FFetchOptions): Promise<Result<T>> {
    const pipeline = requestFromService<T>(`/${this.databaseName}${path}`, options);
    return runLayerResult(this._layer, pipeline);
  }

  // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
  from<T extends FMTable<any, any>>(table: T): EntitySet<T, IncludeSpecialColumns> {
    // Resolve useEntityIds per-call without mutating shared Database state
    let useEntityIds = this._useEntityIds;
    if (Object.hasOwn(table, FMTable.Symbol.UseEntityIds)) {
      // biome-ignore lint/suspicious/noExplicitAny: Type assertion for Symbol property access
      const tableUseEntityIds = (table as any)[FMTable.Symbol.UseEntityIds];
      if (typeof tableUseEntityIds === "boolean") {
        useEntityIds = tableUseEntityIds;
      }
    }

    // If table overrides useEntityIds, create a new layer with the override
    const layer =
      useEntityIds !== this._useEntityIds
        ? createDatabaseLayer(this._layer, {
            databaseName: this.databaseName,
            normalizeDatabaseName: this._normalizeDatabaseName,
            useEntityIds,
            includeSpecialColumns: this._includeSpecialColumns,
          })
        : this._layer;

    return new EntitySet<T, IncludeSpecialColumns>({
      occurrence: table as T,
      layer,
      database: this,
    });
  }

  /**
   * Retrieves the OData metadata for this database.
   * @param args Optional configuration object
   * @param args.format The format to retrieve metadata in. Defaults to "json".
   * @param args.tableName If provided, only the metadata for the specified table will be returned. Requires FileMaker Server 22.0.4 or later.
   * @param args.reduceAnnotations If true, a reduced payload size will be returned by omitting certain annotations.
   * @returns The metadata in the specified format
   */
  async getMetadata(args: { format: "xml" } & MetadataArgs): Promise<string>;
  async getMetadata(args?: { format?: "json" } & MetadataArgs): Promise<Metadata>;
  async getMetadata(args?: MetadataArgs): Promise<string | Metadata> {
    // Build the URL - if tableName is provided, append %23{tableName} to the path
    let url = `/${this.databaseName}/$metadata`;
    if (args?.tableName) {
      url = `/${this.databaseName}/$metadata%23${args.tableName}`;
    }

    // Build headers
    const headers: Record<string, string> = {
      Accept: args?.format === "xml" ? "application/xml" : "application/json",
    };

    // Add Prefer header if reduceAnnotations is true
    if (args?.reduceAnnotations) {
      headers.Prefer = 'include-annotations="-*"';
    }

    const pipeline = requestFromService<Record<string, Metadata> | string>(url, { headers });
    const data = await runLayerOrThrow(this._layer, pipeline, "fmodata.metadata");

    if (args?.format === "xml") {
      return data as string;
    }

    const metadataMap = data as Record<string, Metadata>;
    const metadata = metadataMap[this.databaseName] ?? metadataMap[stripFmp12Extension(this.databaseName)];
    if (!metadata) {
      throw new MetadataNotFoundError(this.databaseName);
    }
    return metadata;
  }

  /**
   * Lists all available tables (entity sets) in this database.
   * @returns Promise resolving to an array of table names
   */
  async listTableNames(): Promise<string[]> {
    const pipeline = requestFromService<{
      value?: Array<{ name: string }>;
    }>(`/${this.databaseName}`);

    const data = await runLayerOrThrow(this._layer, pipeline, "fmodata.listTableNames");
    if (data.value && Array.isArray(data.value)) {
      return data.value.map((item) => item.name);
    }
    return [];
  }

  /**
   * Executes a FileMaker script.
   * @param scriptName - The name of the script to execute (must be valid according to OData rules)
   * @param options - Optional script parameter and result schema
   * @returns Promise resolving to script execution result
   */
  // biome-ignore lint/suspicious/noExplicitAny: Required for type inference with infer
  async runScript<ResultSchema extends StandardSchemaV1<string, any> = never>(
    scriptName: string,
    options?: {
      // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any record shape
      scriptParam?: string | number | Record<string, any>;
      resultSchema?: ResultSchema;
    },
  ): Promise<
    [ResultSchema] extends [never]
      ? { resultCode: number; result?: string }
      : ResultSchema extends StandardSchemaV1<string, infer Output>
        ? { resultCode: number; result: Output }
        : { resultCode: number; result?: string }
  > {
    const body: { scriptParameterValue?: unknown } = {};
    if (options?.scriptParam !== undefined) {
      body.scriptParameterValue = options.scriptParam;
    }

    const pipeline = requestFromService<{
      scriptResult: {
        code: number;
        resultParameter?: string;
      };
    }>(`/${this.databaseName}/Script.${scriptName}`, {
      method: "POST",
      body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
    });

    const response = await runLayerOrThrow(this._layer, pipeline, "fmodata.runScript");

    // If resultSchema is provided, validate the result through it
    if (options?.resultSchema && response.scriptResult !== undefined) {
      const validationResult = options.resultSchema["~standard"].validate(response.scriptResult.resultParameter);
      // Handle both sync and async validation
      const validated = validationResult instanceof Promise ? await validationResult : validationResult;

      if (validated.issues) {
        throw new SchemaValidationFailedError("Database.runScript", JSON.stringify(validated.issues), {
          issues: validated.issues,
        });
      }

      return {
        resultCode: response.scriptResult.code,
        result: validated.value,
        // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
      } as any;
    }

    return {
      resultCode: response.scriptResult.code,
      result: response.scriptResult.resultParameter,
      // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
    } as any;
  }

  /**
   * Create a batch operation builder that allows multiple queries to be executed together
   * in a single atomic request. All operations succeed or fail together (transactional).
   *
   * @param builders - Array of executable query builders to batch
   * @returns A BatchBuilder that can be executed
   * @example
   * ```ts
   * const result = await db.batch([
   *   db.from('contacts').list().top(5),
   *   db.from('users').list().top(5),
   *   db.from('contacts').insert({ name: 'John' })
   * ]).execute();
   *
   * if (result.data) {
   *   const [contacts, users, insertResult] = result.data;
   * }
   * ```
   */
  // biome-ignore lint/suspicious/noExplicitAny: Generic constraint accepting any ExecutableBuilder result type
  batch<const Builders extends readonly ExecutableBuilder<any>[]>(builders: Builders): BatchBuilder<Builders> {
    return new BatchBuilder(builders, this._layer);
  }
}
