This behavior is called lexical scoping. Here's the relevant part of the official Python docs (emphasis mine):
If a name binding operation occurs anywhere within a code block, all
uses of the name within the block are treated as references to the
current block. This can lead to errors when a name is used within a
block before it is bound. This rule is subtle. Python lacks
declarations and allows name binding operations to occur anywhere
within a code block. The local variables of a code block can be
determined by scanning the entire text of the block for name binding
operations.
And for the definition of "name binding operations" (emphasis mine again):
Names refer to objects. Names are introduced by name binding
operations.
The following constructs bind names: formal parameters to functions,
import statements, class and function definitions (these bind the
class or function name in the defining block), and targets that are
identifiers if occurring in an assignment, for loop header, or after
as in a with statement or except clause. The import statement of the
form from ... import * binds all names defined in the imported module,
except those beginning with an underscore. This form may only be used
at the module level.
A target occurring in a del statement is also considered bound for
this purpose (though the actual semantics are to unbind the name).
So in your 3rd code example, the c = 20 at the end of the block qualifies as a name binding operation via the "targets that are identifiers if occurring in an assignment" clause. That makes all occurrences of c in that function refer to the local variable c. At runtime, though, your code hits the a = c line first. Since c hasn't actually been defined in the local scope yet, you get the exception.
In the first block, you define c locally before you try to reference it, so no problem there.
In the second, you don't perform any name binding operations on c within the function, so Python assumes you want the outer definition.
As to why it works this way, see this question:
The rationale for lexical scoping is not just for performance (full lexical scoping including closures actually has a performance cost, see the funcarg problems), it's for simplicity and reliability. While it may be surprising when first learning the language, the rules are actually dead simple so an experienced programmer can instantly tell which use of an identifier refers to which scope. One can understand functions in isolation, as execution isn't affected by who calls this functions and how they decided to name their variables.