import { Effect } from "effect";
import { requestFromService, runLayerResult, tryEffect } from "../effect";
import type { FMODataErrorType } from "../errors";
import { BuilderInvariantError } from "../errors";
import type { FMTable, InferSchemaOutputFromFMTable } from "../orm/table";
import { getBaseTableConfig, getTableName } from "../orm/table";
import type { FMODataLayer, ODataConfig } from "../services";
import { transformFieldNamesToIds, transformResponseFields } from "../transform";
import type {
  ConditionallyWithSpecialColumns,
  ExecutableBuilder,
  ExecuteMethodOptions,
  ExecuteOptions,
  NormalizeIncludeSpecialColumns,
  Result,
} from "../types";
import { getAcceptHeader } from "../types";
import { validateAndTransformInput, validateSingleResponse } from "../validation";
import {
  buildMutationUrl,
  extractAffectedRows,
  mergeMutationExecuteOptions,
  mergePreferHeaderValues,
  type RecordLocator,
  resolveMutationTableId,
} from "./builders/mutation-helpers";
import { normalizeDatabasePath } from "./database-name";
import { parseErrorResponse } from "./error-parser";
import { QueryBuilder } from "./query-builder";
import { createClientRuntime } from "./runtime";

/**
 * Initial update builder returned from EntitySet.update(data)
 * Requires calling .byId() or .where() before .execute() is available
 */
export class UpdateBuilder<
  // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
  Occ extends FMTable<any, any>,
  ReturnPreference extends "minimal" | "representation" = "minimal",
  DatabaseIncludeSpecialColumns extends boolean = false,
> {
  private readonly table: Occ;
  private readonly data: Partial<InferSchemaOutputFromFMTable<Occ>>;
  private readonly returnPreference: ReturnPreference;
  private readonly layer: FMODataLayer;
  private readonly config: ODataConfig;

  constructor(config: {
    occurrence: Occ;
    layer: FMODataLayer;
    data: Partial<InferSchemaOutputFromFMTable<Occ>>;
    returnPreference: ReturnPreference;
  }) {
    this.table = config.occurrence;
    const runtime = createClientRuntime(config.layer);
    this.layer = runtime.layer;
    this.data = config.data;
    this.returnPreference = config.returnPreference;
    this.config = runtime.config;
  }

  /**
   * Update a single record by ID
   * Returns updated count by default, or full record if returnFullRecord was set to true
   */
  byId(id: string | number): ExecutableUpdateBuilder<Occ, true, ReturnPreference, DatabaseIncludeSpecialColumns> {
    return new ExecutableUpdateBuilder<Occ, true, ReturnPreference, DatabaseIncludeSpecialColumns>({
      occurrence: this.table,
      layer: this.layer,
      data: this.data,
      mode: "byId",
      recordLocator: id,
      returnPreference: this.returnPreference,
    });
  }

  /**
   * Update a single record by ROWID
   */
  byRowId(rowId: number): ExecutableUpdateBuilder<Occ, true, ReturnPreference> {
    return new ExecutableUpdateBuilder<Occ, true, ReturnPreference>({
      occurrence: this.table,
      layer: this.layer,
      data: this.data,
      mode: "byId",
      recordLocator: { ROWID: rowId },
      returnPreference: this.returnPreference,
    });
  }

  /**
   * Update records matching a filter query
   * Returns updated count by default, or full record if returnFullRecord was set to true
   * @param fn Callback that receives a QueryBuilder for building the filter
   */
  where(
    fn: (q: QueryBuilder<Occ>) => QueryBuilder<Occ>,
  ): ExecutableUpdateBuilder<Occ, true, ReturnPreference, DatabaseIncludeSpecialColumns> {
    // Create a QueryBuilder for the user to configure
    const queryBuilder = new QueryBuilder<Occ>({
      occurrence: this.table,
      layer: this.layer,
    });

    // Let the user configure it
    const configuredBuilder = fn(queryBuilder);

    return new ExecutableUpdateBuilder<Occ, true, ReturnPreference, DatabaseIncludeSpecialColumns>({
      occurrence: this.table,
      layer: this.layer,
      data: this.data,
      mode: "byFilter",
      queryBuilder: configuredBuilder,
      returnPreference: this.returnPreference,
    });
  }
}

/**
 * Executable update builder - has execute() method
 * Returned after calling .byId() or .where()
 * Can return either updated count or full record based on returnFullRecord option
 */
export class ExecutableUpdateBuilder<
  // biome-ignore lint/suspicious/noExplicitAny: Accepts any FMTable configuration
  Occ extends FMTable<any, any>,
  _IsByFilter extends boolean,
  ReturnPreference extends "minimal" | "representation" = "minimal",
  DatabaseIncludeSpecialColumns extends boolean = false,
> implements
    ExecutableBuilder<
      ReturnPreference extends "minimal"
        ? { updatedCount: number }
        : ConditionallyWithSpecialColumns<InferSchemaOutputFromFMTable<Occ>, DatabaseIncludeSpecialColumns, false>
    >
{
  private readonly table: Occ;
  private readonly data: Partial<InferSchemaOutputFromFMTable<Occ>>;
  private readonly mode: "byId" | "byFilter";
  private readonly recordLocator?: RecordLocator;
  private readonly queryBuilder?: QueryBuilder<Occ>;
  private readonly returnPreference: ReturnPreference;
  private readonly layer: FMODataLayer;
  private readonly config: ODataConfig;

  constructor(config: {
    occurrence: Occ;
    layer: FMODataLayer;
    data: Partial<InferSchemaOutputFromFMTable<Occ>>;
    mode: "byId" | "byFilter";
    recordLocator?: RecordLocator;
    queryBuilder?: QueryBuilder<Occ>;
    returnPreference: ReturnPreference;
  }) {
    this.table = config.occurrence;
    this.layer = config.layer;
    this.data = config.data;
    this.mode = config.mode;
    this.recordLocator = config.recordLocator;
    this.queryBuilder = config.queryBuilder;
    this.returnPreference = config.returnPreference;
    const runtime = createClientRuntime(this.layer);
    this.config = runtime.config;
  }

  execute<EO extends ExecuteOptions>(
    options?: ExecuteMethodOptions<EO>,
  ): Promise<
    Result<
      ReturnPreference extends "minimal"
        ? { updatedCount: number }
        : ConditionallyWithSpecialColumns<
            InferSchemaOutputFromFMTable<Occ>,
            NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>,
            false
          >
    >
  > {
    const mergedOptions = mergeMutationExecuteOptions(
      options,
      this.config.useEntityIds,
      this.config.includeSpecialColumns,
    );
    // biome-ignore lint/suspicious/noExplicitAny: Execute options include dynamic fetch fields
    const { method: _method, body: _body, headers: callerHeaders, ...requestOptions } = mergedOptions as any;
    const shouldUseIds = mergedOptions.useEntityIds ?? this.config.useEntityIds;
    const includeSpecialColumns = mergedOptions.includeSpecialColumns ?? this.config.includeSpecialColumns;
    const tableId = resolveMutationTableId(this.table, shouldUseIds, "ExecutableUpdateBuilder");
    const url = buildMutationUrl({
      databaseName: this.config.databaseName,
      tableId,
      tableName: getTableName(this.table),
      mode: this.mode,
      recordLocator: this.recordLocator,
      queryBuilder: this.queryBuilder,
      useEntityIds: shouldUseIds,
      builderName: "ExecutableUpdateBuilder",
    });

    const requestHeaders = new Headers(callerHeaders || {});
    const preferHeader = mergePreferHeaderValues(
      this.returnPreference === "representation" ? "return=representation" : undefined,
      shouldUseIds ? "fmodata.entity-ids" : undefined,
      includeSpecialColumns ? "fmodata.include-specialcolumns" : undefined,
      requestHeaders.get("Prefer") ?? undefined,
    );
    requestHeaders.set("Content-Type", "application/json");
    if (preferHeader) {
      requestHeaders.set("Prefer", preferHeader);
    } else {
      requestHeaders.delete("Prefer");
    }

    const pipeline = Effect.gen(this, function* () {
      // Step 1: Validate input
      let validatedData = this.data;
      if (this.table) {
        const baseTableConfig = getBaseTableConfig(this.table);
        validatedData = yield* tryEffect(
          () => validateAndTransformInput(this.data, baseTableConfig.inputSchema),
          (e) =>
            (e instanceof Error
              ? e
              : new BuilderInvariantError("ExecutableUpdateBuilder.execute", String(e))) as FMODataErrorType,
        );
      }

      // Step 2: Transform field names
      const transformedData =
        this.table && shouldUseIds ? transformFieldNamesToIds(validatedData, this.table) : validatedData;

      // Step 3: Make PATCH request via DI
      const response = yield* requestFromService(url, {
        ...requestOptions,
        method: "PATCH",
        headers: requestHeaders,
        body: JSON.stringify(transformedData),
      });

      // Step 4: Handle response based on return preference
      if (this.returnPreference === "representation") {
        return response;
      }

      const updatedCount = extractAffectedRows(response, undefined, 0, "updatedCount");
      return { updatedCount };
    });

    return runLayerResult(this.layer, pipeline, "fmodata.update", {
      "fmodata.table": getTableName(this.table),
    }) as Promise<
      Result<
        ReturnPreference extends "minimal"
          ? { updatedCount: number }
          : ConditionallyWithSpecialColumns<
              InferSchemaOutputFromFMTable<Occ>,
              NormalizeIncludeSpecialColumns<EO["includeSpecialColumns"], DatabaseIncludeSpecialColumns>,
              false
            >
      >
    >;
  }

  // biome-ignore lint/suspicious/noExplicitAny: Request body can be any JSON-serializable value
  getRequestConfig(): { method: string; url: string; body?: any } {
    const tableId = resolveMutationTableId(this.table, this.config.useEntityIds, "ExecutableUpdateBuilder");

    // Transform field names to FMFIDs if using entity IDs
    const transformedData =
      this.table && this.config.useEntityIds ? transformFieldNamesToIds(this.data, this.table) : this.data;

    const url = buildMutationUrl({
      databaseName: this.config.databaseName,
      tableId,
      tableName: getTableName(this.table),
      mode: this.mode,
      recordLocator: this.recordLocator,
      queryBuilder: this.queryBuilder,
      useEntityIds: this.config.useEntityIds,
      builderName: "ExecutableUpdateBuilder",
    });

    return {
      method: "PATCH",
      url,
      body: JSON.stringify(transformedData),
    };
  }

  toRequest(baseUrl: string, options?: ExecuteOptions): Request {
    const config = this.getRequestConfig();
    const fullUrl = `${baseUrl}${normalizeDatabasePath(config.url, {
      normalizeDatabaseName: options?.normalizeDatabaseName ?? this.config.normalizeDatabaseName,
    })}`;
    const preferHeader = mergePreferHeaderValues(
      this.returnPreference === "representation" ? "return=representation" : undefined,
      (options?.useEntityIds ?? this.config.useEntityIds) ? "fmodata.entity-ids" : undefined,
      (options?.includeSpecialColumns ?? this.config.includeSpecialColumns)
        ? "fmodata.include-specialcolumns"
        : undefined,
    );

    return new Request(fullUrl, {
      method: config.method,
      headers: {
        "Content-Type": "application/json",
        Accept: getAcceptHeader(options?.includeODataAnnotations),
        ...(preferHeader ? { Prefer: preferHeader } : {}),
      },
      body: config.body,
    });
  }

  async processResponse<EO extends ExecuteOptions | undefined = undefined>(
    response: Response,
    options?: EO,
  ): Promise<
    Result<
      ReturnPreference extends "minimal"
        ? { updatedCount: number }
        : ConditionallyWithSpecialColumns<
            InferSchemaOutputFromFMTable<Occ>,
            NormalizeIncludeSpecialColumns<
              EO extends ExecuteOptions ? EO["includeSpecialColumns"] : undefined,
              DatabaseIncludeSpecialColumns
            >,
            false
          >
    >
  > {
    type UpdateResponse = ReturnPreference extends "minimal"
      ? { updatedCount: number }
      : ConditionallyWithSpecialColumns<
          InferSchemaOutputFromFMTable<Occ>,
          NormalizeIncludeSpecialColumns<
            EO extends ExecuteOptions ? EO["includeSpecialColumns"] : undefined,
            DatabaseIncludeSpecialColumns
          >,
          false
        >;

    // Check for error responses (important for batch operations)
    if (!response.ok) {
      const tableName = getTableName(this.table);
      const error = await parseErrorResponse(response, response.url || `/${this.config.databaseName}/${tableName}`);
      return { data: undefined, error };
    }

    // Check for empty response (204 No Content)
    const text = await response.text();
    if (!text || text.trim() === "") {
      const updatedCount = extractAffectedRows(undefined, response.headers, 1, "updatedCount");
      return {
        data: { updatedCount } as unknown as UpdateResponse,
        error: undefined,
      };
    }

    const rawResponse = JSON.parse(text);

    // Validate and transform input data using input validators (writeValidators)
    // This is needed for processResponse because it's called from batch operations
    // where the data hasn't been validated yet
    let _validatedData = this.data;
    if (this.table) {
      const baseTableConfig = getBaseTableConfig(this.table);
      const inputSchema = baseTableConfig.inputSchema;
      try {
        _validatedData = await validateAndTransformInput(this.data, inputSchema);
      } catch (error) {
        return {
          data: undefined,
          error:
            error instanceof Error
              ? error
              : new BuilderInvariantError("ExecutableUpdateBuilder.processResponse", String(error)),
          // biome-ignore lint/suspicious/noExplicitAny: Type assertion for generic return type
        } as any;
      }
    }

    // Handle based on return preference
    if (this.returnPreference === "representation") {
      const shouldUseIds = options?.useEntityIds ?? this.config.useEntityIds;
      const includeSpecialColumns = options?.includeSpecialColumns ?? this.config.includeSpecialColumns;

      let transformedResponse = rawResponse;
      if (this.table && shouldUseIds) {
        transformedResponse = transformResponseFields(rawResponse, this.table, undefined);
      }

      const validation = await validateSingleResponse<InferSchemaOutputFromFMTable<Occ>>(
        transformedResponse,
        getBaseTableConfig(this.table).schema,
        undefined,
        undefined,
        "exact",
        includeSpecialColumns,
      );

      if (!validation.valid) {
        return { data: undefined, error: validation.error };
      }

      if (validation.data === null) {
        return {
          data: undefined,
          error: new BuilderInvariantError(
            "ExecutableUpdateBuilder.processResponse",
            "update operation returned null response",
          ),
        };
      }

      return {
        data: validation.data as unknown as UpdateResponse,
        error: undefined,
      };
    }
    // Return updated count (minimal)
    const updatedCount = extractAffectedRows(rawResponse, response.headers, 0, "updatedCount");

    return {
      data: { updatedCount } as unknown as UpdateResponse,
      error: undefined,
    };
  }
}
