/**
 * ADK v2 - Base Object
 *
 * Abstract base class for all ADK objects.
 * Supports two construction patterns:
 * 1. Full data: new AdkObject(ctx, data)
 * 2. Deferred load: new AdkObject(ctx, 'OBJECT_NAME').load()
 *
 * Provides lazy loading, caching, and automatic lock/unlock.
 *
 * Hierarchy (mirrors XSD):
 * - AdkObject (AdtObject) - base with name, type, links, description, version, etc.
 * - AdkMainObject (AdtMainObject) - adds packageRef, responsible, masterLanguage
 */

import type { AdkContext } from './context';
import type { AdtClient } from './adt';
import type { AdkKind } from './kinds';
import { resolveLockCorrelation, type LockHandle } from '@abapify/adt-locks';
import { toText } from './fetch-utils';

export type { LockHandle } from '@abapify/adt-locks';

/**
 * Save mode for create/update operations
 * - 'update': PUT to existing object (default, fails if doesn't exist)
 * - 'create': POST to create new object (skips POST if already exists, then updates)
 * - 'upsert': Try PUT first, fall back to POST if 404
 */
export type SaveMode = 'update' | 'create' | 'upsert';

/**
 * Options for save operations
 */
export interface SaveOptions {
  /** Transport request for the change */
  transport?: string;
  /** Save as inactive (default: false) */
  inactive?: boolean;
  /** Save mode: 'update' (default), 'create', or 'upsert' */
  mode?: SaveMode;
  /**
   * Retry only the immediate post-create LOCK when a backend keeps its
   * generated CTS/editor lock briefly after accepting the skeleton POST.
   */
  postCreateLockRetry?: {
    /** Total LOCK attempts, including the first one. */
    attempts: number;
    /** Delay between conflicting LOCK attempts in milliseconds. */
    delayMs: number;
  };
}

type InternalSaveOptions = SaveOptions & { postCreate?: boolean };

/**
 * SAP accepted the skeleton POST, but rejected the immediate follow-up lock.
 *
 * The object therefore exists in SAP, while the source or metadata supplied
 * to the original save call has not yet been persisted. Callers must retry
 * the same object with `mode: 'update'` after the CTS/editor lock is cleared;
 * repeating `mode: 'create'` risks an "already exists" response.
 */
export class AdkPostCreateLockError extends Error {
  readonly code = 'ADT_POST_CREATE_LOCKED' as const;
  override readonly name = 'AdkPostCreateLockError';

  constructor(
    readonly objectUri: string,
    readonly transport: string | undefined,
    cause: unknown,
  ) {
    super(
      `SAP created ${objectUri}, but its post-create CTS lock is still active. ` +
        "Retry the pending change with mode: 'update' after the lock clears; do not create the object again.",
      { cause },
    );
  }
}

/**
 * Result of bulk activation
 */
export interface ActivationResult {
  /** Number of successfully activated objects */
  success: number;
  /** Number of objects that failed activation */
  failed: number;
  /** Activation messages (errors, warnings) */
  messages: string[];
  /** Raw response from SAP (for debugging) */
  response?: string;
}

/**
 * Atom link (from atom:link element)
 * Used for HATEOAS-style navigation in ADT responses
 */
export interface AtomLink {
  href: string;
  rel?: string;
  type?: string;
  title?: string;
  etag?: string;
}

/**
 * ADT object reference (adtcore:objectReference)
 */
export interface AdtObjectReference {
  uri?: string;
  name?: string;
  type?: string;
  description?: string;
  packageName?: string;
}

/**
 * Base data contract for all ADK objects
 * Matches XSD AdtObject type attributes and elements
 */
export interface AdkObjectData {
  // Required attributes
  name: string;
  type: string;
  // Optional AdtObject attributes
  description?: string;
  changedBy?: string;
  changedAt?: string | Date;
  createdBy?: string;
  createdAt?: string | Date;
  version?: string;
  language?: string;
  descriptionTextLimit?: number;
  // Optional AdtObject elements
  links?: AtomLink[];
  containerRef?: AdtObjectReference;
}

/**
 * Main object data contract (extends AdtObject)
 * Matches XSD AdtMainObject type
 */
export interface AdkMainObjectData extends AdkObjectData {
  packageRef?: AdtObjectReference;
  masterSystem?: string;
  masterLanguage?: string;
  responsible?: string;
  abapLanguageVersion?: string;
}

/**
 * Base class for all ADK objects
 *
 * All ADK objects (AdkPackage, AdkTransportRequest, etc.) extend this.
 * Supports deferred loading - create with just a name, then call load().
 *
 * Note: Uses loose type constraint for D to allow schema-inferred types
 * from contracts which may have different shapes. The getters safely
 * handle missing properties with defaults.
 *
 * @typeParam K - The object kind (from AdkKind)
 * @typeParam D - The internal data type (typically schema-inferred from contract)
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export abstract class AdkObject<K extends AdkKind = AdkKind, D = any> {
  /** Object kind identifier */
  abstract readonly kind: K;

  /**
   * ADT object URI - base path for all operations.
   * Example: `/sap/bc/adt/oo/classes/zcl_my_class`
   */
  abstract get objectUri(): string;

  /**
   * ADT collection URI - base path for creating new objects.
   * Example: `/sap/bc/adt/oo/classes`
   * Default: derived from objectUri by removing the object name
   */
  get collectionUri(): string {
    // Default: strip the last path segment from objectUri
    const uri = this.objectUri;
    const lastSlash = uri.lastIndexOf('/');
    return lastSlash > 0 ? uri.substring(0, lastSlash) : uri;
  }

  protected ctx: AdkContext;

  /** Public access to the ADT client for plugins and external code */
  get client(): AdtClient {
    return this.ctx.client;
  }

  protected _data?: D;
  protected _name: string;
  protected cache = new Map<string, unknown>();
  protected dirty = new Set<string>();
  protected _lockHandle?: LockHandle;

  /** Set by save() when all pending changes are identical to SAP state */
  _unchanged = false;

  /**
   * Create ADK object
   * @param ctx - ADK context
   * @param dataOrName - Full data object OR just the object name for deferred loading
   */
  constructor(ctx: AdkContext, dataOrName: D | string) {
    this.ctx = ctx;
    if (typeof dataOrName === 'string') {
      // Deferred load - just store the name
      this._name = dataOrName;
    } else {
      // Full data provided
      this._data = dataOrName;
      // Cast to access name property which may exist on schema-inferred types
      this._name = (dataOrName as AdkObjectData).name;
    }
  }

  // ============================================
  // Context Management
  // ============================================

  /**
   * Adopt a richer context (merge services into existing context).
   *
   * Used by AdkObjectSet to propagate lockService / lockStore
   * to objects created by plugins that only have { client }.
   * Keeps the original client reference intact.
   */
  adoptContext(ctx: AdkContext): void {
    this.ctx = { ...this.ctx, ...ctx };
  }

  // ============================================
  // Deferred Loading Support
  // ============================================

  /**
   * Load object data from SAP
   *
   * Default implementation uses crudContract.get() and unwraps with wrapperKey.
   * Objects without crudContract must override this.
   *
   * @returns this (for chaining)
   */
  async load(): Promise<this> {
    const contract = this.crudContract;
    const wrapperKey = this.wrapperKey;

    if (!contract || !wrapperKey) {
      throw new Error(
        `Load not implemented for ${this.kind}. Override load() or provide crudContract/wrapperKey.`,
      );
    }

    const response = await contract.get(this.name);

    if (!response || !(wrapperKey in response)) {
      throw new Error(
        `${this.kind} '${this.name}' not found or returned empty response`,
      );
    }

    this.setData((response as Record<string, unknown>)[wrapperKey] as D);
    return this;
  }

  /** Whether data has been loaded */
  get isLoaded(): boolean {
    return !!this._data;
  }

  /**
   * Get data - auto-loads if not already loaded
   * @returns Promise that resolves to the data
   */
  async data(): Promise<D> {
    if (!this._data) {
      await this.load();
    }
    // After load(), _data is guaranteed to be set
    return this._data as D;
  }

  /**
   * Get data synchronously - throws if not loaded
   * Use when you know data is already loaded
   */
  get dataSync(): D {
    if (!this._data) {
      throw new Error(
        `${this.kind} '${this._name}' not loaded. Call load() or use data() instead.`,
      );
    }
    return this._data;
  }

  /** Set data (for subclasses to use after loading) */
  protected setData(data: D): void {
    this._data = data;
    // Cast to access name property which may exist on schema-inferred types
    this._name = (data as AdkObjectData).name;
  }

  /** Helper to access data with AdkObjectData properties */
  private get coreData(): AdkObjectData {
    return this.dataSync as unknown as AdkObjectData;
  }

  // ============================================
  // Core Properties (adtcore:* attributes)
  // ============================================

  /** ADT object name (adtcore:name) - available even before load() */
  get name(): string {
    return this._name;
  }

  /** ADT object type (adtcore:type, e.g. 'RQRQ', 'DEVC/K') - requires load() */
  get type(): string {
    return this.coreData.type;
  }

  /** Description text (adtcore:description) */
  get description(): string {
    return this.coreData.description ?? '';
  }

  /** Version state (adtcore:version) - active, inactive, etc. */
  get version(): string {
    return this.coreData.version ?? '';
  }

  /** Current language (adtcore:language) */
  get language(): string {
    return this.coreData.language ?? '';
  }

  /** User who last modified (adtcore:changedBy) */
  get changedBy(): string {
    return this.coreData.changedBy ?? '';
  }

  /** Last modification timestamp (adtcore:changedAt) */
  get changedAt(): Date {
    const val = this.coreData.changedAt;
    return val instanceof Date ? val : val ? new Date(val) : new Date(0);
  }

  /** User who created (adtcore:createdBy) */
  get createdBy(): string {
    return this.coreData.createdBy ?? '';
  }

  /** Creation timestamp (adtcore:createdAt) */
  get createdAt(): Date {
    const val = this.coreData.createdAt;
    return val instanceof Date ? val : val ? new Date(val) : new Date(0);
  }

  // ============================================
  // Links (atom:link elements)
  // ============================================

  /** All atom:link elements */
  get links(): AtomLink[] {
    return this.coreData.links ?? [];
  }

  /** Find link by rel attribute */
  getLink(rel: string): AtomLink | undefined {
    return this.links.find((l) => l.rel === rel);
  }

  /** Get link href by rel, or undefined */
  getLinkHref(rel: string): string | undefined {
    return this.getLink(rel)?.href;
  }

  /** Container reference (adtcore:containerRef) */
  get containerRef(): AdtObjectReference | undefined {
    return this.coreData.containerRef;
  }

  // ============================================
  // Lock State
  // ============================================

  /** Check if object is currently locked */
  get isLocked(): boolean {
    return !!this._lockHandle;
  }

  /** Get current lock handle */
  get lockHandle(): LockHandle | undefined {
    return this._lockHandle;
  }

  /**
   * Lock the object for modification
   *
   * Delegates to the lock service (single lock mechanism).
   *
   * The lock response contains:
   * - LOCK_HANDLE: Required for subsequent PUT/unlock operations
   * - CORRNR: Transport request assigned to this object (use for PUT if no explicit transport)
   * - CORRUSER: User who owns the transport
   *
   * @param transport - Optional transport request to use for locking.
   *                    Required when object is already in a transport.
   */
  async lock(transport?: string): Promise<LockHandle> {
    if (this._lockHandle) return this._lockHandle;

    const lockService = this.ctx.lockService;
    if (!lockService) {
      throw new Error(
        `Lock not available: no lockService in context. Did you call initializeAdk()?`,
      );
    }

    this._lockHandle = await lockService.lock(this.objectUri, {
      transport,
      objectName: this.name,
      objectType: this.kind,
    });

    return this._lockHandle;
  }

  /**
   * Unlock the object
   *
   * Delegates to the lock service (single lock mechanism).
   */
  async unlock(): Promise<void> {
    if (!this._lockHandle) return;

    const lockService = this.ctx.lockService;
    if (!lockService) {
      throw new Error(
        `Unlock not available: no lockService in context. Did you call initializeAdk()?`,
      );
    }

    await lockService.unlock(this.objectUri, {
      lockHandle: this._lockHandle.handle,
    });
    this._lockHandle = undefined;
  }

  /**
   * Set lock handle (for subclasses that need custom lock logic)
   */
  protected setLockHandle(handle: LockHandle | undefined): void {
    this._lockHandle = handle;
  }

  // ============================================
  // Save Operations
  // ============================================

  /**
   * Save the object to SAP
   *
   * Generic implementation that handles:
   * - Lock/unlock lifecycle
   * - Mode switching (create/update/upsert)
   * - Error handling
   *
   * Subclasses implement `saveViaContract()` to provide the typed contract call.
   *
   * Modes:
   * - 'update' (default): PUT to existing object
   * - 'create': POST skeleton, then lock → PUT/source → unlock (Eclipse ADT pattern)
   * - 'upsert': Check existence, create if needed, then update
   *
   * @param options - Save options
   * @returns this (for chaining)
   */
  // SAP ADT lifecycle orchestration is branch-heavy; targeted for follow-up refactor.
  // prettier-ignore
  async save(options: InternalSaveOptions = {}): Promise<this> { //NOSONAR
    const { transport, mode = 'update' } = options;

    // Reset per-save-attempt state
    this._unchanged = false;

    // Create mode: POST skeleton, then continue with update flow.
    // This follows the Eclipse ADT pattern:
    //   1. POST minimal object (name, description, package)
    //   2. Lock → PUT full metadata / PUT source → Unlock → Activate
    //
    // Before POSTing, check if the object already exists on SAP.
    // This prevents "already exists" / "already locked" errors when
    // re-running a deploy against objects that were partially created
    // (e.g. stale CTS locks on BTP that survive object deletion).
    if (mode === 'create') {
      const alreadyExists = await this.checkObjectExists();
      if (!alreadyExists) {
        await this.saveViaContract('create', { transport });
        try {
          // Object now exists — update with full data + sources.
          return await this.save({
            ...options,
            mode: 'update',
            postCreate: true,
          });
        } catch (error) {
          if (this.isPostCreateLockConflict(error)) {
            throw new AdkPostCreateLockError(this.objectUri, transport, error);
          }
          throw error;
        }
      }
      // The object existed before this call — normal update errors are not
      // post-create recovery cases unless the caller explicitly supplied the
      // bounded post-create retry policy to resume a prior skeleton POST.
      return this.save({
        ...options,
        mode: 'update',
        postCreate: options.postCreateLockRetry !== undefined,
      });
    }

    // Check if object has pending sources (from abapGit deserialization)
    const hasPendingSources = this.hasPendingSources();

    // For updates with pending sources, compare with SAP before locking.
    // This avoids acquiring a lock only to discover nothing changed.
    if (hasPendingSources) {
      await this.checkPendingSourcesUnchanged();
      if (this._unchanged) {
        return this;
      }
    }

    // For metadata-only objects (no pending sources), fetch SAP state and
    // compare using recursive subset match. Only fields present in local data
    // are checked; SAP's extra metadata (timestamps, links, etc.) is ignored.
    // Empty-like values (null, "", false, [], {}) are treated as equivalent
    // to handle SAP's default normalization. Works for DDIC types.
    if (!hasPendingSources) {
      await this.checkMetadataUnchanged();
      if (this._unchanged) {
        return this;
      }
    }

    // For upsert mode, check existence before locking.
    // On BTP, locking a non-existent DDIC object creates a draft that
    // persists even after unlock, blocking subsequent POST (create) attempts.
    // A lightweight GET avoids this problem entirely.
    let confirmedExists = false;
    if (mode === 'upsert') {
      confirmedExists = await this.checkObjectExists();
      if (!confirmedExists) {
        return this.fallbackToCreate(options);
      }
    }

    // Lock if not already locked (create mode already returned above)
    const wasLocked = this.isLocked;
    if (!wasLocked) {
      try {
        if (options.postCreate) {
          await this.lockAfterCreate(transport, options.postCreateLockRetry);
        } else {
          await this.lock(transport);
        }
      } catch (e) {
        // For upsert, fallback to create if object doesn't exist (404)
        // or endpoint doesn't support the operation (405 - common for DDIC types).
        // But ONLY if checkObjectExists hasn't already confirmed the object exists.
        // If the object exists and LOCK fails (e.g., 403 S_ABPLNGVS), it's a real
        // auth error — falling back to create would just produce a confusing
        // "already exists" error.
        if (
          mode === 'upsert' &&
          !confirmedExists &&
          this.shouldFallbackToCreate(e)
        ) {
          return this.fallbackToCreate(options);
        }
        throw e;
      }
    }

    try {
      // SAP can resolve a development task to its parent request during LOCK.
      // Subsequent PUTs must use that authoritative CORRNR rather than the
      // caller's task number (TRL rejects the task even with a valid handle).
      const effectiveTransport = resolveLockCorrelation(
        this._lockHandle ?? {},
        transport,
      );

      // Refresh cached ETags after locking.
      // SAP changes the object's internal version when a lock is acquired,
      // which invalidates all cached ETags (metadata AND source).
      // Without this, the subsequent PUT sends a stale If-Match header → HTTP 412.
      if (!wasLocked) {
        await this.refreshETagsAfterLock();
      }

      if (hasPendingSources) {
        // Save sources only - skip metadata PUT which SAP often rejects
        await this.savePendingSources({
          lockHandle: this._lockHandle?.handle,
          transport: effectiveTransport,
        });
      } else {
        // Delegate to subclass for typed contract call
        // Note: 'upsert' is handled by the retry logic, so we pass the effective mode
        const effectiveMode = mode === 'upsert' ? 'update' : mode;
        await this.saveViaContract(effectiveMode, {
          transport: effectiveTransport,
          lockHandle: this._lockHandle?.handle,
        });
      }

      // Clear dirty flags
      this.dirty.clear();

      return this;
    } catch (e: unknown) {
      // For upsert with PUT failure (404/405/S_ABPLNGVS), try POST (create)
      if (mode === 'upsert' && this.shouldFallbackToCreate(e)) {
        // Unlock before falling back to create — the lock from the upsert
        // attempt blocks the POST (create) request on BTP systems.
        if (!wasLocked) {
          try {
            await this.unlock();
          } catch {
            // ignore unlock errors during fallback
          }
        }
        return this.fallbackToCreate(options);
      }
      // If PUT returns 422 "already exists" directly in upsert mode
      if (mode === 'upsert' && this.isAlreadyExistsError(e)) {
        this._unchanged = true;
        return this;
      }
      throw e;
    } finally {
      // Unlock if we locked it
      if (!wasLocked) {
        await this.unlock();
      }
    }
  }

  /**
   * Wrapper key for the data in contract requests/responses
   *
   * Contracts wrap data like { abapClass: ... } or { abapInterface: ... }.
   * Subclasses that support save override this.
   */
  protected get wrapperKey(): string | undefined {
    return undefined;
  }

  /**
   * CRUD contract for this object type
   *
   * Subclasses that support save override this to return their contract.
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  protected get crudContract(): any {
    return undefined;
  }

  /**
   * Execute the typed contract call for save
   *
   * Generic implementation using wrapperKey and crudContract.
   * Objects that don't support save leave wrapperKey/crudContract undefined.
   *
   * For 'create' mode: POSTs skeleton data (minimal fields).
   * For 'update' mode: PUTs full data (all fields except abapLanguageVersion).
   *
   * @param mode - 'create' (POST skeleton) or 'update' (PUT full)
   * @param options - Contract options (transport, lockHandle)
   */
  protected async saveViaContract(
    mode: 'create' | 'update',
    options: { transport?: string; lockHandle?: string },
  ): Promise<void> {
    const wrapperKey = this.wrapperKey;
    const contract = this.crudContract;

    if (!wrapperKey || !contract) {
      throw new Error(`Save not supported for ${this.kind}.`);
    }

    if (mode === 'create') {
      // POST skeleton — minimal fields only (Eclipse ADT pattern)
      const skeleton = await this.getSkeletonData();
      const data = { [wrapperKey]: skeleton };
      await contract.post({ corrNr: options.transport }, data);
    } else {
      const rawData = await this.data();

      // Strip abapLanguageVersion from the payload.
      // Eclipse ADT never sends this attribute — SAP infers it from the package.
      // Including it triggers S_ABPLNGVS authorization checks that fail on BTP.
      const { abapLanguageVersion: _, ...rest } = rawData as Record<
        string,
        unknown
      >;
      const saveData = rest;
      const data = { [wrapperKey]: saveData };

      // Ensure fresh ETag before PUT.
      // The lock POST and other intermediate requests may cache an ETag
      // under the same URL that differs from the object's current ETag.
      // A fresh GET right before PUT guarantees the If-Match header is correct.
      if (contract.get) {
        try {
          await contract.get(this.name);
        } catch {
          this.ctx.client.clearETag();
        }
      }

      await contract.put(
        this.name,
        { corrNr: options.transport, lockHandle: options.lockHandle },
        data,
      );
    }
  }

  /**
   * Get skeleton data for object creation (POST).
   *
   * Returns only the minimal fields needed to create the object on SAP.
   * SAP creates the object structure (includes, etc.) from these fields.
   * Full metadata and source code are sent via PUT in the update step.
   *
   * Override in subclasses that need different skeleton fields.
   */
  protected async getSkeletonData(): Promise<Record<string, unknown>> {
    const rawData = await this.data();
    const d = rawData as Record<string, unknown>;
    return {
      name: d.name,
      type: d.type,
      description: d.description ?? '',
      language: d.language ?? 'EN',
      masterLanguage: d.masterLanguage ?? 'EN',
      packageRef: d.packageRef,
    };
  }

  /**
   * Check if the object exists on the SAP system.
   *
   * Used by upsert mode to decide between update and create paths upfront.
   * On BTP, locking a non-existent DDIC object creates a draft that persists
   * even after unlock, blocking subsequent POST (create) attempts. Checking
   * existence first with GET avoids this problem entirely.
   */
  protected async checkObjectExists(): Promise<boolean> {
    const contract = this.crudContract;
    if (!contract?.get) return true; // assume exists if no contract
    try {
      await contract.get(this.name);
      return true;
    } catch (e) {
      if (this.shouldFallbackToCreate(e)) return false;
      // Unexpected error — rethrow; the caller will handle it.
      throw e;
    }
  }

  /**
   * Check if error is a 404 Not Found
   */
  protected isNotFoundError(e: unknown): boolean {
    if (e instanceof Error) {
      return e.message.includes('404') || e.message.includes('Not Found');
    }
    return false;
  }

  /**
   * Some CTS backends can retain the lock created by a successful object POST
   * long enough for the immediate follow-up LOCK to fail. This is distinct
   * from an arbitrary edit conflict because this save invocation already
   * created the skeleton successfully.
   */
  private isPostCreateLockConflict(e: unknown): boolean {
    let message = '';
    if (typeof e === 'string') {
      message = e;
    } else if (e instanceof Error) {
      message = e.message;
    } else if (
      typeof e === 'object' &&
      e !== null &&
      typeof (e as { message?: unknown }).message === 'string'
    ) {
      message = (e as { message: string }).message;
    }
    const normalized = message.toLowerCase();
    return (
      normalized.includes('already locked in request') ||
      normalized.includes('currently editing') ||
      normalized.includes('is being edited')
    );
  }

  /**
   * Some backends retain the lock implicitly created by a successful POST for
   * a short time. Retry only this known follow-up LOCK; no arbitrary update or
   * source write is repeated, and each successful lock still uses the normal
   * handle-backed save/unlock lifecycle.
   */
  private async lockAfterCreate(
    transport: string | undefined,
    retry: SaveOptions['postCreateLockRetry'],
  ): Promise<LockHandle> {
    const attempts = retry?.attempts ?? 1;
    const delayMs = retry?.delayMs ?? 0;
    if (!Number.isInteger(attempts) || attempts < 1) {
      throw new RangeError(
        'postCreateLockRetry.attempts must be a positive integer.',
      );
    }
    if (!Number.isFinite(delayMs) || delayMs < 0) {
      throw new RangeError(
        'postCreateLockRetry.delayMs must be a non-negative number.',
      );
    }

    let lastConflict: unknown;
    for (let attempt = 1; attempt <= attempts; attempt += 1) {
      try {
        return await this.lock(transport);
      } catch (error) {
        if (!this.isPostCreateLockConflict(error) || attempt === attempts) {
          throw error;
        }
        lastConflict = error;
        if (delayMs > 0) {
          await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
        }
      }
    }
    throw lastConflict;
  }

  /**
   * Upsert fallback: try POST (create), treat 422 "already exists" as unchanged.
   * Extracted to avoid duplication in lock-catch and saveViaContract-catch paths.
   */
  private async fallbackToCreate(options: SaveOptions): Promise<this> {
    try {
      return await this.save({ ...options, mode: 'create' });
    } catch (createErr) {
      if (this.isAlreadyExistsError(createErr)) {
        this._unchanged = true;
        return this;
      }
      throw createErr;
    }
  }

  /**
   * Check if error suggests the object doesn't exist or the operation
   * is not supported for the current state.
   *
   * Some SAP DDIC endpoints (TABL, TTYP) return 405 Method Not Allowed
   * instead of 404 when attempting to lock or PUT a non-existent object.
   * In upsert mode, both should trigger a fallback to POST (create).
   */
  protected shouldFallbackToCreate(e: unknown): boolean {
    if (this.isNotFoundError(e)) return true;
    if (e instanceof Error) {
      const msg = e.message;
      if (msg.includes('405') || msg.includes('Method Not Allowed'))
        return true;
      // S_ABPLNGVS 403 on PUT: BTP creates a draft when locking a new DDIC
      // object, so the lock succeeds even though the object doesn't exist.
      // The subsequent PUT fails with S_ABPLNGVS. Fall back to POST (create)
      // which handles language version authorization correctly.
      if (msg.includes('403') && msg.includes('S_ABPLNGVS')) return true;
    }
    return false;
  }

  /**
   * Check if error is a 422 "already exists" from a POST create attempt
   */
  protected isAlreadyExistsError(e: unknown): boolean {
    const msg = e instanceof Error ? e.message.toLowerCase() : '';
    return (
      (msg.includes('422') || msg.includes('unprocessable')) &&
      msg.includes('already exists')
    );
  }

  /**
   * Check if object has pending sources to save
   * Subclasses with source code (classes, interfaces) override this
   */
  protected hasPendingSources(): boolean {
    const self = this as unknown as {
      _pendingSources?: Record<string, string>;
      _pendingSource?: string;
    };
    return !!(self._pendingSources || self._pendingSource);
  }

  /**
   * Normalize source code for comparison.
   * Trims trailing whitespace/newlines so minor formatting differences
   * don't trigger unnecessary saves.
   */
  protected normalizeSource(source: string): string {
    return source
      .split('\n')
      .map((line) => line.trimEnd())
      .join('\n')
      .trimEnd();
  }

  /**
   * Check if pending sources are unchanged vs SAP (pre-lock comparison).
   * Sets _unchanged = true if nothing needs saving.
   *
   * Default handles single-source objects (_pendingSource) by fetching
   * GET {objectUri}/source/main and comparing.
   * AdkClass overrides this for multi-include logic.
   */
  protected async checkPendingSourcesUnchanged(): Promise<void> {
    const self = this as unknown as { _pendingSource?: string };
    if (!self._pendingSource) return;

    try {
      const response = await this.ctx.client.fetch(
        `${this.objectUri}/source/main`,
        { method: 'GET', headers: { Accept: 'text/plain' } },
      );
      const currentSource = await toText(response);
      if (
        this.normalizeSource(currentSource) ===
        this.normalizeSource(self._pendingSource)
      ) {
        this._unchanged = true;
        delete self._pendingSource;
      }
    } catch {
      // Source doesn't exist on SAP (404) — needs saving
    }
  }

  /**
   * Check if metadata-only objects are unchanged on SAP.
   *
   * For objects without source code (DDIC types like DOMA, DTEL, TABL, TTYP),
   * fetches the current SAP state and compares only the fields present in
   * the local data. SAP returns extra volatile fields (timestamps, links,
   * version, etc.) that are ignored — only semantic fields matter.
   */
  protected async checkMetadataUnchanged(): Promise<void> {
    const contract = this.crudContract;
    const wrapperKey = this.wrapperKey;
    if (!contract?.get || !wrapperKey) return;

    try {
      const sapResponse = await contract.get(this.name);
      const sapData = sapResponse?.[wrapperKey];
      if (!sapData) return;

      const localData = await this.data();

      // Recursive subset match: checks that every field in `local`
      // has the same value in `sap`. Extra fields in `sap` are ignored.
      // "Empty-like" values (null, undefined, "", [], {}) are treated
      // as equivalent, since SAP normalizes defaults differently than
      // abapGit serialization (e.g., style "" → "00", missing → undefined).
      // Note: `false` is NOT empty-like — an explicit `false` must be
      // compared against SAP's value to detect boolean flag toggles.
      const isEmpty = (v: unknown): boolean =>
        v == null ||
        v === '' ||
        (Array.isArray(v) && v.length === 0) ||
        (typeof v === 'object' && Object.keys(v).length === 0);

      const isSubsetMatch = (local: unknown, sap: unknown): boolean => {
        if (local === sap) return true;

        // Both empty-like → match (handles null/undefined/""/false/[]/{}
        // combinations between local and SAP representations)
        if (isEmpty(local) && isEmpty(sap)) return true;

        // Local is empty-like but SAP has a value → "use default" match
        // (SAP may store a different representation of the default)
        if (isEmpty(local) && sap != null) return true;

        if (local == null || sap == null) return false;
        if (typeof local !== typeof sap) return false;

        if (Array.isArray(local)) {
          if (!Array.isArray(sap)) return false;
          if (local.length !== sap.length) return false;
          return local.every((item, i) => isSubsetMatch(item, sap[i]));
        }

        if (typeof local === 'object') {
          const localObj = local as Record<string, unknown>;
          const sapObj = sap as Record<string, unknown>;
          return Object.keys(localObj).every((key) => {
            if (key === 'abapLanguageVersion') return true;
            return isSubsetMatch(localObj[key], sapObj[key]);
          });
        }

        return local === sap;
      };

      if (isSubsetMatch(localData, sapData)) {
        this._unchanged = true;
      }
    } catch {
      // Object doesn't exist on SAP (404) or comparison failed — needs saving
    }
  }

  /**
   * Refresh cached ETags after acquiring a lock.
   *
   * SAP changes the object's internal version when a lock is acquired,
   * which invalidates any cached ETags. Without refreshing, the next
   * PUT sends a stale If-Match header and SAP returns HTTP 412.
   *
   * For objects with pending sources: re-GET {objectUri}/source/main
   * For metadata-only objects: re-GET via crudContract.get()
   *
   * Subclasses with multiple source endpoints (e.g., AdkClass with includes)
   * should override this to refresh all relevant endpoints.
   */
  protected async refreshETagsAfterLock(): Promise<void> {
    if (this.hasPendingSources()) {
      // Re-fetch source to refresh cached ETag for the source URL
      const sourceUrl = `${this.objectUri}/source/main`;
      try {
        await this.ctx.client.fetch(sourceUrl, {
          method: 'GET',
          headers: { Accept: 'text/plain' },
        });
      } catch {
        // Source endpoint may not exist yet (new object) — clear any stale
        // ETag so the subsequent PUT won't send a stale If-Match header
        this.ctx.client.clearETag(sourceUrl);
      }
    } else {
      // Re-fetch metadata to refresh cached ETag for the metadata URL
      const contract = this.crudContract;
      if (contract?.get) {
        try {
          await contract.get(this.name);
        } catch {
          // Clear all ETags — we can't easily reconstruct the metadata URL
          // since the contract may use a different path than objectUri
          this.ctx.client.clearETag();
        }
      }
    }
  }

  /**
   * Save pending sources to SAP.
   *
   * Default handles single-source objects (_pendingSource) by PUTting
   * to {objectUri}/source/main.
   * AdkClass overrides this for multi-include logic.
   */
  protected async savePendingSources(options?: {
    lockHandle?: string;
    transport?: string;
  }): Promise<void> {
    const self = this as unknown as { _pendingSource?: string };
    if (!self._pendingSource) return;

    const sourceUrl = `${this.objectUri}/source/main`;

    // Ensure fresh ETag for the source URL before PUT.
    // Intermediate requests (lock POST, etc.) may leave a stale ETag cached.
    try {
      await this.ctx.client.fetch(sourceUrl, {
        method: 'GET',
        headers: { Accept: 'text/plain' },
      });
    } catch {
      this.ctx.client.clearETag(sourceUrl);
    }

    const params = new URLSearchParams();
    if (options?.lockHandle) params.set('lockHandle', options.lockHandle);
    if (options?.transport) params.set('corrNr', options.transport);

    const qs = params.toString();
    await this.ctx.client.fetch(`${sourceUrl}${qs ? '?' + qs : ''}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'text/plain' },
      body: self._pendingSource,
    });

    delete self._pendingSource;
  }

  /**
   * Set source content for a source-based object
   *
   * Generic implementation for objects with source code (classes, includes, etc.)
   * Subclasses should override if they need custom source handling.
   *
   * @param sourcePath - Relative path to source (e.g., '/source/main')
   * @param content - Source code content
   * @param options - Save options
   */
  async setSource(
    sourcePath: string,
    content: string,
    options: SaveOptions = {},
  ): Promise<void> {
    const { transport } = options;

    // Lock if not already locked
    const wasLocked = this.isLocked;
    if (!wasLocked) {
      await this.lock(transport);
    }

    try {
      // Build URL
      const params = new URLSearchParams();
      const effectiveTransport = resolveLockCorrelation(
        this._lockHandle ?? {},
        transport,
      );
      if (effectiveTransport) {
        params.set('corrNr', effectiveTransport);
      }
      if (this._lockHandle) {
        params.set('lockHandle', this._lockHandle.handle);
      }

      const fullPath = `${this.objectUri}${sourcePath}`;
      const url = params.toString()
        ? `${fullPath}?${params.toString()}`
        : fullPath;

      // PUT source content
      await this.ctx.client.fetch(url, {
        method: 'PUT',
        headers: {
          'Content-Type': 'text/plain; charset=utf-8',
        },
        body: content,
      });
    } finally {
      // Unlock if we locked it
      if (!wasLocked) {
        await this.unlock();
      }
    }
  }

  /**
   * Activate this object
   *
   * Activates a single object. For bulk activation of multiple objects,
   * use `AdkObjectSet.activateAll()`.
   *
   * @returns this (for chaining)
   */
  async activate(): Promise<this> {
    // Build activation request XML for single object
    const activationXml = `<?xml version="1.0" encoding="UTF-8"?>
<adtcore:objectReferences xmlns:adtcore="http://www.sap.com/adt/core">
  <adtcore:objectReference adtcore:uri="${this.objectUri}" adtcore:type="${this.type}" adtcore:name="${this.name}"/>
</adtcore:objectReferences>`;

    await this.ctx.client.fetch(
      '/sap/bc/adt/activation?method=activate&preauditRequested=true',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/xml',
          Accept: 'application/xml',
        },
        body: activationXml,
      },
    );

    return this;
  }

  // ============================================
  // Caching Infrastructure
  // ============================================

  /** Lazy load a segment (async) - fetches only once, then cached */
  protected async lazy<T>(key: string, loader: () => Promise<T>): Promise<T> {
    if (!this.cache.has(key)) {
      this.cache.set(key, await loader());
    }
    return this.cache.get(key) as T;
  }

  /** Lazy compute a segment (sync) - computes only once, then cached */
  protected cached<T>(key: string, compute: () => T): T {
    if (!this.cache.has(key)) {
      this.cache.set(key, compute());
    }
    return this.cache.get(key) as T;
  }

  /** Set a cached value directly (for local/generated data) */
  protected set<T>(key: string, value: T): void {
    this.cache.set(key, value);
  }

  /** Mark a segment as dirty (modified locally) */
  protected markDirty(key: string): void {
    this.dirty.add(key);
  }

  /** Check if any segment (or specific segment) is dirty */
  isDirty(segment?: string): boolean {
    return segment ? this.dirty.has(segment) : this.dirty.size > 0;
  }

  /** Clear cache for a segment (force reload on next access) */
  protected invalidate(key: string): void {
    this.cache.delete(key);
    this.dirty.delete(key);
  }

  // ============================================
  // Public Fetch Utilities
  // ============================================

  /**
   * Fetch a text resource from an ADT endpoint.
   * Delegates to the underlying ADT client's fetch.
   *
   * @param url - ADT endpoint path (e.g., '/sap/bc/adt/ddic/dataelements/spras')
   * @param headers - Optional HTTP headers (defaults to no Accept header, letting server choose)
   * @returns Response content as text, or undefined if 404
   */
  async fetchText(
    url: string,
    headers?: Record<string, string>,
  ): Promise<string | undefined> {
    try {
      const response = await this.ctx.client.fetch(url, {
        method: 'GET',
        headers: headers ?? {},
      });
      return toText(response);
    } catch {
      return undefined;
    }
  }
}

/**
 * Base class for main development objects (AdtMainObject)
 *
 * Extends AdkObject with package reference, responsible user, and master language.
 * Used for repository objects like classes, interfaces, packages, etc.
 *
 * Note: Uses type assertions internally because schema-inferred types from
 * contracts may have different shapes than AdkMainObjectData. The getters
 * safely handle missing properties with defaults.
 *
 * @typeParam K - The object kind (from AdkKind)
 * @typeParam D - The internal data type (typically schema-inferred from contract)
 */
export abstract class AdkMainObject<
  K extends AdkKind = AdkKind,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  D = any,
> extends AdkObject<K, D> {
  // Helper to access data with main object properties
  private get mainData(): AdkMainObjectData {
    return this.dataSync as unknown as AdkMainObjectData;
  }

  // ============================================
  // Main Object Properties (AdtMainObject additions)
  // ============================================

  /** Package reference (adtcore:packageRef) */
  get packageRef(): AdtObjectReference | undefined {
    return this.mainData.packageRef;
  }

  /** Package name (convenience getter) */
  get package(): string {
    return this.mainData.packageRef?.name ?? '';
  }

  /** Master system (adtcore:masterSystem) */
  get masterSystem(): string {
    return this.mainData.masterSystem ?? '';
  }

  /** Master language (adtcore:masterLanguage) */
  get masterLanguage(): string {
    return this.mainData.masterLanguage ?? '';
  }

  /** Responsible user (adtcore:responsible) */
  get responsible(): string {
    return this.mainData.responsible ?? '';
  }

  /** ABAP language version (adtcore:abapLanguageVersion) */
  get abapLanguageVersion(): string {
    return this.mainData.abapLanguageVersion ?? '';
  }

  /**
   * Save main source code to SAP.
   * Requires the object to be locked before calling.
   * Subclasses may override to add additional behavior (e.g. cache invalidation).
   */
  async saveMainSource(
    source: string,
    options?: { lockHandle?: string; transport?: string },
  ): Promise<void> {
    const params = new URLSearchParams();
    if (options?.lockHandle) params.set('lockHandle', options.lockHandle);
    if (options?.transport) params.set('corrNr', options.transport);
    const qs = params.toString();
    await this.ctx.client.fetch(
      `${this.objectUri}/source/main${qs ? '?' + qs : ''}`,
      {
        method: 'PUT',
        headers: { 'Content-Type': 'text/plain' },
        body: source,
      },
    );
  }
}
