import { exec } from "node:child_process";
import { promisify } from "node:util";
import { $inject, $state, z } from "alepha";
import { $command } from "alepha/command";
import { $logger } from "alepha/logger";
import { changelogOptions } from "../../atoms/changelogOptions.ts";
import { GitMessageParser } from "../../services/GitMessageParser.ts";

export {
  type ChangelogOptions,
  changelogOptions,
  DEFAULT_IGNORE,
} from "../../atoms/changelogOptions.ts";
export { GitMessageParser } from "../../services/GitMessageParser.ts";

const execAsync = promisify(exec);

// =============================================================================
// GIT PROVIDER
// =============================================================================

/**
 * Git provider for executing git commands.
 * Can be substituted in tests with a mock implementation.
 */
export class GitProvider {
  async exec(cmd: string, cwd: string): Promise<string> {
    const { stdout } = await execAsync(`git ${cmd}`, { cwd });
    return stdout;
  }
}

// =============================================================================
// TYPES
// =============================================================================

export interface Commit {
  hash: string;
  type: string;
  scope: string | null;
  description: string;
  breaking: boolean;
}

interface ChangelogEntry {
  features: Commit[];
  fixes: Commit[];
}

// =============================================================================
// CHANGELOG COMMAND
// =============================================================================

/**
 * Changelog command for generating release notes from git commits.
 *
 * Usage:
 * - `alepha gen changelog` - Show unreleased changes since latest tag to HEAD
 * - `alepha gen changelog --from=1.0.0` - Show changes from version to HEAD
 * - `alepha gen changelog --from=1.0.0 --to=1.1.0` - Show changes between two refs
 * - `alepha gen changelog | tee -a CHANGELOG.md` - Append to file
 */
export class ChangelogCommand {
  protected readonly log = $logger();
  protected readonly git = $inject(GitProvider);
  protected readonly parser = $inject(GitMessageParser);
  protected readonly config = $state(changelogOptions);

  // ---------------------------------------------------------------------------
  // FORMATTING
  // ---------------------------------------------------------------------------

  /**
   * Format a single commit line.
   * Example: `- **cli**: add new command (\`abc1234\`)`
   * Breaking changes are flagged: `- **cli**: add new command [BREAKING] (\`abc1234\`)`
   */
  protected formatCommit(commit: Commit): string {
    const breaking = commit.breaking ? " [BREAKING]" : "";
    return `- **${commit.scope}**: ${commit.description}${breaking} (\`${commit.hash}\`)`;
  }

  /**
   * Format the changelog entry with sections.
   */
  protected formatEntry(entry: ChangelogEntry): string {
    const sections: string[] = [];

    if (entry.features.length > 0) {
      sections.push("### Features\n");
      for (const commit of entry.features) {
        sections.push(this.formatCommit(commit));
      }
      sections.push("");
    }

    if (entry.fixes.length > 0) {
      sections.push("### Bug Fixes\n");
      for (const commit of entry.fixes) {
        sections.push(this.formatCommit(commit));
      }
      sections.push("");
    }

    return sections.join("\n");
  }

  // ---------------------------------------------------------------------------
  // PARSING
  // ---------------------------------------------------------------------------

  /**
   * Parse git log output into a changelog entry.
   */
  protected parseCommits(commitsOutput: string): ChangelogEntry {
    const entry: ChangelogEntry = {
      features: [],
      fixes: [],
    };

    for (const line of commitsOutput.trim().split("\n")) {
      if (!line.trim()) continue;

      const commit = this.parser.parseCommit(line, this.config);
      if (!commit) {
        this.log.trace("Skipping commit", { line });
        continue;
      }

      this.log.trace("Parsed commit", { commit });

      // Categorize commit (breaking flag is preserved on the commit itself)
      if (commit.type === "feat") {
        entry.features.push(commit);
      } else if (commit.type === "fix") {
        entry.fixes.push(commit);
      }
    }

    return entry;
  }

  /**
   * Check if entry has any public commits.
   */
  protected hasChanges(entry: ChangelogEntry): boolean {
    return entry.features.length > 0 || entry.fixes.length > 0;
  }

  /**
   * Get the latest version tag.
   */
  protected async getLatestTag(
    git: (cmd: string) => Promise<string>,
  ): Promise<string | null> {
    const tagsOutput = await git("tag --sort=-version:refname");
    const tags = tagsOutput
      .trim()
      .split("\n")
      .filter((tag) => tag.match(/^\d+\.\d+\.\d+$/));

    return tags[0] || null;
  }

  // ---------------------------------------------------------------------------
  // COMMAND
  // ---------------------------------------------------------------------------

  public readonly command = $command({
    name: "changelog",
    description:
      "Generate changelog from conventional commits (outputs to stdout)",
    flags: z.object({
      /**
       * Show changes from this ref (tag, commit, branch).
       * Defaults to the latest version tag.
       * Example: --from=1.0.0
       */
      from: z
        .string()
        .meta({ aliases: ["f"] })
        .describe("Starting ref (default: latest tag)")
        .optional(),
      /**
       * Show changes up to this ref (tag, commit, branch).
       * Defaults to HEAD.
       * Example: --to=main
       */
      to: z
        .string()
        .meta({ aliases: ["t"] })
        .describe("Ending ref (default: HEAD)")
        .optional(),
    }),
    handler: async ({ flags, root }) => {
      const git = (cmd: string) => this.git.exec(cmd, root);

      // Determine the starting point
      let fromRef: string;

      if (flags.from) {
        // User specified a ref
        fromRef = flags.from;
        this.log.debug("Using specified from ref", { from: fromRef });
      } else {
        // Use latest tag
        const latestTag = await this.getLatestTag(git);
        if (!latestTag) {
          process.stdout.write("No version tags found in repository\n");
          return;
        }
        fromRef = latestTag;
        this.log.debug("Using latest tag", { from: fromRef });
      }

      // Determine the ending point
      const toRef = flags.to || "HEAD";
      this.log.debug("Using to ref", { to: toRef });

      // Get commits in range
      const commitsOutput = await git(`log ${fromRef}..${toRef} --oneline`);

      if (!commitsOutput.trim()) {
        process.stdout.write(`No changes in range ${fromRef}..${toRef}\n`);
        return;
      }

      // Parse and format
      const entry = this.parseCommits(commitsOutput);

      if (!this.hasChanges(entry)) {
        process.stdout.write(
          `No public changes in range ${fromRef}..${toRef}\n`,
        );
        return;
      }

      // Output the formatted changelog (no header - caller adds it if needed)
      process.stdout.write(this.formatEntry(entry));
    },
  });
}
