/**
 * Source Command
 *
 * Read and write ABAP source code via ADT.
 *
 * Usage:
 *   adt source get <objectName> [--type CLAS]   # Print source to stdout
 *   adt source put <objectName> <file>  [--type CLAS] [--transport DEVK900001]
 */

import { Command } from 'commander';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { getAdtClientV2 } from '../utils/adt-client-v2';
import { normalizeSearchResults } from '../utils/lock-helpers';
import {
  ExactSourceHistoryService,
  toMetadataOnlySourceVersionListing,
  type ListObjectVersionsResult,
} from '../services/source-history';
import { createLockService, resolveLockCorrelation } from '@abapify/adt-locks';
import { getObjectUri } from '@abapify/adk';
import {
  detectMethodBoundary,
  lintSource,
  normalizeMethodBody,
} from '@abapify/adt-lint';

type AdtClient = Awaited<ReturnType<typeof getAdtClientV2>>;

type SourceHistoryServicePort = Pick<
  ExactSourceHistoryService,
  'listObjectVersions' | 'getVersionSource'
>;

export interface SourceHistoryCommandDependencies {
  getClient: () => Promise<AdtClient>;
  createService: (client: AdtClient) => SourceHistoryServicePort;
  writeStdout: (content: string) => void;
  writeFile: (path: string, content: string) => Promise<void>;
  writeLine: (content: string) => void;
  writeError: (content: string) => void;
  setExitCode: (code: number) => void;
}

const DEFAULT_SOURCE_HISTORY_DEPENDENCIES: SourceHistoryCommandDependencies = {
  getClient: getAdtClientV2,
  createService: (client) => new ExactSourceHistoryService(client),
  writeStdout: (content) => process.stdout.write(content),
  writeFile: async (path, content) => writeFile(path, content, 'utf8'),
  writeLine: (content) => console.log(content),
  writeError: (content) => console.error(content),
  setExitCode: (code) => {
    process.exitCode = code;
  },
};

function sourceHistoryDependencies(
  overrides: Partial<SourceHistoryCommandDependencies>,
): SourceHistoryCommandDependencies {
  return { ...DEFAULT_SOURCE_HISTORY_DEPENDENCIES, ...overrides };
}

function safeErrorMessage(error: unknown): string {
  return error instanceof Error
    ? error.message
    : 'Unknown source-history error.';
}

function resolveSafeOutputPath(filePath: string): string {
  if (filePath === '-') return filePath;
  const normalized = path.normalize(filePath);
  if (path.isAbsolute(normalized)) {
    if (normalized.split(path.sep).includes('..')) {
      throw new Error(
        'Output path contains an illegal parent-directory reference.',
      );
    }
    return normalized;
  }
  const cwd = process.cwd();
  const resolved = path.resolve(normalized);
  const prefix = path.join(cwd, path.sep);
  if (!resolved.startsWith(prefix) && resolved !== cwd) {
    throw new Error(
      'Output path must be inside the current working directory.',
    );
  }
  return resolved;
}

function formatObjectVersions(result: ListObjectVersionsResult): string[] {
  const lines = [`${result.object.type} ${result.object.name}`];
  if (result.object.packageName) {
    lines[0] += ` (${result.object.packageName})`;
  }

  if (result.components.length === 0) {
    lines.push('  No source components found.');
    return lines;
  }

  for (const component of result.components) {
    if (component.diagnostic !== undefined) {
      lines.push(
        `  ${component.id}: unavailable [${component.diagnostic.code}] ${component.diagnostic.message}`,
      );
      continue;
    }

    lines.push(`  ${component.id}: ${component.versions.length} version(s)`);
    for (const version of component.versions) {
      const transports =
        version.transports.length > 0
          ? version.transports.join(', ')
          : 'no transport provenance';
      lines.push(`    #${version.ordinal} ${version.id} [${transports}]`);
    }
  }

  return lines;
}

interface SourceOp {
  get: (name: string) => Promise<string>;
  put: (
    name: string,
    opts: { lockHandle: string; corrNr?: string },
    body: string,
  ) => Promise<unknown>;
}

/**
 * Pick the typed source.main contract for well-known object URIs.
 * Returns `undefined` for object types that don't (yet) have a contract,
 * in which case callers should fall back to generic client.fetch().
 */
function pickSourceContract(
  client: AdtClient,
  uri: string,
): { op: SourceOp; objectName: string } | undefined {
  // Patterns: /sap/bc/adt/<area>/<name>
  const match =
    /^\/sap\/bc\/adt\/(oo\/classes|oo\/interfaces|programs\/programs|ddic\/ddl\/sources|ddic\/dcl\/sources)\/([^/?#]+)/i.exec(
      uri,
    );
  if (!match) return undefined;
  const [, area, encodedName] = match;
  const objectName = decodeURIComponent(encodedName);

  switch (area.toLowerCase()) {
    case 'oo/classes':
      return { op: client.adt.oo.classes.source.main, objectName };
    case 'oo/interfaces':
      return { op: client.adt.oo.interfaces.source.main, objectName };
    case 'programs/programs':
      return { op: client.adt.programs.programs.source.main, objectName };
    case 'ddic/ddl/sources':
      return { op: client.adt.ddic.ddl.sources.source.main, objectName };
    case 'ddic/dcl/sources':
      return { op: client.adt.ddic.dcl.sources.source.main, objectName };
    default:
      return undefined;
  }
}

/**
 * Resolve an object to its ADT URI.
 * Uses type-based registry lookup first, then falls back to quickSearch.
 */
async function resolveUri(
  client: AdtClient,
  objectName: string,
  objectType?: string,
): Promise<string> {
  if (objectType) {
    const uri = getObjectUri(objectType, objectName);
    if (uri) return uri;
  }

  const searchResult =
    await client.adt.repository.informationsystem.search.quickSearch({
      query: objectName,
      maxResults: 10,
    });

  const objects = normalizeSearchResults(
    searchResult as Record<string, unknown>,
  );

  const match = objects.find(
    (o) => o.name?.toUpperCase() === objectName.toUpperCase(),
  );

  if (!match?.uri) throw new Error(`Object '${objectName}' not found`);
  return match.uri;
}

// ── sub-commands ─────────────────────────────────────────────────────────────

const getSourceCommand = new Command('get')
  .description('Print ABAP source code for an object to stdout')
  .argument('<objectName>', 'ABAP object name')
  .option('--type <type>', 'Object type hint (e.g. CLAS, PROG, INTF)')
  .option('--json', 'Output result as JSON')
  .action(
    async (objectName: string, options: { type?: string; json?: boolean }) => {
      try {
        const client = await getAdtClientV2();
        const uri = await resolveUri(client, objectName, options.type);

        // Prefer typed source contract for well-known object types (CLAS/INTF/PROG/DDLS/DCLS)
        const contract = pickSourceContract(client, uri);
        let source: string;
        if (contract) {
          source = await contract.op.get(contract.objectName);
        } else {
          // TODO: generic fallback — remove once all object types have
          // typed source contracts (e.g. FUGR/FUNC, DOMA source, etc.).
          source = String(
            await client.fetch(`${uri}/source/main`, {
              method: 'GET',
              headers: { Accept: 'text/plain' },
            }),
          );
        }

        if (options.json) {
          console.log(JSON.stringify({ objectName, uri, source }, null, 2));
        } else {
          process.stdout.write(source);
        }
      } catch (error) {
        console.error(
          '❌ Get source failed:',
          error instanceof Error ? error.message : String(error),
        );
        process.exit(1);
      }
    },
  );

/** Create `adt source versions` with injectable I/O for deterministic tests. */
export function createSourceVersionsCommand(
  overrides: Partial<SourceHistoryCommandDependencies> = {},
): Command {
  const dependencies = sourceHistoryDependencies(overrides);

  return new Command('versions')
    .description('List immutable source-version metadata for an ABAP object')
    .argument('<object>', 'ABAP object name')
    .requiredOption('--type <type>', 'ADT object type (for example CLAS)')
    .option('--component <name>', 'Exact source component id')
    .option('--json', 'Output normalized metadata as JSON')
    .action(
      async (
        objectName: string,
        options: { type: string; component?: string; json?: boolean },
      ) => {
        try {
          const client = await dependencies.getClient();
          const service = dependencies.createService(client);
          const result = await service.listObjectVersions({
            objectName,
            objectType: options.type,
            ...(options.component !== undefined
              ? { component: options.component }
              : {}),
          });

          if (options.json) {
            dependencies.writeLine(
              JSON.stringify(
                toMetadataOnlySourceVersionListing(result),
                null,
                2,
              ),
            );
          } else {
            for (const line of formatObjectVersions(result)) {
              dependencies.writeLine(line);
            }
          }
        } catch (error) {
          dependencies.writeError(
            `Source-version listing failed: ${safeErrorMessage(error)}`,
          );
          dependencies.setExitCode(1);
        }
      },
    );
}

/** Create `adt source version get` with explicit stdout/file delivery. */
export function createSourceVersionCommand(
  overrides: Partial<SourceHistoryCommandDependencies> = {},
): Command {
  const dependencies = sourceHistoryDependencies(overrides);
  const getVersionCommand = new Command('get')
    .description('Read one immutable historical source version')
    .requiredOption(
      '--uri <immutable-uri>',
      'Immutable server-relative ADT source URI returned by SAP',
    )
    .option('--output <file>', "Write source to a file, or '-' for stdout", '-')
    .action(async (options: { uri: string; output: string }) => {
      try {
        const client = await dependencies.getClient();
        const service = dependencies.createService(client);
        const source = await service.getVersionSource({ uri: options.uri });

        if (options.output === '-') {
          dependencies.writeStdout(source);
        } else {
          await dependencies.writeFile(
            resolveSafeOutputPath(options.output),
            source,
          );
        }
      } catch (error) {
        dependencies.writeError(
          `Immutable source retrieval failed: ${safeErrorMessage(error)}`,
        );
        dependencies.setExitCode(1);
      }
    });

  return new Command('version')
    .description('Operate on one immutable source version')
    .addCommand(getVersionCommand);
}

const putSourceCommand = new Command('put')
  .description('Write ABAP source code from a file to an existing object')
  .argument('<objectName>', 'ABAP object name')
  .argument('<file>', 'Path to the source file to upload')
  .option('--type <type>', 'Object type hint (e.g. CLAS, PROG, INTF)')
  .option(
    '--transport <transport>',
    'Transport request number for transportable objects',
  )
  .option(
    '--method <method>',
    'Replace the body of a single method instead of uploading full source',
  )
  .option(
    '--lint-before-write',
    'Run local lint checks and block write on parser/cloud violations',
    false,
  )
  .option(
    '--lint-preset <preset>',
    'Lint preset for --lint-before-write gate: btp|onpremise',
    'onpremise',
  )
  .option('--json', 'Output result as JSON')
  .action(
    async (
      objectName: string,
      file: string,
      options: {
        type?: string;
        transport?: string;
        json?: boolean;
        method?: string;
        lintBeforeWrite?: boolean;
        lintPreset?: 'btp' | 'onpremise';
      },
    ) => {
      try {
        const client = await getAdtClientV2();
        const uri = await resolveUri(client, objectName, options.type);
        const sourceCode = await readFile(file, 'utf8');
        let sourceToWrite = sourceCode;

        if (options.method) {
          if (
            !uri.includes('/oo/classes/') &&
            !uri.includes('/oo/interfaces/')
          ) {
            throw new Error(
              `--method is only supported for classes and interfaces, but '${objectName}' resolved to ${uri}`,
            );
          }

          const currentSource = String(
            await client.fetch(`${uri}/source/main`, {
              method: 'GET',
              headers: { Accept: 'text/plain' },
            }),
          );
          const boundary = detectMethodBoundary(currentSource, options.method);
          if (!boundary) {
            throw new Error(
              `Method ${options.method} not found in ${objectName}`,
            );
          }

          const lines = currentSource.split(/\r?\n/);
          const methodBody = normalizeMethodBody(sourceCode, options.method);
          const methodBlock = [
            `METHOD ${options.method.toUpperCase()}.`,
            methodBody,
            'ENDMETHOD.',
          ].join('\n');
          lines.splice(
            boundary.startLine - 1,
            boundary.endLine - boundary.startLine + 1,
            methodBlock,
          );
          sourceToWrite = lines.join('\n');
        }

        if (options.lintBeforeWrite) {
          const diagnostics = lintSource(sourceToWrite, {
            filename: `${objectName.toLowerCase()}.abap`,
            systemType: options.lintPreset,
          });
          const blocking = diagnostics.filter((d) =>
            ['parser_error', 'cloud_types', 'strict_sql'].includes(d.key),
          );
          if (blocking.length > 0) {
            throw new Error(
              `Lint blocked write:\n${blocking.map((d) => `${d.key}: ${d.message}`).join('\n')}`,
            );
          }
        }

        const lockService = createLockService(client);
        let lockHandle: string | undefined;

        if (!options.json) console.log(`🔄 Locking ${objectName}...`);
        const lock = await lockService.lock(uri, {
          transport: options.transport,
          objectName,
        });
        lockHandle = lock.handle;
        const effectiveTransport = resolveLockCorrelation(
          lock,
          options.transport,
        );

        try {
          if (!options.json) console.log(`🔄 Writing source to ${uri}...`);

          const contract = pickSourceContract(client, uri);
          if (contract) {
            await contract.op.put(
              contract.objectName,
              {
                lockHandle,
                ...(effectiveTransport ? { corrNr: effectiveTransport } : {}),
              },
              sourceToWrite,
            );
          } else {
            // TODO: generic fallback — remove once all object types have
            // typed source contracts.
            const params = new URLSearchParams({ lockHandle });
            if (effectiveTransport) params.set('corrNr', effectiveTransport);
            await client.fetch(`${uri}/source/main?${params.toString()}`, {
              method: 'PUT',
              headers: { 'Content-Type': 'text/plain' },
              body: sourceToWrite,
            });
          }

          await lockService.unlock(uri, { lockHandle });
          lockHandle = undefined;

          if (options.json) {
            console.log(
              JSON.stringify({ objectName, uri, status: 'written' }, null, 2),
            );
          } else {
            console.log(
              `✅ Source written and lock released for ${objectName}`,
            );
          }
        } catch (err) {
          // Best-effort unlock (only if not already unlocked)
          if (lockHandle) {
            try {
              await lockService.unlock(uri, { lockHandle });
            } catch {
              // ignore
            }
          }
          throw err;
        }
      } catch (error) {
        console.error(
          '❌ Put source failed:',
          error instanceof Error ? error.message : String(error),
        );
        process.exit(1);
      }
    },
  );

// ── parent command ────────────────────────────────────────────────────────────

export const sourceCommand = new Command('source')
  .description('Read and write ABAP source code')
  .addCommand(getSourceCommand)
  .addCommand(putSourceCommand)
  .addCommand(createSourceVersionsCommand())
  .addCommand(createSourceVersionCommand());
